react-native-intune 0.1.2 → 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.
@@ -27,9 +27,20 @@ intunemam {
27
27
  }
28
28
  `;
29
29
 
30
- /** Applies the MAM plugin to android/app/build.gradle. */
30
+ /**
31
+ * Applies the MAM plugin to android/app/build.gradle.
32
+ *
33
+ * The guard looks for an actual application at the start of a line, not the plugin id
34
+ * anywhere: commenting the apply out is the first thing anyone does when the bytecode
35
+ * rewrite breaks a library, and a guard that read the comment as applied would leave
36
+ * the app unprotected while `setup` reported success.
37
+ */
31
38
  function withMamPluginApplied(contents) {
32
- if (contents.includes('com.microsoft.intune.mam')) {
39
+ if (
40
+ /^[ \t]*(?:apply\s+plugin\s*:\s*|id\s*\(?\s*)["']com\.microsoft\.intune\.mam["']/m.test(
41
+ contents
42
+ )
43
+ ) {
33
44
  return contents;
34
45
  }
35
46
  return `${contents.trimEnd()}\n${MAM_PLUGIN_BLOCK}`;
@@ -212,7 +223,7 @@ function withMsalResponseHandler(contents, language) {
212
223
  ? /(func\s+application\s*\(\s*_\s+\w+\s*:\s*UIApplication\s*,\s*open\s+url\s*:\s*URL\s*,\s*options\s*:[^)]*\)\s*->\s*Bool\s*\{\s*\n(\s*)return\s+)/
213
224
  : /(-\s*\(BOOL\)\s*application\s*:\s*\(UIApplication\s*\*\)\s*\w+\s+openURL\s*:\s*\(NSURL\s*\*\)\s*url\s+options\s*:[^{]*\{\s*\n(\s*)return\s+)/;
214
225
  if (!handler.test(contents)) {
215
- return null;
226
+ return withNewMsalResponseHandler(contents, isSwift);
216
227
  }
217
228
  const forward = isSwift
218
229
  ? 'MSALPublicClientApplication.handleMSALResponse(url, sourceApplication: options[.sourceApplication] as? String) || '
@@ -232,6 +243,102 @@ function withMsalResponseHandler(contents, language) {
232
243
  return next;
233
244
  }
234
245
 
246
+ /**
247
+ * React Native's own templates have no open-URL handler at all — only Expo's does — so
248
+ * for them the forward is a whole method. It goes at the end of the AppDelegate class,
249
+ * found by matching braces from the class declaration; string literals and comments are
250
+ * skipped so a brace in either cannot end the class early.
251
+ *
252
+ * No `override`: in these templates the class is a UIResponder conforming to
253
+ * UIApplicationDelegate, and the method is the protocol's, not a superclass's.
254
+ */
255
+ function withNewMsalResponseHandler(contents, isSwift) {
256
+ const importLine = isSwift ? 'import MSAL' : '#import <MSAL/MSAL.h>';
257
+ let next = contents;
258
+
259
+ if (isSwift) {
260
+ const declaration = /class\s+AppDelegate\b[^{]*\{/.exec(next);
261
+ if (!declaration) {
262
+ return null;
263
+ }
264
+ const end = matchingBrace(
265
+ next,
266
+ declaration.index + declaration[0].length - 1
267
+ );
268
+ if (end === null) {
269
+ return null;
270
+ }
271
+ const method = `
272
+ /// Hands the sign-in result back to MSAL. Without it the broker's reply reaches the app
273
+ /// and is dropped, and sign-in never completes. Added by react-native-intune setup.
274
+ func application(
275
+ _ app: UIApplication,
276
+ open url: URL,
277
+ options: [UIApplication.OpenURLOptionsKey: Any] = [:]
278
+ ) -> Bool {
279
+ MSALPublicClientApplication.handleMSALResponse(
280
+ url,
281
+ sourceApplication: options[.sourceApplication] as? String
282
+ )
283
+ }
284
+ `;
285
+ next = `${next.slice(0, end).replace(/\s*$/, '\n')}${method}${next.slice(end)}`;
286
+ } else {
287
+ const implementation = /@implementation\s+AppDelegate\b[^\n]*\n/.exec(next);
288
+ if (!implementation) {
289
+ return null;
290
+ }
291
+ const at = implementation.index + implementation[0].length;
292
+ const method = `
293
+ // Hands the sign-in result back to MSAL. Without it the broker's reply reaches the app and
294
+ // is dropped, and sign-in never completes. Added by react-native-intune setup.
295
+ - (BOOL)application:(UIApplication *)application
296
+ openURL:(NSURL *)url
297
+ options:(NSDictionary<UIApplicationOpenURLOptionsKey, id> *)options
298
+ {
299
+ return [MSALPublicClientApplication handleMSALResponse:url
300
+ sourceApplication:options[UIApplicationOpenURLOptionsSourceApplicationKey]];
301
+ }
302
+ `;
303
+ next = `${next.slice(0, at)}${method}${next.slice(at)}`;
304
+ }
305
+
306
+ const imports = isSwift
307
+ ? /^(?:\w+\s+)?import\s+\w+.*$/gm
308
+ : /^#import\s+.*$/gm;
309
+ const lastImport = [...next.matchAll(imports)].pop();
310
+ if (!lastImport) {
311
+ return null;
312
+ }
313
+ const at = lastImport.index + lastImport[0].length;
314
+ return `${next.slice(0, at)}\n${importLine}${next.slice(at)}`;
315
+ }
316
+
317
+ /** The index of the brace closing the one at `open`, skipping strings and comments. */
318
+ function matchingBrace(src, open) {
319
+ let depth = 0;
320
+ for (let i = open; i < src.length; i += 1) {
321
+ const c = src[i];
322
+ if (c === '/' && src[i + 1] === '/') {
323
+ i = src.indexOf('\n', i);
324
+ if (i === -1) return null;
325
+ } else if (c === '/' && src[i + 1] === '*') {
326
+ i = src.indexOf('*/', i + 2) + 1;
327
+ if (i === 0) return null;
328
+ } else if (c === '"') {
329
+ for (i += 1; i < src.length && src[i] !== '"'; i += 1) {
330
+ if (src[i] === '\\') i += 1;
331
+ }
332
+ } else if (c === '{') {
333
+ depth += 1;
334
+ } else if (c === '}') {
335
+ depth -= 1;
336
+ if (depth === 0) return i;
337
+ }
338
+ }
339
+ return null;
340
+ }
341
+
235
342
  module.exports = {
236
343
  MARKER,
237
344
  withMamPluginApplied,
@@ -116,7 +116,8 @@ function main() {
116
116
  2
117
117
  ) + '\n'
118
118
  );
119
- process.exit(s.blocking.length > 0 ? 1 : 0);
119
+ process.exitCode = s.blocking.length > 0 ? 1 : 0;
120
+ return;
120
121
  }
121
122
 
122
123
  process.stdout.write(`\nreact-native-intune doctor\n${project.root}\n`);
@@ -141,7 +142,7 @@ function main() {
141
142
  `licensed for Intune, that an App Protection Policy targets this app, and that\n` +
142
143
  `a broker is installed on the device. Enrollment needs all three.\n\n`
143
144
  );
144
- process.exit(0);
145
+ return;
145
146
  }
146
147
 
147
148
  if (s.silent.length > 0) {
@@ -182,7 +183,10 @@ function main() {
182
183
  }
183
184
 
184
185
  process.stdout.write('\n');
185
- process.exit(s.blocking.length > 0 ? 1 : 0);
186
+ // An exit code, not process.exit(): on macOS a write to a pipe is asynchronous, and
187
+ // exiting straight after a long report cut it off mid-way whenever doctor's output was
188
+ // piped — into a CI log, a file, or `| less`.
189
+ process.exitCode = s.blocking.length > 0 ? 1 : 0;
186
190
  }
187
191
 
188
192
  /**
@@ -25,8 +25,25 @@
25
25
  import { execFileSync } from 'node:child_process';
26
26
  import fs from 'node:fs';
27
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';
28
38
  import { read, rel } from './project.mjs';
29
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
+
30
47
  const MARKER = 'react-native-intune: managed block';
31
48
 
32
49
  export const BROKER_PACKAGES = [
@@ -116,6 +133,12 @@ const androidChecks = [
116
133
  verify = true // catches plugin-induced runtime failures
117
134
  incremental = true // the default is false
118
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
+ },
119
142
  },
120
143
 
121
144
  {
@@ -141,6 +164,57 @@ const androidChecks = [
141
164
  And javassist, whose version must match the SDK exactly:
142
165
 
143
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
+ },
144
218
  },
145
219
 
146
220
  {
@@ -330,6 +404,23 @@ ${BROKER_PACKAGES.map((p) => ` <package android:name="${p}" />`).join('\n
330
404
  RNIntuneAuthCallback.register(this)
331
405
 
332
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
+ },
333
424
  },
334
425
 
335
426
  {
@@ -381,12 +472,51 @@ ${BROKER_PACKAGES.map((p) => ` <package android:name="${p}" />`).join('\n
381
472
  | openssl sha1 -binary | openssl base64`
