passkit 0.1.0 → 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (54) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +121 -5
  3. data/app/assets/images/passkit/add_to_apple_wallet_de.png +0 -0
  4. data/app/assets/images/passkit/add_to_apple_wallet_en.png +0 -0
  5. data/app/assets/images/passkit/add_to_apple_wallet_fr.png +0 -0
  6. data/app/assets/images/passkit/add_to_apple_wallet_it.png +0 -0
  7. data/app/assets/images/passkit/add_to_walletpass_de.png +0 -0
  8. data/app/assets/images/passkit/add_to_walletpass_en.png +0 -0
  9. data/app/assets/images/passkit/add_to_walletpass_fr.png +0 -0
  10. data/app/assets/images/passkit/add_to_walletpass_it.png +0 -0
  11. data/app/controllers/passkit/api/v1/logs_controller.rb +14 -0
  12. data/app/controllers/passkit/api/v1/passes_controller.rb +55 -0
  13. data/app/controllers/passkit/api/v1/registrations_controller.rb +115 -0
  14. data/app/controllers/passkit/logs_controller.rb +9 -0
  15. data/app/controllers/passkit/passes_controller.rb +4 -19
  16. data/app/controllers/passkit/previews_controller.rb +13 -0
  17. data/app/mailers/passkit/example_mailer.rb +8 -0
  18. data/{lib → app/models}/passkit/device.rb +2 -2
  19. data/{lib → app/models}/passkit/log.rb +0 -0
  20. data/app/models/passkit/pass.rb +45 -0
  21. data/app/models/passkit/registration.rb +6 -0
  22. data/app/views/layouts/passkit/application.html.erb +16 -0
  23. data/app/views/passkit/example_mailer/example_email.html.erb +15 -0
  24. data/app/views/passkit/logs/index.html.erb +22 -0
  25. data/app/views/passkit/passes/index.html.erb +32 -0
  26. data/app/views/passkit/previews/index.html.erb +21 -0
  27. data/app/views/shared/passkit/_navigation.html.erb +5 -0
  28. data/config/routes.rb +17 -7
  29. data/docs/wallet.png +0 -0
  30. data/lib/generators/passkit/install_generator.rb +26 -0
  31. data/lib/generators/templates/create_passkit_tables.rb.tt +32 -0
  32. data/lib/generators/templates/passkit.rb +3 -0
  33. data/lib/passkit/base_pass.rb +120 -0
  34. data/lib/passkit/example_store_card/icon.png +0 -0
  35. data/lib/passkit/example_store_card/icon@2x.png +0 -0
  36. data/lib/passkit/example_store_card/icon@3x.png +0 -0
  37. data/lib/passkit/example_store_card/logo.png +0 -0
  38. data/lib/passkit/example_store_card.rb +110 -0
  39. data/lib/passkit/factory.rb +2 -2
  40. data/lib/passkit/generator.rb +30 -22
  41. data/lib/passkit/url_encrypt.rb +3 -3
  42. data/lib/passkit/url_generator.rb +27 -0
  43. data/lib/passkit/version.rb +1 -1
  44. data/lib/passkit.rb +36 -3
  45. data/sig/lib/passkit/encryptable_payload.rbs +5 -0
  46. data/sig/lib/passkit/factory.rbs +5 -0
  47. data/sig/lib/passkit/generator.rbs +8 -0
  48. data/sig/lib/passkit/generator_object.rbs +5 -0
  49. data/sig/lib/passkit/pass.rbs +5 -0
  50. data/sig/lib/passkit/url_encrypt.rbs +6 -0
  51. data/sig/lib/passkit/url_generator.rbs +9 -0
  52. metadata +116 -8
  53. data/lib/passkit/pass.rb +0 -8
  54. data/sig/passkit.rbs +0 -4
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators/base"
4
+ require "rails/generators/migration"
5
+
6
+ module Passkit
7
+ module Generators
8
+ class InstallGenerator < Rails::Generators::Base
9
+ include Rails::Generators::Migration
10
+
11
+ source_root File.expand_path("../../templates", __FILE__)
12
+
13
+ # Implement the required interface for Rails::Generators::Migration.
14
+ def self.next_migration_number(dirname)
15
+ next_migration_number = current_migration_number(dirname) + 1
16
+ ActiveRecord::Migration.next_migration_number(next_migration_number)
17
+ end
18
+
19
+ desc "Copy all files to your application."
20
+ def generate_files
21
+ migration_template "create_passkit_tables.rb", "db/migrate/create_passkit_tables.rb"
22
+ copy_file "passkit.rb", "config/initializers/passkit.rb"
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,32 @@
1
+ class CreatePasskitTables < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
2
+ def change
3
+ create_table :passkit_passes do |t|
4
+ t.string :generator_type
5
+ t.string :klass
6
+ t.bigint :generator_id
7
+ t.string :serial_number
8
+ t.string :authentication_token
9
+ t.json :data
10
+ t.integer :version
11
+ t.timestamps null: false
12
+ t.index [:generator_type, :generator_id], name: 'index_passkit_passes_on_generator'
13
+ end
14
+
15
+ create_table :passkit_devices do |t|
16
+ t.string :identifier
17
+ t.string :push_token
18
+ t.timestamps null: false
19
+ end
20
+
21
+ create_table :passkit_registrations do |t|
22
+ t.belongs_to :passkit_pass, index: true
23
+ t.belongs_to :passkit_device, index: true
24
+ t.timestamps null: false
25
+ end
26
+
27
+ create_table :passkit_logs do |t|
28
+ t.text :content
29
+ t.timestamps null: false
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,3 @@
1
+ Passkit.configure do |config|
2
+ # config.available_passes['Passkit::YourPass'] = -> { User.create }
3
+ end
@@ -0,0 +1,120 @@
1
+ module Passkit
2
+ class BasePass
3
+ def initialize(generator = nil)
4
+ @generator = generator
5
+ end
6
+
7
+ def format_version
8
+ ENV["PASSKIT_FORMAT_VERSION"] || 1
9
+ end
10
+
11
+ def apple_team_identifier
12
+ ENV["PASSKIT_APPLE_TEAM_IDENTIFIER"] || raise(Error.new("Missing environment variable: PASSKIT_APPLE_TEAM_IDENTIFIER"))
13
+ end
14
+
15
+ def pass_type_identifier
16
+ ENV["PASSKIT_PASS_TYPE_IDENTIFIER"] || raise(Error.new("Missing environment variable: PASSKIT_PASS_TYPE_IDENTIFIER"))
17
+ end
18
+
19
+ def language
20
+ nil
21
+ end
22
+
23
+ def pass_path
24
+ rails_folder = Rails.root.join("private/passkit/#{folder_name}")
25
+ # if folder exists, otherwise is in the gem itself under lib/passkit/base_pass
26
+ if File.directory?(rails_folder)
27
+ rails_folder
28
+ else
29
+ File.join(File.dirname(__FILE__), folder_name)
30
+ end
31
+ end
32
+
33
+ def pass_type
34
+ :storeCard
35
+ # :coupon
36
+ end
37
+
38
+ def web_service_url
39
+ raise Error.new("Missing environment variable: PASSKIT_WEB_SERVICE_HOST") unless ENV["PASSKIT_WEB_SERVICE_HOST"]
40
+ "#{ENV["PASSKIT_WEB_SERVICE_HOST"]}/passkit/api"
41
+ end
42
+
43
+ def foreground_color
44
+ "rgb(0, 0, 0)"
45
+ end
46
+
47
+ def background_color
48
+ "rgb(255, 255, 255)"
49
+ end
50
+
51
+ def organization_name
52
+ "Passkit"
53
+ end
54
+
55
+ def description
56
+ "A basic description for a pass"
57
+ end
58
+
59
+ # A pass can have up to ten relevant locations
60
+ #
61
+ # @see https://developer.apple.com/library/archive/documentation/UserExperience/Conceptual/PassKit_PG/Creating.html
62
+ def locations
63
+ []
64
+ end
65
+
66
+ def voided
67
+ false
68
+ end
69
+
70
+ def file_name
71
+ @file_name ||= SecureRandom.uuid
72
+ end
73
+
74
+ # QRCode by default
75
+ def barcode
76
+ {messageEncoding: "iso-8859-1",
77
+ format: "PKBarcodeFormatQR",
78
+ message: "https://github.com/coorasse/passkit",
79
+ altText: "https://github.com/coorasse/passkit"}
80
+ end
81
+
82
+ # Barcode example
83
+ # def barcode
84
+ # { messageEncoding: 'iso-8859-1',
85
+ # format: 'PKBarcodeFormatCode128',
86
+ # message: '12345',
87
+ # altText: '12345' }
88
+ # end
89
+
90
+ def logo_text
91
+ "Logo text"
92
+ end
93
+
94
+ def header_fields
95
+ []
96
+ end
97
+
98
+ def primary_fields
99
+ []
100
+ end
101
+
102
+ def secondary_fields
103
+ []
104
+ end
105
+
106
+ def auxiliary_fields
107
+ []
108
+ end
109
+
110
+ def back_fields
111
+ []
112
+ end
113
+
114
+ private
115
+
116
+ def folder_name
117
+ self.class.name.demodulize.underscore
118
+ end
119
+ end
120
+ end
@@ -0,0 +1,110 @@
1
+ module Passkit
2
+ class ExampleStoreCard < BasePass
3
+ def pass_type
4
+ :storeCard
5
+ # :coupon
6
+ end
7
+
8
+ def foreground_color
9
+ "rgb(0, 0, 0)"
10
+ end
11
+
12
+ def background_color
13
+ "rgb(255, 255, 255)"
14
+ end
15
+
16
+ def organization_name
17
+ "Passkit"
18
+ end
19
+
20
+ def description
21
+ "A basic description for a pass"
22
+ end
23
+
24
+ # A pass can have up to ten relevant locations
25
+ #
26
+ # @see https://developer.apple.com/library/archive/documentation/UserExperience/Conceptual/PassKit_PG/Creating.html
27
+ def locations
28
+ []
29
+ end
30
+
31
+ def voided
32
+ false
33
+ end
34
+
35
+ def file_name
36
+ @file_name ||= SecureRandom.uuid
37
+ end
38
+
39
+ # QRCode by default
40
+ def barcode
41
+ {messageEncoding: "iso-8859-1",
42
+ format: "PKBarcodeFormatQR",
43
+ message: "https://github.com/coorasse/passkit",
44
+ altText: "https://github.com/coorasse/passkit"}
45
+ end
46
+
47
+ # Barcode example
48
+ # def barcode
49
+ # { messageEncoding: 'iso-8859-1',
50
+ # format: 'PKBarcodeFormatCode128',
51
+ # message: '12345',
52
+ # altText: '12345' }
53
+ # end
54
+
55
+ def logo_text
56
+ "Loyalty Card"
57
+ end
58
+
59
+ def header_fields
60
+ [{
61
+ key: "balance",
62
+ label: "Balance",
63
+ value: 100,
64
+ currencyCode: "$"
65
+ }]
66
+ end
67
+
68
+ def back_fields
69
+ [{
70
+ key: "example1",
71
+ label: "Code",
72
+ value: "0123456789"
73
+ },
74
+ {
75
+ key: "example2",
76
+ label: "Creator",
77
+ value: "https://github.com/coorasse"
78
+ },
79
+ {
80
+ key: "example3",
81
+ label: "Contact",
82
+ value: "rodi@hey.com"
83
+ }]
84
+ end
85
+
86
+ def auxiliary_fields
87
+ [{
88
+ key: "name",
89
+ label: "Name",
90
+ value: "Alessandro Rodi"
91
+ },
92
+ {
93
+ key: "email",
94
+ label: "Email",
95
+ value: "rodi@hey.com"
96
+ },
97
+ {
98
+ key: "phone",
99
+ label: "Phone",
100
+ value: "+41 1234567890"
101
+ }]
102
+ end
103
+
104
+ private
105
+
106
+ def folder_name
107
+ self.class.name.demodulize.underscore
108
+ end
109
+ end
110
+ end
@@ -1,8 +1,8 @@
1
1
  module Passkit
