react-native-intune 0.1.1 → 1.0.0-rc.1

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 (36) hide show
  1. package/README.md +8 -4
  2. package/RNIntune.podspec +3 -3
  3. package/android/build.gradle +46 -25
  4. package/android/src/main/java/com/reactnativeintune/RNIntunePackage.kt +11 -6
  5. package/android/src/main/java/com/reactnativeintune/RNIntuneResetJournal.kt +1 -1
  6. package/ios/RNIntunePlistGuard.m +1 -1
  7. package/ios/RNIntuneResetJournal.m +1 -1
  8. package/lib/module/api/tokenProvider.js +3 -0
  9. package/lib/module/api/tokenProvider.js.map +1 -1
  10. package/lib/module/index.js +1 -0
  11. package/lib/module/index.js.map +1 -1
  12. package/lib/module/internal/decode.js +23 -2
  13. package/lib/module/internal/decode.js.map +1 -1
  14. package/lib/module/types.js +37 -6
  15. package/lib/module/types.js.map +1 -1
  16. package/lib/typescript/src/api/tokenProvider.d.ts +3 -0
  17. package/lib/typescript/src/api/tokenProvider.d.ts.map +1 -1
  18. package/lib/typescript/src/index.d.ts +1 -0
  19. package/lib/typescript/src/index.d.ts.map +1 -1
  20. package/lib/typescript/src/internal/decode.d.ts.map +1 -1
  21. package/lib/typescript/src/types.d.ts +61 -11
  22. package/lib/typescript/src/types.d.ts.map +1 -1
  23. package/package.json +7 -8
  24. package/plugin/transforms.js +155 -2
  25. package/plugin/withIntune.js +27 -0
  26. package/scripts/doctor.mjs +7 -3
  27. package/scripts/lib/checks.mjs +483 -27
  28. package/scripts/lib/edits.mjs +329 -0
  29. package/scripts/lib/project.mjs +13 -0
  30. package/scripts/setup.mjs +59 -9
  31. package/sdk-lock.json +9 -8
  32. package/sdk-versions.json +125 -28
  33. package/src/api/tokenProvider.ts +3 -0
  34. package/src/index.ts +1 -0
  35. package/src/internal/decode.ts +27 -8
  36. package/src/types.ts +66 -15
@@ -24,8 +24,26 @@
24
24
 
25
25
  import { execFileSync } from 'node:child_process';
26
26
  import fs from 'node:fs';
27
+ import path from 'node:path';
28
+ import { createRequire } from 'node:module';
29
+ import {
30
+ entitlementsWithKeychainGroups,
31
+ pbxprojAddConfiguratorPhase,
32
+ pbxprojSetDeploymentTarget,
33
+ pbxprojSetEntitlements,
34
+ plistEnsureStrings,
35
+ plistEnsureUrlScheme,
36
+ plistRemoveKeys,
37
+ } from './edits.mjs';
27
38
  import { read, rel } from './project.mjs';
28
39
 
40
+ /**
41
+ * The Expo plugin's transforms, used here for the same edits in a bare project. One
42
+ * implementation of each, already tested and already run against real builds, rather
43
+ * than a second one that drifts.
44
+ */
45
+ const transforms = createRequire(import.meta.url)('../../plugin/transforms.js');
46
+
29
47
  const MARKER = 'react-native-intune: managed block';
30
48
 
