@frak-labs/core-sdk 0.1.0 → 0.1.1-beta.1e44255d

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 (131) hide show
  1. package/README.md +58 -0
  2. package/cdn/bundle.js +3 -8
  3. package/dist/actions.cjs +1 -1
  4. package/dist/actions.d.cts +3 -1400
  5. package/dist/actions.d.ts +3 -1400
  6. package/dist/actions.js +1 -1
  7. package/dist/bundle.cjs +1 -13
  8. package/dist/bundle.d.cts +4 -1927
  9. package/dist/bundle.d.ts +4 -1927
  10. package/dist/bundle.js +1 -13
  11. package/dist/computeLegacyProductId-BkyJ4rEY.d.ts +538 -0
  12. package/dist/computeLegacyProductId-Raks6FXg.d.cts +538 -0
  13. package/dist/index.cjs +1 -13
  14. package/dist/index.d.cts +3 -1269
  15. package/dist/index.d.ts +3 -1269
  16. package/dist/index.js +1 -13
  17. package/dist/openSso-BCJGchIb.d.cts +1022 -0
  18. package/dist/openSso-DG-_9CED.d.ts +1022 -0
  19. package/dist/setupClient-Cfwpu08d.js +13 -0
  20. package/dist/setupClient-Dh8ljuhV.cjs +13 -0
  21. package/dist/siweAuthenticate-BH7Dn7nZ.d.cts +536 -0
  22. package/dist/siweAuthenticate-BJHbtty4.js +1 -0
  23. package/dist/siweAuthenticate-Btem4QHs.d.ts +536 -0
  24. package/dist/siweAuthenticate-Cwj3HP0m.cjs +1 -0
  25. package/dist/trackEvent-M2RLTQ2p.js +1 -0
  26. package/dist/trackEvent-T_R9ER2S.cjs +1 -0
  27. package/package.json +25 -31
  28. package/src/actions/displayEmbeddedWallet.test.ts +194 -0
  29. package/src/actions/displayEmbeddedWallet.ts +21 -0
  30. package/src/actions/displayModal.test.ts +388 -0
  31. package/src/actions/displayModal.ts +120 -0
  32. package/src/actions/ensureIdentity.ts +68 -0
  33. package/src/actions/getMerchantInformation.test.ts +116 -0
  34. package/src/actions/getMerchantInformation.ts +16 -0
  35. package/src/actions/index.ts +30 -0
  36. package/src/actions/openSso.ts +118 -0
  37. package/src/actions/prepareSso.test.ts +223 -0
  38. package/src/actions/prepareSso.ts +48 -0
  39. package/src/actions/referral/processReferral.test.ts +248 -0
  40. package/src/actions/referral/processReferral.ts +232 -0
  41. package/src/actions/referral/referralInteraction.test.ts +147 -0
  42. package/src/actions/referral/referralInteraction.ts +52 -0
  43. package/src/actions/sendInteraction.ts +56 -0
  44. package/src/actions/trackPurchaseStatus.test.ts +500 -0
  45. package/src/actions/trackPurchaseStatus.ts +90 -0
  46. package/src/actions/watchWalletStatus.test.ts +372 -0
  47. package/src/actions/watchWalletStatus.ts +93 -0
  48. package/src/actions/wrapper/modalBuilder.test.ts +239 -0
  49. package/src/actions/wrapper/modalBuilder.ts +203 -0
  50. package/src/actions/wrapper/sendTransaction.test.ts +164 -0
  51. package/src/actions/wrapper/sendTransaction.ts +62 -0
  52. package/src/actions/wrapper/siweAuthenticate.test.ts +290 -0
  53. package/src/actions/wrapper/siweAuthenticate.ts +94 -0
  54. package/src/bundle.ts +2 -0
  55. package/src/clients/DebugInfo.test.ts +418 -0
  56. package/src/clients/DebugInfo.ts +182 -0
  57. package/src/clients/createIFrameFrakClient.ts +292 -0
  58. package/src/clients/index.ts +3 -0
  59. package/src/clients/setupClient.test.ts +343 -0
  60. package/src/clients/setupClient.ts +73 -0
  61. package/src/clients/transports/iframeLifecycleManager.test.ts +558 -0
  62. package/src/clients/transports/iframeLifecycleManager.ts +229 -0
  63. package/src/constants/interactionTypes.ts +15 -0
  64. package/src/constants/locales.ts +14 -0
  65. package/src/index.ts +109 -0
  66. package/src/types/client.ts +14 -0
  67. package/src/types/compression.ts +22 -0
  68. package/src/types/config.ts +117 -0
  69. package/src/types/context.ts +13 -0
  70. package/src/types/index.ts +74 -0
  71. package/src/types/lifecycle/client.ts +69 -0
  72. package/src/types/lifecycle/iframe.ts +41 -0
  73. package/src/types/lifecycle/index.ts +2 -0
  74. package/src/types/rpc/displayModal.ts +82 -0
  75. package/src/types/rpc/embedded/index.ts +68 -0
  76. package/src/types/rpc/embedded/loggedIn.ts +55 -0
  77. package/src/types/rpc/embedded/loggedOut.ts +28 -0
  78. package/src/types/rpc/interaction.ts +30 -0
  79. package/src/types/rpc/merchantInformation.ts +77 -0
  80. package/src/types/rpc/modal/final.ts +46 -0
  81. package/src/types/rpc/modal/generic.ts +46 -0
  82. package/src/types/rpc/modal/index.ts +16 -0
  83. package/src/types/rpc/modal/login.ts +36 -0
  84. package/src/types/rpc/modal/siweAuthenticate.ts +37 -0
  85. package/src/types/rpc/modal/transaction.ts +33 -0
  86. package/src/types/rpc/sso.ts +80 -0
  87. package/src/types/rpc/walletStatus.ts +29 -0
  88. package/src/types/rpc.ts +150 -0
  89. package/src/types/tracking.ts +60 -0
  90. package/src/types/transport.ts +34 -0
  91. package/src/utils/FrakContext.test.ts +407 -0
  92. package/src/utils/FrakContext.ts +158 -0
  93. package/src/utils/backendUrl.test.ts +83 -0
  94. package/src/utils/backendUrl.ts +62 -0
  95. package/src/utils/clientId.test.ts +41 -0
  96. package/src/utils/clientId.ts +43 -0
  97. package/src/utils/compression/b64.test.ts +181 -0
  98. package/src/utils/compression/b64.ts +29 -0
  99. package/src/utils/compression/compress.test.ts +123 -0
  100. package/src/utils/compression/compress.ts +11 -0
  101. package/src/utils/compression/decompress.test.ts +149 -0
  102. package/src/utils/compression/decompress.ts +11 -0
  103. package/src/utils/compression/index.ts +3 -0
  104. package/src/utils/computeLegacyProductId.ts +11 -0
  105. package/src/utils/constants.test.ts +23 -0
  106. package/src/utils/constants.ts +9 -0
  107. package/src/utils/deepLinkWithFallback.test.ts +243 -0
  108. package/src/utils/deepLinkWithFallback.ts +103 -0
  109. package/src/utils/formatAmount.test.ts +113 -0
  110. package/src/utils/formatAmount.ts +24 -0
  111. package/src/utils/getCurrencyAmountKey.test.ts +44 -0
  112. package/src/utils/getCurrencyAmountKey.ts +15 -0
  113. package/src/utils/getSupportedCurrency.test.ts +51 -0
  114. package/src/utils/getSupportedCurrency.ts +14 -0
  115. package/src/utils/getSupportedLocale.test.ts +64 -0
  116. package/src/utils/getSupportedLocale.ts +16 -0
  117. package/src/utils/iframeHelper.test.ts +463 -0
  118. package/src/utils/iframeHelper.ts +150 -0
  119. package/src/utils/index.ts +36 -0
  120. package/src/utils/merchantId.test.ts +653 -0
  121. package/src/utils/merchantId.ts +143 -0
  122. package/src/utils/sso.ts +126 -0
  123. package/src/utils/ssoUrlListener.test.ts +252 -0
  124. package/src/utils/ssoUrlListener.ts +60 -0
  125. package/src/utils/trackEvent.test.ts +180 -0
  126. package/src/utils/trackEvent.ts +41 -0
  127. package/cdn/bundle.js.LICENSE.txt +0 -10
  128. package/dist/interactions.cjs +0 -1
  129. package/dist/interactions.d.cts +0 -182
  130. package/dist/interactions.d.ts +0 -182
  131. 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,3 @@
