@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.
- package/README.md +9 -0
- package/dist/components/device-install/device-install-dialog.d.ts +5 -0
- package/dist/components/device-install/index.d.ts +2 -0
- package/dist/components/inspect-overlay.d.ts +1 -0
- package/dist/components/remote-control.d.ts +13 -2
- package/dist/core/ax-tree.d.ts +2 -0
- package/dist/core/device-install/apple/client.d.ts +17 -0
- package/dist/core/device-install/apple/crypto.d.ts +20 -0
- package/dist/core/device-install/apple/gsa-srp.d.ts +26 -0
- package/dist/core/device-install/apple/index.d.ts +5 -0
- package/dist/core/device-install/apple/provisioning.d.ts +161 -0
- package/dist/core/device-install/apple/relay.d.ts +29 -0
- package/dist/core/device-install/index.d.ts +4 -0
- package/dist/core/device-install/operations/index.d.ts +6 -0
- package/dist/core/device-install/operations/limbuild-client.d.ts +28 -0
- package/dist/core/device-install/operations/operations.d.ts +32 -0
- package/dist/core/device-install/operations/relay-client.d.ts +25 -0
- package/dist/core/device-install/operations/relay-protocol.d.ts +27 -0
- package/dist/core/device-install/operations/usbmux.d.ts +32 -0
- package/dist/core/device-install/operations/webusb.d.ts +21 -0
- package/dist/core/device-install/storage/browser-storage.d.ts +44 -0
- package/dist/core/device-install/storage/index.d.ts +1 -0
- package/dist/core/device-install/types.d.ts +48 -0
- package/dist/device-install/index.cjs +1 -0
- package/dist/device-install/index.d.ts +3 -0
- package/dist/device-install/index.js +78 -0
- package/dist/device-install/react.cjs +1 -0
- package/dist/device-install/react.d.ts +1 -0
- package/dist/device-install/react.js +4 -0
- package/dist/device-install-dialog-86RDdoK9.js +2 -0
- package/dist/device-install-dialog-CnyDWf0q.mjs +462 -0
- package/dist/device-install-dialog.css +1 -0
- package/dist/hooks/index.d.ts +1 -0
- package/dist/hooks/use-device-install.d.ts +73 -0
- package/dist/index.cjs +1 -1
- package/dist/index.css +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.js +737 -703
- package/dist/use-device-install-CbGVvwPp.js +31 -0
- package/dist/use-device-install-j1Gekpl4.mjs +13623 -0
- package/package.json +15 -2
- package/src/components/device-install/device-install-dialog.css +325 -0
- package/src/components/device-install/device-install-dialog.tsx +513 -0
- package/src/components/device-install/index.ts +2 -0
- package/src/components/inspect-overlay.css +6 -0
- package/src/components/inspect-overlay.tsx +46 -15
- package/src/components/remote-control.tsx +16 -2
- package/src/core/ax-tree.test.ts +124 -0
- package/src/core/ax-tree.ts +107 -0
- package/src/core/device-install/apple/client.ts +152 -0
- package/src/core/device-install/apple/crypto.ts +202 -0
- package/src/core/device-install/apple/gsa-srp.ts +127 -0
- package/src/core/device-install/apple/index.ts +5 -0
- package/src/core/device-install/apple/provisioning.ts +298 -0
- package/src/core/device-install/apple/relay.ts +221 -0
- package/src/core/device-install/index.ts +4 -0
- package/src/core/device-install/operations/index.ts +6 -0
- package/src/core/device-install/operations/limbuild-client.ts +104 -0
- package/src/core/device-install/operations/operations.ts +217 -0
- package/src/core/device-install/operations/relay-client.ts +255 -0
- package/src/core/device-install/operations/relay-protocol.ts +71 -0
- package/src/core/device-install/operations/usbmux.ts +270 -0
- package/src/core/device-install/operations/webusb-dom.d.ts +54 -0
- package/src/core/device-install/operations/webusb.ts +105 -0
- package/src/core/device-install/storage/browser-storage.ts +263 -0
- package/src/core/device-install/storage/index.ts +1 -0
- package/src/core/device-install/types.ts +65 -0
- package/src/device-install/index.ts +3 -0
- package/src/device-install/react.ts +1 -0
- package/src/hooks/index.ts +1 -0
- package/src/hooks/use-device-install.ts +1210 -0
- package/src/index.ts +4 -0
- 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
|
|
69
|
-
*
|
|
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) => {
|
package/src/core/ax-tree.test.ts
CHANGED
|
@@ -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');
|
package/src/core/ax-tree.ts
CHANGED
|
@@ -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
|
+
}
|