31
49
  export const BROKER_PACKAGES = [
@@ -115,6 +133,12 @@ const androidChecks = [
115
133
  verify = true // catches plugin-induced runtime failures
116
134
  incremental = true // the default is false
117
135
  }`,
136
+ apply(project) {
137
+ const file = project.android.appBuildGradle;
138
+ const src = read(file);
139
+ // The Kotlin DSL is not edited — reported instead, like it is inspected.
140
+ return src ? { file, contents: transforms.withMamPluginApplied(src) } : null;
141
+ },
118
142
  },
119
143
 
120
144
  {
@@ -140,6 +164,57 @@ const androidChecks = [
140
164
  And javassist, whose version must match the SDK exactly:
141
165
 
142
166
  classpath "org.javassist:javassist:3.29.2-GA"`,
167
+ apply(project) {
168
+ const file = project.android.rootBuildGradle;
169
+ const src = read(file);
170
+ const contents = src && transforms.withMamClasspath(src);
171
+ return contents ? { file, contents } : null;
172
+ },
173
+ },
174
+
175
+ {
176
+ id: 'android-min-sdk',
177
+ platform: 'android',
178
+ title: 'minSdk 24 or later',
179
+ severity: 'loud',
180
+ /**
181
+ * MSAL requires 24 and React Native 0.74's template ships 23. Loud rather than silent
182
+ * — the manifest merger fails and names MSAL — but it is the first thing a 0.74
183
+ * project hits, and cheaper found here than after a Gradle run.
184
+ */
185
+ inspect(project) {
186
+ const root = read(project.android.rootBuildGradle);
187
+ const properties = read(path.join(project.android.dir, 'gradle.properties'));
188
+ const value =
189
+ firstMatch(root, /minSdkVersion\s*=\s*(\d+)/) ??
190
+ firstMatch(properties, /^android\.minSdkVersion\s*=\s*(\d+)/m) ??
191
+ firstMatch(read(project.android.appBuildGradle), /minSdk(?:Version)?\s*=?\s*(\d+)/);
192
+ if (!value) {
193
+ return { state: 'unknown', detail: 'no literal minSdkVersion in the build files' };
194
+ }
195
+ return Number(value) >= 24
196
+ ? { state: 'ok', detail: value }
197
+ : { state: 'wrong', detail: `minSdkVersion ${value}` };
198
+ },
199
+ why:
200
+ 'MSAL declares minSdk 24. Below it the manifest merger refuses the build, and the ' +
201
+ 'fix is one number.',
202
+ instruction: () =>
203
+ `In android/build.gradle:
204
+
205
+ buildscript {
206
+ ext {
207
+ minSdkVersion = 24
208
+ }
209
+ }`,
210
+ apply(project) {
211
+ const file = project.android.rootBuildGradle;
212
+ const src = read(file);
213
+ const m = src && src.match(/(minSdkVersion\s*=\s*)(\d+)/);
214
+ return m && Number(m[2]) < 24
215
+ ? { file, contents: src.replace(m[0], `${m[1]}24`) }
216
+ : null;
217
+ },
143
218
  },
144
219
 
