@limrun/ui 0.9.0-rc.5 → 0.9.0-rc.7

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 (73) hide show
  1. package/README.md +9 -0
  2. package/dist/components/device-install/device-install-dialog.d.ts +5 -0
  3. package/dist/components/device-install/index.d.ts +2 -0
  4. package/dist/components/inspect-overlay.d.ts +1 -0
  5. package/dist/components/remote-control.d.ts +13 -2
  6. package/dist/core/ax-tree.d.ts +2 -0
  7. package/dist/core/device-install/apple/client.d.ts +17 -0
  8. package/dist/core/device-install/apple/crypto.d.ts +20 -0
  9. package/dist/core/device-install/apple/gsa-srp.d.ts +26 -0
  10. package/dist/core/device-install/apple/index.d.ts +5 -0
  11. package/dist/core/device-install/apple/provisioning.d.ts +161 -0
  12. package/dist/core/device-install/apple/relay.d.ts +29 -0
  13. package/dist/core/device-install/index.d.ts +4 -0
  14. package/dist/core/device-install/operations/index.d.ts +6 -0
  15. package/dist/core/device-install/operations/limbuild-client.d.ts +28 -0
  16. package/dist/core/device-install/operations/operations.d.ts +32 -0
  17. package/dist/core/device-install/operations/relay-client.d.ts +25 -0
  18. package/dist/core/device-install/operations/relay-protocol.d.ts +27 -0
  19. package/dist/core/device-install/operations/usbmux.d.ts +32 -0
  20. package/dist/core/device-install/operations/webusb.d.ts +21 -0
  21. package/dist/core/device-install/storage/browser-storage.d.ts +44 -0
  22. package/dist/core/device-install/storage/index.d.ts +1 -0
  23. package/dist/core/device-install/types.d.ts +48 -0
  24. package/dist/device-install/index.cjs +1 -0
  25. package/dist/device-install/index.d.ts +3 -0
  26. package/dist/device-install/index.js +78 -0
  27. package/dist/device-install/react.cjs +1 -0
  28. package/dist/device-install/react.d.ts +1 -0
  29. package/dist/device-install/react.js +4 -0
  30. package/dist/device-install-dialog-86RDdoK9.js +2 -0
  31. package/dist/device-install-dialog-CnyDWf0q.mjs +462 -0
  32. package/dist/device-install-dialog.css +1 -0
  33. package/dist/hooks/index.d.ts +1 -0
  34. package/dist/hooks/use-device-install.d.ts +73 -0
  35. package/dist/index.cjs +1 -1
  36. package/dist/index.css +1 -1
  37. package/dist/index.d.ts +3 -1
  38. package/dist/index.js +737 -703
  39. package/dist/use-device-install-CbGVvwPp.js +31 -0
  40. package/dist/use-device-install-j1Gekpl4.mjs +13623 -0
  41. package/package.json +15 -2
  42. package/src/components/device-install/device-install-dialog.css +325 -0
  43. package/src/components/device-install/device-install-dialog.tsx +513 -0
  44. package/src/components/device-install/index.ts +2 -0
  45. package/src/components/inspect-overlay.css +6 -0
  46. package/src/components/inspect-overlay.tsx +46 -15
  47. package/src/components/remote-control.tsx +16 -2
  48. package/src/core/ax-tree.test.ts +124 -0
  49. package/src/core/ax-tree.ts +107 -0
  50. package/src/core/device-install/apple/client.ts +152 -0
  51. package/src/core/device-install/apple/crypto.ts +202 -0
  52. package/src/core/device-install/apple/gsa-srp.ts +127 -0
  53. package/src/core/device-install/apple/index.ts +5 -0
  54. package/src/core/device-install/apple/provisioning.ts +298 -0
  55. package/src/core/device-install/apple/relay.ts +221 -0
  56. package/src/core/device-install/index.ts +4 -0
  57. package/src/core/device-install/operations/index.ts +6 -0
  58. package/src/core/device-install/operations/limbuild-client.ts +104 -0
  59. package/src/core/device-install/operations/operations.ts +217 -0
  60. package/src/core/device-install/operations/relay-client.ts +255 -0
  61. package/src/core/device-install/operations/relay-protocol.ts +71 -0
  62. package/src/core/device-install/operations/usbmux.ts +270 -0
  63. package/src/core/device-install/operations/webusb-dom.d.ts +54 -0
  64. package/src/core/device-install/operations/webusb.ts +105 -0
  65. package/src/core/device-install/storage/browser-storage.ts +263 -0
  66. package/src/core/device-install/storage/index.ts +1 -0
  67. package/src/core/device-install/types.ts +65 -0
  68. package/src/device-install/index.ts +3 -0
  69. package/src/device-install/react.ts +1 -0
  70. package/src/hooks/index.ts +1 -0
  71. package/src/hooks/use-device-install.ts +1210 -0
  72. package/src/index.ts +4 -0
  73. package/vite.config.ts +6 -2