2
2
  class Factory
3
3
  class << self
4
- def create_pass(generator)
5
- pass = Pass.create!(generator: generator)
4
+ def create_pass(generator, pass_class)
5
+ pass = Pass.create!(generator: generator, klass: pass_class)
6
6
  Passkit::Generator.new(pass).generate_and_sign
7
7
  end
8
8
  end
@@ -1,13 +1,16 @@
1
+ require "zip"
2
+
1
3
  module Passkit
2
4
  class Generator
3
- TMP_FOLDER = Rails.root.join('tmp/pass_kit').freeze
5
+ TMP_FOLDER = Rails.root.join("tmp/passkit").freeze
4
6
 
5
- def initialize(pass_name, pass)
7
+ def initialize(pass)
6
8
  @pass = pass
7
9
  @generator = pass.generator
8
10
  end
9
11
 
10
12
  def generate_and_sign
13
+ check_necessary_files
11
14
  create_temporary_directory
12
15
  copy_pass_to_tmp_location
13
16
  clean_ds_store_files
@@ -21,9 +24,13 @@ module Passkit
21
24
 
22
25
  private
23
26
 
27
+ def check_necessary_files
28
+ raise "icon.png is not present in #{@pass.pass_path}" unless File.exist?(File.join(@pass.pass_path, "icon.png"))
29
+ end
30
+
24
31
  def create_temporary_directory