145
220
  {
@@ -329,6 +404,23 @@ ${BROKER_PACKAGES.map((p) => ` <package android:name="${p}" />`).join('\n
329
404
  RNIntuneAuthCallback.register(this)
330
405
 
331
406
  Code inside your own class, so setup reports it rather than editing it.`,
407
+ /**
408
+ * An edit to the developer's own source, which setup otherwise avoids — but the change
409
+ * is one line after `super.onCreate()`, it is shown as a diff before anything is
410
+ * written, and the tree has to be clean so it is one revert away. Leaving it manual
411
+ * leaves the one silent omission here that nothing else would catch.
412
+ */
413
+ apply(project) {
414
+ const [file] = project.android.applicationSources;
415
+ if (!file) {
416
+ return null;
417
+ }
418
+ const contents = transforms.withMamApplication(
419
+ read(file),
420
+ file.endsWith('.kt') ? 'kt' : 'java'
421
+ );
422
+ return contents ? { file, contents } : null;
423
+ },
332
424
  },
333
425
 
334
426
  {
@@ -380,12 +472,51 @@ ${BROKER_PACKAGES.map((p) => ` <package android:name="${p}" />`).join('\n
380
472
  | openssl sha1 -binary | openssl base64`
381
473
  }`;
382
474
  },
475
+ /**
476
+ * Written with the hash of android/app/debug.keystore, computed here — the key a
477
+ * debug build is signed with, and the one most apps are first run with. A release
478
+ * signed with another key needs that key's hash as a second <data> entry, which
479
+ * setup cannot know; the comment it writes says so.
480
+ */
481
+ apply(project) {
482
+ const file = project.android.manifest;
483
+ const src = read(file);
484
+ const hash = signatureHash(project);
485
+ const applicationId = read(project.android.appBuildGradle).match(
486
+ /applicationId\s*=?\s*["']([\w.]+)["']/
487
+ )?.[1];
488
+ if (!src || !hash || !applicationId || !/<\/application>/.test(src)) {
489
+ return null;
490
+ }
491
+ const activity = `
492
+ <!-- ${MARKER}: the MSAL redirect. The path is the hash of the debug keystore;
493
+ a release build signed with another key needs that key's hash as a second
494
+ <data> element, and the matching redirect URI registered in Entra. -->
495
+ <activity
496
+ android:name="com.microsoft.identity.client.BrowserTabActivity"
497
+ android:exported="true">
498
+ <intent-filter>
499
+ <action android:name="android.intent.action.VIEW" />
500
+ <category android:name="android.intent.category.DEFAULT" />
501
+ <category android:name="android.intent.category.BROWSABLE" />
502
+ <data
503
+ android:scheme="msauth"
504
+ android:host="${applicationId}"
505
+ android:path="/${hash}" />
506
+ </intent-filter>
507
+ </activity>
508
+ `;
509
+ return {
510
+ file,
511
+ contents: src.replace(/(\n?)([ \t]*)<\/application>/, `${activity}$2</application>`),
512
+ };
513
+ },
383
514
  },
384
515
 
385
516
  {
386
517
  id: 'android-toolchain',
387
518
  platform: 'android',
388
- title: 'App toolchain on the MAM compatibility matrix row',
519
+ title: 'App toolchain is a tested combination',
389
520
  /**
390
521
  * Advisory, and deliberately so. Mixing matrix rows is *untested* by Microsoft, not
391
522
  * known-broken — plenty of combinations work. But when one does not, the failure lands
@@ -420,26 +551,63 @@ ${BROKER_PACKAGES.map((p) => ` <package android:name="${p}" />`).join('\n
420
551
  firstMatch(src, /kotlinVersion["' ]*[:=]["' ]*([\d.]+)/),
421
552
  gradle: gradleWrapperVersion(project),
422
553
  };
554
+ // React Native's templates leave AGP and Kotlin to React Native's own version
555
+ // catalog — `classpath("com.android.tools.build:gradle")` with no version, which
556
+ // the React Native Gradle plugin resolves from it. So that catalog is where the
557
+ // versions the app actually builds with are written.
558
+ const catalog = read(
559
+ path.join(project.root, 'node_modules/react-native/gradle/libs.versions.toml')
560
+ );
561
+ found.agp ||= firstMatch(catalog, /^agp\s*=\s*"([\d.]+)"/m);
562
+ found.kotlin ||= firstMatch(catalog, /^kotlin\s*=\s*"([\d.]+)"/m);
563
+ const rn = (() => {
564
+ try {
565
+ return JSON.parse(
566
+ read(path.join(project.root, 'node_modules/react-native/package.json')) || '{}'
567
+ ).version;
568
+ } catch {
569
+ return undefined;
570
+ }
571
+ })();
423
572
 
424
- const mismatched = Object.entries(REQUIRED_TOOLCHAIN)
425
- .filter(([k]) => found[k])
426
- .filter(([k, want]) => found[k] !== want)
427
- .map(([k, want]) => `${k} ${found[k]} vs ${want}`);
428
-
429
- const unseen = Object.keys(REQUIRED_TOOLCHAIN).filter((k) => !found[k]);
430
-
431
- if (mismatched.length > 0) {
432
- return { state: 'wrong', detail: mismatched.join(', ') };
433
- }
434
- if (unseen.length === Object.keys(REQUIRED_TOOLCHAIN).length) {
573
+ const seen = Object.keys(REQUIRED_TOOLCHAIN).filter((k) => found[k]);
574
+ if (seen.length === 0) {
435
575
  return {
436
576
  state: 'unknown',
437
577
  detail: 'could not read any version from the build files',
438
578
  };
439
579
  }
580
+ const unread = Object.keys(REQUIRED_TOOLCHAIN).filter((k) => !found[k]);
581
+ const partly = unread.length > 0 ? `; ${unread.join(', ')} not read` : '';
582
+ const agrees = (row) =>
583
+ seen.every((k) => row[k] === undefined || row[k] === found[k]) &&
584
+ seen.some((k) => row[k] !== undefined);
585
+
586
+ if (agrees(REQUIRED_TOOLCHAIN)) {
587
+ return { state: 'ok', detail: `on Microsoft's tested row${partly}` };
588
+ }
589
+ // Off Microsoft's row, but a combination this project has built and run — which is
590
+ // worth saying so, and worth saying whose claim it is.
591
+ // The row for this project's own React Native version first, so a report names the
592
+ // combination that was actually built rather than one that happens to share a
593
+ // Gradle version.
594
+ const candidates = VERIFIED_TOOLCHAINS.filter(agrees);
595
+ const ours =
596
+ candidates.find((v) => rn && String(v.reactNative).includes(rn)) ?? candidates[0];
597
+ if (ours) {
598
+ return {
599
+ state: 'ok',
600
+ detail:
601
+ `${seen.map((k) => `${k} ${found[k]}`).join(', ')} — verified by this project ` +
602
+ `on ${ours.date} (React Native ${ours.reactNative}), not by Microsoft${partly}`,
603
+ };
604
+ }
440
605
  return {
441
- state: 'ok',
442
- detail: unseen.length > 0 ? `${unseen.join(', ')} not read` : undefined,
606
+ state: 'wrong',
607
+ detail:
608
+ seen.map((k) => `${k} ${found[k]}`).join(', ') +
609
+ ' — neither Microsoft\u2019s row nor a combination verified here' +
610
+ partly,
443
611
  };
444
612
  },