382
473
  }`;
383
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
+ },
384
514
  },
385
515
 
386
516
  {
387
517
  id: 'android-toolchain',
388
518
  platform: 'android',
389
- title: 'App toolchain on the MAM compatibility matrix row',
519
+ title: 'App toolchain is a tested combination',
390
520
  /**
391
521
  * Advisory, and deliberately so. Mixing matrix rows is *untested* by Microsoft, not
392
522
  * known-broken — plenty of combinations work. But when one does not, the failure lands
@@ -421,26 +551,63 @@ ${BROKER_PACKAGES.map((p) => ` <package android:name="${p}" />`).join('\n
421
551
  firstMatch(src, /kotlinVersion["' ]*[:=]["' ]*([\d.]+)/),
422
552
  gradle: gradleWrapperVersion(project),
423
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
+ })();
424
572
 
425
- const mismatched = Object.entries(REQUIRED_TOOLCHAIN)
426
- .filter(([k]) => found[k])
427
- .filter(([k, want]) => found[k] !== want)
428
- .map(([k, want]) => `${k} ${found[k]} vs ${want}`);
429
-
430
- const unseen = Object.keys(REQUIRED_TOOLCHAIN).filter((k) => !found[k]);
431
-
432
- if (mismatched.length > 0) {
433
- return { state: 'wrong', detail: mismatched.join(', ') };
434
- }
435
- if (unseen.length === Object.keys(REQUIRED_TOOLCHAIN).length) {
573
+ const seen = Object.keys(REQUIRED_TOOLCHAIN).filter((k) => found[k]);
574
+ if (seen.length === 0) {
436
575
  return {
437
576
  state: 'unknown',
438
577
  detail: 'could not read any version from the build files',
439
578
  };
440
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
+ }
441
605
  return {
442
- state: 'ok',
443
- 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,
444
611
  };
445
612
  },
446
613
  why:
@@ -449,15 +616,20 @@ ${BROKER_PACKAGES.map((p) => ` <package android:name="${p}" />`).join('\n
449
616
  'rewrites bytecode in your app module, so when a mixed row does fail it fails ' +
450
617
  'inside javassist and the error names one of your classes, never a version.',
451
618
  instruction: () =>
452
- `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
622
+
623
+ Built and run by this project, off that row:
453
624
 