25
32
  FileUtils.mkdir_p(TMP_FOLDER) unless File.directory?(TMP_FOLDER)
26
- @temporary_path = TMP_FOLDER.join(@pass.file_name)
33
+ @temporary_path = TMP_FOLDER.join(@pass.file_name.to_s)
27
34
 
28
35
  FileUtils.rm_rf(@temporary_path) if File.directory?(@temporary_path)
29
36
  end
@@ -33,10 +40,9 @@ module Passkit
33
40
  end
34
41
 
35
42
  def clean_ds_store_files
36
- Dir.glob(@temporary_path.join('**/.DS_Store')).each { |file| File.delete(file) }
43
+ Dir.glob(@temporary_path.join("**/.DS_Store")).each { |file| File.delete(file) }
37
44
  end
38
45
 
39
- # rubocop:disable Metrics/AbcSize
40
46
  def generate_json_pass
41
47
  pass = {
42
48
  formatVersion: @pass.format_version,
@@ -53,35 +59,37 @@ module Passkit
53
59
  }
54
60
 
55
61
  pass = pass.merge({
56
- serialNumber: @wallet_pass.serial_number,
57
- passTypeIdentifier: @wallet_pass.pass_type_identifier,
58
- authenticationToken: @wallet_pass.authentication_token
59
- })
62
+ serialNumber: @pass.serial_number,
63
+ passTypeIdentifier: @pass.pass_type_identifier,
64
+ authenticationToken: @pass.authentication_token
65
+ })
60
66
 