445
613
  why:
@@ -448,21 +616,223 @@ ${BROKER_PACKAGES.map((p) => ` <package android:name="${p}" />`).join('\n
448
616
  'rewrites bytecode in your app module, so when a mixed row does fail it fails ' +
449
617
  'inside javassist and the error names one of your classes, never a version.',
450
618
  instruction: () =>
451
- `The row this library is pinned to:
619
+ `Microsoft's tested row for the pinned MAM SDK:
620
+
621
+ Gradle ${REQUIRED_TOOLCHAIN.gradle} AGP ${REQUIRED_TOOLCHAIN.agp} Kotlin ${REQUIRED_TOOLCHAIN.kotlin} Java 17
452
622
 
453
- Gradle ${REQUIRED_TOOLCHAIN.gradle}
454
- AGP ${REQUIRED_TOOLCHAIN.agp}
455
- Kotlin ${REQUIRED_TOOLCHAIN.kotlin}
456
- Java 17
623
+ Built and run by this project, off that row:
457
624
 
458
- Matching it exactly is the tested path. If your app cannot move, the alternative is
459
- pinning an older MAM SDK whose row you do match — see sdk-versions.json.`,
625
+ ${VERIFIED_TOOLCHAINS.map(
626
+ (v) =>
627
+ ` Gradle ${v.gradle}${v.agp ? ` AGP ${v.agp}` : ''} Kotlin ${v.kotlin} ` +
628
+ `(React Native ${v.reactNative}, ${v.date})`
629
+ ).join('\n')}
630
+
631
+ Anything else is untested rather than broken. If a build fails inside the MAM plugin
632
+ with an error naming one of your classes, move to one of these first.`,
460
633
  },
461
634
  ];
462
635
 
463
636
  // ---------------------------------------------------------------- ios
464
637
 
