@multiplatform.one/web3 7.34.2 → 7.34.4

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 (55) hide show
  1. package/README.md +8 -2
  2. package/dist/cjs/bundler/metaMaskConnectTimeouts.cjs +15 -6
  3. package/dist/cjs/bundler/metaMaskConnectTimeouts.native.js +15 -6
  4. package/dist/cjs/bundler/metaMaskConnectTimeouts.native.js.map +1 -1
  5. package/dist/cjs/connectkit/openConnector/device.native.js.map +1 -1
  6. package/dist/cjs/keycloak/device.cjs +3 -1
  7. package/dist/cjs/keycloak/device.native.js +4 -2
  8. package/dist/cjs/keycloak/device.native.js.map +1 -1
  9. package/dist/cjs/vite.native.js.map +1 -1
  10. package/dist/esm/bundler/metaMaskConnectTimeouts.mjs +15 -6
  11. package/dist/esm/bundler/metaMaskConnectTimeouts.mjs.map +1 -1
  12. package/dist/esm/bundler/metaMaskConnectTimeouts.native.js +15 -6
  13. package/dist/esm/bundler/metaMaskConnectTimeouts.native.js.map +1 -1
  14. package/dist/esm/connectkit/openConnector/device.mjs.map +1 -1
  15. package/dist/esm/connectkit/openConnector/device.native.js.map +1 -1
  16. package/dist/esm/keycloak/device.mjs +3 -1
  17. package/dist/esm/keycloak/device.mjs.map +1 -1
  18. package/dist/esm/keycloak/device.native.js +4 -2
  19. package/dist/esm/keycloak/device.native.js.map +1 -1
  20. package/dist/esm/vite.mjs.map +1 -1
  21. package/dist/esm/vite.native.js.map +1 -1
  22. package/dist/jsx/bundler/metaMaskConnectTimeouts.mjs +15 -6
  23. package/dist/jsx/bundler/metaMaskConnectTimeouts.mjs.map +1 -1
  24. package/dist/jsx/bundler/metaMaskConnectTimeouts.native.js +15 -6
  25. package/dist/jsx/bundler/metaMaskConnectTimeouts.native.js.map +1 -1
  26. package/dist/jsx/connectkit/openConnector/device.mjs.map +1 -1
  27. package/dist/jsx/connectkit/openConnector/device.native.js.map +1 -1
  28. package/dist/jsx/keycloak/device.mjs +3 -1
  29. package/dist/jsx/keycloak/device.mjs.map +1 -1
  30. package/dist/jsx/keycloak/device.native.js +4 -2
  31. package/dist/jsx/keycloak/device.native.js.map +1 -1
  32. package/dist/jsx/vite.mjs.map +1 -1
  33. package/dist/jsx/vite.native.js.map +1 -1
  34. package/dist/keycloak/login.iife.js +277 -277
  35. package/package.json +6 -6
  36. package/src/bundler/metaMaskConnectTimeouts.spec.ts +82 -7
  37. package/src/bundler/metaMaskConnectTimeouts.ts +29 -10
  38. package/src/connectkit/connectors/metaMaskConnectConnector.spec.ts +32 -0
  39. package/src/connectkit/openConnector/context.tsx +3 -3
  40. package/src/connectkit/openConnector/device.spec.ts +27 -0
  41. package/src/connectkit/openConnector/device.ts +5 -4
  42. package/src/esbuild.ts +3 -3
  43. package/src/keycloak/device.spec.ts +39 -0
  44. package/src/keycloak/device.ts +5 -1
  45. package/src/vite.ts +5 -3
  46. package/types/bundler/metaMaskConnectTimeouts.d.ts +6 -4
  47. package/types/bundler/metaMaskConnectTimeouts.d.ts.map +1 -1
  48. package/types/connectkit/openConnector/context.d.ts +3 -3
  49. package/types/connectkit/openConnector/device.d.ts +5 -4
  50. package/types/connectkit/openConnector/device.d.ts.map +1 -1
  51. package/types/esbuild.d.ts +3 -3
  52. package/types/keycloak/device.d.ts +1 -0
  53. package/types/keycloak/device.d.ts.map +1 -1
  54. package/types/vite.d.ts +5 -3
  55. package/types/vite.d.ts.map +1 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@multiplatform.one/web3",
3
- "version": "7.34.2",
3
+ "version": "7.34.4",
4
4
  "description": "Web3 wallet integration for multiplatform.one ecosystem with ConnectKit and wagmi",
5
5
  "keywords": [
6
6
  "connectkit",
@@ -120,10 +120,10 @@
120
120
  "@aave/account": "^0.2.1",
121
121
  "@coinbase/wallet-sdk": "^4.4.0",
122
122
  "@metamask/connect-evm": "^2.1.1",
123
- "@multiplatform.one/connectkit": "7.34.2",
124
- "@multiplatform.one/gpg": "7.34.2",
125
- "@multiplatform.one/platform": "7.34.2",
126
- "@multiplatform.one/theme": "7.34.2",
123
+ "@multiplatform.one/connectkit": "^7.34.4",
124
+ "@multiplatform.one/gpg": "^7.34.4",
125
+ "@multiplatform.one/platform": "^7.34.4",
126
+ "@multiplatform.one/theme": "^7.34.4",
127
127
  "@noble/ciphers": "^1.3.0",
128
128
  "@noble/curves": "^1.9.7",
129
129
  "@solana/kit": "^8.5.0",
@@ -146,7 +146,7 @@
146
146
  "wagmi": "^3.7.1"
147
147
  },
