@dl2app/react-native 0.0.0-stage → 0.1.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.
- package/README.md +308 -2
- package/android/build.gradle +24 -0
- package/android/src/main/AndroidManifest.xml +1 -0
- package/android/src/main/java/app/dl2/reactnative/DL2Module.kt +107 -0
- package/app.plugin.js +3 -0
- package/build/plugin/src/withDL2.d.ts +62 -0
- package/build/plugin/src/withDL2.js +169 -0
- package/build/src/PasteButton.d.ts +14 -0
- package/build/src/PasteButton.js +44 -0
- package/build/src/core.d.ts +32 -0
- package/build/src/core.js +833 -0
- package/build/src/index.d.ts +22 -0
- package/build/src/index.js +33 -0
- package/build/src/types.d.ts +120 -0
- package/build/src/types.js +2 -0
- package/expo-module.config.json +5 -0
- package/ios/DL2.podspec +22 -0
- package/ios/DL2Module.swift +50 -0
- package/ios/DL2PasteButtonView.swift +41 -0
- package/ios/PrivacyInfo.xcprivacy +67 -0
- package/package.json +35 -4
- package/plugin/src/withDL2.ts +194 -0
- package/src/PasteButton.ts +48 -0
- package/src/core.ts +827 -0
- package/src/index.ts +41 -0
- package/src/types.ts +117 -0
|
@@ -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,120 @@
|
|
|
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
|
+
uuid?(): Promise<string>;
|
|
87
|
+
}
|
|
88
|
+
export type DL2FetchResponse = {
|
|
89
|
+
ok: boolean;
|
|
90
|
+
status: number;
|
|
91
|
+
json(): Promise<unknown>;
|
|
92
|
+
};
|
|
93
|
+
/** Everything the core needs from the platform. Injected so the core runs in plain Node for tests. */
|
|
94
|
+
export interface DL2Deps {
|
|
95
|
+
/** null when the native module is not linked (for example in Expo Go). */
|
|
96
|
+
native: DL2Native | null;
|
|
97
|
+
linking: {
|
|
98
|
+
getInitialURL(): Promise<string | null>;
|
|
99
|
+
addEventListener(type: "url", handler: (e: {
|
|
100
|
+
url: string;
|
|
101
|
+
}) => void): {
|
|
102
|
+
remove(): void;
|
|
103
|
+
} | void;
|
|
104
|
+
};
|
|
105
|
+
platform: string;
|
|
106
|
+
screen(): {
|
|
107
|
+
w: number;
|
|
108
|
+
h: number;
|
|
109
|
+
dpr: number;
|
|
110
|
+
};
|
|
111
|
+
fetch(url: string, init: {
|
|
112
|
+
method: string;
|
|
113
|
+
headers: Record<string, string>;
|
|
114
|
+
body: string;
|
|
115
|
+
signal?: unknown;
|
|
116
|
+
}): Promise<DL2FetchResponse>;
|
|
117
|
+
sleep?(ms: number): Promise<void>;
|
|
118
|
+
now?(): number;
|
|
119
|
+
random?(): number;
|
|
120
|
+
}
|
package/ios/DL2.podspec
ADDED
|
@@ -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 = 'DL2'
|
|
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 = { 'DL2_Privacy' => ['PrivacyInfo.xcprivacy'] }
|
|
22
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import ExpoModulesCore
|
|
2
|
+
import UIKit
|
|
3
|
+
|
|
4
|
+
// Key-value storage, device info 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
|
+
View(DL2PasteButtonView.self) {
|
|
47
|
+
Events("onPaste")
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -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,67 @@
|
|
|
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
|
+
</array>
|
|
66
|
+
</dict>
|
|
67
|
+
</plist>
|
package/package.json
CHANGED
|
@@ -1,6 +1,37 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dl2app/react-native",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
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,194 @@
|
|
|
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. */
|
|
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
|
+
const wanted = `applinks:${d}${developerMode ? "?mode=developer" : ""}`;
|
|
128
|
+
const i = out.findIndex((e) => e.split("?")[0].toLowerCase() === `applinks:${d}`);
|
|
129
|
+
if (i < 0) out.push(wanted);
|
|
130
|
+
else out[i] = wanted; // same host already listed: only the mode can differ
|
|
131
|
+
}
|
|
132
|
+
return out;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function list<T>(v: T | T[] | undefined): T[] {
|
|
136
|
+
return v === undefined ? [] : Array.isArray(v) ? v : [v];
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function isBrowsableView(f: IntentFilter): boolean {
|
|
140
|
+
return f.action === "VIEW" && list(f.category).indexOf("BROWSABLE") >= 0;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Adds one https intent filter for the hosts that are not covered yet, and one for the scheme. */
|
|
144
|
+
export function mergeIntentFilters(existing: unknown, domains: string[], autoVerify: boolean, uriScheme: string | null): IntentFilter[] {
|
|
145
|
+
const out: IntentFilter[] = Array.isArray(existing) ? (existing as IntentFilter[]).slice() : [];
|
|
146
|
+
const covered = new Set<string>();
|
|
147
|
+
const schemes = new Set<string>();
|
|
148
|
+
for (const f of out) {
|
|
149
|
+
if (!f || !isBrowsableView(f)) continue;
|
|
150
|
+
for (const d of list(f.data)) {
|
|
151
|
+
if (d.scheme === "https" && typeof d.host === "string" && (!autoVerify || f.autoVerify === true)) covered.add(d.host.toLowerCase());
|
|
152
|
+
if (d.scheme && !d.host) schemes.add(d.scheme);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
const missing = domains.filter((d) => !covered.has(d));
|
|
156
|
+
if (missing.length) {
|
|
157
|
+
const filter: IntentFilter = {
|
|
158
|
+
action: "VIEW",
|
|
159
|
+
data: missing.map((host) => ({ scheme: "https", host })),
|
|
160
|
+
category: ["BROWSABLE", "DEFAULT"],
|
|
161
|
+
};
|
|
162
|
+
if (autoVerify) filter.autoVerify = true;
|
|
163
|
+
out.push(filter);
|
|
164
|
+
}
|
|
165
|
+
if (uriScheme && !schemes.has(uriScheme)) {
|
|
166
|
+
out.push({ action: "VIEW", data: [{ scheme: uriScheme }], category: ["BROWSABLE", "DEFAULT"] });
|
|
167
|
+
}
|
|
168
|
+
return out;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function mergeSchemes(existing: unknown, uriScheme: string | null): string | string[] | undefined {
|
|
172
|
+
const current = list(existing as string | string[] | undefined).filter((s) => typeof s === "string");
|
|
173
|
+
if (!uriScheme || current.indexOf(uriScheme) >= 0) return existing as string | string[] | undefined;
|
|
174
|
+
return current.length ? current.concat(uriScheme) : uriScheme;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** The whole plugin as a pure function. Does not modify its input. */
|
|
178
|
+
export function applyDL2<T extends object>(config: T, rawOptions: unknown): T & ExpoConfigLike {
|
|
179
|
+
const input = config as ExpoConfigLike;
|
|
180
|
+
const o = normalizeOptions(rawOptions);
|
|
181
|
+
const next: ExpoConfigLike = { ...input };
|
|
182
|
+
next.ios = { ...(input.ios || {}), associatedDomains: mergeAssociatedDomains(input.ios?.associatedDomains, o.domains, o.iosDeveloperMode) };
|
|
183
|
+
next.android = {
|
|
184
|
+
...(input.android || {}),
|
|
185
|
+
intentFilters: mergeIntentFilters(input.android?.intentFilters, o.domains, o.androidAutoVerify, o.uriScheme),
|
|
186
|
+
};
|
|
187
|
+
const scheme = mergeSchemes(input.scheme, o.uriScheme);
|
|
188
|
+
if (scheme !== undefined) next.scheme = scheme;
|
|
189
|
+
return next as T & ExpoConfigLike;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const withDL2 = <T extends object>(config: T, options: DL2PluginOptions): T & ExpoConfigLike => applyDL2(config, options);
|
|
193
|
+
|
|
194
|
+
export default withDL2;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { createElement, type ComponentType, type ReactElement } from "react";
|
|
2
|
+
import { Platform } from "react-native";
|
|
3
|
+
import { requireNativeViewManager } from "expo-modules-core";
|
|
4
|
+
|
|
5
|
+
export type DL2PasteButtonProps = {
|
|
6
|
+
/** Called with the pasted text after the user taps the button. */
|
|
7
|
+
onPaste: (text: string) => void;
|
|
8
|
+
/** Size the button here; the system draws its label and icon. */
|
|
9
|
+
style?: unknown;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
type NativeProps = { style?: unknown; onPaste: (e: { nativeEvent: { text?: string } }) => void };
|
|
13
|
+
|
|
14
|
+
let NativeView: ComponentType<NativeProps> | null | undefined;
|
|
15
|
+
|
|
16
|
+
function nativeView(): ComponentType<NativeProps> | null {
|
|
17
|
+
if (NativeView === undefined) {
|
|
18
|
+
try {
|
|
19
|
+
NativeView = requireNativeViewManager<NativeProps>("DL2");
|
|
20
|
+
} catch {
|
|
21
|
+
NativeView = null;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return NativeView || null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The system paste button (UIPasteControl, iOS 16+). iOS hands over the clipboard only when the
|
|
29
|
+
* user taps it, and shows no "pasted from" banner. Renders nothing on Android and on older iOS.
|
|
30
|
+
*
|
|
31
|
+
* <DL2PasteButton style={{ width: 160, height: 44 }} onPaste={(text) => DL2.matchClipboard(text)} />
|
|
32
|
+
*/
|
|
33
|
+
export function DL2PasteButton(props: DL2PasteButtonProps): ReactElement | null {
|
|
34
|
+
if (Platform.OS !== "ios" || parseInt(String(Platform.Version), 10) < 16) return null;
|
|
35
|
+
const View = nativeView();
|
|
36
|
+
if (!View) return null;
|
|
37
|
+
return createElement(View, {
|
|
38
|
+
style: props.style || { width: 160, height: 44 },
|
|
39
|
+
onPaste: (e) => {
|
|
40
|
+
try {
|
|
41
|
+
const text = e && e.nativeEvent ? e.nativeEvent.text : undefined;
|
|
42
|
+
if (typeof text === "string" && text) props.onPaste(text);
|
|
43
|
+
} catch {
|
|
44
|
+
/* never let a handler error reach the native event dispatcher */
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
});
|
|
48
|
+
}
|