@dvai-bridge/ios 4.0.0 → 4.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 (41) hide show
  1. package/Package.swift +104 -104
  2. package/ios/Sources/DVAIBridge/BackendKind.swift +23 -23
  3. package/ios/Sources/DVAIBridge/BoundServer.swift +46 -46
  4. package/ios/Sources/DVAIBridge/DVAIBridge.swift +658 -658
  5. package/ios/Sources/DVAIBridge/DVAIBridgeConfig.swift +86 -86
  6. package/ios/Sources/DVAIBridge/DVAIBridgeError.swift +33 -33
  7. package/ios/Sources/DVAIBridge/Internal/BackendSelector.swift +59 -59
  8. package/ios/Sources/DVAIBridge/Internal/ProgressBroadcaster.swift +84 -84
  9. package/ios/Sources/DVAIBridge/License/Audience.swift +133 -133
  10. package/ios/Sources/DVAIBridge/License/Discovery.swift +164 -164
  11. package/ios/Sources/DVAIBridge/License/LicenseValidator.swift +392 -392
  12. package/ios/Sources/DVAIBridge/License/PublicKeys.swift +114 -114
  13. package/ios/Sources/DVAIBridge/License/Types.swift +195 -195
  14. package/ios/Sources/DVAIBridge/Offload/OffloadConfig.swift +118 -118
  15. package/ios/Sources/DVAIBridge/ProgressEvent.swift +34 -34
  16. package/ios/Sources/DVAICoreMLCore/CoreMLBackendError.swift +19 -19
  17. package/ios/Sources/DVAICoreMLCore/CoreMLHandlers.swift +123 -123
  18. package/ios/Sources/DVAICoreMLCore/CoreMLPluginState.swift +130 -130
  19. package/ios/Sources/DVAICoreMLCore/Internal/CoreMLEngine.swift +137 -137
  20. package/ios/Sources/DVAICoreMLCore/Internal/CoreMLGenerator.swift +108 -108
  21. package/ios/Sources/DVAICoreMLCore/Internal/CoreMLSampler.swift +96 -96
  22. package/ios/Sources/DVAICoreMLCore/Internal/CoreMLTokenizer.swift +69 -69
  23. package/ios/Tests/DVAIBridgeTests/BackendSelectorTests.swift +53 -53
  24. package/ios/Tests/DVAIBridgeTests/CoreMLEngineTests.swift +18 -18
  25. package/ios/Tests/DVAIBridgeTests/CoreMLGeneratorShapeTests.swift +11 -11
  26. package/ios/Tests/DVAIBridgeTests/CoreMLHandlersTests.swift +32 -32
  27. package/ios/Tests/DVAIBridgeTests/CoreMLPluginStateTests.swift +41 -41
  28. package/ios/Tests/DVAIBridgeTests/CoreMLSamplerTests.swift +40 -40
  29. package/ios/Tests/DVAIBridgeTests/CoreMLTokenizerTests.swift +19 -19
  30. package/ios/Tests/DVAIBridgeTests/DVAIBridgeAPIShapeTests.swift +37 -37
  31. package/ios/Tests/DVAIBridgeTests/DVAIBridgeConfigTests.swift +52 -52
  32. package/ios/Tests/DVAIBridgeTests/DVAIBridgeErrorTests.swift +33 -33
  33. package/ios/Tests/DVAIBridgeTests/LicenseValidatorTests.swift +658 -658
  34. package/ios/Tests/DVAIBridgeTests/ProgressBroadcasterTests.swift +69 -69
  35. package/ios/Tests/DVAIBridgeTests/ProgressEventTests.swift +25 -25
  36. package/ios/Tests/DVAIBridgeTests/ReactiveStateTests.swift +45 -45
  37. package/ios/Tests/DVAIBridgeTests/RealModelIntegrationTest.swift +385 -359
  38. package/package.json +3 -4
  39. package/DVAIBridge.podspec +0 -120
  40. package/LICENSE +0 -51
  41. package/README.md +0 -199