61
67
  pass[@pass.pass_type] = {
62
68
  headerFields: @pass.header_fields,
69
+ primaryFields: @pass.primary_fields,
70
+ secondaryFields: @pass.secondary_fields,
63
71
  auxiliaryFields: @pass.auxiliary_fields,
64
72
  backFields: @pass.back_fields
65
73
  }
66
74
 
67
- File.open(@temporary_path.join('pass.json'), 'w') { |f| f.write(pass.to_json) }
75
+ File.write(@temporary_path.join("pass.json"), pass.to_json)
68
76
  end
69
77
 
70
78
  # rubocop:enable Metrics/AbcSize
71
79
 
72
80
  def generate_json_manifest
73
81
  manifest = {}
74
- Dir.glob(@temporary_path.join('**')).each do |file|
82
+ Dir.glob(@temporary_path.join("**")).each do |file|
75
83
  manifest[File.basename(file)] = Digest::SHA1.hexdigest(File.read(file))
76
84
  end
77
85
 
78
- @manifest_url = @temporary_path.join('manifest.json')
79
- File.open(@manifest_url, 'w') { |f| f.write(manifest.to_json) }
86
+ @manifest_url = @temporary_path.join("manifest.json")
87
+ File.write(@manifest_url, manifest.to_json)
80
88
  end
81
89
 
