@dl2app/react-native 0.0.0-stage → 0.1.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.
@@ -0,0 +1,22 @@
1
+ declare const DL2: {
2
+ configure: (config: import("./types").DL2Config) => void;
3
+ onLink: (listener: (event: import("./types").DL2LinkEvent) => void) => () => void;
4
+ getInitialLink: () => Promise<import("./types").DL2LinkEvent | null>;
5
+ resolve: (url: string) => Promise<import("./types").DL2LinkEvent | null>;
6
+ track: (name: string, options?: import("./types").DL2TrackOptions) => void;
7
+ flush: () => Promise<void | undefined>;
8
+ createLink: (input: import("./types").DL2CreateLinkInput) => Promise<{
9
+ url: string;
10
+ } | null>;
11
+ matchClipboard: (text: string) => Promise<import("./types").DL2LinkEvent | null>;
12
+ reset: () => Promise<void>;
13
+ getInstallId: () => Promise<string | null>;
14
+ getDebugInfo: () => import("./types").DL2DebugInfo;
15
+ version: string;
16
+ };
17
+ export default DL2;
18
+ export { DL2 };
19
+ export { DL2PasteButton } from "./PasteButton";
20
+ export type { DL2PasteButtonProps } from "./PasteButton";
21
+ export { parseUrl, extractClipboardToken, SDK_VERSION } from "./core";
22
+ export type { DL2Config, DL2CreateLinkInput, DL2DebugInfo, DL2Error, DL2ErrorCode, DL2LinkEvent, DL2LinkSource, DL2MatchMethod, DL2TrackOptions, } from "./types";
@@ -0,0 +1,33 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.SDK_VERSION = exports.extractClipboardToken = exports.parseUrl = exports.DL2PasteButton = exports.DL2 = void 0;
4
+ const react_native_1 = require("react-native");
5
+ const expo_modules_core_1 = require("expo-modules-core");
6
+ const core_1 = require("./core");
7
+ function loadNative() {
8
+ try {
9
+ return (0, expo_modules_core_1.requireOptionalNativeModule)("DL2");
10
+ }
11
+ catch {
12
+ return null;
13
+ }
14
+ }
15
+ const DL2 = (0, core_1.createDL2)({
16
+ native: loadNative(),
17
+ linking: react_native_1.Linking,
18
+ platform: react_native_1.Platform.OS,
19
+ screen: () => {
20
+ // Points (iOS) and dp (Android): the same unit as `screen.width` in the browser that clicked.
21
+ const s = react_native_1.Dimensions.get("screen");
22
+ return { w: Math.round(s.width), h: Math.round(s.height), dpr: react_native_1.PixelRatio.get() };
23
+ },
24
+ fetch: (url, init) => fetch(url, init),
25
+ });
26
+ exports.DL2 = DL2;
27
+ exports.default = DL2;
28
+ var PasteButton_1 = require("./PasteButton");
29
+ Object.defineProperty(exports, "DL2PasteButton", { enumerable: true, get: function () { return PasteButton_1.DL2PasteButton; } });
30
+ var core_2 = require("./core");
31
+ Object.defineProperty(exports, "parseUrl", { enumerable: true, get: function () { return core_2.parseUrl; } });
32
+ Object.defineProperty(exports, "extractClipboardToken", { enumerable: true, get: function () { return core_2.extractClipboardToken; } });
33
+ Object.defineProperty(exports, "SDK_VERSION", { enumerable: true, get: function () { return core_2.SDK_VERSION; } });
@@ -0,0 +1,130 @@
1
+ export type DL2ErrorCode = "not_configured" | "network" | "timeout" | "invalid_key" | "app_mismatch" | "link_not_found" | "rate_limited" | "unknown";
2
+ export type DL2Error = {
3
+ code: DL2ErrorCode;
4
+ message: string;
5
+ cause?: unknown;
6
+ /** HTTP status when the error came from an API response. */
7
+ status?: number;
8
+ };
9
+ export type DL2LinkSource = "universal_link" | "app_link" | "uri_scheme" | "deferred";
10
+ export type DL2MatchMethod = "install_referrer" | "fingerprint" | "clipboard";
11
+ export type DL2LinkEvent = {
12
+ /** The URL that opened the app, or the short link that was matched. */
13
+ url: string | null;
14
+ /** Path to navigate to, for example "/invite/42". */
15
+ path: string;
16
+ /** Query parameters of the link. */
17
+ params: Record<string, string>;
18
+ /** The link's deep link data. */
19
+ data: Record<string, unknown>;
20
+ source: DL2LinkSource;
21
+ isFirstOpen: boolean;
22
+ matchMethod?: DL2MatchMethod;
23
+ confidence?: number;
24
+ linkId?: string;
25
+ utm?: Record<string, string>;
26
+ };
27
+ export type DL2Config = {
28
+ publishableKey: string;
29
+ /** Every host your links use: the default host, the "-alt" host and custom domains. */
30
+ linkDomains: string[];
31
+ /** Optional custom URL scheme, without "://". */
32
+ uriScheme?: string;
33
+ /** Defaults: enabled true, timeoutMs 4000, useClipboard false. */
34
+ deferred?: {
35
+ enabled?: boolean;
36
+ timeoutMs?: number;
37
+ useClipboard?: boolean;
38
+ };
39
+ /** Default https://api.dl2.app */
40
+ apiBaseUrl?: string;
41
+ debug?: boolean;
42
+ onError?: (e: DL2Error) => void;
43
+ };
44
+ export type DL2TrackOptions = {
45
+ value?: number;
46
+ /** ISO 4217 code, for example "USD". */
47
+ currency?: string;
48
+ props?: Record<string, unknown>;
49
+ };
50
+ export type DL2CreateLinkInput = {
51
+ path: string;
52
+ data?: Record<string, unknown>;
53
+ og?: {
54
+ title?: string;
55
+ description?: string;
56
+ image?: string;
57
+ };
58
+ utm?: Record<string, string>;
59
+ };
60
+ export type DL2DebugInfo = {
61
+ sdkVersion: string;
62
+ configured: boolean;
63
+ nativeModule: boolean;
64
+ installId: string | null;
65
+ isFirstLaunch: boolean;
66
+ queuedEvents: number;
67
+ lastOpenResponse: unknown;
68
+ lastResolveResponse: unknown;
69
+ lastError: DL2Error | null;
70
+ };
71
+ export type DL2DeviceInfo = {
72
+ osVersion?: string;
73
+ appId?: string;
74
+ appVersion?: string;
75
+ lang?: string;
76
+ tz?: string;
77
+ };
78
+ /** The native module (Expo Modules API). Every function is asynchronous. */
79
+ export interface DL2Native {
80
+ getItem(key: string): Promise<string | null>;
81
+ setItem(key: string, value: string): Promise<void>;
82
+ removeItem(key: string): Promise<void>;
83
+ /** Android: Play Install Referrer string. iOS: always null. */
84
+ getInstallReferrer(): Promise<string | null>;
85
+ getDeviceInfo(): Promise<DL2DeviceInfo>;
86
+ /**
87
+ * When the app was installed, in ms since epoch, or null when unknown. Android: the package's
88
+ * firstInstallTime. iOS: the creation date of the app's data container.
89
+ */
90
+ getInstalledAt?(): Promise<number | null>;
91
+ /**
92
+ * Android: true when the activity was reopened from Recents, so its intent (and the URL in it)
93
+ * is the one from the original launch. iOS does not have it.
94
+ */
95
+ isLaunchedFromHistory?(): Promise<boolean>;
96
+ uuid?(): Promise<string>;
97
+ }
98
+ export type DL2FetchResponse = {
99
+ ok: boolean;
100
+ status: number;
101
+ json(): Promise<unknown>;
102
+ };
103
+ /** Everything the core needs from the platform. Injected so the core runs in plain Node for tests. */
104
+ export interface DL2Deps {
105
+ /** null when the native module is not linked (for example in Expo Go). */
106
+ native: DL2Native | null;
107
+ linking: {
108
+ getInitialURL(): Promise<string | null>;
109
+ addEventListener(type: "url", handler: (e: {
110
+ url: string;
111
+ }) => void): {
112
+ remove(): void;
113
+ } | void;
114
+ };
115
+ platform: string;
116
+ screen(): {
117
+ w: number;
118
+ h: number;
119
+ dpr: number;
120
+ };
121
+ fetch(url: string, init: {
122
+ method: string;
123
+ headers: Record<string, string>;
124
+ body: string;
125
+ signal?: unknown;
126
+ }): Promise<DL2FetchResponse>;
127
+ sleep?(ms: number): Promise<void>;
128
+ now?(): number;
129
+ random?(): number;
130
+ }
@@ -0,0 +1,2 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
@@ -0,0 +1,5 @@
1
+ {
2
+ "platforms": ["ios", "android"],
3
+ "ios": { "modules": ["DL2Module"] },
4
+ "android": { "modules": ["app.dl2.reactnative.DL2Module"] }
5
+ }
@@ -0,0 +1,59 @@
1
+ import ExpoModulesCore
2
+ import UIKit
3
+
4
+ // Key-value storage, device info, the install time and the system paste button. Nothing else.
5
+ // The clipboard is never read here: text only arrives when the user taps DL2PasteButtonView.
6
+ public class DL2Module: Module {
7
+ private var store: UserDefaults {
8
+ return UserDefaults(suiteName: "app.dl2.sdk") ?? UserDefaults.standard
9
+ }
10
+
11
+ public func definition() -> ModuleDefinition {
12
+ Name("DL2")
13
+
14
+ AsyncFunction("getItem") { (key: String) -> String? in
15
+ return self.store.string(forKey: key)
16
+ }
17
+
18
+ AsyncFunction("setItem") { (key: String, value: String) in
19
+ self.store.set(value, forKey: key)
20
+ }
21
+
22
+ AsyncFunction("removeItem") { (key: String) in
23
+ self.store.removeObject(forKey: key)
24
+ }
25
+
26
+ // The install referrer is an Android (Google Play) concept.
27
+ AsyncFunction("getInstallReferrer") { () -> String? in
28
+ return nil
29
+ }
30
+
31
+ AsyncFunction("uuid") { () -> String in
32
+ return UUID().uuidString.lowercased()
33
+ }
34
+
35
+ AsyncFunction("getDeviceInfo") { () -> [String: String] in
36
+ let bundle = Bundle.main
37
+ return [
38
+ "osVersion": UIDevice.current.systemVersion,
39
+ "appId": bundle.bundleIdentifier ?? "",
40
+ "appVersion": (bundle.infoDictionary?["CFBundleShortVersionString"] as? String) ?? "",
41
+ "lang": Locale.preferredLanguages.first ?? "",
42
+ "tz": TimeZone.current.identifier
43
+ ]
44
+ }
45
+
46
+ // When the app was installed, in ms since epoch. iOS has no API for it; the data
47
+ // container's Library directory is created with the install. Nil when it cannot be read.
48
+ AsyncFunction("getInstalledAt") { () -> Double? in
49
+ guard let dir = FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask).first,
50
+ let created = (try? FileManager.default.attributesOfItem(atPath: dir.path))?[.creationDate] as? Date
51
+ else { return nil }
52
+ return (created.timeIntervalSince1970 * 1000).rounded()
53
+ }
54
+
55
+ View(DL2PasteButtonView.self) {
56
+ Events("onPaste")
57
+ }
58
+ }
59
+ }
@@ -0,0 +1,41 @@
1
+ import ExpoModulesCore
2
+ import UIKit
3
+
4
+ // Hosts the system paste button (UIPasteControl, iOS 16+). iOS gives the app the clipboard
5
+ // content only after the user taps it, without the "pasted from" banner or a permission alert.
6
+ // On older iOS the view stays empty.
7
+ final class DL2PasteButtonView: ExpoView {
8
+ let onPaste = EventDispatcher()
9
+ private var control: UIView?
10
+
11
+ required init(appContext: AppContext? = nil) {
12
+ super.init(appContext: appContext)
13
+ pasteConfiguration = UIPasteConfiguration(acceptableTypeIdentifiers: ["public.plain-text", "public.url"])
14
+ if #available(iOS 16.0, *) {
15
+ let configuration = UIPasteControl.Configuration()
16
+ configuration.displayMode = .iconAndLabel
17
+ configuration.cornerStyle = .capsule
18
+ let paste = UIPasteControl(configuration: configuration)
19
+ paste.target = self
20
+ addSubview(paste)
21
+ control = paste
22
+ }
23
+ }
24
+
25
+ override func layoutSubviews() {
26
+ super.layoutSubviews()
27
+ control?.frame = bounds
28
+ }
29
+
30
+ override func paste(itemProviders: [NSItemProvider]) {
31
+ guard let provider = itemProviders.first(where: { $0.canLoadObject(ofClass: NSString.self) }) else {
32
+ return
33
+ }
34
+ _ = provider.loadObject(ofClass: NSString.self) { [weak self] value, _ in
35
+ guard let text = value as? String else { return }
36
+ DispatchQueue.main.async {
37
+ self?.onPaste(["text": text])
38
+ }
39
+ }
40
+ }
41
+ }
@@ -0,0 +1,22 @@
1
+ require 'json'
2
+
3
+ package = JSON.parse(File.read(File.join(__dir__, '..', 'package.json')))
4
+
5
+ Pod::Spec.new do |s|
6
+ s.name = 'DL2ReactNative'
7
+ s.version = package['version']
8
+ s.summary = package['description']
9
+ s.description = package['description']
10
+ s.license = package['license']
11
+ s.author = 'DL2'
12
+ s.homepage = 'https://dl2.app'
13
+ s.platforms = { :ios => '13.4' }
14
+ s.swift_version = '5.4'
15
+ s.source = { :git => 'https://github.com/dl2app/dl2-react-native.git', :tag => "v#{s.version}" }
16
+ s.static_framework = true
17
+
18
+ s.dependency 'ExpoModulesCore'
19
+
20
+ s.source_files = '*.swift'
21
+ s.resource_bundles = { 'DL2ReactNative_Privacy' => ['PrivacyInfo.xcprivacy'] }
22
+ end
@@ -0,0 +1,76 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3
+ <plist version="1.0">
4
+ <dict>
5
+ <!-- The SDK does not track: no IDFA, no cross-app or cross-site identifiers. -->
6
+ <key>NSPrivacyTracking</key>
7
+ <false/>
8
+ <key>NSPrivacyTrackingDomains</key>
9
+ <array/>
10
+ <key>NSPrivacyCollectedDataTypes</key>
11
+ <array>
12
+ <!-- install_id: a random ID created by the SDK for this install. -->
13
+ <dict>
14
+ <key>NSPrivacyCollectedDataType</key>
15
+ <string>NSPrivacyCollectedDataTypeDeviceID</string>
16
+ <key>NSPrivacyCollectedDataTypeLinked</key>
17
+ <false/>
18
+ <key>NSPrivacyCollectedDataTypeTracking</key>
19
+ <false/>
20
+ <key>NSPrivacyCollectedDataTypePurposes</key>
21
+ <array>
22
+ <string>NSPrivacyCollectedDataTypePurposeAppFunctionality</string>
23
+ <string>NSPrivacyCollectedDataTypePurposeAnalytics</string>
24
+ </array>
25
+ </dict>
26
+ <!-- Link opens and events sent with DL2.track(). -->
27
+ <dict>
28
+ <key>NSPrivacyCollectedDataType</key>
29
+ <string>NSPrivacyCollectedDataTypeProductInteraction</string>
30
+ <key>NSPrivacyCollectedDataTypeLinked</key>
31
+ <false/>
32
+ <key>NSPrivacyCollectedDataTypeTracking</key>
33
+ <false/>
34
+ <key>NSPrivacyCollectedDataTypePurposes</key>
35
+ <array>
36
+ <string>NSPrivacyCollectedDataTypePurposeAppFunctionality</string>
37
+ <string>NSPrivacyCollectedDataTypePurposeAnalytics</string>
38
+ </array>
39
+ </dict>
40
+ <!-- OS version, screen size, language and time zone, used once to match a deferred link. -->
41
+ <dict>
42
+ <key>NSPrivacyCollectedDataType</key>
43
+ <string>NSPrivacyCollectedDataTypeOtherDiagnosticData</string>
44
+ <key>NSPrivacyCollectedDataTypeLinked</key>
45
+ <false/>
46
+ <key>NSPrivacyCollectedDataTypeTracking</key>
47
+ <false/>
48
+ <key>NSPrivacyCollectedDataTypePurposes</key>
49
+ <array>
50
+ <string>NSPrivacyCollectedDataTypePurposeAppFunctionality</string>
51
+ </array>
52
+ </dict>
53
+ </array>
54
+ <key>NSPrivacyAccessedAPITypes</key>
55
+ <array>
56
+ <!-- UserDefaults: the SDK stores its own install_id and event queue. -->
57
+ <dict>
58
+ <key>NSPrivacyAccessedAPIType</key>
59
+ <string>NSPrivacyAccessedAPICategoryUserDefaults</string>
60
+ <key>NSPrivacyAccessedAPITypeReasons</key>
61
+ <array>
62
+ <string>CA92.1</string>
63
+ </array>
64
+ </dict>
65
+ <!-- File timestamp: the creation date of the app's own Library directory, read as the install time. -->
66
+ <dict>
67
+ <key>NSPrivacyAccessedAPIType</key>
68
+ <string>NSPrivacyAccessedAPICategoryFileTimestamp</string>
69
+ <key>NSPrivacyAccessedAPITypeReasons</key>
70
+ <array>
71
+ <string>C617.1</string>
72
+ </array>
73
+ </dict>
74
+ </array>
75
+ </dict>
76
+ </plist>
package/package.json CHANGED
@@ -1,6 +1,37 @@
1
1
  {
2
2
  "name": "@dl2app/react-native",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.1",
4
+ "description": "DL2App deep links for React Native and Expo: universal links, app links, deferred deep links and events.",
5
+ "license": "MIT",
6
+ "homepage": "https://dl2.app/docs",
7
+ "main": "build/src/index.js",
8
+ "types": "build/src/index.d.ts",
9
+ "react-native": "src/index.ts",
10
+ "sideEffects": false,
11
+ "files": [
12
+ "src",
13
+ "build",
14
+ "plugin/src",
15
+ "app.plugin.js",
16
+ "expo-module.config.json",
17
+ "ios",
18
+ "android/build.gradle",
19
+ "android/src",
20
+ "README.md"
21
+ ],
22
+ "scripts": {
23
+ "typecheck": "tsc -p . --noEmit",
24
+ "build": "tsc -p tsconfig.build.json",
25
+ "prepare": "npm run build"
26
+ },
27
+ "keywords": ["react-native", "expo", "deep-linking", "universal-links", "app-links", "deferred-deep-link"],
28
+ "peerDependencies": {
29
+ "expo": ">=51.0.0",
30
+ "expo-modules-core": "*",
31
+ "react": "*",
32
+ "react-native": ">=0.74.0"
33
+ },
34
+ "devDependencies": {
35
+ "typescript": "^5.9.3"
36
+ }
37
+ }
@@ -0,0 +1,202 @@
1
+ /**
2
+ * Expo config plugin for @dl2app/react-native.
3
+ *
4
+ * It only edits the Expo config object (`ios.associatedDomains`, `android.intentFilters`,
5
+ * `scheme`); Expo's own prebuild steps turn those into entitlements and manifest entries.
6
+ * That keeps it a pure function: same input, same output, safe to run any number of times.
7
+ */
8
+
9
+ export type DL2PluginOptions = {
10
+ /** Your DL2 project subdomain. Adds `{subdomain}.dl2.app` and `{subdomain}-alt.dl2.app`. */
11
+ subdomain?: string;
12
+ /** Only for self-hosted DL2. Default "dl2.app". */
13
+ rootDomain?: string;
14
+ /** Extra hosts (custom domains), or the full list if you do not pass `subdomain`. */
15
+ domains?: string[];
16
+ /** Adds `?mode=developer` to the iOS entries so iOS fetches the AASA file directly. Default false. */
17
+ iosDeveloperMode?: boolean;
18
+ /** Adds `android:autoVerify="true"` to the intent filter. Default true. */
19
+ androidAutoVerify?: boolean;
20
+ /** Optional custom URL scheme, without "://". */
21
+ uriScheme?: string;
22
+ };
23
+
24
+ export type NormalizedOptions = {
25
+ domains: string[];
26
+ iosDeveloperMode: boolean;
27
+ androidAutoVerify: boolean;
28
+ uriScheme: string | null;
29
+ };
30
+
31
+ type IntentFilterData = { scheme?: string; host?: string; [k: string]: unknown };
32
+ type IntentFilter = {
33
+ action?: string;
34
+ autoVerify?: boolean;
35
+ data?: IntentFilterData | IntentFilterData[];
36
+ category?: string | string[];
37
+ [k: string]: unknown;
38
+ };
39
+
40
+ export type ExpoConfigLike = {
41
+ scheme?: string | string[];
42
+ ios?: { associatedDomains?: string[]; [k: string]: unknown };
43
+ android?: { intentFilters?: IntentFilter[]; [k: string]: unknown };
44
+ [k: string]: unknown;
45
+ };
46
+
47
+ const NAME = "@dl2app/react-native";
48
+ const ALLOWED = ["subdomain", "rootDomain", "domains", "iosDeveloperMode", "androidAutoVerify", "uriScheme"];
49
+ const HOST = /^(\*\.)?([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/;
50
+ const LABEL = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/;
51
+ const SCHEME = /^[a-z][a-z0-9+.-]*$/;
52
+
53
+ function fail(message: string): never {
54
+ throw new Error(`[${NAME}] ${message}`);
55
+ }
56
+
57
+ function checkHost(value: unknown, where: string): string {
58
+ if (typeof value !== "string" || !value.trim()) fail(`${where} must be a host name such as "myapp.dl2.app".`);
59
+ const host = (value as string).trim().toLowerCase();
60
+ if (/^[a-z][a-z0-9+.-]*:\/\//.test(host)) fail(`${where} is "${value}". Remove the "https://": use the host only, for example "myapp.dl2.app".`);
61
+ if (/[/?#]/.test(host)) fail(`${where} is "${value}". Remove the path: use the host only, for example "myapp.dl2.app".`);
62
+ if (/:\d+$/.test(host)) fail(`${where} is "${value}". Remove the port: app links only work on the default HTTPS port.`);
63
+ if (!HOST.test(host)) fail(`${where} is "${value}", which is not a valid host name. Example: "myapp.dl2.app".`);
64
+ return host;
65
+ }
66
+
67
+ function checkBool(value: unknown, name: string, fallback: boolean): boolean {
68
+ if (value === undefined) return fallback;
69
+ if (typeof value !== "boolean") fail(`"${name}" must be true or false, got ${JSON.stringify(value)}.`);
70
+ return value as boolean;
71
+ }
72
+
73
+ /** Validates the plugin options and returns the full host list. Throws with a fix in the message. */
74
+ export function normalizeOptions(raw: unknown): NormalizedOptions {
75
+ if (raw === undefined || raw === null || typeof raw !== "object" || Array.isArray(raw)) {
76
+ fail(`Missing options. Use ["${NAME}", { "subdomain": "myapp" }] or ["${NAME}", { "domains": ["myapp.dl2.app", "myapp-alt.dl2.app"] }].`);
77
+ }
78
+ const o = raw as Record<string, unknown>;
79
+ for (const key of Object.keys(o)) {
80
+ if (ALLOWED.indexOf(key) < 0) fail(`Unknown option "${key}". Allowed options: ${ALLOWED.join(", ")}.`);
81
+ }
82
+
83
+ const domains: string[] = [];
84
+ const add = (h: string) => {
85
+ if (domains.indexOf(h) < 0) domains.push(h);
86
+ };
87
+
88
+ if (o.rootDomain !== undefined && o.subdomain === undefined) fail(`"rootDomain" only makes sense together with "subdomain".`);
89
+ if (o.subdomain !== undefined) {
90
+ if (typeof o.subdomain !== "string" || !LABEL.test(o.subdomain.trim().toLowerCase())) {
91
+ fail(`"subdomain" is ${JSON.stringify(o.subdomain)}. Use only the first part of your link host: "myapp" for myapp.dl2.app.`);
92
+ }
93
+ const sub = (o.subdomain as string).trim().toLowerCase();
94
+ if (/-alt$/.test(sub)) fail(`"subdomain" is "${sub}". Leave out "-alt": the alternate host is added for you.`);
95
+ const root = o.rootDomain === undefined ? "dl2.app" : checkHost(o.rootDomain, `"rootDomain"`);
96
+ add(`${sub}.${root}`);
97
+ add(`${sub}-alt.${root}`);
98
+ }
99
+
100
+ if (o.domains !== undefined) {
101
+ if (!Array.isArray(o.domains)) fail(`"domains" must be a list of host names, got ${JSON.stringify(o.domains)}.`);
102
+ (o.domains as unknown[]).forEach((d, i) => add(checkHost(d, `"domains[${i}]"`)));
103
+ }
104
+ if (!domains.length) fail(`No link hosts. Pass "subdomain" (your DL2 project subdomain) or a non-empty "domains" list.`);
105
+
106
+ let uriScheme: string | null = null;
107
+ if (o.uriScheme !== undefined) {
108
+ if (typeof o.uriScheme !== "string" || !o.uriScheme) fail(`"uriScheme" must be text such as "myapp".`);
109
+ const s = (o.uriScheme as string).replace(/:\/\/$/, "");
110
+ if (!SCHEME.test(s)) fail(`"uriScheme" is "${o.uriScheme}". Use lowercase letters, digits, "+", "-" or ".", starting with a letter, and no "://".`);
111
+ if (s === "http" || s === "https") fail(`"uriScheme" cannot be "${s}". Use a scheme of your own such as "myapp".`);
112
+ uriScheme = s;
113
+ }
114
+
115
+ return {
116
+ domains,
117
+ iosDeveloperMode: checkBool(o.iosDeveloperMode, "iosDeveloperMode", false),
118
+ androidAutoVerify: checkBool(o.androidAutoVerify, "androidAutoVerify", true),
119
+ uriScheme,
120
+ };
121
+ }
122
+
123
+ /** Adds `applinks:` entries, keeping every entry the app already has exactly as it is. */
124
+ export function mergeAssociatedDomains(existing: unknown, domains: string[], developerMode: boolean): string[] {
125
+ const out: string[] = Array.isArray(existing) ? existing.filter((e): e is string => typeof e === "string") : [];
126
+ for (const d of domains) {
127
+ // A host the app already lists keeps its own mode: the option only applies to entries added here.
128
+ if (out.some((e) => e.split("?")[0].toLowerCase() === `applinks:${d}`)) continue;
129
+ out.push(`applinks:${d}${developerMode ? "?mode=developer" : ""}`);
130
+ }
131
+ return out;
132
+ }
133
+
134
+ function list<T>(v: T | T[] | undefined): T[] {
135
+ return v === undefined ? [] : Array.isArray(v) ? v : [v];
136
+ }
137
+
138
+ function isBrowsableView(f: IntentFilter): boolean {
139
+ return f.action === "VIEW" && list(f.category).indexOf("BROWSABLE") >= 0;
140
+ }
141
+
142
+ const RESTRICTIONS = ["port", "path", "pathPrefix", "pathPattern", "pathSuffix", "pathAdvancedPattern", "mimeType"];
143
+
144
+ /** Android applies every path, port and type in a filter to all of its hosts, so one of them narrows the whole filter. */
145
+ function isRestricted(f: IntentFilter): boolean {
146
+ return list(f.data).some((d) => RESTRICTIONS.some((k) => d[k] !== undefined && d[k] !== null && d[k] !== ""));
147
+ }
148
+
149
+ /** Adds one https intent filter for the hosts that are not covered yet, and one for the scheme. */
150
+ export function mergeIntentFilters(existing: unknown, domains: string[], autoVerify: boolean, uriScheme: string | null): IntentFilter[] {
151
+ const out: IntentFilter[] = Array.isArray(existing) ? (existing as IntentFilter[]).slice() : [];
152
+ const covered = new Set<string>();
153
+ const schemes = new Set<string>();
154
+ for (const f of out) {
155
+ if (!f || !isBrowsableView(f)) continue;
156
+ // A filter limited to some paths does not open every link of the host.
157
+ const open = !isRestricted(f);
158
+ for (const d of list(f.data)) {
159
+ if (open && d.scheme === "https" && typeof d.host === "string" && (!autoVerify || f.autoVerify === true)) covered.add(d.host.toLowerCase());
160
+ if (d.scheme && !d.host) schemes.add(d.scheme);
161
+ }
162
+ }
163
+ const missing = domains.filter((d) => !covered.has(d));
164
+ if (missing.length) {
165
+ const filter: IntentFilter = {
166
+ action: "VIEW",
167
+ data: missing.map((host) => ({ scheme: "https", host })),
168
+ category: ["BROWSABLE", "DEFAULT"],
169
+ };
170
+ if (autoVerify) filter.autoVerify = true;
171
+ out.push(filter);
172
+ }
173
+ if (uriScheme && !schemes.has(uriScheme)) {
174
+ out.push({ action: "VIEW", data: [{ scheme: uriScheme }], category: ["BROWSABLE", "DEFAULT"] });
175
+ }
176
+ return out;
177
+ }
178
+
179
+ export function mergeSchemes(existing: unknown, uriScheme: string | null): string | string[] | undefined {
180
+ const current = list(existing as string | string[] | undefined).filter((s) => typeof s === "string");
181
+ if (!uriScheme || current.indexOf(uriScheme) >= 0) return existing as string | string[] | undefined;
182
+ return current.length ? current.concat(uriScheme) : uriScheme;
183
+ }
184
+
185
+ /** The whole plugin as a pure function. Does not modify its input. */
186
+ export function applyDL2<T extends object>(config: T, rawOptions: unknown): T & ExpoConfigLike {
187
+ const input = config as ExpoConfigLike;
188
+ const o = normalizeOptions(rawOptions);
189
+ const next: ExpoConfigLike = { ...input };
190
+ next.ios = { ...(input.ios || {}), associatedDomains: mergeAssociatedDomains(input.ios?.associatedDomains, o.domains, o.iosDeveloperMode) };
191
+ next.android = {
192
+ ...(input.android || {}),
193
+ intentFilters: mergeIntentFilters(input.android?.intentFilters, o.domains, o.androidAutoVerify, o.uriScheme),
194
+ };
195
+ const scheme = mergeSchemes(input.scheme, o.uriScheme);
196
+ if (scheme !== undefined) next.scheme = scheme;
197
+ return next as T & ExpoConfigLike;
198
+ }
199
+
200
+ const withDL2 = <T extends object>(config: T, options: DL2PluginOptions): T & ExpoConfigLike => applyDL2(config, options);
201
+
202
+ export default withDL2;