148
148
  "devDependencies": {
149
- "@multiplatform.one/config": "7.34.2",
149
+ "@multiplatform.one/config": "7.34.4",
150
150
  "@tamagui/build": "2.7.6",
151
151
  "@tanstack/react-query": "^5.104.3",
152
152
  "@testing-library/jest-dom": "^7.0.1",
@@ -49,6 +49,9 @@ const multichainFiles = exportedFiles(multichainDir);
49
49
  const dappClientFiles = exportedFiles(dappClientDir);
50
50
  const read = (file: string) => readFileSync(file, "utf8");
51
51
 
52
+ const iPadCheck =
53
+ /if \(window\.navigator\.maxTouchPoints > 1 && \/Macintosh\/\.test\(window\.navigator\.userAgent\)\) return true;/g;
54
+
52
55
  const pageLoadResume =
53
56
  /resumeSession_fn = function\(session, options\)[\s\S]*?isContinuingPriorAttempt \? this\.options\.resumeTimeout : options \? this\.options\.connectionTimeout : 120 \* 1e3/;
54
57
  const stockConnectionTimer =
@@ -96,6 +99,18 @@ describe("the timer patches on the installed MetaMask Connect", () => {
96
99
  }
97
100
  });
98
101
 
102
+ it("adds the iPad touch check to the platform check once", () => {
103
+ for (const file of multichainFiles) {
104
+ const code = read(file);
105
+ expect(code).not.toContain("maxTouchPoints");
106
+ const patched = patchMetaMaskConnectTimeouts(file, code)!.code;
107
+ expect(patched.match(iPadCheck)).toHaveLength(1);
108
+ expect(patched).toMatch(
109
+ /function isMobile\d*\(\) \{\n {2}if \(window\.navigator\.maxTouchPoints > 1/,
110
+ );
111
+ }
112
+ });
113
+
99
114
  it("fails the build when a package renames the marker it looks for", () => {
100
115
  const [multichain] = multichainFiles;
101
116
  const [dappClient] = dappClientFiles;
@@ -109,9 +124,7 @@ describe("the timer patches on the installed MetaMask Connect", () => {
109
124
  ),
110
125
  ).toBeUndefined();
111
126
  renamedGrace.patch(dappClient!, read(dappClient!));
112
- expect(renamedGrace.problem()).toMatch(
113
- /left the timers in @metamask\/connect-multichain unpatched/,
114
- );
127
+ expect(renamedGrace.problem()).toMatch(/left @metamask\/connect-multichain unpatched/);
115
128
 
116
129
  const renamedHandshake = createMetaMaskConnectTimeoutTracker();
117
130
  renamedHandshake.see(connectEvmEntry);
@@ -121,7 +134,7 @@ describe("the timer patches on the installed MetaMask Connect", () => {
121
134
  read(dappClient!).replaceAll("HANDSHAKE_TIMEOUT", "HANDSHAKE_GRACE"),
122
135
  );
123
136
  expect(renamedHandshake.problem()).toMatch(
124
- /left the timers in @metamask\/mobile-wallet-protocol-dapp-client unpatched/,
137
+ /left @metamask\/mobile-wallet-protocol-dapp-client unpatched/,
125
138
  );
126
139
  });
127
140
 
@@ -142,6 +155,12 @@ describe("the timer patches on the installed MetaMask Connect", () => {
142
155
  read(dappClient!).replace("HANDSHAKE_TIMEOUT = 60 * 1e3;", "HANDSHAKE_TIMEOUT = 45 * 1e3;"),
143
156
  ),
144
157
  ).toThrow(/timer HANDSHAKE_TIMEOUT not found/);
158
+ expect(() =>
159
+ patchMetaMaskConnectTimeouts(
160
+ multichain!,
161
+ code.replace(/function isMobile(\d*)\(\)/, "function isPhone$1()"),
162
+ ),
163
+ ).toThrow(/platform check isMobile not found .* would lose the MetaMask app on an iPad/);
145
164
  // A resume that no longer reads the connection timer must not send the
146
165
  // rewrite on to startSession's copy of the expression.
147
166
  expect(() =>
@@ -259,6 +278,61 @@ describe("the patched MetaMask Connect transport", () => {
259
278
  });
260
279
  });
261
280
 
281
+ const userAgents = {
282
+ iPadSafari:
283
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Safari/605.1.15",
284
+ iPhone:
285
+ "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1",
286
+ windows:
287
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36",
288
+ };
289
+
290
+ /** connect-multichain's getPlatformType from its node build, with the patch applied or not. */
291
+ function loadGetPlatformType(patched: boolean): () => string {
292
+ const file = requireHere.resolve(multichainPkg);
293
+ const code = patched ? patchMetaMaskConnectTimeouts(file, read(file))!.code : read(file);
294
+ const wrapper = runInThisContext(
295
+ `(function (module, exports, require, __filename, __dirname) {${code}\nreturn (init_platform(), getPlatformType);\n})`,
296
+ { filename: file },
297
+ );
298
+ const module = { exports: {} };
299
+ return wrapper(module, module.exports, createRequire(file), file, dirname(file));
300
+ }
301
+
302
+ describe("the patched MetaMask Connect platform check", () => {
303
+ const stock = loadGetPlatformType(false);
304
+ const patched = loadGetPlatformType(true);
305
+
306
+ function on(userAgent: string, maxTouchPoints: number) {
307
+ const navigator = { userAgent, maxTouchPoints, product: "Gecko" };
308
+ vi.stubGlobal("navigator", navigator);
309
+ vi.stubGlobal("window", { navigator });
310
+ }
311
+
312
+ afterEach(() => vi.unstubAllGlobals());
313
+
314
+ it("takes an iPad on Safari's desktop site for mobile web, which stock MetaMask Connect does not", () => {
315
+ on(userAgents.iPadSafari, 5);
316
+ expect(stock()).toBe("web-desktop");
317
+ expect(patched()).toBe("web-mobile");
318
+ });
319
+
320
+ it("keeps a Mac, which reports no touch points, on desktop web", () => {
321
+ on(userAgents.iPadSafari, 0);
322
+ expect(patched()).toBe("web-desktop");
323
+ });
324
+
325
+ it("keeps a touch-screen Windows laptop on desktop web", () => {
326
+ on(userAgents.windows, 10);
327
+ expect(patched()).toBe("web-desktop");
328
+ });
329
+
330
+ it("still takes an iPhone for mobile web", () => {
331
+ on(userAgents.iPhone, 5);
332
+ expect(patched()).toBe("web-mobile");
333
+ });
334
+ });
335
+
262
336
  const entryId = "mpo-web3-timer-entry";
263
337
  const entrySource = 'export { createEVMClient } from "@metamask/connect-evm";';
264
338
 
@@ -285,6 +359,7 @@ function expectLongTimers(code: string) {
285
359
  expect(code).toMatch(/resumeTimeout : [\w$]+ \? this\.options\.connectionTimeout : 120 \* 1e3/);
286
360
  expect(code).not.toMatch(/DEFAULT_REQUEST_TIMEOUT[\w$]* = 60 \* 1e3;\s+CONNECTION_GRACE_PERIOD/);
287
361
  expect(code).not.toMatch(/HANDSHAKE_TIMEOUT[\w$]* = 60 \* 1e3;/);
362
+ expect(code.match(iPadCheck)).toHaveLength(1);
288
363
  }
289
364
 
290
365
  function viteBuild(plugins: Plugin[]) {
@@ -315,7 +390,7 @@ describe("the bundler plugins", () => {
315
390
 
316
391
  it("fails a vite build that bundles MetaMask Connect with a marker gone", async () => {
317
392
  await expect(viteBuild([entry, renameGracePeriod, metaMaskConnectTimeouts()])).rejects.toThrow(
318
- /left the timers in @metamask\/connect-multichain unpatched/,
393
+ /left @metamask\/connect-multichain unpatched/,
319
394
  );
320
395
  }, 120_000);
321
396
 
@@ -351,7 +426,7 @@ describe("the bundler plugins", () => {
351
426
 
352
427
  it("fails the dev server's pre-bundle when a marker is gone", async () => {
353
428
  await expect(preBundle([renameGracePeriod])).rejects.toThrow(
354
- /left the timers in @metamask\/connect-multichain unpatched/,
429
+ /left @metamask\/connect-multichain unpatched/,
355
430
  );
356
431
  }, 120_000);
357
432
 
@@ -387,7 +462,7 @@ describe("the bundler plugins", () => {
387
462
  },
388
463
  };
389
464
  await expect(esbuildBundle([renamed])).rejects.toThrow(
390
- /left the timers in @metamask\/connect-multichain unpatched/,
465
+ /left @metamask\/connect-multichain unpatched/,
391
466
  );
392
467
  }, 120_000);
393
468
  });
@@ -13,16 +13,25 @@
13
13
  // MetaMask and connector calls wait on the resume. The resume made without
14
14
  // connect options (the page-load one) keeps the stock 120 s.
15
15
  //
16
+ // The same patch makes MetaMask Connect's platform check count an iPad. iPadOS
17
+ // Safari sends a Mac user agent by default, so MetaMask Connect took it for a
18
+ // desktop and showed its install/QR modal instead of opening the MetaMask app.
19
+ // A Mac reports no touch points, so it keeps the extension and QR path.
20
+ //
16
21
  // A pattern that stops matching fails the build instead of quietly shipping the
17
- // short timers again.
22
+ // short timers, or the desktop iPad, again.
18
23
 
19
24
  export type MetaMaskConnectTimerRewrite = {
20
- /** The timer, as a build error names it. */
21
- timer: string;
25
+ /** What the rewrite changes, as a build error names it. */
26
+ target: string;
27
+ /** What a build without the rewrite would lose, as a build error names it. */
28
+ lost: string;
22
29
  pattern: RegExp;
23
30
  replacement: string;
24
31
  };
25
32
 
33
+ const longTimers = "the long Android timers";
34
+
26
35
  export type MetaMaskConnectTimeoutPatch = {
27
36
  pkg: string;
28
37
  marker: string;
@@ -35,16 +44,25 @@ export const metaMaskConnectTimeoutPatches: readonly MetaMaskConnectTimeoutPatch
35
44
  marker: "CONNECTION_GRACE_PERIOD",
36
45
  rewrites: [
37
46
  {
38
- timer: "DEFAULT_REQUEST_TIMEOUT",
47
+ target: "timer DEFAULT_REQUEST_TIMEOUT",
48
+ lost: longTimers,
39
49
  pattern: /(DEFAULT_REQUEST_TIMEOUT\d*) = 60 \* 1e3;(\s+CONNECTION_GRACE_PERIOD =)/,
40
50
  replacement: "$1 = 600 * 1e3;$2",
41
51
  },
42
52
  {
43
- timer: "the page-load session resume",
53
+ target: "timer the page-load session resume",
54
+ lost: longTimers,
44
55
  pattern:
45
56
  /(resumeSession_fn = function\((\w+), (\w+)\)(?:(?!startSession_fn)[\s\S])*?isContinuingPriorAttempt \? this\.options\.resumeTimeout : )this\.options\.connectionTimeout/,
46
57
  replacement: "$1$3 ? this.options.connectionTimeout : 120 * 1e3",
47
58
  },
59
+ {
60
+ target: "platform check isMobile",
61
+ lost: "the MetaMask app on an iPad",
62
+ pattern: /(function isMobile\d*\(\) \{)(?=[^}]*?\.parse\(window\.navigator\.userAgent\))/,
63
+ replacement:
64
+ "$1\n if (window.navigator.maxTouchPoints > 1 && /Macintosh/.test(window.navigator.userAgent)) return true;",
65
+ },
48
66
  ],
49
67
  },
50
68
  {
@@ -52,7 +70,8 @@ export const metaMaskConnectTimeoutPatches: readonly MetaMaskConnectTimeoutPatch
52
70
  marker: "HANDSHAKE_TIMEOUT",
53
71
  rewrites: [
54
72
  {
55
- timer: "HANDSHAKE_TIMEOUT",
73
+ target: "timer HANDSHAKE_TIMEOUT",
74
+ lost: longTimers,
56
75
  pattern: /(HANDSHAKE_TIMEOUT) = 60 \* 1e3;/,
57
76
  replacement: "$1 = 540 * 1e3;",
58
77
  },
@@ -89,7 +108,7 @@ export function patchMetaMaskConnectTimeouts(
89
108
  for (const rewrite of patch.rewrites) {
90
109
  if (!rewrite.pattern.test(patched))
91
110
  throw new Error(
92
- `MetaMask Connect timer ${rewrite.timer} not found in ${id}: ${patch.pkg} changed its source, so the long Android timers cannot be applied`,
111
+ `MetaMask Connect ${rewrite.target} not found in ${id}: ${patch.pkg} changed its source, and a build without this rewrite would lose ${rewrite.lost}`,
93
112
  );
94
113
  patched = patched.replace(rewrite.pattern, rewrite.replacement);
95
114
  }
@@ -98,7 +117,7 @@ export function patchMetaMaskConnectTimeouts(
98
117
 
99
118
  /**
100
119
  * Counts what one build patched, so the build can refuse to ship MetaMask Connect
101
- * with a timer left short (a package renamed the marker the patch looks for).
120
+ * with a patch left out (a package renamed the marker the patch looks for).
102
121
  */
103
122
  export function createMetaMaskConnectTimeoutTracker() {
104
123
  const patched = new Map<string, number>();
@@ -113,14 +132,14 @@ export function createMetaMaskConnectTimeoutTracker() {
113
132
  if (result) patched.set(result.pkg, (patched.get(result.pkg) ?? 0) + 1);
114
133
  return result;
115
134
  },
116
- /** Error text when the build bundles MetaMask Connect with a timer left unpatched. */
135
+ /** Error text when the build bundles MetaMask Connect with a patch left out. */
117
136
  problem(): string | undefined {
118
137
  if (!bundlesMetaMaskConnect) return undefined;
119
138
  const missing = metaMaskConnectTimeoutPatches
120
139
  .map((patch) => patch.pkg)
121
140
  .filter((pkg) => !patched.get(pkg));
122
141
  if (!missing.length) return undefined;
123
- return `the build bundles ${metaMaskConnectPkg} but left the timers in ${missing.join(", ")} unpatched`;
142
+ return `the build bundles ${metaMaskConnectPkg} but left ${missing.join(", ")} unpatched`;
124
143
  },
125
144
  };
126
145
  }
@@ -620,6 +620,38 @@ describe("metaMaskConnect switchChain on a phone", () => {
620
620
  expect(addCalls()).toHaveLength(1);
621
621
  });
622
622
 
623
+ describe("without an explicit mobile", () => {
624
+ const macUserAgent =
625
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Safari/605.1.15";
626
+
627
+ afterEach(() => vi.unstubAllGlobals());
628
+
629
+ it("takes the phone path on an iPad on Safari's desktop site, which sends a Mac user agent", async () => {
630
+ vi.stubGlobal("navigator", { userAgent: macUserAgent, maxTouchPoints: 5 });
631
+ const connector = build([baseSepolia, bscTestnet]);
632
+ await connector.connect();
633
+
634
+ await connector.switchChain!({ chainId: baseSepolia.id });
635
+
636
+ expect(client.switchChain).not.toHaveBeenCalled();
637
+ expect(addCalls()).toHaveLength(1);
638
+ });
639
+
640
+ it("keeps a Mac, which reports no touch points, on the desktop path", async () => {
641
+ vi.stubGlobal("navigator", { userAgent: macUserAgent, maxTouchPoints: 0 });
642
+ const connector = build([baseSepolia, bscTestnet]);
643
+ await connector.connect();
644
+
645
+ await connector.switchChain!({ chainId: baseSepolia.id });
646
+
647
+ expect(addCalls()).toHaveLength(0);
648
+ expect(client.switchChain).toHaveBeenCalledWith({
649
+ chainId: hex(baseSepolia.id),
650
+ chainConfiguration: expect.objectContaining({ chainId: hex(baseSepolia.id) }),
651
+ });
652
+ });
653
+ });
654
+
623
655
  it("keeps the desktop switch as MetaMask Connect does it, with the chain configuration", async () => {
624
656
  const connector = build([baseSepolia], { mobile: false });
625
657
  await connector.connect();
@@ -31,9 +31,9 @@ export type MobileConnectOptions = {
31
31
  /**
32
32
  * Connector id a phone connects straight to (e.g. `"metaMaskSDK"`): `openSIWE()`
33
33
  * and the default ConnectKit button skip the wallet list for it. Desktop keeps
34
- * the list. A phone here is what connectkit's `isMobile()` reads from the user
35
- * agent, so iPadOS Safari and Android's "Desktop site", which both send a desktop
36
- * one, keep the list too.
34
+ * the list. A phone here is what connectkit's `isMobile()` reads: an iPad on
35
+ * Safari's default desktop site counts, a Mac does not, and Android's
36
+ * "Desktop site" keeps the list.
37
37
  */
38
38
  preferredConnector?: string;
39
39
  /** Passed to `openConnector` for the preferred connector. */
@@ -1,4 +1,5 @@
1
1
  // @vitest-environment happy-dom
2
+ import { isMobile } from "@multiplatform.one/connectkit";
2
3
  import { afterEach, describe, expect, it } from "vitest";
3
4
 
4
5
  import { defaultOpenConnectorFallbackMs, isTouchDevice } from "./device";
@@ -11,6 +12,8 @@ const IPADOS_SAFARI_UA =
11
12
  "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Safari/605.1.15";
12
13
  const ANDROID_DESKTOP_SITE_UA =
13
14
  "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36";
15
+ const IPHONE_UA =
16
+ "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1";
14
17
  const ANDROID_UA =
15
18
  "Mozilla/5.0 (Linux; Android 16; Pixel 9) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Mobile Safari/537.36";
16
19
  const WINDOWS_UA =
@@ -84,3 +87,27 @@ describe("defaultOpenConnectorFallbackMs", () => {
84
87
  expect(DEFAULT_DESKTOP_OPEN_CONNECTOR_FALLBACK_MS).toBeGreaterThan(0);
85
88
  });
86
89
  });
90
+
91
+ describe("connectkit's isMobile", () => {
92
+ it("counts an iPad on Safari's default desktop site, which sends a Mac user agent", () => {
93
+ setNavigator(IPADOS_SAFARI_UA, 5);
94
+ expect(isMobile()).toBe(true);
95
+ });
96
+
97
+ it("does not count a Mac, which reports no touch points", () => {
98
+ setNavigator(IPADOS_SAFARI_UA, 0);
99
+ expect(isMobile()).toBe(false);
100
+ });
101
+
102
+ it("counts an iPhone and an Android phone", () => {
103
+ setNavigator(IPHONE_UA, 5);
104
+ expect(isMobile()).toBe(true);
105
+ setNavigator(ANDROID_UA, 5);
106
+ expect(isMobile()).toBe(true);
107
+ });
108
+
109
+ it("does not count a touch-screen Windows laptop", () => {
110
+ setNavigator(WINDOWS_UA, 10);
111
+ expect(isMobile()).toBe(false);
112
+ });
113
+ });
@@ -9,10 +9,11 @@ type DeviceNavigator = { userAgent?: string; maxTouchPoints?: number };
9
9
  type MatchMedia = (query: string) => { matches: boolean };
10
10
 
11
11
  /**
12
- * A touch device whose browser sends a desktop user agent, which connectkit's
13
- * `isMobile()` reads as desktop: iPadOS Safari sends a Mac one, and Android's
14
- * "Desktop site" a Linux one. A Mac reports no touch points, and a laptop's touch
15
- * screen is not its primary pointer, so neither counts.
12
+ * A touch device whose browser sends a desktop user agent: iPadOS Safari sends a
13
+ * Mac one, which connectkit's `isMobile()` also counts, and Android's "Desktop
14
+ * site" a Linux one, which `isMobile()` reads as desktop. A Mac reports no touch
15
+ * points, and a laptop's touch screen is not its primary pointer, so neither
16
+ * counts.
16
17
  */
17
18
  export function isTouchDevice(
18
19
  nav: DeviceNavigator | null = typeof navigator === "undefined" ? null : navigator,
package/src/esbuild.ts CHANGED
@@ -14,9 +14,9 @@ export type MetaMaskConnectTimeoutsEsbuildBuild = {
14
14
 
15
15
  /**
16
16
  * esbuild counterpart of `@multiplatform.one/web3/vite`'s metaMaskConnectTimeouts:
17
- * raises MetaMask Connect's relay timers to ten minutes in a bundle that carries
18
- * the wallet modal, and fails the build if it bundles MetaMask Connect with a
19
- * timer left short.
17
+ * raises MetaMask Connect's relay timers to ten minutes and makes it count an
18
+ * iPad as a tablet in a bundle that carries the wallet modal, and fails the
19
+ * build if it bundles MetaMask Connect with a patch left out.
20
20
  */
21
21
  export function metaMaskConnectTimeoutsEsbuild() {
22
22
  return {
@@ -0,0 +1,39 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { isMobileBrowser } from "./device";
4
+
5
+ const macUserAgent =
6
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Safari/605.1.15";
7
+ const iPhoneUserAgent =
8
+ "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1";
9
+ const windowsUserAgent =
10
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36";
11
+
12
+ describe("isMobileBrowser", () => {
13
+ it("counts an iPad on Safari's default desktop site, which sends a Mac user agent", () => {
14
+ expect(isMobileBrowser({ userAgent: macUserAgent, maxTouchPoints: 5 })).toBe(true);
15
+ });
16
+
17
+ it("does not count a Mac, which reports no touch points", () => {
18
+ expect(isMobileBrowser({ userAgent: macUserAgent, maxTouchPoints: 0 })).toBe(false);
19
+ expect(isMobileBrowser({ userAgent: macUserAgent })).toBe(false);
20
+ });
21
+
22
+ it("counts an iPhone", () => {
23
+ expect(isMobileBrowser({ userAgent: iPhoneUserAgent, maxTouchPoints: 5 })).toBe(true);
24
+ });
25
+
26
+ it("does not count a touch-screen Windows laptop", () => {
27
+ expect(isMobileBrowser({ userAgent: windowsUserAgent, maxTouchPoints: 10 })).toBe(false);
28
+ });
29
+
30
+ it("trusts the browser's own mobile hint where it gives one", () => {
31
+ expect(
32
+ isMobileBrowser({
33
+ userAgent: windowsUserAgent,
34
+ maxTouchPoints: 10,
35
+ userAgentData: { mobile: true },
36
+ }),
37
+ ).toBe(true);
38
+ });
39
+ });
@@ -1,5 +1,6 @@
1
1
  type NavigatorLike = {
2
2
  userAgent?: string;
3
+ maxTouchPoints?: number;
3
4
  userAgentData?: { mobile?: boolean };
4
5
  userActivation?: { isActive: boolean };
5
6
  };
@@ -8,7 +9,10 @@ const nav = () => (globalThis as { navigator?: NavigatorLike }).navigator;
8
9
 
9
10
  export function isMobileBrowser(navigator: NavigatorLike | undefined = nav()): boolean {
10
11
  if (typeof navigator?.userAgentData?.mobile === "boolean") return navigator.userAgentData.mobile;
11
- return /Android|iPhone|iPad|iPod/i.test(navigator?.userAgent ?? "");
12
+ const userAgent = navigator?.userAgent ?? "";
13
+ if (/Android|iPhone|iPad|iPod/i.test(userAgent)) return true;
14
+ // iPadOS Safari sends a Mac user agent; a Mac reports no touch points.
15
+ return /Macintosh/.test(userAgent) && (navigator?.maxTouchPoints ?? 0) > 1;
12
16
  }
13
17
 
14
18
  /** Whether the page holds a user gesture; true where the browser does not report it. */
package/src/vite.ts CHANGED
@@ -8,10 +8,12 @@ const name = "mpo-web3:metamask-connect-timeouts";
8
8
  * Raises MetaMask Connect's relay timers to ten minutes in every bundle that
9
9
  * carries @multiplatform.one/web3's MetaMask connector. MetaMask Connect has no
10
10
  * option for them, and on Android 16 a user who stays in MetaMask past 60 s
11
- * otherwise loses the connect or the signature. Add it to any vite (or rollup /
11
+ * otherwise loses the connect or the signature. It also makes MetaMask Connect
12
+ * take an iPad on Safari's default desktop site for the tablet it is, so the
13
+ * MetaMask app opens instead of the desktop QR. Add it to any vite (or rollup /
12
14
  * rolldown) build that bundles the wallet modal; the build, and the dev server's
13
- * dependency pre-bundle, fail if they bundle MetaMask Connect and a timer could
14
- * not be raised.
15
+ * dependency pre-bundle, fail if they bundle MetaMask Connect and a patch could
16
+ * not be applied.
15
17
  */
16
18
  export function metaMaskConnectTimeouts(): Plugin {
17
19
  const tracker = createMetaMaskConnectTimeoutTracker();
@@ -1,6 +1,8 @@
1
1
  export type MetaMaskConnectTimerRewrite = {
2
- /** The timer, as a build error names it. */
3
- timer: string;
2
+ /** What the rewrite changes, as a build error names it. */
3
+ target: string;
4
+ /** What a build without the rewrite would lose, as a build error names it. */
5
+ lost: string;
4
6
  pattern: RegExp;
5
7
  replacement: string;
6
8
  };
@@ -22,7 +24,7 @@ export declare function patchMetaMaskConnectTimeouts(id: string, code: string):
22
24
  } | undefined;
23
25
  /**
24
26
  * Counts what one build patched, so the build can refuse to ship MetaMask Connect
25
- * with a timer left short (a package renamed the marker the patch looks for).
27
+ * with a patch left out (a package renamed the marker the patch looks for).
26
28
  */
27
29
  export declare function createMetaMaskConnectTimeoutTracker(): {
28
30
  see(id: string): void;
@@ -30,7 +32,7 @@ export declare function createMetaMaskConnectTimeoutTracker(): {
30
32
  pkg: string;
31
33
  code: string;
32
34
  } | undefined;
33
- /** Error text when the build bundles MetaMask Connect with a timer left unpatched. */
35
+ /** Error text when the build bundles MetaMask Connect with a patch left out. */
34
36
  problem(): string | undefined;
35
37
  };
36
38
  //# sourceMappingURL=metaMaskConnectTimeouts.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"metaMaskConnectTimeouts.d.ts","sourceRoot":"","sources":["../../src/bundler/metaMaskConnectTimeouts.ts"],"names":[],"mappings":"AAkBA,MAAM,MAAM,2BAA2B,GAAG;IACxC,4CAA4C;IAC5C,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,MAAM,CAAC;IAChB,WAAW,EAAE,MAAM,CAAC;CACrB,CAAC;AAEF,MAAM,MAAM,2BAA2B,GAAG;IACxC,GAAG,EAAE,MAAM,CAAC;IACZ,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,SAAS,2BAA2B,EAAE,CAAC;CAClD,CAAC;AAEF,eAAO,MAAM,6BAA6B,EAAE,SAAS,2BAA2B,EA6B/E,CAAC;AAMF,2EAA2E;AAC3E,wBAAgB,8BAA8B,CAC5C,EAAE,EAAE,MAAM,EACV,IAAI,EAAE,MAAM,GACX,2BAA2B,GAAG,SAAS,CAKzC;AAED;;;GAGG;AACH,wBAAgB,4BAA4B,CAC1C,EAAE,EAAE,MAAM,EACV,IAAI,EAAE,MAAM,GACX;IAAE,GAAG,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,GAAG,SAAS,CAY3C;AAED;;;GAGG;AACH,wBAAgB,mCAAmC;YAIvC,MAAM;cAGJ,MAAM,QAAQ,MAAM;aAzBxB,MAAM;cAAQ,MAAM;;IA+B1B,sFAAsF;eAC3E,MAAM,GAAG,SAAS;EAShC"}
1
+ {"version":3,"file":"metaMaskConnectTimeouts.d.ts","sourceRoot":"","sources":["../../src/bundler/metaMaskConnectTimeouts.ts"],"names":[],"mappings":"AAuBA,MAAM,MAAM,2BAA2B,GAAG;IACxC,2DAA2D;IAC3D,MAAM,EAAE,MAAM,CAAC;IACf,8EAA8E;IAC9E,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;IAChB,WAAW,EAAE,MAAM,CAAC;CACrB,CAAC;AAIF,MAAM,MAAM,2BAA2B,GAAG;IACxC,GAAG,EAAE,MAAM,CAAC;IACZ,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,SAAS,2BAA2B,EAAE,CAAC;CAClD,CAAC;AAEF,eAAO,MAAM,6BAA6B,EAAE,SAAS,2BAA2B,EAuC/E,CAAC;AAMF,2EAA2E;AAC3E,wBAAgB,8BAA8B,CAC5C,EAAE,EAAE,MAAM,EACV,IAAI,EAAE,MAAM,GACX,2BAA2B,GAAG,SAAS,CAKzC;AAED;;;GAGG;AACH,wBAAgB,4BAA4B,CAC1C,EAAE,EAAE,MAAM,EACV,IAAI,EAAE,MAAM,GACX;IAAE,GAAG,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,GAAG,SAAS,CAY3C;AAED;;;GAGG;AACH,wBAAgB,mCAAmC;YAIvC,MAAM;cAGJ,MAAM,QAAQ,MAAM;aAzBxB,MAAM;cAAQ,MAAM;;IA+B1B,gFAAgF;eACrE,MAAM,GAAG,SAAS;EAShC"}
@@ -7,9 +7,9 @@ export type MobileConnectOptions = {
7
7
  /**
8
8
  * Connector id a phone connects straight to (e.g. `"metaMaskSDK"`): `openSIWE()`
9
9
  * and the default ConnectKit button skip the wallet list for it. Desktop keeps
10
- * the list. A phone here is what connectkit's `isMobile()` reads from the user
11
- * agent, so iPadOS Safari and Android's "Desktop site", which both send a desktop
12
- * one, keep the list too.
10
+ * the list. A phone here is what connectkit's `isMobile()` reads: an iPad on
11
+ * Safari's default desktop site counts, a Mac does not, and Android's
12
+ * "Desktop site" keeps the list.
13
13
  */
14
14
  preferredConnector?: string;
15
15
  /** Passed to `openConnector` for the preferred connector. */
@@ -6,10 +6,11 @@ type MatchMedia = (query: string) => {
6
6
  matches: boolean;
7
7
  };
8
8
  /**
9
- * A touch device whose browser sends a desktop user agent, which connectkit's
10
- * `isMobile()` reads as desktop: iPadOS Safari sends a Mac one, and Android's
11
- * "Desktop site" a Linux one. A Mac reports no touch points, and a laptop's touch
12
- * screen is not its primary pointer, so neither counts.
9
+ * A touch device whose browser sends a desktop user agent: iPadOS Safari sends a
10
+ * Mac one, which connectkit's `isMobile()` also counts, and Android's "Desktop
11
+ * site" a Linux one, which `isMobile()` reads as desktop. A Mac reports no touch
12
+ * points, and a laptop's touch screen is not its primary pointer, so neither
13
+ * counts.
13
14
  */
14
15
  export declare function isTouchDevice(nav?: DeviceNavigator | null, matchMedia?: MatchMedia | null): boolean;
15
16
  /** The `fallbackMs` an `openConnector` call without one gets on this device. */
@@ -1 +1 @@
1
- {"version":3,"file":"device.d.ts","sourceRoot":"","sources":["../../../src/connectkit/openConnector/device.ts"],"names":[],"mappings":"AAOA,KAAK,eAAe,GAAG;IAAE,SAAS,CAAC,EAAE,MAAM,CAAC;IAAC,cAAc,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC;AACvE,KAAK,UAAU,GAAG,CAAC,KAAK,EAAE,MAAM,KAAK;IAAE,OAAO,EAAE,OAAO,CAAA;CAAE,CAAC;AAE1D;;;;;GAKG;AACH,wBAAgB,aAAa,CAC3B,GAAG,GAAE,eAAe,GAAG,IAA0D,EACjF,UAAU,GAAE,UAAU,GAAG,IAEc,GACtC,OAAO,CAQT;AAED,gFAAgF;AAChF,wBAAgB,8BAA8B,IAAI,MAAM,CAIvD"}
1
+ {"version":3,"file":"device.d.ts","sourceRoot":"","sources":["../../../src/connectkit/openConnector/device.ts"],"names":[],"mappings":"AAOA,KAAK,eAAe,GAAG;IAAE,SAAS,CAAC,EAAE,MAAM,CAAC;IAAC,cAAc,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC;AACvE,KAAK,UAAU,GAAG,CAAC,KAAK,EAAE,MAAM,KAAK;IAAE,OAAO,EAAE,OAAO,CAAA;CAAE,CAAC;AAE1D;;;;;;GAMG;AACH,wBAAgB,aAAa,CAC3B,GAAG,GAAE,eAAe,GAAG,IAA0D,EACjF,UAAU,GAAE,UAAU,GAAG,IAEc,GACtC,OAAO,CAQT;AAED,gFAAgF;AAChF,wBAAgB,8BAA8B,IAAI,MAAM,CAIvD"}
@@ -19,9 +19,9 @@ export type MetaMaskConnectTimeoutsEsbuildBuild = {
19
19
  };
20
20
  /**
21
21
  * esbuild counterpart of `@multiplatform.one/web3/vite`'s metaMaskConnectTimeouts:
22
- * raises MetaMask Connect's relay timers to ten minutes in a bundle that carries
23
- * the wallet modal, and fails the build if it bundles MetaMask Connect with a
24
- * timer left short.
22
+ * raises MetaMask Connect's relay timers to ten minutes and makes it count an
23
+ * iPad as a tablet in a bundle that carries the wallet modal, and fails the
24
+ * build if it bundles MetaMask Connect with a patch left out.
25
25
  */
26
26
  export declare function metaMaskConnectTimeoutsEsbuild(): {
27
27
  name: string;
@@ -1,5 +1,6 @@
1
1
  type NavigatorLike = {
2
2
  userAgent?: string;
3
+ maxTouchPoints?: number;
3
4
  userAgentData?: {
4
5
  mobile?: boolean;
5
6
  };
@@ -1 +1 @@
1
- {"version":3,"file":"device.d.ts","sourceRoot":"","sources":["../../src/keycloak/device.ts"],"names":[],"mappings":"AAAA,KAAK,aAAa,GAAG;IACnB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,aAAa,CAAC,EAAE;QAAE,MAAM,CAAC,EAAE,OAAO,CAAA;KAAE,CAAC;IACrC,cAAc,CAAC,EAAE;QAAE,QAAQ,EAAE,OAAO,CAAA;KAAE,CAAC;CACxC,CAAC;AAIF,wBAAgB,eAAe,CAAC,SAAS,GAAE,aAAa,GAAG,SAAiB,GAAG,OAAO,CAGrF;AAED,wFAAwF;AACxF,wBAAgB,iBAAiB,CAAC,SAAS,GAAE,aAAa,GAAG,SAAiB,GAAG,OAAO,CAEvF"}
1
+ {"version":3,"file":"device.d.ts","sourceRoot":"","sources":["../../src/keycloak/device.ts"],"names":[],"mappings":"AAAA,KAAK,aAAa,GAAG;IACnB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,aAAa,CAAC,EAAE;QAAE,MAAM,CAAC,EAAE,OAAO,CAAA;KAAE,CAAC;IACrC,cAAc,CAAC,EAAE;QAAE,QAAQ,EAAE,OAAO,CAAA;KAAE,CAAC;CACxC,CAAC;AAIF,wBAAgB,eAAe,CAAC,SAAS,GAAE,aAAa,GAAG,SAAiB,GAAG,OAAO,CAMrF;AAED,wFAAwF;AACxF,wBAAgB,iBAAiB,CAAC,SAAS,GAAE,aAAa,GAAG,SAAiB,GAAG,OAAO,CAEvF"}
package/types/vite.d.ts CHANGED
@@ -3,10 +3,12 @@ import type { Plugin } from "vite";
3
3
  * Raises MetaMask Connect's relay timers to ten minutes in every bundle that
4
4
  * carries @multiplatform.one/web3's MetaMask connector. MetaMask Connect has no
5
5
  * option for them, and on Android 16 a user who stays in MetaMask past 60 s
6
- * otherwise loses the connect or the signature. Add it to any vite (or rollup /
6
+ * otherwise loses the connect or the signature. It also makes MetaMask Connect
7
+ * take an iPad on Safari's default desktop site for the tablet it is, so the
8
+ * MetaMask app opens instead of the desktop QR. Add it to any vite (or rollup /
7
9
  * rolldown) build that bundles the wallet modal; the build, and the dev server's
8
- * dependency pre-bundle, fail if they bundle MetaMask Connect and a timer could
9
- * not be raised.
10
+ * dependency pre-bundle, fail if they bundle MetaMask Connect and a patch could
11
+ * not be applied.
10
12
  */
11
13
  export declare function metaMaskConnectTimeouts(): Plugin;
12
14
  //# sourceMappingURL=vite.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"vite.d.ts","sourceRoot":"","sources":["../src/vite.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,MAAM,CAAC;AAMnC;;;;;;;;GAQG;AACH,wBAAgB,uBAAuB,IAAI,MAAM,CA4ChD"}
1
+ {"version":3,"file":"vite.d.ts","sourceRoot":"","sources":["../src/vite.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,MAAM,CAAC;AAMnC;;;;;;;;;;GAUG;AACH,wBAAgB,uBAAuB,IAAI,MAAM,CA4ChD"}