82
- CERTIFICATE = Rails.root.join(ENV['PASSKIT_PRIVATE_P12_CERTIFICATE'])
83
- INTERMEDIATE_CERTIFICATE = Rails.root.join(ENV['APPLE_INTERMEDIATE_CERTIFICATE'])
84
- CERTIFICATE_PASSWORD = ENV['PASSKIT_CERTIFICATE_KEY']
90
+ CERTIFICATE = Rails.root.join(ENV["PASSKIT_PRIVATE_P12_CERTIFICATE"])
91
+ INTERMEDIATE_CERTIFICATE = Rails.root.join(ENV["PASSKIT_APPLE_INTERMEDIATE_CERTIFICATE"])
92
+ CERTIFICATE_PASSWORD = ENV["PASSKIT_CERTIFICATE_KEY"]
85
93
 
86
94
  # :nocov:
87
95
  def sign_manifest
@@ -90,21 +98,21 @@ module Passkit
90
98
 
91
99
  flag = OpenSSL::PKCS7::DETACHED | OpenSSL::PKCS7::BINARY
92
100
  signed = OpenSSL::PKCS7.sign(p12_certificate.certificate,
93
- p12_certificate.key, File.read(@manifest_url),
94
- [intermediate_certificate], flag)
101
+ p12_certificate.key, File.read(@manifest_url),
102
+ [intermediate_certificate], flag)
95
103
 
96
- @signature_url = @temporary_path.join('signature')
97
- File.open(@signature_url, 'w') { |f| f.syswrite signed.to_der }
104
+ @signature_url = @temporary_path.join("signature")
105
+ File.open(@signature_url, "w") { |f| f.syswrite signed.to_der }
98
106
  end
99
107
 
100
108
  # :nocov:
101
109
 
102
110
  def compress_pass_file
103
111
  zip_path = TMP_FOLDER.join("#{@pass.file_name}.pkpass")
104
- zipped_file = File.open(zip_path, 'w')
112
+ zipped_file = File.open(zip_path, "w")
105
113
 
106
114
  Zip::OutputStream.open(zipped_file.path) do |z|
107
- Dir.glob(@temporary_path.join('**')).each do |file|
115
+ Dir.glob(@temporary_path.join("**")).each do |file|
108
116
  z.put_next_entry(File.basename(file))
109
117
  z.print File.read(file)
110
118
  end
@@ -7,13 +7,13 @@ module Passkit
7
7
  cipher.key = encryption_key
8
8
  s = cipher.update(string) + cipher.final
9
9
 
10
- s.unpack1('H*').upcase
10
+ s.unpack1("H*").upcase
11
11
  end
12
12
 
13
13
  def decrypt(string)
14
14
  cipher = cypher.decrypt
15
15
  cipher.key = encryption_key
16
- s = [string].pack('H*').unpack('C*').pack('c*')
16
+ s = [string].pack("H*").unpack("C*").pack("c*")
17
17
 
18
18
  JSON.parse(cipher.update(s) + cipher.final, symbolize_names: true)
19
19
  end
@@ -25,7 +25,7 @@ module Passkit
25
25
  end
26
26
 
27
27
  def cypher
28
- OpenSSL::Cipher.new('AES-128-CBC')
28
+ OpenSSL::Cipher.new("AES-128-CBC")
29
29
  end
30
30
  end
31
31
  end
@@ -0,0 +1,27 @@
1
+ module Passkit
2
+ class UrlGenerator
3
+ include Passkit::Engine.routes.url_helpers
4
+
5
+ VALIDITY = 30.days
6
+
7
+ def initialize(pass_class, generator = nil)
8
+ valid_until = VALIDITY.from_now
9
+
10
+ payload = {valid_until: valid_until,
11
+ generator_class: generator&.class&.name,
12
+ generator_id: generator&.id,
13
+ pass_class: pass_class.name}
14
+ @url = passes_api_url(host: ENV["PASSKIT_WEB_SERVICE_HOST"], payload: UrlEncrypt.encrypt(payload))
15
+ end
16
+
17
+ def ios
18
+ @url
19
+ end
20
+
21
+ WALLET_PASS_PREFIX = "https://walletpass.io?u=".freeze
22
+ # @see https://walletpasses.io/developer/
23
+ def android
24
+ "#{WALLET_PASS_PREFIX}#{@url}"
25
+ end
26
+ end
27
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Passkit
4
- VERSION = "0.1.0"
4
+ VERSION = "0.2.0"
5
5
  end