@@ -1,133 +1,133 @@
1
- /*
2
- * Runtime audience + platform + dev-mode detection for the iOS SDK.
3
- *
4
- * Mirrors `packages/dvai-bridge-core/src/license/audience.ts`. The
5
- * semantics are identical; only the platform APIs differ.
6
- *
7
- * - audience = `Bundle.main.bundleIdentifier` (may be `nil` in some
8
- * unit-test hosts; null is treated like the JS side does
9
- * — only `"*"` aud entries match)
10
- * - platform = always `.ios` from this SDK (the Capacitor consumers
11
- * use the Capacitor SDK, which detects `.capacitor`
12
- * from JS-side; this iOS SDK is for native iOS apps)
13
- * - dev mode = DEBUG build OR simulator OR DVAI_FORCE_DEV=1
14
- * (DVAI_FORCE_PROD=1 overrides DEBUG / simulator)
15
- */
16
- import Foundation
17
-
18
- /// Detect the current SDK platform identifier. Always `.ios` on iOS —
19
- /// Capacitor consumers use a different SDK (the Capacitor plugin's
20
- /// JS-side validator detects `capacitor`). React Native consumers
21
- /// likewise use a different SDK.
22
- public func detectPlatform() -> DvaiPlatform {
23
- return .ios
24
- }
25
-
26
- /// Read an environment variable via C's `getenv` so we observe live
27
- /// updates (`setenv` in tests). `ProcessInfo.environment` caches its
28
- /// dict on Apple's Foundation, which makes per-test env mutation
29
- /// unreliable.
30
- internal func envVar(_ name: String) -> String? {
31
- guard let cstr = getenv(name) else { return nil }
32
- let value = String(cString: cstr)
33
- return value.isEmpty ? nil : value
34
- }
35
-
36
- /// Detect the current audience string the license must bind. On iOS
37
- /// this is the running app's bundle identifier (e.g. `"com.acme.app"`).
38
- ///
39
- /// Returns `nil` only when no bundle identifier is available — uncommon
40
- /// in production, but possible in some SwiftPM test hosts. Null is
41
- /// handled by the validator: it matches only the `"*"` aud entry.
42
- ///
43
- /// An explicit `DVAI_AUDIENCE` environment variable overrides the bundle
44
- /// id (matches the JS-side override; mostly useful for tests).
45
- public func detectAudience() -> String? {
46
- if let override = envVar("DVAI_AUDIENCE") {
47
- return override
48
- }
49
- if let bundleId = Bundle.main.bundleIdentifier, !bundleId.isEmpty {
50
- return bundleId
51
- }
52
- return nil
53
- }
54
-
55
- /// Detect whether the SDK is running in a developer environment where
56
- /// license enforcement should be bypassed. The bypass list is
57
- /// intentionally generous: blocking a developer mid-Xcode-run with a
58
- /// license-not-found error would be hostile.
59
- ///
60
- /// Order (first match wins):
61
- /// 1. `DVAI_FORCE_PROD=1` → force production (overrides DEBUG +
62
- /// simulator). The host app's CI sets this to exercise license
63
- /// enforcement in non-RELEASE builds.
64
- /// 2. `DVAI_FORCE_DEV=1` → force dev. Explicit operator opt-in.
65
- /// 3. `#if DEBUG` → dev. Compile-time check — the DEBUG configuration
66
- /// bypasses licensing entirely so the in-Xcode dev loop never
67
- /// surfaces a license error.
68
- /// 4. `targetEnvironment(simulator)` → dev. Same intent: a simulator
69
- /// run is by construction a dev environment.
70
- /// 5. otherwise → production, license required.
71
- public func detectDevMode() -> (isDev: Bool, reason: String) {
72
- // 1. Explicit env-var overrides (operator-level, runtime). Use
73
- // `envVar` (libc getenv) so DVAI_FORCE_* mutated mid-process —
74
- // e.g. by tests — is picked up.
75
- let forceProd = envVar("DVAI_FORCE_PROD")
76
- if forceProd == "1" || forceProd == "true" {
77
- return (false, "DVAI_FORCE_PROD set")
78
- }
79
- let forceDev = envVar("DVAI_FORCE_DEV")
80
- if forceDev == "1" || forceDev == "true" {
81
- return (true, "DVAI_FORCE_DEV set")
82
- }
83
-
84
- // 2. DEBUG configuration.
85
- #if DEBUG
86
- return (true, "DEBUG build configuration")
87
- #else
88
- // 3. Simulator (DEBUG would have already caught most simulator
89
- // runs; this branch matters for a Release-configuration build
90
- // happening to run on the simulator — rare, but harmless to
91
- // bypass).
92
- #if targetEnvironment(simulator)
93
- return (true, "iOS Simulator")
94
- #else
95
- return (false, "production-class environment")
96
- #endif
97
- #endif
98
- }
99
-
100
- /// Decide whether a license-payload `aud` entry matches the current
101
- /// runtime audience. Supports exact match and `*.example.com` wildcard
102
- /// matching for subdomain (here: bundle-id-suffix) binding. Returns
103
- /// the matched `aud` pattern on success so it can be recorded for
104
- /// audit, or `nil` on miss.
105
- ///
106
- /// Match rules (identical to the JS-side `matchAudience`):
107
- /// - `"foo"` matches `"foo"` exactly (case-insensitive)
108
- /// - `"*.example.com"` matches `"example.com"` AND any
109
- /// `"<sub>.example.com"`
110
- /// - `"*"` matches any non-nil audience (intentionally permissive;
111
- /// used for trial / site licenses that span all of a customer's
112
- /// deployments)
113
- ///
114
- /// A `nil` runtime audience matches only `"*"` entries — a build with
115
- /// no bundle identifier can activate any-domain licenses but not
116
- /// bundle-bound ones.
117
- public func matchAudience(runtimeAudience: String?, audClaim: [String]) -> String? {
118
- guard let runtime = runtimeAudience?.lowercased(), !runtime.isEmpty else {
119
- return audClaim.contains("*") ? "*" : nil
120
- }
121
- for pattern in audClaim {
122
- let p = pattern.lowercased()
123
- if p == "*" { return pattern } // permissive wildcard
124
- if p == runtime { return pattern } // exact match
125
- if p.hasPrefix("*.") {
126
- let suffix = String(p.dropFirst(2))
127
- if runtime == suffix || runtime.hasSuffix("." + suffix) {
128
- return pattern
129
- }
130
- }
131
- }
132
- return nil
133
- }
1
+ /*
2
+ * Runtime audience + platform + dev-mode detection for the iOS SDK.
3
+ *
4
+ * Mirrors `packages/dvai-bridge-core/src/license/audience.ts`. The
5
+ * semantics are identical; only the platform APIs differ.
6
+ *
7
+ * - audience = `Bundle.main.bundleIdentifier` (may be `nil` in some
8
+ * unit-test hosts; null is treated like the JS side does
9
+ * — only `"*"` aud entries match)
10
+ * - platform = always `.ios` from this SDK (the Capacitor consumers
11
+ * use the Capacitor SDK, which detects `.capacitor`
12
+ * from JS-side; this iOS SDK is for native iOS apps)
13
+ * - dev mode = DEBUG build OR simulator OR DVAI_FORCE_DEV=1
14
+ * (DVAI_FORCE_PROD=1 overrides DEBUG / simulator)
15
+ */
16
+ import Foundation
17
+
18
+ /// Detect the current SDK platform identifier. Always `.ios` on iOS —
19
+ /// Capacitor consumers use a different SDK (the Capacitor plugin's
20
+ /// JS-side validator detects `capacitor`). React Native consumers
21
+ /// likewise use a different SDK.
22
+ public func detectPlatform() -> DvaiPlatform {
23
+ return .ios
24
+ }
25
+
26
+ /// Read an environment variable via C's `getenv` so we observe live
27
+ /// updates (`setenv` in tests). `ProcessInfo.environment` caches its
28
+ /// dict on Apple's Foundation, which makes per-test env mutation
29
+ /// unreliable.
30
+ internal func envVar(_ name: String) -> String? {
31
+ guard let cstr = getenv(name) else { return nil }
32
+ let value = String(cString: cstr)
33
+ return value.isEmpty ? nil : value
34
+ }
35
+
36
+ /// Detect the current audience string the license must bind. On iOS
37
+ /// this is the running app's bundle identifier (e.g. `"com.acme.app"`).
38
+ ///
39
+ /// Returns `nil` only when no bundle identifier is available — uncommon
40
+ /// in production, but possible in some SwiftPM test hosts. Null is
41
+ /// handled by the validator: it matches only the `"*"` aud entry.
42
+ ///
43
+ /// An explicit `DVAI_AUDIENCE` environment variable overrides the bundle
44
+ /// id (matches the JS-side override; mostly useful for tests).
45
+ public func detectAudience() -> String? {
46
+ if let override = envVar("DVAI_AUDIENCE") {
47
+ return override
48
+ }
49
+ if let bundleId = Bundle.main.bundleIdentifier, !bundleId.isEmpty {
50
+ return bundleId
51
+ }
52
+ return nil
53
+ }
54
+
55
+ /// Detect whether the SDK is running in a developer environment where
56
+ /// license enforcement should be bypassed. The bypass list is
57
+ /// intentionally generous: blocking a developer mid-Xcode-run with a
58
+ /// license-not-found error would be hostile.
59
+ ///
60
+ /// Order (first match wins):
61
+ /// 1. `DVAI_FORCE_PROD=1` → force production (overrides DEBUG +
62
+ /// simulator). The host app's CI sets this to exercise license
63
+ /// enforcement in non-RELEASE builds.
64
+ /// 2. `DVAI_FORCE_DEV=1` → force dev. Explicit operator opt-in.
65
+ /// 3. `#if DEBUG` → dev. Compile-time check — the DEBUG configuration
66
+ /// bypasses licensing entirely so the in-Xcode dev loop never
67
+ /// surfaces a license error.
68
+ /// 4. `targetEnvironment(simulator)` → dev. Same intent: a simulator
69
+ /// run is by construction a dev environment.
70
+ /// 5. otherwise → production, license required.
71
+ public func detectDevMode() -> (isDev: Bool, reason: String) {
72
+ // 1. Explicit env-var overrides (operator-level, runtime). Use
73
+ // `envVar` (libc getenv) so DVAI_FORCE_* mutated mid-process —
74
+ // e.g. by tests — is picked up.
75
+ let forceProd = envVar("DVAI_FORCE_PROD")
76
+ if forceProd == "1" || forceProd == "true" {
77
+ return (false, "DVAI_FORCE_PROD set")
78
+ }
79
+ let forceDev = envVar("DVAI_FORCE_DEV")
80
+ if forceDev == "1" || forceDev == "true" {
81
+ return (true, "DVAI_FORCE_DEV set")
82
+ }
83
+
84
+ // 2. DEBUG configuration.
85
+ #if DEBUG
86
+ return (true, "DEBUG build configuration")
87
+ #else
88
+ // 3. Simulator (DEBUG would have already caught most simulator
89
+ // runs; this branch matters for a Release-configuration build
90
+ // happening to run on the simulator — rare, but harmless to
91
+ // bypass).
92
+ #if targetEnvironment(simulator)
93
+ return (true, "iOS Simulator")
94
+ #else
95
+ return (false, "production-class environment")
96
+ #endif
97
+ #endif
98
+ }
99
+
100
+ /// Decide whether a license-payload `aud` entry matches the current
101
+ /// runtime audience. Supports exact match and `*.example.com` wildcard
102
+ /// matching for subdomain (here: bundle-id-suffix) binding. Returns
103
+ /// the matched `aud` pattern on success so it can be recorded for
104
+ /// audit, or `nil` on miss.
105
+ ///
106
+ /// Match rules (identical to the JS-side `matchAudience`):
107
+ /// - `"foo"` matches `"foo"` exactly (case-insensitive)
108
+ /// - `"*.example.com"` matches `"example.com"` AND any
109
+ /// `"<sub>.example.com"`
110
+ /// - `"*"` matches any non-nil audience (intentionally permissive;
111
+ /// used for trial / site licenses that span all of a customer's
112
+ /// deployments)
113
+ ///
114
+ /// A `nil` runtime audience matches only `"*"` entries — a build with
115
+ /// no bundle identifier can activate any-domain licenses but not
116
+ /// bundle-bound ones.
117
+ public func matchAudience(runtimeAudience: String?, audClaim: [String]) -> String? {
118
+ guard let runtime = runtimeAudience?.lowercased(), !runtime.isEmpty else {
119
+ return audClaim.contains("*") ? "*" : nil
120
+ }
121
+ for pattern in audClaim {
122
+ let p = pattern.lowercased()
123
+ if p == "*" { return pattern } // permissive wildcard
124
+ if p == runtime { return pattern } // exact match
125
+ if p.hasPrefix("*.") {
126
+ let suffix = String(p.dropFirst(2))
127
+ if runtime == suffix || runtime.hasSuffix("." + suffix) {
128
+ return pattern
129
+ }
130
+ }
131
+ }
132
+ return nil
133
+ }
@@ -1,164 +1,164 @@
1
- /*
2
- * License-file discovery for the iOS SDK.
3
- *
4
- * Mirrors `packages/dvai-bridge-core/src/license/discovery.ts` but with
5
- * iOS-native locations (Bundle resources, Documents directory, App Group
6
- * containers) in place of the JS side's CWD-based filesystem walk.
7
- *
8
- * Priority order (first hit wins):
9
- *
10
- * 1. `LicenseDiscoveryOptions.token` — inline JWT string. Useful when
11
- * the host app fetches its license over the network at runtime
12
- * and wants to inject the result without touching disk.
13
- *
14
- * 2. `LicenseDiscoveryOptions.path` — explicit file path. Useful for
15
- * tests or for hosts that store the .jwt outside the standard
16
- * locations. If this path is set but the file isn't there or
17
- * isn't readable, this is a real miss (we do NOT silently fall
18
- * through to auto-discovery) — same semantics as the JS side.
19
- *
20
- * 3. `DVAI_LICENSE_PATH` env var. Operator-supplied path; helpful
21
- * for CI / TestFlight where you don't want to ship the .jwt in
22
- * the bundle. Misses fall through to (5).
23
- *
24
- * 4. `DVAI_LICENSE_TOKEN` env var. Inline JWT in the environment.
25
- *
26
- * 5. `dvai-license.jwt` resource in `Bundle.main`. The dev-friendly
27
- * happy path: add the file to the app's "Copy Bundle Resources"
28
- * phase, the SDK picks it up automatically.
29
- *
30
- * 6. `Application Support/dvai-bridge/dvai-license.jwt` in the app's
31
- * sandbox. Useful for licenses fetched after install (e.g. via
32
- * an in-app purchase flow).
33
- *
34
- * 7. `Documents/dvai-license.jwt`. Last-resort fallback — visible
35
- * via iTunes File Sharing if the app opts in, so handy for
36
- * side-loading a license during development.
37
- *
38
- * Returning `nil` means "no license token found"; the validator treats
39
- * that as the free-tier case (after the dev-mode bypass).
40
- */
41
- import Foundation
42
-
43
- /// Default filename the SDK looks for in Bundle / Documents / App Support.
44
- /// Chosen to be self-documenting and to encourage commit-to-vcs (so the
45
- /// license travels with the code).
46
- public let DVAI_DEFAULT_LICENSE_FILENAME = "dvai-license.jwt"
47
-
48
- public struct LicenseDiscoveryOptions: Sendable {
49
- /// Pre-loaded JWT string (skips all filesystem lookups).
50
- public var token: String?
51
- /// Explicit path to load from. Overrides auto-discovery.
52
- public var path: String?
53
- /// App Group identifier to also search (e.g. for shared licenses
54
- /// across an app + extension). Optional; default `nil` skips the
55
- /// App Group lookup.
56
- public var appGroupIdentifier: String?
57
-
58
- public init(
59
- token: String? = nil,
60
- path: String? = nil,
61
- appGroupIdentifier: String? = nil
62
- ) {
63
- self.token = token
64
- self.path = path
65
- self.appGroupIdentifier = appGroupIdentifier
66
- }
67
- }
68
-
69
- /// Best-effort load of a license JWT. Returns the raw token string +
70
- /// source description on success, or nil on miss. Errors during loading
71
- /// (file not found, permission denied) collapse to nil — the
72
- /// validator's responsibility is to handle the no-license case
73
- /// gracefully, not the discovery layer's.
74
- ///
75
- /// Source descriptions are surfaced in `LicenseStatus.freeProd.reason`
76
- /// for debug, and shown in dashboards so the developer can see which
77
- /// of the seven locations resolved.
78
- public func discoverLicenseToken(
79
- options: LicenseDiscoveryOptions = LicenseDiscoveryOptions()
80
- ) -> (token: String, source: String)? {
81
- // 1. Explicit token wins.
82
- if let token = options.token, !token.isEmpty {
83
- return (token.trimmingCharacters(in: .whitespacesAndNewlines), "options.token")
84
- }
85
-
86
- // 2. Explicit path.
87
- if let path = options.path, !path.isEmpty {
88
- if let loaded = tryLoadFromPath(path) {
89
- return (loaded, path)
90
- }
91
- return nil // explicit miss — do NOT fall through
92
- }
93
-
94
- // 3. Env-var path. Use `envVar` (libc getenv) so DVAI_LICENSE_PATH
95
- // mutated mid-process — e.g. by tests — is picked up.
96
- if let envPath = envVar("DVAI_LICENSE_PATH") {
97
- if let loaded = tryLoadFromPath(envPath) {
98
- return (loaded, "DVAI_LICENSE_PATH=\(envPath)")
99
- }
100
- }
101
-
102
- // 4. Env-var inline token.
103
- if let envToken = envVar("DVAI_LICENSE_TOKEN") {
104
- return (
105
- envToken.trimmingCharacters(in: .whitespacesAndNewlines),
106
- "DVAI_LICENSE_TOKEN env var"
107
- )
108
- }
109
-
110
- // 5. Bundle resource (dvai-license.jwt next to the app's other
111
- // bundled resources).
112
- if let bundleURL = Bundle.main.url(forResource: "dvai-license", withExtension: "jwt") {
113
- if let loaded = tryLoadFromPath(bundleURL.path) {
114
- return (loaded, "Bundle.main resource dvai-license.jwt")
115
- }
116
- }
117
-
118
- // 6. Application Support / dvai-bridge / dvai-license.jwt.
119
- if let appSupportURL = (try? FileManager.default.url(
120
- for: .applicationSupportDirectory,
121
- in: .userDomainMask,
122
- appropriateFor: nil,
123
- create: false
124
- )) {
125
- let candidate = appSupportURL
126
- .appendingPathComponent("dvai-bridge", isDirectory: true)
127
- .appendingPathComponent(DVAI_DEFAULT_LICENSE_FILENAME)
128
- if let loaded = tryLoadFromPath(candidate.path) {
129
- return (loaded, candidate.path)
130
- }
131
- }
132
-
133
- // 7. Documents directory.
134
- if let docsURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first {
135
- let candidate = docsURL.appendingPathComponent(DVAI_DEFAULT_LICENSE_FILENAME)
136
- if let loaded = tryLoadFromPath(candidate.path) {
137
- return (loaded, candidate.path)
138
- }
139
- }
140
-
141
- // 8. (Optional) App Group container, if configured.
142
- if let groupId = options.appGroupIdentifier, !groupId.isEmpty {
143
- if let groupURL = FileManager.default
144
- .containerURL(forSecurityApplicationGroupIdentifier: groupId)?
145
- .appendingPathComponent(DVAI_DEFAULT_LICENSE_FILENAME) {
146
- if let loaded = tryLoadFromPath(groupURL.path) {
147
- return (loaded, groupURL.path)
148
- }
149
- }
150
- }
151
-
152
- return nil
153
- }
154
-
155
- /// Read a file at `path` and return its trimmed UTF-8 contents, or
156
- /// nil on any error (missing / permission / encoding).
157
- private func tryLoadFromPath(_ path: String) -> String? {
158
- let fm = FileManager.default
159
- guard fm.fileExists(atPath: path) else { return nil }
160
- guard let data = try? Data(contentsOf: URL(fileURLWithPath: path)) else { return nil }
161
- guard let text = String(data: data, encoding: .utf8) else { return nil }
162
- let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
163
- return trimmed.isEmpty ? nil : trimmed
164
- }
1
+ /*
2
+ * License-file discovery for the iOS SDK.
3
+ *
4
+ * Mirrors `packages/dvai-bridge-core/src/license/discovery.ts` but with
5
+ * iOS-native locations (Bundle resources, Documents directory, App Group
6
+ * containers) in place of the JS side's CWD-based filesystem walk.
7
+ *
8
+ * Priority order (first hit wins):
9
+ *
10
+ * 1. `LicenseDiscoveryOptions.token` — inline JWT string. Useful when
11
+ * the host app fetches its license over the network at runtime
12
+ * and wants to inject the result without touching disk.
13
+ *
14
+ * 2. `LicenseDiscoveryOptions.path` — explicit file path. Useful for
15
+ * tests or for hosts that store the .jwt outside the standard
16
+ * locations. If this path is set but the file isn't there or
17
+ * isn't readable, this is a real miss (we do NOT silently fall
18
+ * through to auto-discovery) — same semantics as the JS side.
19
+ *
20
+ * 3. `DVAI_LICENSE_PATH` env var. Operator-supplied path; helpful
21
+ * for CI / TestFlight where you don't want to ship the .jwt in
22
+ * the bundle. Misses fall through to (5).
23
+ *
24
+ * 4. `DVAI_LICENSE_TOKEN` env var. Inline JWT in the environment.
25
+ *
26
+ * 5. `dvai-license.jwt` resource in `Bundle.main`. The dev-friendly
27
+ * happy path: add the file to the app's "Copy Bundle Resources"
28
+ * phase, the SDK picks it up automatically.
29
+ *
30
+ * 6. `Application Support/dvai-bridge/dvai-license.jwt` in the app's
31
+ * sandbox. Useful for licenses fetched after install (e.g. via
32
+ * an in-app purchase flow).
33
+ *
34
+ * 7. `Documents/dvai-license.jwt`. Last-resort fallback — visible
35
+ * via iTunes File Sharing if the app opts in, so handy for
36
+ * side-loading a license during development.
37
+ *
38
+ * Returning `nil` means "no license token found"; the validator treats
39
+ * that as the free-tier case (after the dev-mode bypass).
40
+ */
41
+ import Foundation
42
+
43
+ /// Default filename the SDK looks for in Bundle / Documents / App Support.
44
+ /// Chosen to be self-documenting and to encourage commit-to-vcs (so the
45
+ /// license travels with the code).
46
+ public let DVAI_DEFAULT_LICENSE_FILENAME = "dvai-license.jwt"
47
+
48
+ public struct LicenseDiscoveryOptions: Sendable {
49
+ /// Pre-loaded JWT string (skips all filesystem lookups).
50
+ public var token: String?
51
+ /// Explicit path to load from. Overrides auto-discovery.
52
+ public var path: String?
53
+ /// App Group identifier to also search (e.g. for shared licenses
54
+ /// across an app + extension). Optional; default `nil` skips the
55
+ /// App Group lookup.
56
+ public var appGroupIdentifier: String?
57
+
58
+ public init(
59
+ token: String? = nil,
60
+ path: String? = nil,
61
+ appGroupIdentifier: String? = nil
62
+ ) {
63
+ self.token = token
64
+ self.path = path
65
+ self.appGroupIdentifier = appGroupIdentifier
66
+ }
67
+ }
68
+
69
+ /// Best-effort load of a license JWT. Returns the raw token string +
70
+ /// source description on success, or nil on miss. Errors during loading
71
+ /// (file not found, permission denied) collapse to nil — the
72
+ /// validator's responsibility is to handle the no-license case
73
+ /// gracefully, not the discovery layer's.
74
+ ///
75
+ /// Source descriptions are surfaced in `LicenseStatus.freeProd.reason`
76
+ /// for debug, and shown in dashboards so the developer can see which
77
+ /// of the seven locations resolved.
78
+ public func discoverLicenseToken(
79
+ options: LicenseDiscoveryOptions = LicenseDiscoveryOptions()
80
+ ) -> (token: String, source: String)? {
81
+ // 1. Explicit token wins.
82
+ if let token = options.token, !token.isEmpty {
83
+ return (token.trimmingCharacters(in: .whitespacesAndNewlines), "options.token")
84
+ }
85
+
86
+ // 2. Explicit path.
87
+ if let path = options.path, !path.isEmpty {
88
+ if let loaded = tryLoadFromPath(path) {
89
+ return (loaded, path)
90
+ }
91
+ return nil // explicit miss — do NOT fall through
92
+ }
93
+
94
+ // 3. Env-var path. Use `envVar` (libc getenv) so DVAI_LICENSE_PATH
95
+ // mutated mid-process — e.g. by tests — is picked up.
96
+ if let envPath = envVar("DVAI_LICENSE_PATH") {
97
+ if let loaded = tryLoadFromPath(envPath) {
98
+ return (loaded, "DVAI_LICENSE_PATH=\(envPath)")
99
+ }
100
+ }
101
+
102
+ // 4. Env-var inline token.
103
+ if let envToken = envVar("DVAI_LICENSE_TOKEN") {
104
+ return (
105
+ envToken.trimmingCharacters(in: .whitespacesAndNewlines),
106
+ "DVAI_LICENSE_TOKEN env var"
107
+ )
108
+ }
109
+
110
+ // 5. Bundle resource (dvai-license.jwt next to the app's other
111
+ // bundled resources).
112
+ if let bundleURL = Bundle.main.url(forResource: "dvai-license", withExtension: "jwt") {
113
+ if let loaded = tryLoadFromPath(bundleURL.path) {
114
+ return (loaded, "Bundle.main resource dvai-license.jwt")
115
+ }
116
+ }
117
+
118
+ // 6. Application Support / dvai-bridge / dvai-license.jwt.
119
+ if let appSupportURL = (try? FileManager.default.url(
120
+ for: .applicationSupportDirectory,
121
+ in: .userDomainMask,
122
+ appropriateFor: nil,
123
+ create: false
124
+ )) {
125
+ let candidate = appSupportURL
126
+ .appendingPathComponent("dvai-bridge", isDirectory: true)
127
+ .appendingPathComponent(DVAI_DEFAULT_LICENSE_FILENAME)
128
+ if let loaded = tryLoadFromPath(candidate.path) {
129
+ return (loaded, candidate.path)
130
+ }
131
+ }
132
+
133
+ // 7. Documents directory.
134
+ if let docsURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first {
135
+ let candidate = docsURL.appendingPathComponent(DVAI_DEFAULT_LICENSE_FILENAME)
136
+ if let loaded = tryLoadFromPath(candidate.path) {
137
+ return (loaded, candidate.path)
138
+ }
139
+ }
140
+
141
+ // 8. (Optional) App Group container, if configured.
142
+ if let groupId = options.appGroupIdentifier, !groupId.isEmpty {
143
+ if let groupURL = FileManager.default
144
+ .containerURL(forSecurityApplicationGroupIdentifier: groupId)?
145
+ .appendingPathComponent(DVAI_DEFAULT_LICENSE_FILENAME) {
146
+ if let loaded = tryLoadFromPath(groupURL.path) {
147
+ return (loaded, groupURL.path)
148
+ }
149
+ }
150
+ }
151
+
152
+ return nil
153
+ }
154
+
155
+ /// Read a file at `path` and return its trimmed UTF-8 contents, or
156
+ /// nil on any error (missing / permission / encoding).
157
+ private func tryLoadFromPath(_ path: String) -> String? {
158
+ let fm = FileManager.default
159
+ guard fm.fileExists(atPath: path) else { return nil }
160
+ guard let data = try? Data(contentsOf: URL(fileURLWithPath: path)) else { return nil }
161
+ guard let text = String(data: data, encoding: .utf8) else { return nil }
162
+ let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
163
+ return trimmed.isEmpty ? nil : trimmed
164
+ }