1
+ export { base64urlDecode, base64urlEncode } from "./b64";
2
+ export { compressJsonToB64 } from "./compress";
3
+ export { decompressJsonFromB64 } from "./decompress";
@@ -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,9 @@
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://";
@@ -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;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;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,103 @@
1
+ import { 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
+ * Note: We intentionally omit the `package` parameter. Including it
33
+ * causes Chrome to redirect to the Play Store when the app is not
34
+ * installed, which breaks the visibility-based fallback detection.
35
+ * Without `package`, Chrome simply does nothing when the app is
36
+ * missing, allowing the fallback mechanism to fire correctly.
37
+ *
38
+ * Format: intent://path#Intent;scheme=frakwallet;end
39
+ */
40
+ export function toAndroidIntentUrl(deepLink: string): string {
41
+ // Extract everything after "frakwallet://"
42
+ const path = deepLink.slice(DEEP_LINK_SCHEME.length);
43
+ return `intent://${path}#Intent;scheme=frakwallet;end`;
44
+ }
45
+
46
+ /**
47
+ * Trigger a deep link with visibility-based fallback detection.
48
+ *
49
+ * Uses the Page Visibility API to detect if the app opened (page goes hidden).
50
+ * If the page remains visible after the timeout, assumes app is not installed
51
+ * and invokes the onFallback callback.
52
+ *
53
+ * On Chromium Android, converts custom scheme to intent:// URL to avoid
54
+ * the "Continue to app?" confirmation bar.
55
+ *
56
+ * @param deepLink - The deep link URL to trigger (e.g., "frakwallet://wallet")
57
+ * @param options - Optional configuration (timeout, onFallback callback)
58
+ */
59
+ export function triggerDeepLinkWithFallback(
60
+ deepLink: string,
61
+ options?: DeepLinkFallbackOptions
62
+ ): void {
63
+ const timeout = options?.timeout ?? 2500;
64
+
65
+ // Track if the app opened (page went to background)
66
+ let appOpened = false;
67
+
68
+ const onVisibilityChange = () => {
69
+ if (document.hidden) {
70
+ appOpened = true;
71
+ }
72
+ };
73
+
74
+ // Start listening for visibility changes
75
+ document.addEventListener("visibilitychange", onVisibilityChange);
76
+
77
+ // On Chromium Android, use intent:// to avoid confirmation bar
78
+ const url =
79
+ isChromiumAndroid() && isFrakDeepLink(deepLink)
80
+ ? toAndroidIntentUrl(deepLink)
81
+ : deepLink;
82
+
83
+ // Trigger the deep link
84
+ window.location.href = url;
85
+
86
+ // Check after timeout if app opened
87
+ setTimeout(() => {
88
+ // Clean up listener
89
+ document.removeEventListener("visibilitychange", onVisibilityChange);
90
+
91
+ if (!appOpened) {
92
+ // App didn't open - trigger fallback callback
93
+ options?.onFallback?.();
94
+ }
95
+ }, timeout);
96
+ }
97
+
98
+ /**
99
+ * Check if a URL is a Frak deep link
100
+ */
101
+ export function isFrakDeepLink(url: string): boolean {
102
+ return url.startsWith(DEEP_LINK_SCHEME);
103
+ }