@frak-labs/core-sdk 0.1.0 → 0.1.1-beta.4dfea079
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 +58 -0
- package/cdn/bundle.js +3 -8
- package/dist/actions.cjs +1 -1
- package/dist/actions.d.cts +3 -1400
- package/dist/actions.d.ts +3 -1400
- package/dist/actions.js +1 -1
- package/dist/bundle.cjs +1 -13
- package/dist/bundle.d.cts +4 -1927
- package/dist/bundle.d.ts +4 -1927
- package/dist/bundle.js +1 -13
- package/dist/computeLegacyProductId-CscYhyUi.d.cts +525 -0
- package/dist/computeLegacyProductId-WbD1gXV9.d.ts +525 -0
- package/dist/index.cjs +1 -13
- package/dist/index.d.cts +3 -1269
- package/dist/index.d.ts +3 -1269
- package/dist/index.js +1 -13
- package/dist/openSso-CC1-loUk.d.cts +1019 -0
- package/dist/openSso-tkqaDQLV.d.ts +1019 -0
- package/dist/setupClient-BjIbK6XJ.cjs +13 -0
- package/dist/setupClient-D_HId3e2.js +13 -0
- package/dist/siweAuthenticate-B_Z2OZmj.cjs +1 -0
- package/dist/siweAuthenticate-CQ4OfPuA.js +1 -0
- package/dist/siweAuthenticate-CR4Dpji6.d.cts +467 -0
- package/dist/siweAuthenticate-udoruuy9.d.ts +467 -0
- package/dist/trackEvent-CGIryq5h.cjs +1 -0
- package/dist/trackEvent-YfUh4jrx.js +1 -0
- package/package.json +24 -30
- package/src/actions/displayEmbeddedWallet.test.ts +194 -0
- package/src/actions/displayEmbeddedWallet.ts +20 -0
- package/src/actions/displayModal.test.ts +388 -0
- package/src/actions/displayModal.ts +120 -0
- package/src/actions/getMerchantInformation.test.ts +116 -0
- package/src/actions/getMerchantInformation.ts +9 -0
- package/src/actions/index.ts +29 -0
- package/src/actions/openSso.ts +116 -0
- package/src/actions/prepareSso.test.ts +223 -0
- package/src/actions/prepareSso.ts +48 -0
- package/src/actions/referral/processReferral.test.ts +248 -0
- package/src/actions/referral/processReferral.ts +232 -0
- package/src/actions/referral/referralInteraction.test.ts +147 -0
- package/src/actions/referral/referralInteraction.ts +52 -0
- package/src/actions/sendInteraction.ts +24 -0
- package/src/actions/trackPurchaseStatus.test.ts +287 -0
- package/src/actions/trackPurchaseStatus.ts +56 -0
- package/src/actions/watchWalletStatus.test.ts +372 -0
- package/src/actions/watchWalletStatus.ts +93 -0
- package/src/actions/wrapper/modalBuilder.test.ts +239 -0
- package/src/actions/wrapper/modalBuilder.ts +203 -0
- package/src/actions/wrapper/sendTransaction.test.ts +164 -0
- package/src/actions/wrapper/sendTransaction.ts +62 -0
- package/src/actions/wrapper/siweAuthenticate.test.ts +290 -0
- package/src/actions/wrapper/siweAuthenticate.ts +94 -0
- package/src/bundle.ts +2 -0
- package/src/clients/DebugInfo.test.ts +418 -0
- package/src/clients/DebugInfo.ts +182 -0
- package/src/clients/createIFrameFrakClient.ts +289 -0
- package/src/clients/index.ts +3 -0
- package/src/clients/setupClient.test.ts +343 -0
- package/src/clients/setupClient.ts +73 -0
- package/src/clients/transports/iframeLifecycleManager.test.ts +558 -0
- package/src/clients/transports/iframeLifecycleManager.ts +174 -0
- package/src/constants/interactionTypes.ts +15 -0
- package/src/constants/locales.ts +14 -0
- package/src/index.ts +110 -0
- package/src/types/client.ts +14 -0
- package/src/types/compression.ts +22 -0
- package/src/types/config.ts +117 -0
- package/src/types/context.ts +13 -0
- package/src/types/index.ts +75 -0
- package/src/types/lifecycle/client.ts +69 -0
- package/src/types/lifecycle/iframe.ts +41 -0
- package/src/types/lifecycle/index.ts +2 -0
- package/src/types/rpc/displayModal.ts +82 -0
- package/src/types/rpc/embedded/index.ts +68 -0
- package/src/types/rpc/embedded/loggedIn.ts +55 -0
- package/src/types/rpc/embedded/loggedOut.ts +28 -0
- package/src/types/rpc/interaction.ts +30 -0
- package/src/types/rpc/merchantInformation.ts +77 -0
- package/src/types/rpc/modal/final.ts +46 -0
- package/src/types/rpc/modal/generic.ts +46 -0
- package/src/types/rpc/modal/index.ts +16 -0
- package/src/types/rpc/modal/login.ts +36 -0
- package/src/types/rpc/modal/siweAuthenticate.ts +37 -0
- package/src/types/rpc/modal/transaction.ts +33 -0
- package/src/types/rpc/sso.ts +80 -0
- package/src/types/rpc/walletStatus.ts +29 -0
- package/src/types/rpc.ts +146 -0
- package/src/types/tracking.ts +60 -0
- package/src/types/transport.ts +34 -0
- package/src/utils/FrakContext.test.ts +407 -0
- package/src/utils/FrakContext.ts +158 -0
- package/src/utils/backendUrl.test.ts +83 -0
- package/src/utils/backendUrl.ts +62 -0
- package/src/utils/clientId.test.ts +41 -0
- package/src/utils/clientId.ts +40 -0
- package/src/utils/compression/b64.test.ts +181 -0
- package/src/utils/compression/b64.ts +29 -0
- package/src/utils/compression/compress.test.ts +123 -0
- package/src/utils/compression/compress.ts +11 -0
- package/src/utils/compression/decompress.test.ts +149 -0
- package/src/utils/compression/decompress.ts +11 -0
- package/src/utils/compression/index.ts +3 -0
- package/src/utils/computeLegacyProductId.ts +11 -0
- package/src/utils/constants.test.ts +23 -0
- package/src/utils/constants.ts +14 -0
- package/src/utils/deepLinkWithFallback.test.ts +243 -0
- package/src/utils/deepLinkWithFallback.ts +97 -0
- package/src/utils/formatAmount.test.ts +113 -0
- package/src/utils/formatAmount.ts +18 -0
- package/src/utils/getCurrencyAmountKey.test.ts +44 -0
- package/src/utils/getCurrencyAmountKey.ts +15 -0
- package/src/utils/getSupportedCurrency.test.ts +51 -0
- package/src/utils/getSupportedCurrency.ts +14 -0
- package/src/utils/getSupportedLocale.test.ts +64 -0
- package/src/utils/getSupportedLocale.ts +16 -0
- package/src/utils/iframeHelper.test.ts +450 -0
- package/src/utils/iframeHelper.ts +147 -0
- package/src/utils/index.ts +36 -0
- package/src/utils/merchantId.test.ts +564 -0
- package/src/utils/merchantId.ts +122 -0
- package/src/utils/sso.ts +126 -0
- package/src/utils/ssoUrlListener.test.ts +252 -0
- package/src/utils/ssoUrlListener.ts +60 -0
- package/src/utils/trackEvent.test.ts +180 -0
- package/src/utils/trackEvent.ts +31 -0
- package/cdn/bundle.js.LICENSE.txt +0 -10
- package/dist/interactions.cjs +0 -1
- package/dist/interactions.d.cts +0 -182
- package/dist/interactions.d.ts +0 -182
- package/dist/interactions.js +0 -1
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for compressJsonToB64 utility function
|
|
3
|
+
* Tests JSON compression to base64url-encoded string
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { vi } from "vitest";
|
|
7
|
+
|
|
8
|
+
// Mock the frame-connector module - must be before imports
|
|
9
|
+
vi.mock("@frak-labs/frame-connector", () => ({
|
|
10
|
+
jsonEncode: vi.fn((data: unknown) => {
|
|
11
|
+
// Simple mock: convert JSON to Uint8Array
|
|
12
|
+
const jsonString = JSON.stringify(data);
|
|
13
|
+
return new TextEncoder().encode(jsonString);
|
|
14
|
+
}),
|
|
15
|
+
}));
|
|
16
|
+
|
|
17
|
+
import { describe, expect, it } from "../../../tests/vitest-fixtures";
|
|
18
|
+
import { compressJsonToB64 } from "./compress";
|
|
19
|
+
|
|
20
|
+
describe("compressJsonToB64", () => {
|
|
21
|
+
describe("success cases", () => {
|
|
22
|
+
it("should compress and encode simple object", () => {
|
|
23
|
+
const data = { key: "value" };
|
|
24
|
+
const result = compressJsonToB64(data);
|
|
25
|
+
|
|
26
|
+
// Result should be a base64url-encoded string
|
|
27
|
+
expect(result).toBeDefined();
|
|
28
|
+
expect(typeof result).toBe("string");
|
|
29
|
+
expect(result.length).toBeGreaterThan(0);
|
|
30
|
+
// Base64url should not contain +, /, or = characters
|
|
31
|
+
expect(result).not.toMatch(/[+/=]/);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("should compress and encode array data", () => {
|
|
35
|
+
const data = [1, 2, 3, 4, 5];
|
|
36
|
+
const result = compressJsonToB64(data);
|
|
37
|
+
|
|
38
|
+
expect(result).toBeDefined();
|
|
39
|
+
expect(typeof result).toBe("string");
|
|
40
|
+
expect(result.length).toBeGreaterThan(0);
|
|
41
|
+
expect(result).not.toMatch(/[+/=]/);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("should compress and encode nested object", () => {
|
|
45
|
+
const data = {
|
|
46
|
+
user: {
|
|
47
|
+
name: "John",
|
|
48
|
+
address: {
|
|
49
|
+
city: "Paris",
|
|
50
|
+
country: "France",
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
const result = compressJsonToB64(data);
|
|
55
|
+
|
|
56
|
+
expect(result).toBeDefined();
|
|
57
|
+
expect(typeof result).toBe("string");
|
|
58
|
+
expect(result.length).toBeGreaterThan(0);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("should compress and encode string data", () => {
|
|
62
|
+
const data = "Hello, World!";
|
|
63
|
+
const result = compressJsonToB64(data);
|
|
64
|
+
|
|
65
|
+
expect(result).toBeDefined();
|
|
66
|
+
expect(typeof result).toBe("string");
|
|
67
|
+
expect(result.length).toBeGreaterThan(0);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("should compress and encode number data", () => {
|
|
71
|
+
const data = 12345;
|
|
72
|
+
const result = compressJsonToB64(data);
|
|
73
|
+
|
|
74
|
+
expect(result).toBeDefined();
|
|
75
|
+
expect(typeof result).toBe("string");
|
|
76
|
+
expect(result.length).toBeGreaterThan(0);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("should compress and encode boolean data", () => {
|
|
80
|
+
const data = true;
|
|
81
|
+
const result = compressJsonToB64(data);
|
|
82
|
+
|
|
83
|
+
expect(result).toBeDefined();
|
|
84
|
+
expect(typeof result).toBe("string");
|
|
85
|
+
expect(result.length).toBeGreaterThan(0);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("should compress and encode null", () => {
|
|
89
|
+
const data = null;
|
|
90
|
+
const result = compressJsonToB64(data);
|
|
91
|
+
|
|
92
|
+
expect(result).toBeDefined();
|
|
93
|
+
expect(typeof result).toBe("string");
|
|
94
|
+
expect(result.length).toBeGreaterThan(0);
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe("edge cases", () => {
|
|
99
|
+
it("should handle empty object", () => {
|
|
100
|
+
const data = {};
|
|
101
|
+
const result = compressJsonToB64(data);
|
|
102
|
+
|
|
103
|
+
expect(result).toBeDefined();
|
|
104
|
+
expect(typeof result).toBe("string");
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("should handle empty array", () => {
|
|
108
|
+
const data: unknown[] = [];
|
|
109
|
+
const result = compressJsonToB64(data);
|
|
110
|
+
|
|
111
|
+
expect(result).toBeDefined();
|
|
112
|
+
expect(typeof result).toBe("string");
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("should handle empty string", () => {
|
|
116
|
+
const data = "";
|
|
117
|
+
const result = compressJsonToB64(data);
|
|
118
|
+
|
|
119
|
+
expect(result).toBeDefined();
|
|
120
|
+
expect(typeof result).toBe("string");
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
});
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { jsonEncode } from "@frak-labs/frame-connector";
|
|
2
|
+
import { base64urlEncode } from "./b64";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Compress json data
|
|
6
|
+
* @param data
|
|
7
|
+
* @ignore
|
|
8
|
+
*/
|
|
9
|
+
export function compressJsonToB64(data: unknown): string {
|
|
10
|
+
return base64urlEncode(jsonEncode(data));
|
|
11
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for decompressJsonFromB64 utility function
|
|
3
|
+
* Tests decompression of base64url-encoded JSON strings
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { vi } from "vitest";
|
|
7
|
+
|
|
8
|
+
// Mock the frame-connector module - must be before imports
|
|
9
|
+
vi.mock("@frak-labs/frame-connector", () => ({
|
|
10
|
+
jsonEncode: vi.fn((data: unknown) => {
|
|
11
|
+
// Simple mock: convert JSON to Uint8Array
|
|
12
|
+
const jsonString = JSON.stringify(data);
|
|
13
|
+
return new TextEncoder().encode(jsonString);
|
|
14
|
+
}),
|
|
15
|
+
jsonDecode: vi.fn((data: Uint8Array) => {
|
|
16
|
+
// Simple mock: convert Uint8Array back to JSON
|
|
17
|
+
try {
|
|
18
|
+
const jsonString = new TextDecoder().decode(data);
|
|
19
|
+
return JSON.parse(jsonString);
|
|
20
|
+
} catch {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
}),
|
|
24
|
+
}));
|
|
25
|
+
|
|
26
|
+
import { describe, expect, it } from "../../../tests/vitest-fixtures";
|
|
27
|
+
import { compressJsonToB64 } from "./compress";
|
|
28
|
+
import { decompressJsonFromB64 } from "./decompress";
|
|
29
|
+
|
|
30
|
+
describe("decompressJsonFromB64", () => {
|
|
31
|
+
describe("success cases", () => {
|
|
32
|
+
it("should decompress simple object", () => {
|
|
33
|
+
const original = { key: "value" };
|
|
34
|
+
const compressed = compressJsonToB64(original);
|
|
35
|
+
const decompressed =
|
|
36
|
+
decompressJsonFromB64<typeof original>(compressed);
|
|
37
|
+
|
|
38
|
+
expect(decompressed).toEqual(original);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("should decompress array data", () => {
|
|
42
|
+
const original = [1, 2, 3, 4, 5];
|
|
43
|
+
const compressed = compressJsonToB64(original);
|
|
44
|
+
const decompressed =
|
|
45
|
+
decompressJsonFromB64<typeof original>(compressed);
|
|
46
|
+
|
|
47
|
+
expect(decompressed).toEqual(original);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("should decompress nested object", () => {
|
|
51
|
+
const original = {
|
|
52
|
+
user: {
|
|
53
|
+
name: "John",
|
|
54
|
+
address: {
|
|
55
|
+
city: "Paris",
|
|
56
|
+
country: "France",
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
const compressed = compressJsonToB64(original);
|
|
61
|
+
const decompressed =
|
|
62
|
+
decompressJsonFromB64<typeof original>(compressed);
|
|
63
|
+
|
|
64
|
+
expect(decompressed).toEqual(original);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("should decompress string data", () => {
|
|
68
|
+
const original = "Hello, World!";
|
|
69
|
+
const compressed = compressJsonToB64(original);
|
|
70
|
+
const decompressed = decompressJsonFromB64<string>(compressed);
|
|
71
|
+
|
|
72
|
+
expect(decompressed).toBe(original);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("should decompress number data", () => {
|
|
76
|
+
const original = 12345;
|
|
77
|
+
const compressed = compressJsonToB64(original);
|
|
78
|
+
const decompressed = decompressJsonFromB64<number>(compressed);
|
|
79
|
+
|
|
80
|
+
expect(decompressed).toBe(original);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("should decompress boolean data", () => {
|
|
84
|
+
const original = true;
|
|
85
|
+
const compressed = compressJsonToB64(original);
|
|
86
|
+
const decompressed = decompressJsonFromB64<boolean>(compressed);
|
|
87
|
+
|
|
88
|
+
expect(decompressed).toBe(original);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("should decompress null", () => {
|
|
92
|
+
const original = null;
|
|
93
|
+
const compressed = compressJsonToB64(original);
|
|
94
|
+
const decompressed = decompressJsonFromB64<null>(compressed);
|
|
95
|
+
|
|
96
|
+
expect(decompressed).toBeNull();
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe("round-trip compression", () => {
|
|
101
|
+
it("should preserve data through compress-decompress cycle", () => {
|
|
102
|
+
const original = {
|
|
103
|
+
id: 123,
|
|
104
|
+
name: "Test User",
|
|
105
|
+
tags: ["tag1", "tag2", "tag3"],
|
|
106
|
+
metadata: {
|
|
107
|
+
created: "2024-01-01",
|
|
108
|
+
updated: "2024-01-02",
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const compressed = compressJsonToB64(original);
|
|
113
|
+
const decompressed =
|
|
114
|
+
decompressJsonFromB64<typeof original>(compressed);
|
|
115
|
+
|
|
116
|
+
expect(decompressed).toEqual(original);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("should handle empty object round-trip", () => {
|
|
120
|
+
const original = {};
|
|
121
|
+
const compressed = compressJsonToB64(original);
|
|
122
|
+
const decompressed =
|
|
123
|
+
decompressJsonFromB64<typeof original>(compressed);
|
|
124
|
+
|
|
125
|
+
expect(decompressed).toEqual(original);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("should handle empty array round-trip", () => {
|
|
129
|
+
const original: unknown[] = [];
|
|
130
|
+
const compressed = compressJsonToB64(original);
|
|
131
|
+
const decompressed =
|
|
132
|
+
decompressJsonFromB64<typeof original>(compressed);
|
|
133
|
+
|
|
134
|
+
expect(decompressed).toEqual(original);
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
describe("edge cases", () => {
|
|
139
|
+
it("should handle empty object round-trip gracefully", () => {
|
|
140
|
+
// Empty objects should compress and decompress correctly
|
|
141
|
+
const original = {};
|
|
142
|
+
const compressed = compressJsonToB64(original);
|
|
143
|
+
const decompressed =
|
|
144
|
+
decompressJsonFromB64<typeof original>(compressed);
|
|
145
|
+
|
|
146
|
+
expect(decompressed).toEqual(original);
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
});
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { jsonDecode } from "@frak-labs/frame-connector";
|
|
2
|
+
import { base64urlDecode } from "./b64";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Decompress json data
|
|
6
|
+
* @param data
|
|
7
|
+
* @ignore
|
|
8
|
+
*/
|
|
9
|
+
export function decompressJsonFromB64<T>(data: string): T | null {
|
|
10
|
+
return jsonDecode<T>(base64urlDecode(data));
|
|
11
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { keccak256, toHex } from "viem";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Compute the legacy product id from a domain
|
|
5
|
+
* @ignore
|
|
6
|
+
*/
|
|
7
|
+
export function computeLegacyProductId({ domain }: { domain?: string } = {}) {
|
|
8
|
+
const effectiveDomain = domain ?? window.location.host;
|
|
9
|
+
const normalizedDomain = effectiveDomain.replace("www.", "");
|
|
10
|
+
return keccak256(toHex(normalizedDomain));
|
|
11
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for constants
|
|
3
|
+
* Tests backup key constant value
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, expect, it } from "../../tests/vitest-fixtures";
|
|
7
|
+
import { BACKUP_KEY } from "./constants";
|
|
8
|
+
|
|
9
|
+
describe("constants", () => {
|
|
10
|
+
describe("BACKUP_KEY", () => {
|
|
11
|
+
it("should have correct backup key value", () => {
|
|
12
|
+
expect(BACKUP_KEY).toBe("nexus-wallet-backup");
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("should be a string", () => {
|
|
16
|
+
expect(typeof BACKUP_KEY).toBe("string");
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("should not be empty", () => {
|
|
20
|
+
expect(BACKUP_KEY.length).toBeGreaterThan(0);
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The backup key for client side backup if needed
|
|
3
|
+
*/
|
|
4
|
+
export const BACKUP_KEY = "nexus-wallet-backup";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Deep link scheme for Frak Wallet mobile app
|
|
8
|
+
*/
|
|
9
|
+
export const DEEP_LINK_SCHEME = "frakwallet://";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Android package name for Frak Wallet (used in intent:// URLs)
|
|
13
|
+
*/
|
|
14
|
+
export const ANDROID_PACKAGE = "id.frak.wallet";
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
isFrakDeepLink,
|
|
4
|
+
triggerDeepLinkWithFallback,
|
|
5
|
+
} from "./deepLinkWithFallback";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Set navigator.userAgent for testing platform-specific behavior
|
|
9
|
+
*/
|
|
10
|
+
function mockUserAgent(ua: string) {
|
|
11
|
+
Object.defineProperty(navigator, "userAgent", {
|
|
12
|
+
value: ua,
|
|
13
|
+
writable: true,
|
|
14
|
+
configurable: true,
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const CHROME_ANDROID_UA =
|
|
19
|
+
"Mozilla/5.0 (Linux; Android 14) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36";
|
|
20
|
+
const FIREFOX_ANDROID_UA =
|
|
21
|
+
"Mozilla/5.0 (Android 14; Mobile; rv:121.0) Gecko/121.0 Firefox/121.0";
|
|
22
|
+
const DESKTOP_CHROME_UA =
|
|
23
|
+
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36";
|
|
24
|
+
|
|
25
|
+
describe("deepLinkWithFallback", () => {
|
|
26
|
+
let originalHidden: boolean;
|
|
27
|
+
let originalAddEventListener: typeof document.addEventListener;
|
|
28
|
+
let originalRemoveEventListener: typeof document.removeEventListener;
|
|
29
|
+
let originalUserAgent: string;
|
|
30
|
+
let visibilityChangeHandler: (() => void) | null = null;
|
|
31
|
+
|
|
32
|
+
beforeEach(() => {
|
|
33
|
+
vi.useFakeTimers();
|
|
34
|
+
|
|
35
|
+
// Store originals
|
|
36
|
+
originalHidden = document.hidden;
|
|
37
|
+
originalAddEventListener = document.addEventListener;
|
|
38
|
+
originalRemoveEventListener = document.removeEventListener;
|
|
39
|
+
originalUserAgent = navigator.userAgent;
|
|
40
|
+
|
|
41
|
+
// Default to desktop Chrome (non-Android)
|
|
42
|
+
mockUserAgent(DESKTOP_CHROME_UA);
|
|
43
|
+
|
|
44
|
+
// Mock document.hidden
|
|
45
|
+
Object.defineProperty(document, "hidden", {
|
|
46
|
+
value: false,
|
|
47
|
+
writable: true,
|
|
48
|
+
configurable: true,
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
// Mock window.location
|
|
52
|
+
Object.defineProperty(window, "location", {
|
|
53
|
+
value: { href: "https://test.com" },
|
|
54
|
+
writable: true,
|
|
55
|
+
configurable: true,
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
// Capture visibilitychange handler
|
|
59
|
+
visibilityChangeHandler = null;
|
|
60
|
+
document.addEventListener = vi.fn(
|
|
61
|
+
(event: string, handler: EventListener) => {
|
|
62
|
+
if (event === "visibilitychange") {
|
|
63
|
+
visibilityChangeHandler = handler as () => void;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
);
|
|
67
|
+
document.removeEventListener = vi.fn();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
afterEach(() => {
|
|
71
|
+
vi.useRealTimers();
|
|
72
|
+
vi.restoreAllMocks();
|
|
73
|
+
|
|
74
|
+
// Restore originals
|
|
75
|
+
Object.defineProperty(document, "hidden", {
|
|
76
|
+
value: originalHidden,
|
|
77
|
+
writable: true,
|
|
78
|
+
configurable: true,
|
|
79
|
+
});
|
|
80
|
+
document.addEventListener = originalAddEventListener;
|
|
81
|
+
document.removeEventListener = originalRemoveEventListener;
|
|
82
|
+
mockUserAgent(originalUserAgent);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe("triggerDeepLinkWithFallback", () => {
|
|
86
|
+
test("should trigger deep link immediately", () => {
|
|
87
|
+
triggerDeepLinkWithFallback("frakwallet://wallet");
|
|
88
|
+
|
|
89
|
+
expect(window.location.href).toBe("frakwallet://wallet");
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("should add visibilitychange listener", () => {
|
|
93
|
+
triggerDeepLinkWithFallback("frakwallet://wallet");
|
|
94
|
+
|
|
95
|
+
expect(document.addEventListener).toHaveBeenCalledWith(
|
|
96
|
+
"visibilitychange",
|
|
97
|
+
expect.any(Function)
|
|
98
|
+
);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("should trigger fallback callback when page stays visible after timeout", () => {
|
|
102
|
+
const onFallback = vi.fn();
|
|
103
|
+
|
|
104
|
+
triggerDeepLinkWithFallback("frakwallet://wallet", { onFallback });
|
|
105
|
+
|
|
106
|
+
// Document stays visible (app not installed)
|
|
107
|
+
Object.defineProperty(document, "hidden", {
|
|
108
|
+
value: false,
|
|
109
|
+
configurable: true,
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
// Advance past timeout
|
|
113
|
+
vi.advanceTimersByTime(2500);
|
|
114
|
+
|
|
115
|
+
expect(onFallback).toHaveBeenCalledTimes(1);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("should NOT trigger fallback when page goes hidden (app opened)", () => {
|
|
119
|
+
const onFallback = vi.fn();
|
|
120
|
+
|
|
121
|
+
triggerDeepLinkWithFallback("frakwallet://wallet", { onFallback });
|
|
122
|
+
|
|
123
|
+
// Simulate app opening (page goes to background)
|
|
124
|
+
Object.defineProperty(document, "hidden", {
|
|
125
|
+
value: true,
|
|
126
|
+
configurable: true,
|
|
127
|
+
});
|
|
128
|
+
visibilityChangeHandler?.();
|
|
129
|
+
|
|
130
|
+
// Advance past timeout
|
|
131
|
+
vi.advanceTimersByTime(2500);
|
|
132
|
+
|
|
133
|
+
// Fallback should NOT be triggered
|
|
134
|
+
expect(onFallback).not.toHaveBeenCalled();
|
|
135
|
+
// Location should still be the deep link
|
|
136
|
+
expect(window.location.href).toBe("frakwallet://wallet");
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("should respect custom timeout", () => {
|
|
140
|
+
const onFallback = vi.fn();
|
|
141
|
+
|
|
142
|
+
triggerDeepLinkWithFallback("frakwallet://wallet", {
|
|
143
|
+
timeout: 1000,
|
|
144
|
+
onFallback,
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
// Advance to just before custom timeout
|
|
148
|
+
vi.advanceTimersByTime(999);
|
|
149
|
+
expect(onFallback).not.toHaveBeenCalled();
|
|
150
|
+
|
|
151
|
+
// Advance past custom timeout
|
|
152
|
+
vi.advanceTimersByTime(1);
|
|
153
|
+
expect(onFallback).toHaveBeenCalledTimes(1);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("should remove event listener after timeout", () => {
|
|
157
|
+
triggerDeepLinkWithFallback("frakwallet://wallet");
|
|
158
|
+
|
|
159
|
+
// Advance past timeout
|
|
160
|
+
vi.advanceTimersByTime(2500);
|
|
161
|
+
|
|
162
|
+
expect(document.removeEventListener).toHaveBeenCalledWith(
|
|
163
|
+
"visibilitychange",
|
|
164
|
+
expect.any(Function)
|
|
165
|
+
);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test("should work without onFallback callback", () => {
|
|
169
|
+
// Should not throw
|
|
170
|
+
triggerDeepLinkWithFallback("frakwallet://wallet");
|
|
171
|
+
|
|
172
|
+
// Advance past timeout
|
|
173
|
+
vi.advanceTimersByTime(2500);
|
|
174
|
+
|
|
175
|
+
// No error should occur, callback is optional
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
describe("Android Intent URL conversion", () => {
|
|
179
|
+
test("should use intent:// URL on Chromium Android", () => {
|
|
180
|
+
mockUserAgent(CHROME_ANDROID_UA);
|
|
181
|
+
|
|
182
|
+
triggerDeepLinkWithFallback("frakwallet://wallet");
|
|
183
|
+
|
|
184
|
+
expect(window.location.href).toBe(
|
|
185
|
+
"intent://wallet#Intent;scheme=frakwallet;package=id.frak.wallet;end"
|
|
186
|
+
);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test("should preserve path and query params in intent URL", () => {
|
|
190
|
+
mockUserAgent(CHROME_ANDROID_UA);
|
|
191
|
+
|
|
192
|
+
triggerDeepLinkWithFallback(
|
|
193
|
+
"frakwallet://pair?id=abc-123&mode=embedded"
|
|
194
|
+
);
|
|
195
|
+
|
|
196
|
+
expect(window.location.href).toBe(
|
|
197
|
+
"intent://pair?id=abc-123&mode=embedded#Intent;scheme=frakwallet;package=id.frak.wallet;end"
|
|
198
|
+
);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test("should use custom scheme on Firefox Android", () => {
|
|
202
|
+
mockUserAgent(FIREFOX_ANDROID_UA);
|
|
203
|
+
|
|
204
|
+
triggerDeepLinkWithFallback("frakwallet://wallet");
|
|
205
|
+
|
|
206
|
+
expect(window.location.href).toBe("frakwallet://wallet");
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test("should use custom scheme on desktop Chrome", () => {
|
|
210
|
+
mockUserAgent(DESKTOP_CHROME_UA);
|
|
211
|
+
|
|
212
|
+
triggerDeepLinkWithFallback("frakwallet://wallet");
|
|
213
|
+
|
|
214
|
+
expect(window.location.href).toBe("frakwallet://wallet");
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
test("should not convert non-frak deep links to intent URL", () => {
|
|
218
|
+
mockUserAgent(CHROME_ANDROID_UA);
|
|
219
|
+
|
|
220
|
+
triggerDeepLinkWithFallback("https://wallet.frak.id/pair");
|
|
221
|
+
|
|
222
|
+
expect(window.location.href).toBe(
|
|
223
|
+
"https://wallet.frak.id/pair"
|
|
224
|
+
);
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
describe("isFrakDeepLink", () => {
|
|
230
|
+
test("should return true for frakwallet:// URLs", () => {
|
|
231
|
+
expect(isFrakDeepLink("frakwallet://wallet")).toBe(true);
|
|
232
|
+
expect(isFrakDeepLink("frakwallet://pair?id=123")).toBe(true);
|
|
233
|
+
expect(isFrakDeepLink("frakwallet://")).toBe(true);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
test("should return false for non-frakwallet URLs", () => {
|
|
237
|
+
expect(isFrakDeepLink("https://wallet.frak.id")).toBe(false);
|
|
238
|
+
expect(isFrakDeepLink("http://example.com")).toBe(false);
|
|
239
|
+
expect(isFrakDeepLink("myapp://something")).toBe(false);
|
|
240
|
+
expect(isFrakDeepLink("")).toBe(false);
|
|
241
|
+
});
|
|
242
|
+
});
|
|
243
|
+
});
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { ANDROID_PACKAGE, DEEP_LINK_SCHEME } from "./constants";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Options for deep link with fallback
|
|
5
|
+
*/
|
|
6
|
+
export type DeepLinkFallbackOptions = {
|
|
7
|
+
/** Timeout in ms before triggering fallback (default: 2500ms) */
|
|
8
|
+
timeout?: number;
|
|
9
|
+
/** Callback invoked when fallback is triggered (app not installed) */
|
|
10
|
+
onFallback?: () => void;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Check if running on a Chromium-based Android browser.
|
|
15
|
+
*
|
|
16
|
+
* On Chrome Android, custom scheme deep links (e.g. frakwallet://) trigger
|
|
17
|
+
* a confirmation bar ("Continue to Frak Wallet?"). Using intent:// URLs
|
|
18
|
+
* instead bypasses this for Chromium browsers while keeping custom scheme
|
|
19
|
+
* fallback for non-Chromium browsers (e.g. Firefox) where it works fine.
|
|
20
|
+
*/
|
|
21
|
+
export function isChromiumAndroid(): boolean {
|
|
22
|
+
const ua = navigator.userAgent;
|
|
23
|
+
return /Android/i.test(ua) && /Chrome\/\d+/i.test(ua);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Convert a frakwallet:// deep link to an Android intent:// URL.
|
|
28
|
+
*
|
|
29
|
+
* Intent URLs let Chromium browsers open the app directly without
|
|
30
|
+
* showing the "Continue to app?" confirmation bar.
|
|
31
|
+
*
|
|
32
|
+
* Format: intent://path#Intent;scheme=frakwallet;package=id.frak.wallet;end
|
|
33
|
+
*/
|
|
34
|
+
export function toAndroidIntentUrl(deepLink: string): string {
|
|
35
|
+
// Extract everything after "frakwallet://"
|
|
36
|
+
const path = deepLink.slice(DEEP_LINK_SCHEME.length);
|
|
37
|
+
return `intent://${path}#Intent;scheme=frakwallet;package=${ANDROID_PACKAGE};end`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Trigger a deep link with visibility-based fallback detection.
|
|
42
|
+
*
|
|
43
|
+
* Uses the Page Visibility API to detect if the app opened (page goes hidden).
|
|
44
|
+
* If the page remains visible after the timeout, assumes app is not installed
|
|
45
|
+
* and invokes the onFallback callback.
|
|
46
|
+
*
|
|
47
|
+
* On Chromium Android, converts custom scheme to intent:// URL to avoid
|
|
48
|
+
* the "Continue to app?" confirmation bar.
|
|
49
|
+
*
|
|
50
|
+
* @param deepLink - The deep link URL to trigger (e.g., "frakwallet://wallet")
|
|
51
|
+
* @param options - Optional configuration (timeout, onFallback callback)
|
|
52
|
+
*/
|
|
53
|
+
export function triggerDeepLinkWithFallback(
|
|
54
|
+
deepLink: string,
|
|
55
|
+
options?: DeepLinkFallbackOptions
|
|
56
|
+
): void {
|
|
57
|
+
const timeout = options?.timeout ?? 2500;
|
|
58
|
+
|
|
59
|
+
// Track if the app opened (page went to background)
|
|
60
|
+
let appOpened = false;
|
|
61
|
+
|
|
62
|
+
const onVisibilityChange = () => {
|
|
63
|
+
if (document.hidden) {
|
|
64
|
+
appOpened = true;
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
// Start listening for visibility changes
|
|
69
|
+
document.addEventListener("visibilitychange", onVisibilityChange);
|
|
70
|
+
|
|
71
|
+
// On Chromium Android, use intent:// to avoid confirmation bar
|
|
72
|
+
const url =
|
|
73
|
+
isChromiumAndroid() && isFrakDeepLink(deepLink)
|
|
74
|
+
? toAndroidIntentUrl(deepLink)
|
|
75
|
+
: deepLink;
|
|
76
|
+
|
|
77
|
+
// Trigger the deep link
|
|
78
|
+
window.location.href = url;
|
|
79
|
+
|
|
80
|
+
// Check after timeout if app opened
|
|
81
|
+
setTimeout(() => {
|
|
82
|
+
// Clean up listener
|
|
83
|
+
document.removeEventListener("visibilitychange", onVisibilityChange);
|
|
84
|
+
|
|
85
|
+
if (!appOpened) {
|
|
86
|
+
// App didn't open - trigger fallback callback
|
|
87
|
+
options?.onFallback?.();
|
|
88
|
+
}
|
|
89
|
+
}, timeout);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Check if a URL is a Frak deep link
|
|
94
|
+
*/
|
|
95
|
+
export function isFrakDeepLink(url: string): boolean {
|
|
96
|
+
return url.startsWith(DEEP_LINK_SCHEME);
|
|
97
|
+
}
|