data/lib/passkit.rb CHANGED
@@ -1,9 +1,42 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "passkit/version"
4
- require 'passkit/engine'
3
+ require "passkit/engine"
4
+
5
+ require "zeitwerk"
6
+ loader = Zeitwerk::Loader.for_gem
7
+ loader.ignore("#{__dir__}/generators")
8
+ loader.setup
5
9
 
6
10
  module Passkit
7
11
  class Error < StandardError; end
8
- # Your code goes here...
12
+
13
+ class << self
14
+ attr_accessor :configuration
15
+ end
16
+
17
+ def self.configure
18
+ self.configuration ||= Configuration.new
19
+ yield(configuration)
20
+ end
21
+
22
+ class Configuration
23
+ attr_accessor :available_passes,
24
+ :web_service_host,
25
+ :certificate_key,
26
+ :private_p12_certificate,
27
+ :apple_intermediate_certificate,
28
+ :apple_team_identifier,
29
+ :pass_type_identifier
30
+
31
+ def initialize
32
+ @available_passes = {"Passkit::ExampleStoreCard" => -> {}}
33
+ @web_service_host = ENV["PASSKIT_WEB_SERVICE_HOST"] || (raise "Please set PASSKIT_WEB_SERVICE_HOST")
34
+ raise("PASSKIT_WEB_SERVICE_HOST must start with https://") unless @web_service_host.starts_with?("https://")
35
+ @certificate_key = ENV["PASSKIT_CERTIFICATE_KEY"] || (raise "Please set PASSKIT_CERTIFICATE_KEY")
36
+ @private_p12_certificate = ENV["PASSKIT_PRIVATE_P12_CERTIFICATE"] || (raise "Please set PASSKIT_PRIVATE_P12_CERTIFICATE")
37
+ @apple_intermediate_certificate = ENV["PASSKIT_APPLE_INTERMEDIATE_CERTIFICATE"] || (raise "Please set PASSKIT_APPLE_INTERMEDIATE_CERTIFICATE")
38
+ @apple_team_identifier = ENV["PASSKIT_APPLE_TEAM_IDENTIFIER"] || (raise "Please set PASSKIT_APPLE_TEAM_IDENTIFIER")
39
+ @pass_type_identifier = ENV["PASSKIT_PASS_TYPE_IDENTIFIER"] || (raise "Please set PASSKIT_PASS_TYPE_IDENTIFIER")
40
+ end
41
+ end
9
42
  end
@@ -0,0 +1,5 @@
1
+ module Passkit
2
+ class EncryptablePayload
3
+ def to_json: () -> Hash[Symbol, String]
4
+ end
5
+ end
@@ -0,0 +1,5 @@
1
+ module Passkit
2
+ class Factory
3
+ def self.create_pass: (Passkit::GeneratorObject generator, String pass_class) -> String
4
+ end
5
+ end
@@ -0,0 +1,8 @@
1
+ module Passkit
2
+ class Generator
3
+ TMP_FOLDER: String
4
+
5
+ def initialize: (Passkit::Pass pass) -> void
6
+ def generate_and_sign: () -> String
7
+ end
8
+ end
@@ -0,0 +1,5 @@
1
+ module Passkit
2
+ class GeneratorObject
3
+ def id: () -> String
4
+ end
5
+ end
@@ -0,0 +1,5 @@
1
+ module Passkit
2
+ class Pass
3
+ def instance: () -> GeneratorObject
4
+ end
5
+ end
@@ -0,0 +1,6 @@
1
+ module Passkit
2
+ class UrlEncrypt
3
+ def self.encrypt: (Hash[untyped, untyped] payload) -> String
4
+ def self.decrypt: (String string) -> Hash[Symbol, untyped]
5
+ end
6
+ end
@@ -0,0 +1,9 @@
1
+ module Passkit
2
+ class UrlGenerator
3
+ def initialize: (Class passClass, GeneratorObject ?generator) -> void
4
+ def ios: () -> String
5
+
6
+ # @see https://walletpasses.io/developer/
7
+ def android: () -> String
8
+ end
9
+ end