454
- Gradle ${REQUIRED_TOOLCHAIN.gradle}
455
- AGP ${REQUIRED_TOOLCHAIN.agp}
456
- Kotlin ${REQUIRED_TOOLCHAIN.kotlin}
457
- Java 17
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')}
458
630
 
459
- Matching it exactly is the tested path. If your app cannot move, the alternative is
460
- pinning an older MAM SDK whose row you do match — see sdk-versions.json.`,
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.`,
461
633
  },
462
634
  ];
463
635
 
@@ -465,7 +637,7 @@ pinning an older MAM SDK whose row you do match — see sdk-versions.json.`,
465
637
 
466
638
  /**
467
639
  * The lowest iOS the vendored MAM SDK runs on. Read from the binary (`otool -l` on
468
- * IntuneMAMSwift.framework in the 21.8.0 drop reports `minos 17.0`), not from
640
+ * IntuneMAMSwift.framework reports `minos 17.0` in both 21.8.0 and 21.9.0), not from
469
641
  * Microsoft's release notes — the SDK should move this, not a guess.
470
642
  */
471
643
  const MIN_IOS = [17, 0];
@@ -527,6 +699,26 @@ function appDeploymentTargets(pbxproj) {
527
699
  /** Ruby comments — whole-line or trailing — so a commented-out platform line is ignored. */
528
700
  const stripRubyComments = (src) => src.replace(/(^|\s)#.*$/gm, '$1');
529
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
+
530
722
  const iosChecks = [
531
723
  {
532
724
  id: 'ios-deployment-target',
@@ -616,6 +808,29 @@ const iosChecks = [
616
808
  (IPHONEOS_DEPLOYMENT_TARGET), then run pod install again.
617
809
 
618
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
+ },
619
834
  },
620
835
 
621
836
  {
@@ -679,6 +894,40 @@ ${KEYCHAIN_GROUPS.map((g) => ` <string>${g}</string>`).join('\n')}
679
894
  The app's own group first, then the SDK's, then MSAL's cache. The first entry may be
680
895
  written as the literal bundle id or as $(PRODUCT_BUNDLE_IDENTIFIER) — both work.`;
