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.
@@ -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