638
+ /**
639
+ * The lowest iOS the vendored MAM SDK runs on. Read from the binary (`otool -l` on
640
+ * IntuneMAMSwift.framework reports `minos 17.0` in both 21.8.0 and 21.9.0), not from
641
+ * Microsoft's release notes — the SDK should move this, not a guess.
642
+ */
643
+ const MIN_IOS = [17, 0];
644
+
645
+ const versionAtLeast = (value, [major, minor]) => {
646
+ const [a = 0, b = 0] = String(value).split('.').map(Number);
647
+ return a > major || (a === major && b >= minor);
648
+ };
649
+
650
+ /**
651
+ * The deployment target each configuration of each application target actually builds
652
+ * with: the target's own setting, or the project-level one it inherits when it sets
653
+ * none. A value elsewhere in the file — a project-level default the target overrides,
654
+ * another target — says nothing about what the app is built for, and counting those is
655
+ * how an Expo project, whose project level reads 16.4 while the app target reads 17.0,
656
+ * was reported as wrong.
657
+ */
658
+ function appDeploymentTargets(pbxproj) {
659
+ const object = (isa) =>
660
+ new RegExp(
661
+ `(\\w{24})\\s*(?:/\\*[^*]*\\*/)?\\s*=\\s*\\{\\s*isa\\s*=\\s*${isa};([\\s\\S]*?)\\n\\t\\t\\};`,
662
+ 'g'
663
+ );
664
+ const configs = new Map();
665
+ for (const [, id, body] of pbxproj.matchAll(object('XCBuildConfiguration'))) {
666
+ configs.set(id, {
667
+ name: body.match(/\bname\s*=\s*"?([^";]+)"?;/)?.[1],
668
+ value: body.match(/IPHONEOS_DEPLOYMENT_TARGET\s*=\s*"?([\d.]+)"?;/)?.[1],
669
+ });
670
+ }
671
+ const lists = new Map();
672
+ for (const [, id, body] of pbxproj.matchAll(object('XCConfigurationList'))) {
673
+ const ids = body.match(/buildConfigurations\s*=\s*\(([^)]*)\)/)?.[1] ?? '';
674
+ lists.set(id, [...ids.matchAll(/\w{24}/g)].map((m) => m[0]));
675
+ }
676
+ const listOf = (body) => body.match(/buildConfigurationList\s*=\s*(\w{24})/)?.[1];
677
+
678
+ const [, , projectBody = ''] = [...pbxproj.matchAll(object('PBXProject'))][0] ?? [];
679
+ const inherited = new Map(
680
+ (lists.get(listOf(projectBody)) ?? []).map((id) => [
681
+ configs.get(id)?.name,
682
+ configs.get(id)?.value,
683
+ ])
684
+ );
685
+
686
+ const effective = [];
687
+ for (const [, , body] of pbxproj.matchAll(object('PBXNativeTarget'))) {
688
+ if (!/productType\s*=\s*"com\.apple\.product-type\.application"/.test(body)) {
689
+ continue;
690
+ }
691
+ for (const id of lists.get(listOf(body)) ?? []) {
692
+ const c = configs.get(id) ?? {};
693
+ effective.push({ name: c.name, value: c.value ?? inherited.get(c.name) ?? null });
694
+ }
695
+ }
696
+ return effective;
697
+ }
698
+
699
+ /** Ruby comments — whole-line or trailing — so a commented-out platform line is ignored. */
700
+ const stripRubyComments = (src) => src.replace(/(^|\s)#.*$/gm, '$1');
701
+
702
+ /**
703
+ * The Run Script phase setup adds. It fails the build when the tool is missing rather
704
+ * than skipping it, because a skipped run leaves a plist without the schemes the SDK
705
+ * needs, and that fails at sign-in with an empty log instead of here with a reason.
706
+ */
707
+ const CONFIGURATOR_SCRIPT = `# ${MARKER}: Microsoft's IntuneMAMConfigurator, run on this app's Info.plist and
708
+ # entitlements. A build phase rather than a manual step, because what it writes is not a
709
+ # fixed list and changes between SDK versions. It is idempotent.
710
+ set -euo pipefail
711
+
712
+ CONFIGURATOR="$SRCROOT/../node_modules/react-native-intune/vendor/ios/IntuneMAMConfigurator"
713
+
714
+ if [ ! -x "$CONFIGURATOR" ]; then
715
+ echo "error: IntuneMAMConfigurator is missing — run: node node_modules/react-native-intune/scripts/fetch-sdks.mjs"
716
+ exit 1
717
+ fi
718
+
719
+ "$CONFIGURATOR" -i "$SRCROOT/$INFOPLIST_FILE" -e "$SRCROOT/$CODE_SIGN_ENTITLEMENTS"
720
+ `;
721
+
465
722
  const iosChecks = [
723
+ {
724
+ id: 'ios-deployment-target',
725
+ platform: 'ios',
726
+ title: `Deployment target is iOS ${MIN_IOS.join('.')} or later`,
727
+ severity: 'loud',
728
+ /**
729
+ * React Native's template targets `min_ios_version_supported` — 15.1 — and the SDK
730
+ * is built for 17.0, so a new project fails at `pod install` before anything else
731
+ * here can be tried. The Podfile and the Xcode target are read separately because
732
+ * they disagree independently: a raised Podfile with a 15.1 app target installs the
733
+ * pod and builds, then crashes at launch on any iOS 16 device it was allowed onto.
734
+ */
735
+ inspect(project) {
736
+ const podfile = stripRubyComments(read(project.ios.podfile));
737
+ if (!podfile) {
738
+ return { state: 'unknown', detail: 'ios/Podfile not found' };
739
+ }
740
+ const line = podfile.match(/^\s*platform\s+:ios\s*,\s*(.+)$/m)?.[1]?.trim();
741
+ if (!line) {
742
+ return { state: 'unknown', detail: 'no `platform :ios` line in the Podfile' };
743
+ }
744
+
745
+ let target = line.match(/^['"]([\d.]+)['"]$/)?.[1];
746
+ let source = 'Podfile';
747
+ if (!target && /ios\.deploymentTarget/.test(line)) {
748
+ // Expo's generated Podfile reads Podfile.properties.json first, which is what
749
+ // expo-build-properties writes, and falls back to the template minimum.
750
+ const props = path.join(path.dirname(project.ios.podfile), 'Podfile.properties.json');
751
+ try {
752
+ target = JSON.parse(read(props) || '{}')['ios.deploymentTarget'];
753
+ source = 'Podfile.properties.json';
754
+ } catch {
755
+ return { state: 'unknown', detail: 'Podfile.properties.json is not valid JSON' };
756
+ }
757
+ if (!target) {
758
+ return {
759
+ state: 'wrong',
760
+ detail:
761
+ 'ios.deploymentTarget is not set, so the Podfile falls back to ' +
762
+ 'min_ios_version_supported',
763
+ };
764
+ }
765
+ }
766
+ if (!target) {
767
+ return /min_ios_version_supported/.test(line)
768
+ ? {
769
+ state: 'wrong',
770
+ detail: 'the Podfile uses min_ios_version_supported, which is below 17.0',
771
+ }
772
+ : { state: 'unknown', detail: `cannot read a version from: ${line}` };
773
+ }
774
+ if (!versionAtLeast(target, MIN_IOS)) {
775
+ return { state: 'wrong', detail: `${source} targets ${target}` };
776
+ }
777
+
778
+ const effective = appDeploymentTargets(read(project.ios.pbxproj));
779
+ if (effective.length === 0) {
780
+ return {
781
+ state: 'unknown',
782
+ detail: `${source} targets ${target}; no application target found in the Xcode project`,
783
+ };
784
+ }
785
+ // A configuration that sets it nowhere builds for the SDK's own default, which is
786
+ // the newest iOS — not a problem, so only explicit low values count.
787
+ const low = effective.filter((e) => e.value && !versionAtLeast(e.value, MIN_IOS));
788
+ if (low.length > 0) {
789
+ return {
790
+ state: 'wrong',
791
+ detail:
792
+ `${source} targets ${target}, but the app target builds for ` +
793
+ low.map((e) => `${e.value} (${e.name})`).join(', '),
794
+ };
795
+ }
796
+ return { state: 'ok', detail: `${source}: ${target}` };
797
+ },
798
+ why:
799
+ 'MAM SDK 21.x is built for iOS 17.0. Below it CocoaPods refuses the pod; with only ' +
800
+ 'the Podfile raised, the app builds and then crashes at launch on older devices it ' +
801
+ 'still claims to support.',
802
+ instruction: () =>
803
+ `In ios/Podfile:
804
+
805
+ platform :ios, '17.0'
806
+
807
+ and set Minimum Deployments to 17.0 on your app target in Xcode
808
+ (IPHONEOS_DEPLOYMENT_TARGET), then run pod install again.
809
+
810
+ Expo: set it with expo-build-properties — { "ios": { "deploymentTarget": "17.0" } }.`,
811
+ /**
812
+ * The Podfile and the app target together — raising one without the other is the case
813
+ * this check exists to catch. An Expo Podfile is left alone: prebuild regenerates it,
814
+ * and the value belongs in expo-build-properties.
815
+ */
816
+ apply(project) {
817
+ const podfile = project.ios.podfile;
818
+ const src = read(podfile);
819
+ const line = src.match(/^(\s*platform\s+:ios\s*,\s*)(.+)$/m);
820
+ if (!line || /ios\.deploymentTarget/.test(line[2])) {
821
+ return null;
822
+ }
823
+ const version = MIN_IOS.join('.');
824
+ const edits = [{ file: podfile, contents: src.replace(line[0], `${line[1]}'${version}'`) }];
825
+ const pbxproj = read(project.ios.pbxproj);
826
+ const next =
827
+ pbxproj &&
828
+ pbxprojSetDeploymentTarget(pbxproj, version, (v) => versionAtLeast(v, MIN_IOS));
829
+ if (next) {
830
+ edits.push({ file: project.ios.pbxproj, contents: next });
831
+ }
832
+ return edits;
833
+ },
834
+ },
835
+
466
836
  {
467
837
  id: 'ios-keychain-groups',
468
838
  platform: 'ios',
@@ -524,6 +894,40 @@ ${KEYCHAIN_GROUPS.map((g) => ` <string>${g}</string>`).join('\n')}
524
894
  The app's own group first, then the SDK's, then MSAL's cache. The first entry may be
525
895
  written as the literal bundle id or as $(PRODUCT_BUNDLE_IDENTIFIER) — both work.`;
526
896
  },
897
+ /**
898
+ * React Native's template has no entitlements file, so this usually creates one and
899
+ * points the app target at it. An app target that already names a differently-called
900
+ * entitlements file is left to be edited by hand: creating a second file it never
901
+ * reads would look fixed and change nothing.
902
+ */
903
+ apply(project) {
904
+ const ios = project.ios;
905
+ const pbxproj = read(ios.pbxproj);
906
+ if (!ios.appName || !pbxproj) {
907
+ return null;
908
+ }
909
+ if (!ios.entitlements && /CODE_SIGN_ENTITLEMENTS\s*=/.test(pbxproj)) {
910
+ return null;
911
+ }
912
+ const file =
913
+ ios.entitlements ?? path.join(ios.dir, ios.appName, `${ios.appName}.entitlements`);
914
+ const contents = entitlementsWithKeychainGroups(read(file), [
915
+ '$(AppIdentifierPrefix)$(PRODUCT_BUNDLE_IDENTIFIER)',
916
+ ...KEYCHAIN_GROUPS,
917
+ ]);
918
+ if (!contents) {
919
+ return null;
920
+ }
921
+ const edits = [{ file, contents }];
922
+ const next = pbxprojSetEntitlements(
923
+ pbxproj,
924
+ `${ios.appName}/${ios.appName}.entitlements`
925
+ );
926
+ if (next) {
927
+ edits.push({ file: ios.pbxproj, contents: next });
928
+ }
929
+ return edits;
930
+ },
527
931
  },
528
932
 
529
933
  {
@@ -558,6 +962,12 @@ ${KEYCHAIN_GROUPS.map((g) => ` <string>${g}</string>`).join('\n')}
558
962
  </array>
559
963
  </dict>
560
964
  </array>`,
965
+ apply(project) {
966
+ const file = project.ios.infoPlist;
967
+ const src = read(file);
968
+ const contents = src && plistEnsureUrlScheme(src, 'msauth.$(PRODUCT_BUNDLE_IDENTIFIER)');
969
+ return contents ? { file, contents } : null;
970
+ },
561
971
  },
562
972
 
563
973
  {
@@ -587,6 +997,13 @@ ${KEYCHAIN_GROUPS.map((g) => ` <string>${g}</string>`).join('\n')}
587
997
  ${MSAL_QUERY_SCHEMES.map((s) => ` <string>${s}</string>`).join('\n')}
588
998
  <string>companyportal</string>
589
999
  </array>`,
1000
+ apply(project) {
1001
+ const file = project.ios.infoPlist;
1002
+ const src = read(file);
1003
+ const contents =
1004
+ src && plistEnsureStrings(src, 'LSApplicationQueriesSchemes', MSAL_QUERY_SCHEMES);
1005
+ return contents ? { file, contents } : null;
1006
+ },
590
1007
  },
591
1008
 
592
1009
  {
@@ -617,6 +1034,16 @@ ${MSAL_QUERY_SCHEMES.map((s) => ` <string>${s}</string>`).join('\n')}
617
1034
 
618
1035
  Note: ADALCacheKeychainGroupOverride is a different key and is NOT removed — it has
619
1036
  no runtime equivalent and is the only way to set the keychain group.`,
1037
+ apply(project) {
1038
+ const file = project.ios.infoPlist;
1039
+ const src = read(file);
1040
+ return src
1041
+ ? {
1042
+ file,
1043
+ contents: plistRemoveKeys(src, ['ADALClientId', 'ADALAuthority', 'ADALRedirectUri']),
1044
+ }
1045
+ : null;
1046
+ },
620
1047
  },
621
1048
 
622
1049
  {
@@ -649,6 +1076,18 @@ ${MSAL_QUERY_SCHEMES.map((s) => ` <string>${s}</string>`).join('\n')}
649
1076
  }
650
1077
 
651
1078
  Code in your own AppDelegate, so setup reports it rather than editing it.`,
1079
+ /** The Expo plugin's transform, which adds the method when a template has none. */
1080
+ apply(project) {
1081
+ const file = project.ios.appDelegate;
1082
+ if (!file) {
1083
+ return null;
1084
+ }
1085
+ const contents = transforms.withMsalResponseHandler(
1086
+ read(file),
1087
+ file.endsWith('.swift') ? 'swift' : 'objcpp'
1088
+ );
1089
+ return contents ? { file, contents } : null;
1090
+ },
652
1091
  },
653
1092
 
654
1093
  {
@@ -707,8 +1146,14 @@ ${MSAL_QUERY_SCHEMES.map((s) => ` <string>${s}</string>`).join('\n')}
707
1146
  -i "$SRCROOT/$INFOPLIST_FILE" \\
708
1147
  -e "$SRCROOT/${'$'}{CODE_SIGN_ENTITLEMENTS}"
709
1148
 
710
- Not wrapped by setup: it is Microsoft's tool, and wrapping it means owning what it
711
- does. The line above is what to paste.`,
1149
+ setup adds it for you. It runs Microsoft's tool as it is — setup writes the phase,
1150
+ not a wrapper around what the tool does.`,
1151
+ apply(project) {
1152
+ const file = project.ios.pbxproj;
1153
+ const src = read(file);
1154
+ const contents = src && pbxprojAddConfiguratorPhase(src, CONFIGURATOR_SCRIPT);
1155
+ return contents ? { file, contents } : null;
1156
+ },
712
1157
  },
713
1158
  ];
714
1159
 
@@ -785,16 +1230,27 @@ export function signatureHash(project) {
785
1230
  * there is one copy. Duplicating it here is how the two drift and the check starts
786
1231
  * reporting a row nobody targets.
787
1232
  */
788
- const REQUIRED_TOOLCHAIN = (() => {
1233
+ const TOOLCHAIN = (() => {
789
1234
  try {
790
1235
  const url = new URL('../../sdk-versions.json', import.meta.url);
791
- const { toolchain } = JSON.parse(fs.readFileSync(url, 'utf8'));
792
- const { requires } = toolchain;
793
- return { gradle: requires.gradle, agp: requires.agp, kotlin: requires.kotlin };
1236
+ return JSON.parse(fs.readFileSync(url, 'utf8')).toolchain ?? {};
794
1237
  } catch {
795
1238
  return {};
796
1239
  }
797
1240
  })();
1241
+ const REQUIRED_TOOLCHAIN = TOOLCHAIN.requires
1242
+ ? {
1243
+ gradle: TOOLCHAIN.requires.gradle,
1244
+ agp: TOOLCHAIN.requires.agp,
1245
+ kotlin: TOOLCHAIN.requires.kotlin,
1246
+ }
1247
+ : {};
1248
+
1249
+ /**
1250
+ * Combinations this project has built and run itself, off Microsoft's row — see the
1251
+ * comment in sdk-versions.json for what qualifies one.
1252
+ */
1253
+ const VERIFIED_TOOLCHAINS = TOOLCHAIN.verified ?? [];
798
1254
 
799
1255
  function firstMatch(src, re) {
800
1256
  const m = src.match(re);