681
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
+ },
682
931
  },
683
932
 
684
933
  {
@@ -713,6 +962,12 @@ ${KEYCHAIN_GROUPS.map((g) => ` <string>${g}</string>`).join('\n')}
713
962
  </array>
714
963
  </dict>
715
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
+ },
716
971
  },
717
972
 
718
973
  {
@@ -742,6 +997,13 @@ ${KEYCHAIN_GROUPS.map((g) => ` <string>${g}</string>`).join('\n')}
742
997
  ${MSAL_QUERY_SCHEMES.map((s) => ` <string>${s}</string>`).join('\n')}
743
998
  <string>companyportal</string>
744
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
+ },
745
1007
  },
746
1008
 
747
1009
  {
@@ -772,6 +1034,16 @@ ${MSAL_QUERY_SCHEMES.map((s) => ` <string>${s}</string>`).join('\n')}
772
1034
 
773
1035
  Note: ADALCacheKeychainGroupOverride is a different key and is NOT removed — it has
774
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
+ },
775
1047
  },
776
1048
 
777
1049
  {
@@ -804,6 +1076,18 @@ ${MSAL_QUERY_SCHEMES.map((s) => ` <string>${s}</string>`).join('\n')}
804
1076
  }
805
1077
 
806
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
+ },
807
1091
  },
808
1092
 
809
1093
  {
@@ -862,8 +1146,14 @@ ${MSAL_QUERY_SCHEMES.map((s) => ` <string>${s}</string>`).join('\n')}
862
1146
  -i "$SRCROOT/$INFOPLIST_FILE" \\
863
1147
  -e "$SRCROOT/${'$'}{CODE_SIGN_ENTITLEMENTS}"
864
1148
 
865
- Not wrapped by setup: it is Microsoft's tool, and wrapping it means owning what it
866
- 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
+ },
867
1157
  },
868
1158
  ];
869
1159
 
@@ -940,16 +1230,27 @@ export function signatureHash(project) {
940
1230
  * there is one copy. Duplicating it here is how the two drift and the check starts
941
1231
  * reporting a row nobody targets.
942
1232
  */
943
- const REQUIRED_TOOLCHAIN = (() => {
1233
+ const TOOLCHAIN = (() => {
944
1234
  try {
945
1235
  const url = new URL('../../sdk-versions.json', import.meta.url);
946
- const { toolchain } = JSON.parse(fs.readFileSync(url, 'utf8'));
947
- const { requires } = toolchain;
948
- return { gradle: requires.gradle, agp: requires.agp, kotlin: requires.kotlin };
1236
+ return JSON.parse(fs.readFileSync(url, 'utf8')).toolchain ?? {};
949
1237
  } catch {
950
1238
  return {};
951
1239
  }
952
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 ?? [];
953
1254
 
954
1255
  function firstMatch(src, re) {
955
1256
  const m = src.match(re);