@@ -65,8 +65,10 @@ interface RemoteControlProps {
65
65
  * video stream.
66
66
  *
67
67
  * - `true` — Select mode. Boxes are clickable, click pins a selection
68
- * with action buttons (Tap / Copy selector / Copy id), ESC clears.
69
- * Device input is blocked while in this mode.
68
+ * with action buttons (Tap / Copy selector / Copy command), ESC
69
+ * clears. The cursor turns into a crosshair while inspecting and the
70
+ * info card hangs off the pointer. Device input is blocked while in
71
+ * this mode.
70
72
  * - `'hover-only'` — Boxes follow the cursor as a visual preview. Device
71
73
  * input still passes through, so you can drive the simulator while
72
74
  * inspecting.
@@ -74,6 +76,16 @@ interface RemoteControlProps {
74
76
  */
75
77
  inspectMode?: boolean | 'hover-only';
76
78
 
79
+ /**
80
+ * Optional instance id used to render the "Copy command" button in the
81
+ * inspect-mode info card. When provided, the button copies a CLI
82
+ * invocation like
83
+ * `lim ios tap-element --ax-label 'Sign in' --type Button --id <instanceId>`
84
+ * (or its Android equivalent) that targets this exact element on this
85
+ * exact instance. When omitted the button is hidden.
86
+ */
87
+ instanceId?: string;
88
+
77
89
  /**
78
90
  * Fires whenever a fresh accessibility snapshot is delivered.
79
91
  *
@@ -333,6 +345,7 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
333
345
  showFrame = true,
334
346
  autoReconnect = false,
335
347
  inspectMode,
348
+ instanceId,
336
349
  onAxSnapshotChange,
337
350
  onInspectSelectionChange,
338
351
  onAxStatusChange,
@@ -2529,6 +2542,7 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
2529
2542
  highlightedId={axHighlightedId}
2530
2543
  selectedId={axSelectedId}
2531
2544
  mode={inspectModeResolved}
2545
+ instanceId={instanceId}
2532
2546
  cursorPosition={axCursorPosition}
2533
2547
  frozenCursorPosition={axFrozenCursorPosition}
2534
2548
  onSelectChange={(element, clickPosition) => {
@@ -11,9 +11,11 @@ import {
11
11
  AxElement,
12
12
  AxSnapshot,
13
13
  AX_UNAVAILABLE_ERROR,
14
+ axCliTapCommand,
14
15
  axElementAtPoint,
15
16
  axElementRoleLabel,
16
17
  axElementSelectorExpression,
18
+ axElementSelectorObject,
17
19
  axElementSummary,
18
20
  axElementsEqual,
19
21
  axSnapshotsEqual,
@@ -483,6 +485,128 @@ describe('axElementSelectorExpression', () => {
483
485
  });
484
486
  });
485
487
 
488
+ describe('axElementSelectorObject', () => {
489
+ const mk = (sel: Partial<AxElement['selectors']>, value = ''): AxElement => ({
490
+ id: '0',
491
+ path: '0',
492
+ label: '',
493
+ value,
494
+ role: '',
495
+ type: '',
496
+ enabled: true,
497
+ focused: false,
498
+ frame: { x: 0, y: 0, width: 1, height: 1 },
499
+ selectors: sel,
500
+ raw: {},
501
+ });
502
+
503
+ test('iOS: includes every available selector field, mapping className to type', () => {
504
+ expect(
505
+ axElementSelectorObject(mk({ AXUniqueId: 'signin', AXLabel: 'Sign in', className: 'Button' }), 'ios'),
506
+ ).toEqual({ AXUniqueId: 'signin', AXLabel: 'Sign in', type: 'Button' });
507
+ });
508
+
509
+ test('iOS: falls back to AXLabel + type when AXUniqueId is missing', () => {
510
+ expect(axElementSelectorObject(mk({ AXLabel: 'OK', className: 'Button' }), 'ios')).toEqual({
511
+ AXLabel: 'OK',
512
+ type: 'Button',
513
+ });
514
+ });
515
+
516
+ test('iOS: returns null when only a generic type/className is available', () => {
517
+ expect(axElementSelectorObject(mk({ className: 'Button' }), 'ios')).toBeNull();
518
+ expect(axElementSelectorObject(mk({}), 'ios')).toBeNull();
519
+ });
520
+
521
+ test('Android: includes every available selector field', () => {
522
+ expect(
523
+ axElementSelectorObject(
524
+ mk({
525
+ resourceId: 'com.example:id/submit',
526
+ contentDesc: 'Submit',
527
+ text: 'Submit',
528
+ className: 'android.widget.Button',
529
+ }),
530
+ 'android',
531
+ ),
532
+ ).toEqual({
533
+ resourceId: 'com.example:id/submit',
534
+ contentDesc: 'Submit',
535
+ text: 'Submit',
536
+ className: 'android.widget.Button',
537
+ });
538
+ });
539
+
540
+ test('Android: returns null when only a className is present', () => {
541
+ expect(axElementSelectorObject(mk({ className: 'android.widget.View' }), 'android')).toBeNull();
542
+ expect(axElementSelectorObject(mk({}), 'android')).toBeNull();
543
+ });
544
+ });
545
+
546
+ describe('axCliTapCommand', () => {
547
+ const mk = (sel: Partial<AxElement['selectors']>): AxElement => ({
548
+ id: '0',
549
+ path: '0',
550
+ label: '',
551
+ value: '',
552
+ role: '',
553
+ type: '',
554
+ enabled: true,
555
+ focused: false,
556
+ frame: { x: 0, y: 0, width: 1, height: 1 },
557
+ selectors: sel,
558
+ raw: {},
559
+ });
560
+
561
+ test('iOS: emits tap-element with every available selector flag', () => {
562
+ expect(
563
+ axCliTapCommand(
564
+ mk({ AXUniqueId: 'signin', AXLabel: 'Sign in', className: 'Button' }),
565
+ 'ios',
566
+ 'ios_abc',
567
+ ),
568
+ ).toBe(`lim ios tap-element --ax-unique-id signin --ax-label 'Sign in' --type Button --id ios_abc`);
569
+ });
570
+
571
+ test('iOS: AXLabel + type fallback when AXUniqueId is missing', () => {
572
+ expect(axCliTapCommand(mk({ AXLabel: 'OK', className: 'Button' }), 'ios', 'ios_abc')).toBe(
573
+ `lim ios tap-element --ax-label OK --type Button --id ios_abc`,
574
+ );
575
+ });
576
+
577
+ test('Android: emits tap-element with resource-id / content-desc / text / class-name', () => {
578
+ expect(
579
+ axCliTapCommand(
580
+ mk({
581
+ resourceId: 'com.example:id/submit',
582
+ contentDesc: 'Submit button',
583
+ text: 'Submit',
584
+ className: 'android.widget.Button',
585
+ }),
586
+ 'android',
587
+ 'and_1',
588
+ ),
589
+ ).toBe(
590
+ `lim android tap-element --resource-id com.example:id/submit --content-desc 'Submit button' --text Submit --class-name android.widget.Button --id and_1`,
591
+ );
592
+ });
593
+
594
+ test('shell-quotes values containing apostrophes', () => {
595
+ expect(axCliTapCommand(mk({ AXLabel: `Bob's button` }), 'ios', 'ios_abc')).toBe(
596
+ `lim ios tap-element --ax-label 'Bob'\\''s button' --id ios_abc`,
597
+ );
598
+ });
599
+
600
+ test('returns null when no specific selector is available', () => {
601
+ expect(axCliTapCommand(mk({ className: 'Button' }), 'ios', 'ios_abc')).toBeNull();
602
+ expect(axCliTapCommand(mk({}), 'android', 'and_1')).toBeNull();
603
+ });
604
+
605
+ test('returns null when instanceId is empty', () => {
606
+ expect(axCliTapCommand(mk({ AXUniqueId: 'signin' }), 'ios', '')).toBeNull();
607
+ });
608
+ });
609
+
486
610
  describe('AX_UNAVAILABLE_ERROR', () => {
487
611
  test('is a non-empty string customers can compare against', () => {
488
612
  expect(typeof AX_UNAVAILABLE_ERROR).toBe('string');
@@ -382,6 +382,113 @@ export function axElementSelectorExpression(el: AxElement, platform: AxPlatform)
382
382
  return null;
383
383
  }
384
384
 
385
+ // Returns a plain selector object for the element using the same key names the
386
+ // CLI / instance API expect (`AXUniqueId`, `AXLabel`, `type` on iOS;
387
+ // `resourceId`, `contentDesc`, `text`, `className` on Android). Includes every
388
+ // CLI-supportable selector field present on the element so the consumer can
389
+ // disambiguate (e.g. `{AXLabel:"Submit", type:"Button"}`).
390
+ //
391
+ // Returns null when the element has no anchor field specific enough to drive
392
+ // a tap — a lone `type` / `className` is intentionally treated as
393
+ // non-actionable to avoid copying a near-useless selector. This mirrors the
394
+ // precedence used by `axElementSelectorExpression`.
395
+ export function axElementSelectorObject(el: AxElement, platform: AxPlatform): Record<string, string> | null {
396
+ const out: Record<string, string> = {};
397
+ let hasSpecific = false;
398
+ if (platform === 'ios') {
399
+ if (el.selectors.AXUniqueId) {
400
+ out.AXUniqueId = el.selectors.AXUniqueId;
401
+ hasSpecific = true;
402
+ }
403
+ if (el.selectors.AXLabel) {
404
+ out.AXLabel = el.selectors.AXLabel;
405
+ hasSpecific = true;
406
+ }
407
+ // `selectors.className` carries the iOS element `type` (e.g. "Button").
408
+ // The CLI / API expose it under the key `type`, so rename here.
409
+ if (el.selectors.className) {
410
+ out.type = el.selectors.className;
411
+ }
412
+ } else {
413
+ if (el.selectors.resourceId) {
414
+ out.resourceId = el.selectors.resourceId;
415
+ hasSpecific = true;
416
+ }
417
+ if (el.selectors.contentDesc) {
418
+ out.contentDesc = el.selectors.contentDesc;
419
+ hasSpecific = true;
420
+ }
421
+ if (el.selectors.text) {
422
+ out.text = el.selectors.text;
423
+ hasSpecific = true;
424
+ }
425
+ if (el.selectors.className) {
426
+ out.className = el.selectors.className;
427
+ }
428
+ }
429
+ return hasSpecific ? out : null;
430
+ }
431
+
432
+ // POSIX-safe single quoting for shell command rendering. Bare-prints "safe"
433
+ // tokens (alphanumerics, slashes, dots, etc.) so common identifiers like
434
+ // UUIDs and Android resource IDs (`com.example:id/submit`) stay readable;
435
+ // everything else gets single-quoted with internal apostrophes escaped as
436
+ // `'\''`.
437
+ const shellQuote = (value: string): string => {
438
+ if (value === '') return "''";
439
+ if (/^[A-Za-z0-9_./\-:@%+=,]+$/.test(value)) return value;
440
+ return `'${value.replace(/'/g, `'\\''`)}'`;
441
+ };
442
+
443
+ // Maps `axElementSelectorObject` keys to their `lim … tap-element` CLI
444
+ // flags. Scoped to the keys emitted by `axElementSelectorObject` — any
445
+ // future selector field added there must also be added here.
446
+ const CLI_FLAG_BY_SELECTOR_KEY: Record<AxPlatform, Record<string, string>> = {
447
+ ios: {
448
+ AXUniqueId: '--ax-unique-id',
449
+ AXLabel: '--ax-label',
450
+ type: '--type',
451
+ },
452
+ android: {
453
+ resourceId: '--resource-id',
454
+ contentDesc: '--content-desc',
455
+ text: '--text',
456
+ className: '--class-name',
457
+ },
458
+ };
459
+
460
+ // Renders the `lim` CLI command that taps the element via selector flags
461
+ // rather than coordinates. Includes every CLI-supportable selector field
462
+ // present on the element (same precedence as `axElementSelectorObject`) so
463
+ // matches are unambiguous and resilient to layout changes.
464
+ //
465
+ // Example outputs:
466
+ // lim ios tap-element --ax-unique-id signin --id <id>
467
+ // lim ios tap-element --ax-label 'Sign in' --type Button --id <id>
468
+ // lim android tap-element --resource-id com.example:id/submit --id <id>
469
+ //
470
+ // Returns null when:
471
+ // - `instanceId` is empty, or
472
+ // - the element has no field specific enough to drive a selector tap
473
+ // (matches `axElementSelectorObject` returning null — e.g. only a
474
+ // generic className/type is available).
475
+ export function axCliTapCommand(el: AxElement, platform: AxPlatform, instanceId: string): string | null {
476
+ if (!instanceId) return null;
477
+ const selectors = axElementSelectorObject(el, platform);
478
+ if (!selectors) return null;
479
+
480
+ const flagMap = CLI_FLAG_BY_SELECTOR_KEY[platform];
481
+ const parts: string[] = ['lim', platform, 'tap-element'];
482
+ for (const [key, flag] of Object.entries(flagMap)) {
483
+ const value = selectors[key];
484
+ if (value) {
485
+ parts.push(flag, shellQuote(value));
486
+ }
487
+ }
488
+ parts.push('--id', shellQuote(instanceId));
489
+ return parts.join(' ');
490
+ }
491
+
385
492
  // Cleans up internal-looking role tokens. iOS's `role_description` can be
386
493
  // raw strings like "AXGenericElement" or empty when AppKit doesn't have a
387
494
  // description for the role. Android sets role to the className which is
@@ -0,0 +1,152 @@
1
+ import { AppleGsaSrpClient } from './gsa-srp';
2
+ import {
3
+ createAppleRelaySession,
4
+ deleteAppleRelaySession,
5
+ fetchAppleAccountSession,
6
+ proxyPhoneTwoFactorCode,
7
+ proxySrpComplete,
8
+ proxySrpInit,
9
+ proxyTwoFactorCode,
10
+ triggerPhoneTwoFactor,
11
+ triggerTrustedDeviceTwoFactor,
12
+ type AppleRelayResponse,
13
+ } from './relay';
14
+
15
+ export type AppleIDLoginInput = {
16
+ limbuildApiUrl: string;
17
+ accountName: string;
18
+ password: string;
19
+ token?: string;
20
+ };
21
+
22
+ export type AppleIDLoginResult = {
23
+ appleSessionId: string;
24
+ completeResponse: AppleRelayResponse;
25
+ twoFactorChallengeResponse?: AppleRelayResponse;
26
+ requiresTwoFactor: boolean;
27
+ finishTwoFactor: (code: string) => Promise<AppleRelayResponse>;
28
+ finalize: () => Promise<AppleRelayResponse>;
29
+ close: () => Promise<void>;
30
+ };
31
+
32
+ type TwoFactorMethod =
33
+ | { type: 'trustedDevice' }
34
+ | { type: 'phone'; phoneNumberId: number; mode: string };
35
+
36
+ export async function startBrowserOwnedAppleIDLogin({
37
+ limbuildApiUrl,
38
+ accountName,
39
+ password,
40
+ token,
41
+ }: AppleIDLoginInput): Promise<AppleIDLoginResult> {
42
+ const { appleSessionId } = await createAppleRelaySession(limbuildApiUrl, token);
43
+ try {
44
+ const srp = new AppleGsaSrpClient(accountName);
45
+ const initResponse = await proxySrpInit(limbuildApiUrl, appleSessionId, await srp.init(), token);
46
+ if (initResponse.status < 200 || initResponse.status >= 300) {
47
+ throw new Error(`Apple SRP init failed: HTTP ${initResponse.status} ${initResponse.rawBody ?? ''}`.trim());
48
+ }
49
+ if (!initResponse.body) {
50
+ throw new Error('Apple SRP init response did not include a body.');
51
+ }
52
+ const proof = await srp.complete(password, initResponse.body);
53
+ const completeResponse = await proxySrpComplete(
54
+ limbuildApiUrl,
55
+ appleSessionId,
56
+ {
57
+ ...proof,
58
+ rememberMe: false,
59
+ trustTokens: [],
60
+ },
61
+ token,
62
+ );
63
+ const requiresTwoFactor = completeResponse.status === 409;
64
+ let twoFactorChallengeResponse: AppleRelayResponse | undefined;
65
+ let twoFactorMethod: TwoFactorMethod = { type: 'trustedDevice' };
66
+ if (requiresTwoFactor) {
67
+ twoFactorChallengeResponse = await triggerTrustedDeviceTwoFactor(limbuildApiUrl, appleSessionId, token);
68
+ const phone = trustedPhoneNumberFromChallenge(twoFactorChallengeResponse.body);
69
+ if (phone) {
70
+ twoFactorMethod = {
71
+ type: 'phone',
72
+ phoneNumberId: phone.id,
73
+ mode: phone.pushMode ?? 'sms',
74
+ };
75
+ }
76
+ if (twoFactorChallengeResponse.status === 412) {
77
+ if (!phone) {
78
+ throw new Error('Apple requested phone verification but did not include a trusted phone number.');
79
+ }
80
+ twoFactorChallengeResponse = await triggerPhoneTwoFactor(
81
+ limbuildApiUrl,
82
+ appleSessionId,
83
+ phone.id,
84
+ phone.pushMode ?? 'sms',
85
+ token,
86
+ );
87
+ }
88
+ if (twoFactorChallengeResponse.status < 200 || twoFactorChallengeResponse.status >= 300) {
89
+ throw new Error(
90
+ `Apple two-factor challenge failed: HTTP ${twoFactorChallengeResponse.status} ${
91
+ twoFactorChallengeResponse.rawBody ?? ''
92
+ }`.trim(),
93
+ );
94
+ }
95
+ } else if (completeResponse.status < 200 || completeResponse.status >= 300) {
96
+ throw new Error(`Apple SRP complete failed: HTTP ${completeResponse.status} ${completeResponse.rawBody ?? ''}`.trim());
97
+ }
98
+ return {
99
+ appleSessionId,
100
+ completeResponse,
101
+ twoFactorChallengeResponse,
102
+ requiresTwoFactor,
103
+ finishTwoFactor: async (code) => {
104
+ const response =
105
+ twoFactorMethod.type === 'phone'
106
+ ? await proxyPhoneTwoFactorCode(
107
+ limbuildApiUrl,
108
+ appleSessionId,
109
+ twoFactorMethod.phoneNumberId,
110
+ code,
111
+ twoFactorMethod.mode,
112
+ token,
113
+ )
114
+ : await proxyTwoFactorCode(limbuildApiUrl, appleSessionId, code, token);
115
+ if (response.status < 200 || response.status >= 300) {
116
+ throw new Error(`Apple two-factor code failed: HTTP ${response.status} ${response.rawBody ?? ''}`.trim());
117
+ }
118
+ return response;
119
+ },
120
+ finalize: async () => fetchAppleAccountSession(limbuildApiUrl, appleSessionId, token),
121
+ close: () => deleteAppleRelaySession(limbuildApiUrl, appleSessionId, token),
122
+ };
123
+ } catch (error) {
124
+ await deleteAppleRelaySession(limbuildApiUrl, appleSessionId, token).catch(() => undefined);
125
+ throw error;
126
+ }
127
+ }
128
+
129
+ function trustedPhoneNumberFromChallenge(body: unknown) {
130
+ if (!isRecord(body)) return undefined;
131
+ const verification = isRecord(body.phoneNumberVerification) ? body.phoneNumberVerification : undefined;
132
+ const trustedPhoneNumber =
133
+ recordValue(verification?.trustedPhoneNumber) ??
134
+ recordValue(body.trustedPhoneNumber) ??
135
+ recordValue(body.phoneNumber);
136
+ if (!trustedPhoneNumber) return undefined;
137
+ const id = trustedPhoneNumber.id;
138
+ if (typeof id !== 'number') return undefined;
139
+ const pushMode =
140
+ typeof trustedPhoneNumber.pushMode === 'string' ? trustedPhoneNumber.pushMode
141
+ : typeof body.mode === 'string' ? body.mode
142
+ : undefined;
143
+ return { id, pushMode };
144
+ }
145
+
146
+ function recordValue(value: unknown) {
147
+ return isRecord(value) ? value : undefined;
148
+ }
149
+
150
+ function isRecord(value: unknown): value is Record<string, unknown> {
151
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
152
+ }
@@ -0,0 +1,202 @@
1
+ import forge from 'node-forge';
2
+
3
+ export type AppleSigningKeyMaterial = {
4
+ privateKey: CryptoKey;
5
+ privateKeyPKCS8Base64: string;
6
+ publicKeySPKIBase64: string;
7
+ csrPEM: string;
8
+ csrBase64: string;
9
+ };
10
+
11
+ export type AppleCSRInput = {
12
+ commonName: string;
13
+ emailAddress?: string;
14
+ };
15
+
16
+ export type ExportP12Input = {
17
+ privateKeyPKCS8Base64: string;
18
+ certificateBase64?: string;
19
+ certificatePEM?: string;
20
+ password: string;
21
+ friendlyName?: string;
22
+ };
23
+
24
+ const rsaAlgorithm: RsaHashedKeyGenParams = {
25
+ name: 'RSASSA-PKCS1-v1_5',
26
+ modulusLength: 2048,
27
+ publicExponent: new Uint8Array([0x01, 0x00, 0x01]),
28
+ hash: 'SHA-256',
29
+ };
30
+
31
+ export async function generateAppleSigningKeyAndCSR(input: AppleCSRInput): Promise<AppleSigningKeyMaterial> {
32
+ if (!crypto.subtle) {
33
+ throw new Error('WebCrypto is not available in this browser.');
34
+ }
35
+ const keyPair = await crypto.subtle.generateKey(rsaAlgorithm, true, ['sign', 'verify']);
36
+ const publicKeySPKI = new Uint8Array(await crypto.subtle.exportKey('spki', keyPair.publicKey));
37
+ const certificationRequestInfo = derSequence(
38
+ derInteger(0),
39
+ derName(input),
40
+ publicKeySPKI,
41
+ derContext(0, new Uint8Array()),
42
+ );
43
+ const signature = new Uint8Array(
44
+ await crypto.subtle.sign('RSASSA-PKCS1-v1_5', keyPair.privateKey, toArrayBuffer(certificationRequestInfo)),
45
+ );
46
+ const csrDER = derSequence(
47
+ certificationRequestInfo,
48
+ derSequence(derOID('1.2.840.113549.1.1.11'), derNull()),
49
+ derBitString(signature),
50
+ );
51
+ const privateKeyPKCS8 = new Uint8Array(await crypto.subtle.exportKey('pkcs8', keyPair.privateKey));
52
+ return {
53
+ privateKey: keyPair.privateKey,
54
+ privateKeyPKCS8Base64: bytesToBase64(privateKeyPKCS8),
55
+ publicKeySPKIBase64: bytesToBase64(publicKeySPKI),
56
+ csrPEM: pemBlock('CERTIFICATE REQUEST', csrDER),
57
+ csrBase64: bytesToBase64(csrDER),
58
+ };
59
+ }
60
+
61
+ export function exportAppleCertificateP12(input: ExportP12Input) {
62
+ if (!input.certificateBase64 && !input.certificatePEM) {
63
+ throw new Error('certificateBase64 or certificatePEM is required.');
64
+ }
65
+ const privateKey = forge.pki.privateKeyFromPem(
66
+ pemFromBase64('PRIVATE KEY', input.privateKeyPKCS8Base64),
67
+ );
68
+ const certificate = input.certificatePEM
69
+ ? forge.pki.certificateFromPem(input.certificatePEM)
70
+ : forge.pki.certificateFromAsn1(
71
+ forge.asn1.fromDer(forge.util.createBuffer(base64ToBinary(input.certificateBase64!))),
72
+ );
73
+ const p12 = forge.pkcs12.toPkcs12Asn1(privateKey, [certificate], input.password, {
74
+ algorithm: '3des',
75
+ friendlyName: input.friendlyName,
76
+ });
77
+ const der = forge.asn1.toDer(p12).getBytes();
78
+ return binaryToBase64(der);
79
+ }
80
+
81
+ function derName(input: AppleCSRInput) {
82
+ const attributes = [derAttribute('2.5.4.3', derUTF8String(input.commonName))];
83
+ if (input.emailAddress) {
84
+ attributes.push(derAttribute('1.2.840.113549.1.9.1', derIA5String(input.emailAddress)));
85
+ }
86
+ return derSequence(...attributes);
87
+ }
88
+
89
+ function derAttribute(oid: string, value: Uint8Array) {
90
+ return derSet(derSequence(derOID(oid), value));
91
+ }
92
+
93
+ function derSequence(...values: Uint8Array[]) {
94
+ return derTLV(0x30, concatBytes(...values));
95
+ }
96
+
97
+ function derSet(...values: Uint8Array[]) {
98
+ return derTLV(0x31, concatBytes(...values));
99
+ }
100
+
101
+ function derContext(tag: number, value: Uint8Array) {
102
+ return derTLV(0xa0 + tag, value);
103
+ }
104
+
105
+ function derInteger(value: number) {
106
+ return derTLV(0x02, new Uint8Array([value]));
107
+ }
108
+
109
+ function derNull() {
110
+ return new Uint8Array([0x05, 0x00]);
111
+ }
112
+
113
+ function derUTF8String(value: string) {
114
+ return derTLV(0x0c, new TextEncoder().encode(value));
115
+ }
116
+
117
+ function derIA5String(value: string) {
118
+ return derTLV(0x16, new TextEncoder().encode(value));
119
+ }
120
+
121
+ function derBitString(value: Uint8Array) {
122
+ return derTLV(0x03, concatBytes(new Uint8Array([0]), value));
123
+ }
124
+
125
+ function derOID(oid: string) {
126
+ const parts = oid.split('.').map((part) => parseInt(part, 10));
127
+ if (parts.length < 2 || parts.some((part) => !Number.isFinite(part))) {
128
+ throw new Error(`Invalid OID: ${oid}`);
129
+ }
130
+ const encoded = [parts[0] * 40 + parts[1]];
131
+ for (const part of parts.slice(2)) {
132
+ const stack = [part & 0x7f];
133
+ let value = part >> 7;
134
+ while (value > 0) {
135
+ stack.unshift((value & 0x7f) | 0x80);
136
+ value >>= 7;
137
+ }
138
+ encoded.push(...stack);
139
+ }
140
+ return derTLV(0x06, new Uint8Array(encoded));
141
+ }
142
+
143
+ function derTLV(tag: number, value: Uint8Array) {
144
+ return concatBytes(new Uint8Array([tag]), derLength(value.byteLength), value);
145
+ }
146
+
147
+ function derLength(length: number) {
148
+ if (length < 0x80) {
149
+ return new Uint8Array([length]);
150
+ }
151
+ const bytes: number[] = [];
152
+ let value = length;
153
+ while (value > 0) {
154
+ bytes.unshift(value & 0xff);
155
+ value >>= 8;
156
+ }
157
+ return new Uint8Array([0x80 | bytes.length, ...bytes]);
158
+ }
159
+
160
+ function concatBytes(...chunks: Uint8Array[]) {
161
+ const length = chunks.reduce((sum, chunk) => sum + chunk.byteLength, 0);
162
+ const out = new Uint8Array(length);
163
+ let offset = 0;
164
+ for (const chunk of chunks) {
165
+ out.set(chunk, offset);
166
+ offset += chunk.byteLength;
167
+ }
168
+ return out;
169
+ }
170
+
171
+ function pemBlock(label: string, der: Uint8Array) {
172
+ const base64 = bytesToBase64(der);
173
+ const lines = base64.match(/.{1,64}/g) ?? [];
174
+ return `-----BEGIN ${label}-----\n${lines.join('\n')}\n-----END ${label}-----\n`;
175
+ }
176
+
177
+ function pemFromBase64(label: string, base64: string) {
178
+ const lines = base64.match(/.{1,64}/g) ?? [];
179
+ return `-----BEGIN ${label}-----\n${lines.join('\n')}\n-----END ${label}-----\n`;
180
+ }
181
+
182
+ function bytesToBase64(bytes: Uint8Array) {
183
+ let binary = '';
184
+ for (const byte of bytes) {
185
+ binary += String.fromCharCode(byte);
186
+ }
187
+ return btoa(binary);
188
+ }
189
+
190
+ function binaryToBase64(binary: string) {
191
+ return btoa(binary);
192
+ }
193
+
194
+ function base64ToBinary(value: string) {
195
+ return atob(value);
196
+ }
197
+
198
+ function toArrayBuffer(bytes: Uint8Array): ArrayBuffer {
199
+ const copy = new Uint8Array(bytes.byteLength);
200
+ copy.set(bytes);
201
+ return copy.buffer;
202
+ }