linkio 1.0.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 +7 -0
- data/CHANGELOG.md +47 -0
- data/LICENSE +21 -0
- data/README.md +295 -0
- data/app/controllers/linkio/application_controller.rb +50 -0
- data/app/controllers/linkio/deep_links_controller.rb +11 -0
- data/app/controllers/linkio/pending_links_controller.rb +33 -0
- data/app/controllers/linkio/referrals_controller.rb +23 -0
- data/app/controllers/linkio/well_known_controller.rb +16 -0
- data/config/routes.rb +18 -0
- data/lib/generators/linkio/install/install_generator.rb +29 -0
- data/lib/generators/linkio/install/templates/linkio.rb +63 -0
- data/lib/linkio/app_target.rb +79 -0
- data/lib/linkio/client.rb +213 -0
- data/lib/linkio/configuration.rb +152 -0
- data/lib/linkio/deep_link_data.rb +30 -0
- data/lib/linkio/engine.rb +24 -0
- data/lib/linkio/errors.rb +12 -0
- data/lib/linkio/pending_link_data.rb +57 -0
- data/lib/linkio/platform.rb +31 -0
- data/lib/linkio/rack/middleware.rb +105 -0
- data/lib/linkio/referral_data.rb +48 -0
- data/lib/linkio/request.rb +76 -0
- data/lib/linkio/result.rb +74 -0
- data/lib/linkio/smart_redirect.rb +135 -0
- data/lib/linkio/storage/base.rb +69 -0
- data/lib/linkio/storage/in_memory_storage.rb +79 -0
- data/lib/linkio/storage/redis_storage.rb +110 -0
- data/lib/linkio/utils.rb +125 -0
- data/lib/linkio/version.rb +5 -0
- data/lib/linkio.rb +71 -0
- metadata +136 -0
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LinkIO
|
|
4
|
+
# A single app target that LinkIO can route to. A configuration may hold many
|
|
5
|
+
# of them, keyed by a routing value (e.g. a "role" such as "user" or
|
|
6
|
+
# "vendor"). Each target carries its own iOS/Android identifiers, schemes,
|
|
7
|
+
# store URLs, and an optional per-role +fallback_url+.
|
|
8
|
+
class AppTarget
|
|
9
|
+
# Fields required for a target to be usable.
|
|
10
|
+
REQUIRED = %i[ios_app_id ios_team_id ios_bundle_id android_package_name].freeze
|
|
11
|
+
|
|
12
|
+
attr_accessor :key,
|
|
13
|
+
:ios_app_id,
|
|
14
|
+
:ios_team_id,
|
|
15
|
+
:ios_bundle_id,
|
|
16
|
+
:ios_app_scheme,
|
|
17
|
+
:android_package_name,
|
|
18
|
+
:android_app_scheme,
|
|
19
|
+
:fallback_url
|
|
20
|
+
attr_reader :android_sha256_fingerprints
|
|
21
|
+
|
|
22
|
+
# @param key [String, Symbol] the routing key (role) for this target
|
|
23
|
+
# @param attributes [Hash] optional attributes to assign
|
|
24
|
+
def initialize(key, attributes = {})
|
|
25
|
+
@key = key.to_s
|
|
26
|
+
@android_sha256_fingerprints = []
|
|
27
|
+
attributes.each do |name, value|
|
|
28
|
+
setter = "#{name}="
|
|
29
|
+
public_send(setter, value) if respond_to?(setter)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def android_sha256_fingerprints=(value)
|
|
34
|
+
@android_sha256_fingerprints = Array(value)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# @return [String] the iOS App Store URL for this target
|
|
38
|
+
def ios_store_url
|
|
39
|
+
"https://apps.apple.com/app/id#{ios_app_id}"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# @return [String] the Google Play URL for this target
|
|
43
|
+
def android_store_url
|
|
44
|
+
"https://play.google.com/store/apps/details?id=#{android_package_name}"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# The store URL appropriate for the given platform.
|
|
48
|
+
#
|
|
49
|
+
# @param platform [String] a {LinkIO::Platform} value
|
|
50
|
+
# @return [String]
|
|
51
|
+
def store_url(platform)
|
|
52
|
+
platform == Platform::ANDROID ? android_store_url : ios_store_url
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# The custom URL scheme for the given platform, if any.
|
|
56
|
+
#
|
|
57
|
+
# @param platform [String]
|
|
58
|
+
# @return [String, nil]
|
|
59
|
+
def scheme_for(platform)
|
|
60
|
+
platform == Platform::ANDROID ? android_app_scheme : ios_app_scheme
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# @return [String] the AASA appID, "<teamId>.<bundleId>"
|
|
64
|
+
def apple_app_id
|
|
65
|
+
"#{ios_team_id}.#{ios_bundle_id}"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# @return [Array<Symbol>] required fields that are missing
|
|
69
|
+
def missing_fields
|
|
70
|
+
REQUIRED.select { |field| blank?(public_send(field)) }
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
def blank?(value)
|
|
76
|
+
value.nil? || (value.respond_to?(:empty?) && value.empty?)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LinkIO
|
|
4
|
+
# The core deep linking engine. Framework-agnostic: it consumes a
|
|
5
|
+
# {LinkIO::Request} and returns a {LinkIO::Result}, and exposes plain methods
|
|
6
|
+
# for pending links and referrals. This is the Ruby port of the Node.js
|
|
7
|
+
# `LinkIO` class.
|
|
8
|
+
class Client
|
|
9
|
+
# 7 days, in milliseconds.
|
|
10
|
+
PENDING_TTL_MS = 7 * 24 * 60 * 60 * 1000
|
|
11
|
+
|
|
12
|
+
# @return [LinkIO::Configuration]
|
|
13
|
+
attr_reader :config
|
|
14
|
+
|
|
15
|
+
# @param config [LinkIO::Configuration, Hash] a configuration or attributes hash
|
|
16
|
+
def initialize(config = nil, **attributes)
|
|
17
|
+
@config =
|
|
18
|
+
if config.is_a?(Configuration)
|
|
19
|
+
config
|
|
20
|
+
elsif config.is_a?(Hash)
|
|
21
|
+
Configuration.new(config)
|
|
22
|
+
else
|
|
23
|
+
Configuration.new(attributes)
|
|
24
|
+
end
|
|
25
|
+
@config.validate!
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Handle an incoming deep link request. Selects the app target for the
|
|
29
|
+
# request's role (see {Configuration#role_param}), stores a pending link (by
|
|
30
|
+
# IP fingerprint, and by device id when present) for deferred deep linking,
|
|
31
|
+
# then returns the appropriate response for the detected platform.
|
|
32
|
+
#
|
|
33
|
+
# On mobile, it renders a smart-redirect page that opens the role's app and
|
|
34
|
+
# falls back to that role's store (or the role's +fallback_url+ if set). On
|
|
35
|
+
# web it redirects to the role's +fallback_url+ when configured, otherwise
|
|
36
|
+
# returns a JSON message.
|
|
37
|
+
#
|
|
38
|
+
# @param request [LinkIO::Request]
|
|
39
|
+
# @return [LinkIO::Result]
|
|
40
|
+
def handle_deep_link(request)
|
|
41
|
+
platform = Platform.detect(request.user_agent)
|
|
42
|
+
params = request.params
|
|
43
|
+
app = config.app_for(params[config.role_param])
|
|
44
|
+
|
|
45
|
+
persist_pending_link(request, params)
|
|
46
|
+
|
|
47
|
+
case platform
|
|
48
|
+
when Platform::IOS, Platform::ANDROID
|
|
49
|
+
platform_result(app: app, params: params, platform: platform)
|
|
50
|
+
else
|
|
51
|
+
web_result(app, params)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Retrieve (and consume) a pending link by device id.
|
|
56
|
+
#
|
|
57
|
+
# @param device_id [String]
|
|
58
|
+
# @return [LinkIO::DeepLinkData, nil]
|
|
59
|
+
def pending_link(device_id)
|
|
60
|
+
data = config.storage.get_pending_link(device_id)
|
|
61
|
+
return nil if data.nil?
|
|
62
|
+
|
|
63
|
+
config.storage.delete_pending_link(device_id)
|
|
64
|
+
DeepLinkData.new(url: data.url, params: data.params, deferred: true)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Retrieve (and consume) a pending link by client IP fingerprint. Used for
|
|
68
|
+
# deferred deep linking after app install. Uses IP-only matching so it works
|
|
69
|
+
# even when the browser and app send different User-Agents.
|
|
70
|
+
#
|
|
71
|
+
# @param ip [String]
|
|
72
|
+
# @param _user_agent [String, nil] accepted for API parity; unused
|
|
73
|
+
# @return [LinkIO::DeepLinkData, nil]
|
|
74
|
+
def pending_link_by_fingerprint(ip, _user_agent = nil)
|
|
75
|
+
fingerprint = Utils.generate_ip_fingerprint(ip)
|
|
76
|
+
data = config.storage.get_pending_link_by_fingerprint(fingerprint)
|
|
77
|
+
return nil if data.nil?
|
|
78
|
+
|
|
79
|
+
config.storage.delete_pending_link_by_fingerprint(fingerprint)
|
|
80
|
+
DeepLinkData.new(url: data.url, params: data.params, deferred: true)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Record a referral. The referral code doubles as the referrer id, matching
|
|
84
|
+
# the Node.js backend.
|
|
85
|
+
#
|
|
86
|
+
# @param referral_code [String]
|
|
87
|
+
# @param referee_id [String]
|
|
88
|
+
# @param metadata [Hash, nil]
|
|
89
|
+
# @return [void]
|
|
90
|
+
def track_referral(referral_code, referee_id, metadata = nil)
|
|
91
|
+
referral = ReferralData.new(
|
|
92
|
+
referrer_id: referral_code,
|
|
93
|
+
referee_id: referee_id,
|
|
94
|
+
referral_code: referral_code,
|
|
95
|
+
timestamp: now_ms,
|
|
96
|
+
metadata: metadata
|
|
97
|
+
)
|
|
98
|
+
config.storage.save_referral(referral)
|
|
99
|
+
nil
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# @param referrer_id [String]
|
|
103
|
+
# @return [Array<LinkIO::ReferralData>]
|
|
104
|
+
def referrals(referrer_id)
|
|
105
|
+
config.storage.get_referrals_by_referrer(referrer_id)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# @param user_id [String]
|
|
109
|
+
# @return [LinkIO::ReferralData, nil]
|
|
110
|
+
def referral_for_user(user_id)
|
|
111
|
+
config.storage.get_referral_by_referee(user_id)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# @return [Hash] the apple-app-site-association document (one detail entry
|
|
115
|
+
# per configured app target)
|
|
116
|
+
def apple_app_site_association
|
|
117
|
+
details = config.apps.values.map do |app|
|
|
118
|
+
{ "appID" => app.apple_app_id, "paths" => ["*"] }
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
{ "applinks" => { "apps" => [], "details" => details } }
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# @return [Array<Hash>] the assetlinks.json document (one entry per
|
|
125
|
+
# app-target/fingerprint pair)
|
|
126
|
+
def asset_links
|
|
127
|
+
config.apps.values.flat_map do |app|
|
|
128
|
+
app.android_sha256_fingerprints.map do |fingerprint|
|
|
129
|
+
{
|
|
130
|
+
"relation" => ["delegate_permission/common.handle_all_urls"],
|
|
131
|
+
"target" => {
|
|
132
|
+
"namespace" => "android_app",
|
|
133
|
+
"package_name" => app.android_package_name,
|
|
134
|
+
"sha256_cert_fingerprints" => [fingerprint]
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Serve an /.well-known/* verification file.
|
|
142
|
+
#
|
|
143
|
+
# @param path [String] the request path
|
|
144
|
+
# @return [LinkIO::Result]
|
|
145
|
+
def well_known(path)
|
|
146
|
+
case path
|
|
147
|
+
when "/.well-known/apple-app-site-association"
|
|
148
|
+
Result.json(apple_app_site_association)
|
|
149
|
+
when "/.well-known/assetlinks.json"
|
|
150
|
+
Result.json(asset_links)
|
|
151
|
+
else
|
|
152
|
+
Result.new(type: :html, status: 404, body: "Not Found", headers: { "content-type" => "text/plain" })
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
private
|
|
157
|
+
|
|
158
|
+
def persist_pending_link(request, params)
|
|
159
|
+
pending = PendingLinkData.new(
|
|
160
|
+
url: request.full_url,
|
|
161
|
+
params: params,
|
|
162
|
+
created_at: now_ms,
|
|
163
|
+
expires_at: now_ms + PENDING_TTL_MS
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
ip_fingerprint = Utils.generate_ip_fingerprint(request.client_ip)
|
|
167
|
+
config.storage.save_pending_link_by_fingerprint(ip_fingerprint, pending)
|
|
168
|
+
config.storage.save_pending_link(request.device_id, pending) if present?(request.device_id)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def platform_result(app:, params:, platform:)
|
|
172
|
+
scheme = app.scheme_for(platform)
|
|
173
|
+
fallback = fallback_target(app, platform, params)
|
|
174
|
+
return Result.redirect(fallback) if scheme.nil? || scheme.to_s.empty?
|
|
175
|
+
|
|
176
|
+
html = SmartRedirect.page(
|
|
177
|
+
app_scheme: scheme,
|
|
178
|
+
params: params,
|
|
179
|
+
store_url: fallback,
|
|
180
|
+
platform: platform,
|
|
181
|
+
package_name: app.android_package_name,
|
|
182
|
+
timeout: config.fallback_timeout
|
|
183
|
+
)
|
|
184
|
+
Result.html(html)
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# For web/desktop visitors: redirect to the role's fallback_url when set,
|
|
188
|
+
# otherwise return the Node-compatible JSON message.
|
|
189
|
+
def web_result(app, params)
|
|
190
|
+
if present?(app.fallback_url)
|
|
191
|
+
Result.redirect(Utils.interpolate(app.fallback_url, params))
|
|
192
|
+
else
|
|
193
|
+
Result.json({ "message" => "Please open this link on your mobile device", "platform" => "web" })
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# The destination used when the app is not installed: the role's custom
|
|
198
|
+
# fallback_url (interpolated) if set, otherwise the role's store URL.
|
|
199
|
+
def fallback_target(app, platform, params)
|
|
200
|
+
return Utils.interpolate(app.fallback_url, params) if present?(app.fallback_url)
|
|
201
|
+
|
|
202
|
+
app.store_url(platform)
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def now_ms
|
|
206
|
+
(Time.now.to_f * 1000).to_i
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def present?(value)
|
|
210
|
+
!value.nil? && !value.to_s.empty?
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
end
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LinkIO
|
|
4
|
+
# Configuration for a {LinkIO::Client}.
|
|
5
|
+
#
|
|
6
|
+
# Two ways to configure app targets:
|
|
7
|
+
#
|
|
8
|
+
# 1. Single app (flat, Node.js-compatible) — set the iOS/Android attributes
|
|
9
|
+
# directly and they apply to one default target:
|
|
10
|
+
#
|
|
11
|
+
# config.ios_app_id = "123"
|
|
12
|
+
# config.android_package_name = "com.example.app"
|
|
13
|
+
#
|
|
14
|
+
# 2. Multiple apps keyed by a routing value (e.g. a "role") — register a
|
|
15
|
+
# target per role. The deep link's +role_param+ selects which one to open:
|
|
16
|
+
#
|
|
17
|
+
# config.role_param = "role" # query/path param used to route (default)
|
|
18
|
+
# config.default_role = "user" # used when the param is missing/unknown
|
|
19
|
+
#
|
|
20
|
+
# config.app "user" do |app|
|
|
21
|
+
# app.ios_app_id = "111"
|
|
22
|
+
# # ...
|
|
23
|
+
# end
|
|
24
|
+
# config.app "vendor" do |app|
|
|
25
|
+
# app.ios_app_id = "222"
|
|
26
|
+
# # ...
|
|
27
|
+
# end
|
|
28
|
+
class Configuration
|
|
29
|
+
# ms to wait in the smart-redirect page before falling back.
|
|
30
|
+
DEFAULT_FALLBACK_TIMEOUT = 2500
|
|
31
|
+
# Key of the implicit target used by the flat single-app API.
|
|
32
|
+
DEFAULT_APP_KEY = "default"
|
|
33
|
+
# Default name of the query/path param used to select an app target.
|
|
34
|
+
DEFAULT_ROLE_PARAM = "role"
|
|
35
|
+
|
|
36
|
+
# Attributes proxied to the implicit default {AppTarget} for single-app use.
|
|
37
|
+
FLAT_ATTRIBUTES = %i[
|
|
38
|
+
ios_app_id ios_team_id ios_bundle_id ios_app_scheme
|
|
39
|
+
android_package_name android_app_scheme android_sha256_fingerprints
|
|
40
|
+
fallback_url
|
|
41
|
+
].freeze
|
|
42
|
+
|
|
43
|
+
attr_accessor :domain, :default_deep_link_path, :storage, :default_role
|
|
44
|
+
attr_writer :fallback_timeout, :role_param
|
|
45
|
+
attr_reader :apps
|
|
46
|
+
|
|
47
|
+
# @param attributes [Hash] top-level attributes to assign
|
|
48
|
+
def initialize(attributes = {})
|
|
49
|
+
@apps = {}
|
|
50
|
+
@fallback_timeout = DEFAULT_FALLBACK_TIMEOUT
|
|
51
|
+
@role_param = DEFAULT_ROLE_PARAM
|
|
52
|
+
@default_role = nil
|
|
53
|
+
attributes.each do |name, value|
|
|
54
|
+
setter = "#{name}="
|
|
55
|
+
public_send(setter, value) if respond_to?(setter)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Proxy the flat single-app attributes to the implicit default target.
|
|
60
|
+
FLAT_ATTRIBUTES.each do |name|
|
|
61
|
+
define_method(name) { default_app.public_send(name) }
|
|
62
|
+
define_method("#{name}=") { |value| default_app.public_send("#{name}=", value) }
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# @return [String] the routing param name (never blank)
|
|
66
|
+
def role_param
|
|
67
|
+
value = @role_param.to_s
|
|
68
|
+
value.empty? ? DEFAULT_ROLE_PARAM : value
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# @return [Integer]
|
|
72
|
+
def fallback_timeout
|
|
73
|
+
@fallback_timeout || DEFAULT_FALLBACK_TIMEOUT
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Register or update an app target for a routing key (role).
|
|
77
|
+
#
|
|
78
|
+
# config.app("vendor") { |app| app.ios_app_id = "222" }
|
|
79
|
+
# config.app(:user, ios_app_id: "111")
|
|
80
|
+
#
|
|
81
|
+
# @param key [String, Symbol]
|
|
82
|
+
# @param attributes [Hash]
|
|
83
|
+
# @yieldparam target [AppTarget]
|
|
84
|
+
# @return [AppTarget]
|
|
85
|
+
def app(key, attributes = {})
|
|
86
|
+
target = (@apps[key.to_s] ||= AppTarget.new(key))
|
|
87
|
+
attributes.each do |name, value|
|
|
88
|
+
setter = "#{name}="
|
|
89
|
+
target.public_send(setter, value) if target.respond_to?(setter)
|
|
90
|
+
end
|
|
91
|
+
yield target if block_given?
|
|
92
|
+
target
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# The implicit target backing the flat single-app API.
|
|
96
|
+
#
|
|
97
|
+
# @return [AppTarget]
|
|
98
|
+
def default_app
|
|
99
|
+
@apps[DEFAULT_APP_KEY] ||= AppTarget.new(DEFAULT_APP_KEY)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Resolve the app target for a routing value, falling back to the configured
|
|
103
|
+
# default role, then the implicit default target, then the first registered
|
|
104
|
+
# target.
|
|
105
|
+
#
|
|
106
|
+
# @param role [String, nil]
|
|
107
|
+
# @return [AppTarget, nil]
|
|
108
|
+
def app_for(role)
|
|
109
|
+
key = role.to_s
|
|
110
|
+
return @apps[key] if !key.empty? && @apps.key?(key)
|
|
111
|
+
|
|
112
|
+
default_target
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# @raise [ConfigurationError] if the configuration is incomplete
|
|
116
|
+
# @return [self]
|
|
117
|
+
def validate!
|
|
118
|
+
raise ConfigurationError, "LinkIO configuration requires a domain" if blank?(domain)
|
|
119
|
+
raise ConfigurationError, "LinkIO configuration requires storage" if storage.nil?
|
|
120
|
+
|
|
121
|
+
unless storage.respond_to?(:save_pending_link)
|
|
122
|
+
raise ConfigurationError, "storage must implement the LinkIO::Storage::Base interface"
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
raise ConfigurationError, "at least one app target must be configured" if apps.empty?
|
|
126
|
+
|
|
127
|
+
apps.each_value { |target| validate_target!(target) }
|
|
128
|
+
self
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
private
|
|
132
|
+
|
|
133
|
+
def default_target
|
|
134
|
+
key = default_role.to_s
|
|
135
|
+
return @apps[key] if !key.empty? && @apps.key?(key)
|
|
136
|
+
|
|
137
|
+
@apps[DEFAULT_APP_KEY] || @apps.values.first
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def validate_target!(target)
|
|
141
|
+
missing = target.missing_fields
|
|
142
|
+
return if missing.empty?
|
|
143
|
+
|
|
144
|
+
raise ConfigurationError,
|
|
145
|
+
"app target '#{target.key}' is missing required values: #{missing.join(", ")}"
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def blank?(value)
|
|
149
|
+
value.nil? || (value.respond_to?(:empty?) && value.empty?)
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LinkIO
|
|
4
|
+
# The resolved deep link returned to a client (e.g. an SDK) when it retrieves
|
|
5
|
+
# a pending link. `deferred` indicates the link was stored before the app was
|
|
6
|
+
# installed/opened.
|
|
7
|
+
class DeepLinkData
|
|
8
|
+
attr_reader :url, :params, :deferred
|
|
9
|
+
|
|
10
|
+
# @param url [String]
|
|
11
|
+
# @param params [Hash]
|
|
12
|
+
# @param deferred [Boolean]
|
|
13
|
+
def initialize(url:, params:, deferred: true)
|
|
14
|
+
@url = url
|
|
15
|
+
@params = params || {}
|
|
16
|
+
@deferred = deferred
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
alias deferred? deferred
|
|
20
|
+
|
|
21
|
+
# @return [Hash]
|
|
22
|
+
def to_h
|
|
23
|
+
{
|
|
24
|
+
"url" => url,
|
|
25
|
+
"params" => params,
|
|
26
|
+
"isDeferred" => deferred
|
|
27
|
+
}
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/engine"
|
|
4
|
+
|
|
5
|
+
module LinkIO
|
|
6
|
+
# Mountable Rails engine that wires the LinkIO endpoints into a Rails app.
|
|
7
|
+
# Mount it at the domain root so the /.well-known verification files are
|
|
8
|
+
# served correctly:
|
|
9
|
+
#
|
|
10
|
+
# # config/routes.rb
|
|
11
|
+
# mount LinkIO::Engine => "/"
|
|
12
|
+
#
|
|
13
|
+
# Configure the global client in an initializer (see the install generator):
|
|
14
|
+
#
|
|
15
|
+
# # config/initializers/linkio.rb
|
|
16
|
+
# LinkIO.configure do |config|
|
|
17
|
+
# config.domain = "example.com"
|
|
18
|
+
# # ...
|
|
19
|
+
# config.storage = LinkIO::Storage::InMemoryStorage.new
|
|
20
|
+
# end
|
|
21
|
+
class Engine < ::Rails::Engine
|
|
22
|
+
isolate_namespace LinkIO
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LinkIO
|
|
4
|
+
# Base error class for all LinkIO errors.
|
|
5
|
+
class Error < StandardError; end
|
|
6
|
+
|
|
7
|
+
# Raised when the configuration is missing required values or is invalid.
|
|
8
|
+
class ConfigurationError < Error; end
|
|
9
|
+
|
|
10
|
+
# Raised when a storage adapter is asked to do something it cannot.
|
|
11
|
+
class NotImplementedError < Error; end
|
|
12
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LinkIO
|
|
4
|
+
# A deep link stored for later retrieval (deferred deep linking).
|
|
5
|
+
# Timestamps are epoch milliseconds, matching the Node.js backend so that
|
|
6
|
+
# data is interchangeable between the two implementations.
|
|
7
|
+
class PendingLinkData
|
|
8
|
+
attr_reader :url, :params, :created_at, :expires_at
|
|
9
|
+
|
|
10
|
+
# @param url [String] the full URL that produced this pending link
|
|
11
|
+
# @param params [Hash] parsed query/path parameters
|
|
12
|
+
# @param created_at [Integer] epoch milliseconds
|
|
13
|
+
# @param expires_at [Integer] epoch milliseconds
|
|
14
|
+
def initialize(url:, params:, created_at:, expires_at:)
|
|
15
|
+
@url = url
|
|
16
|
+
@params = params || {}
|
|
17
|
+
@created_at = created_at
|
|
18
|
+
@expires_at = expires_at
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# @param now_ms [Integer] current time in epoch milliseconds
|
|
22
|
+
# @return [Boolean]
|
|
23
|
+
def expired?(now_ms = (Time.now.to_f * 1000).to_i)
|
|
24
|
+
now_ms > expires_at
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# @return [Hash]
|
|
28
|
+
def to_h
|
|
29
|
+
{
|
|
30
|
+
"url" => url,
|
|
31
|
+
"params" => params,
|
|
32
|
+
"createdAt" => created_at,
|
|
33
|
+
"expiresAt" => expires_at
|
|
34
|
+
}
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Build from a hash using either camelCase (JSON/Node-compatible) or
|
|
38
|
+
# snake_case keys.
|
|
39
|
+
#
|
|
40
|
+
# @param hash [Hash]
|
|
41
|
+
# @return [PendingLinkData]
|
|
42
|
+
def self.from_h(hash)
|
|
43
|
+
h = stringify(hash)
|
|
44
|
+
new(
|
|
45
|
+
url: h["url"],
|
|
46
|
+
params: h["params"] || {},
|
|
47
|
+
created_at: h["createdAt"] || h["created_at"],
|
|
48
|
+
expires_at: h["expiresAt"] || h["expires_at"]
|
|
49
|
+
)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def self.stringify(hash)
|
|
53
|
+
hash.each_with_object({}) { |(k, v), acc| acc[k.to_s] = v }
|
|
54
|
+
end
|
|
55
|
+
private_class_method :stringify
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LinkIO
|
|
4
|
+
# Platform detection for incoming requests. Mirrors the `Platform` enum from
|
|
5
|
+
# the Node.js LinkIO backend.
|
|
6
|
+
module Platform
|
|
7
|
+
IOS = "ios"
|
|
8
|
+
ANDROID = "android"
|
|
9
|
+
WEB = "web"
|
|
10
|
+
UNKNOWN = "unknown"
|
|
11
|
+
|
|
12
|
+
ALL = [IOS, ANDROID, WEB, UNKNOWN].freeze
|
|
13
|
+
|
|
14
|
+
IOS_PATTERN = /iphone|ipad|ipod|ios/i
|
|
15
|
+
ANDROID_PATTERN = /android/i
|
|
16
|
+
|
|
17
|
+
module_function
|
|
18
|
+
|
|
19
|
+
# Detect the platform from a User-Agent string.
|
|
20
|
+
#
|
|
21
|
+
# @param user_agent [String, nil]
|
|
22
|
+
# @return [String] one of {IOS}, {ANDROID} or {WEB}
|
|
23
|
+
def detect(user_agent)
|
|
24
|
+
ua = user_agent.to_s
|
|
25
|
+
return IOS if ua.match?(IOS_PATTERN)
|
|
26
|
+
return ANDROID if ua.match?(ANDROID_PATTERN)
|
|
27
|
+
|
|
28
|
+
WEB
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|