@bacons/apple-targets 0.1.16 → 0.1.18

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 CHANGED
@@ -405,43 +405,91 @@ let index = defaults?.string(forKey: "myKey")
405
405
 
406
406
  This plugin makes use of my proprietary Xcode parsing library, [`@bacons/xcode`](https://github.com/evanbacon/xcode). It's mostly typed, very untested, and possibly full of bugs––however, it's still 10x nicer than the alternative.
407
407
 
408
- ## Generated App Intents
408
+ ## App Clips
409
409
 
410
- You can make any type of App Intent that you'd like with this plugin. However, the majority of app intents simply launch a URL (specifically a universal link for the same app). Expo Router apps have automatic deep linking and websites, meaning you can get to universal links pretty easily. As such, I've built in support for generating link-opening app intents from JSON.
410
+ App Clips leverage the true power of Expo Router, enabling you to link a website and native app to just instantly open the native app on iOS. They're pretty hard to get working though.
411
411
 
412
- These actions will show up in the control center, siri suggestions, Shortcuts, and can be added to the bottom of the lock screen in iOS 18.
412
+ Here are a few notes from my experience building https://pillarvalley.expo.app (open on iOS to test).
413
413
 
414
- ```js
414
+ Build the app first, then the website. You can always instantly update the website if it's wrong. This includes the AASA, and metadata.
415
+
416
+ You may need [this RN patch](https://github.com/facebook/react-native/pull/47000) to get your project working, otherwise it'll crash when launched from Test Flight. Alternatively, you can add App Clip experiences in App Store Connect and it'll launch as expected.
417
+
418
+ After running prebuild, open the project in Xcode and navigate to the signing tab for each target, this'll ensure the first version of codesigning is absolutely correct. We'll need to adjust EAS Build to ensure it can do this too.
419
+
420
+ Ensure your App Clip does not have `expo-updates` installed, otherwise it'll fail to build with some cryptic error about React missing in the AppDelegate.
421
+
422
+ Ensure all the build numbers are the same across the `CURRENT_PROJECT_VERSION` and `CFBundleVersion` (Info.plist) otherwise the app will fail to build.
423
+
424
+ Ensure you add a `public/.well-known/apple-app-site-association` file to your website and deploy it to the web (`eas deploy --prod`). Here's [an example](https://github.com/EvanBacon/pillar-valley/blob/d5ab82ae04f519310acf4b31aad8d9e22eb3747d/public/.well-known/apple-app-site-association#L27-L29).
425
+
426
+ The value will be `<Apple Team ID>.<App Clip Bundle ID>`:
427
+
428
+ ```
415
429
  {
416
- intents: [
417
- {
418
- // Label that will show up in the control center.
419
- label: "Bacon",
420
- // Name of an SF Symbol (no custom icons are supported).
421
- icon: "laurel.leading",
422
- // Universal link to open.
423
- url: "https://pillarvalley.netlify.app/settings",
424
- // Auxiliary data
425
- displayName: "Open Bacon AI",
426
- description: "Launch the Bacon AI app.",
427
- },
428
- ];
430
+ "appclips": {
431
+ "apps": ["QQ57RJ5UTD.com.evanbacon.pillarvalley.clip"]
432
+ }
429
433
  }
430
434
  ```
431
435
 
432
- You can add multiple intents, they'll be generated during prebuild to the `[target]/_shared/generated-intents.swift` file. This file will have a comment block in it like:
436
+ Add the website URL to your App Clip entitlements (not the main entitlements). Here's an example with `https://pillarvalley.expo.app`:
433
437
 
434
- ```swift
435
- // TODO: These must be added to the WidgetBundle manually. They need to be linked outside of the _shared folder.
436
- // @main
437
- // struct exportWidgets: WidgetBundle {
438
- // var body: some Widget {
439
- // widgetControl0()
440
- // widgetControl1()
441
- // }
442
- // }
438
+ ```xml
439
+ <key>com.apple.developer.associated-domains</key>
440
+ <array>
441
+ <string>appclips:pillarvalley.expo.app</string>
442
+ </array>
443
+ ```
444
+
445
+ If this isn't done, then your App Clip will only be able to be launched from the default App Store URL: `https://appclip.apple.com/id?p=com.evanbacon.pillarvalley.clip` (where your App Clip bundle ID will be the ID in the URL).
446
+
447
+ You should handle redirection from this default URL too with a [`app/+native-intent.ts`](https://docs.expo.dev/router/advanced/native-intent/) file:
448
+
449
+ ```ts
450
+ export function redirectSystemPath({ path }: { path: string }): string {
451
+ try {
452
+ // Handle App Clip default page redirection.
453
+ // If path matches https://appclip.apple.com/id?p=com.evanbacon.pillarvalley.clip (with any query parameters), then redirect to `/` path.
454
+ const url = new URL(path);
455
+ if (url.hostname === "appclip.apple.com") {
456
+ // Redirect to the root path and make the original URL available as a query parameter (optional).
457
+ return "/?ref=" + encodeURIComponent(path);
458
+ }
459
+ return path;
460
+ } catch {
461
+ return path;
462
+ }
463
+ }
464
+ ```
465
+
466
+ You should use `expo-linking` to get URLs related to the App Clip as the upstream React Native Linking has some issues handling App Clips.
467
+
468
+ When you publish an App Clip, the binary will take about 5 minutes to show up in the App Store (after it's approved) but the App Clip will take more like 25 minutes to show up in your website.
469
+
470
+ You also need to add some meta tags to your website. These need to run fast so I recommend putting them in your `app/+html.tsx` file:
471
+
472
+ ```js
473
+ <meta
474
+ name="apple-itunes-app"
475
+ content={
476
+ "app-id=1336398804, app-clip-bundle-id=com.evanbacon.pillarvalley.clip, app-clip-display=card"
477
+ }
478
+ />
443
479
  ```
444
480
 
445
- You should copy the generated intents into your main `WidgetBundle` struct.
481
+ You should also add the `og:image` property using `expo-router/head`. [Learn more](https://developer.apple.com/documentation/appclip/supporting-invocations-from-your-website-and-the-messages-app). It seems like an absolute path to a png image that is `1200×630` in dimensions ([based on this](https://developer.apple.com/library/archive/technotes/tn2444/_index.html)).
482
+
483
+ ```js
484
+ <Head>
485
+ {/* Required for app clips: */}
486
+ {/* https://developer.apple.com/documentation/appclip/supporting-invocations-from-your-website-and-the-messages-app */}
487
+ <meta property="og:image" content="https://pillarvalley.expo.app/og.png" />
488
+ </Head>
489
+ ```
490
+
491
+ You also need a `1800x1200` image for the App Store Connect image preview, so make both of these images around the same time.
492
+
493
+ Launch App Clips from Test Flight to test deep linking. It doesn't seem like there's any reasonable way to test launching from your website in development. I got this to work once by setting up a local experience in my app's "Settings > Developer" screen, then installing the app, opening the website, deleting the app, then installing the App Clip without the app. You'll mostly need to go with God on this one.
446
494
 
447
- This system is subject to change with the default intended behavior to be that anywhere a SF Symbol can be used to launch a deep link should be populated by this feature.
495
+ You can generate codes using the CLI tool [download here](https://developer.apple.com/download/all/?q=%22app%20clip%22).
package/build/config.d.ts CHANGED
@@ -59,6 +59,7 @@ export type Entitlements = Partial<{
59
59
  "com.apple.developer.driverkit.transport.hid": boolean;
60
60
  "com.apple.developer.driverkit.family.audio": boolean;
61
61
  "com.apple.developer.shared-with-you": boolean;
62
+ "com.apple.developer.associated-domains": string[];
62
63
  }>;
63
64
  export type Config = {
64
65
  /**
@@ -51,7 +51,7 @@ function kebabToCamelCase(str) {
51
51
  });
52
52
  }
53
53
  const withWidget = (config, props) => {
54
- var _a, _b, _c, _d, _e, _f, _g, _h, _j;
54
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l;
55
55
  prebuildLogQueue.add(() => warnOnce((0, chalk_1.default) `\nUsing experimental Config Plugin {bold @bacons/apple-targets} that is subject to breaking changes.`));
56
56
  // TODO: Magically based on the top-level folders in the `ios-widgets/` folder
57
57
  if (props.icon && !/https?:\/\//.test(props.icon)) {
@@ -74,11 +74,54 @@ const withWidget = (config, props) => {
74
74
  if (entitlementsJson) {
75
75
  // Apply default entitlements that must be present for a target to work.
76
76
  const applyDefaultEntitlements = (entitlements) => {
77
- var _a, _b;
77
+ var _a, _b, _c, _d, _e, _f;
78
78
  if (props.type === "clip") {
79
79
  entitlements["com.apple.developer.parent-application-identifiers"] = [
80
80
  `$(AppIdentifierPrefix)${config.ios.bundleIdentifier}`,
81
81
  ];
82
+ // Try to extract the linked website from the original associated domains:
83
+ const associatedDomainsKey = "com.apple.developer.associated-domains";
84
+ // If the target doesn't explicitly define associated domains, then try to use the main app's associated domains.
85
+ if (!entitlements[associatedDomainsKey]) {
86
+ const associatedDomains = (_b = (_a = config.ios) === null || _a === void 0 ? void 0 : _a.associatedDomains) !== null && _b !== void 0 ? _b : (_d = (_c = config.ios) === null || _c === void 0 ? void 0 : _c.entitlements) === null || _d === void 0 ? void 0 : _d["com.apple.developer.associated-domains"];
87
+ if (!associatedDomains ||
88
+ !Array.isArray(associatedDomains) ||
89
+ associatedDomains.length === 0) {
90
+ warnOnce((0, chalk_1.default) `{yellow [${widget}]} Apple App Clip may require the associated domains entitlement but none were found in the Expo config.\nExample:\n${JSON.stringify({
91
+ ios: {
92
+ associatedDomains: [`applinks:placeholder.expo.app`],
93
+ },
94
+ }, null, 2)}`);
95
+ }
96
+ else {
97
+ // Associated domains are found:
98
+ // "applinks:pillarvalley.expo.app",
99
+ // "webcredentials:pillarvalley.expo.app",
100
+ // "activitycontinuation:pillarvalley.expo.app"
101
+ const sanitizedUrls = associatedDomains
102
+ .map((url) => {
103
+ return (url
104
+ .replace(/^(appclips|applinks|webcredentials|activitycontinuation):/, "")
105
+ // Remove trailing slashes
106
+ .replace(/\/$/, "")
107
+ // Remove http/https
108
+ .replace(/^https?:\/\//, ""));
109
+ })
110
+ .filter(Boolean);
111
+ const unique = [...new Set(sanitizedUrls)];
112
+ if (unique.length) {
113
+ warnOnce((0, chalk_1.default) `{gray [${widget}]} Apple App Clip expo-target.config.js missing associated domains entitlements in the target config. Using the following defaults:\n${JSON.stringify({
114
+ entitlements: {
115
+ [associatedDomainsKey]: [
116
+ `appclips:${unique[0] || "mywebsite.expo.app"}`,
117
+ ],
118
+ },
119
+ }, null, 2)}`);
120
+ // Add anyways
121
+ entitlements[associatedDomainsKey] = unique.map((url) => `appclips:${url}`);
122
+ }
123
+ }
124
+ }
82
125
  // NOTE: This doesn't seem to be required anymore (Oct 12 2024):
83
126
  // entitlements["com.apple.developer.on-demand-install-capable"] = true;
84
127
  }
@@ -89,7 +132,7 @@ const withWidget = (config, props) => {
89
132
  !hasDefinedAppGroupsManually &&
90
133
  // And the target is part of a predefined list of types that benefit from app groups that match the main app...
91
134
  target_1.SHOULD_USE_APP_GROUPS_BY_DEFAULT[props.type]) {
92
- const mainAppGroups = (_b = (_a = config.ios) === null || _a === void 0 ? void 0 : _a.entitlements) === null || _b === void 0 ? void 0 : _b[APP_GROUP_KEY];
135
+ const mainAppGroups = (_f = (_e = config.ios) === null || _e === void 0 ? void 0 : _e.entitlements) === null || _f === void 0 ? void 0 : _f[APP_GROUP_KEY];
93
136
  if (Array.isArray(mainAppGroups) && mainAppGroups.length > 0) {
94
137
  // Then set the target app groups to match the main app.
95
138
  entitlements[APP_GROUP_KEY] = mainAppGroups;
@@ -177,22 +220,32 @@ const withWidget = (config, props) => {
177
220
  const mainAppBundleId = config.ios.bundleIdentifier;
178
221
  const bundleId = ((_d = props.bundleIdentifier) === null || _d === void 0 ? void 0 : _d.startsWith("."))
179
222
  ? mainAppBundleId + props.bundleIdentifier
180
- : (_e = props.bundleIdentifier) !== null && _e !== void 0 ? _e : `${mainAppBundleId}.${getSanitizedBundleIdentifier(targetName)}`;
223
+ : (_e = props.bundleIdentifier) !== null && _e !== void 0 ? _e : `${mainAppBundleId}.${props.type === "clip"
224
+ ? // Use a more standardized bundle identifier for App Clips.
225
+ "clip"
226
+ : getSanitizedBundleIdentifier(targetName)}`;
227
+ const deviceFamilies = ((_f = config.ios) === null || _f === void 0 ? void 0 : _f.isTabletOnly)
228
+ ? ["tablet"]
229
+ : ((_g = config.ios) === null || _g === void 0 ? void 0 : _g.supportsTablet)
230
+ ? ["phone", "tablet"]
231
+ : ["phone"];
181
232
  (0, withXcodeChanges_1.withXcodeChanges)(config, {
182
233
  configPath: props.configPath,
183
234
  name: targetName,
184
235
  cwd: "../" +
185
236
  path_1.default.relative(config._internal.projectRoot, path_1.default.resolve(props.directory)),
186
- deploymentTarget: (_f = props.deploymentTarget) !== null && _f !== void 0 ? _f : DEFAULT_DEPLOYMENT_TARGET,
237
+ deploymentTarget: (_h = props.deploymentTarget) !== null && _h !== void 0 ? _h : DEFAULT_DEPLOYMENT_TARGET,
187
238
  bundleId,
188
239
  icon: props.icon,
189
- hasAccentColor: !!((_g = props.colors) === null || _g === void 0 ? void 0 : _g.$accent),
240
+ orientation: config.orientation,
241
+ hasAccentColor: !!((_j = props.colors) === null || _j === void 0 ? void 0 : _j.$accent),
242
+ deviceFamilies,
190
243
  // @ts-expect-error: who cares
191
- currentProjectVersion: ((_h = config.ios) === null || _h === void 0 ? void 0 : _h.buildNumber) || 1,
244
+ currentProjectVersion: ((_k = config.ios) === null || _k === void 0 ? void 0 : _k.buildNumber) || 1,
192
245
  frameworks: (0, target_1.getFrameworksForType)(props.type).concat(props.frameworks || []),
193
246
  type: props.type,
194
247
  teamId: props.appleTeamId,
195
- exportJs: (_j = props.exportJs) !== null && _j !== void 0 ? _j :
248
+ exportJs: (_l = props.exportJs) !== null && _l !== void 0 ? _l :
196
249
  // Assume App Clips are used for React Native.
197
250
  props.type === "clip",
198
251
  });
@@ -16,5 +16,8 @@ export type XcodeSettings = {
16
16
  exportJs?: boolean;
17
17
  /** File path to the extension config file. */
18
18
  configPath: string;
19
+ orientation?: "default" | "portrait" | "landscape";
20
+ deviceFamilies?: DeviceFamily[];
19
21
  };
22
+ export type DeviceFamily = "phone" | "tablet";
20
23
  export declare const withXcodeChanges: ConfigPlugin<XcodeSettings>;
@@ -441,7 +441,7 @@ function createSafariConfigurationList(project, { name, cwd, bundleId, deploymen
441
441
  });
442
442
  return configurationList;
443
443
  }
444
- function createAppClipConfigurationList(project, { name, cwd, bundleId, deploymentTarget, currentProjectVersion, hasAccentColor, }) {
444
+ function createAppClipConfigurationList(project, { name, cwd, bundleId, deploymentTarget, currentProjectVersion, hasAccentColor, orientation, deviceFamilies, }) {
445
445
  // TODO: Unify AppIcon and AccentColor logic
446
446
  const dynamic = {
447
447
  CURRENT_PROJECT_VERSION: currentProjectVersion,
@@ -469,15 +469,14 @@ function createAppClipConfigurationList(project, { name, cwd, bundleId, deployme
469
469
  PRODUCT_NAME: "$(TARGET_NAME)",
470
470
  SWIFT_EMIT_LOC_STRINGS: "YES",
471
471
  SWIFT_VERSION: "5.0",
472
- TARGETED_DEVICE_FAMILY: "1,2",
472
+ ...getDeviceFamilyBuildSettings(deviceFamilies),
473
473
  };
474
474
  const infoPlist = {
475
475
  GENERATE_INFOPLIST_FILE: "YES",
476
476
  INFOPLIST_KEY_UIApplicationSceneManifest_Generation: "YES",
477
477
  INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents: "YES",
478
478
  INFOPLIST_KEY_UILaunchScreen_Generation: "YES",
479
- INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad: "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight",
480
- INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone: "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight",
479
+ ...getOrientationBuildSettings(orientation),
481
480
  };
482
481
  // @ts-expect-error
483
482
  const common = {
@@ -518,6 +517,42 @@ function createAppClipConfigurationList(project, { name, cwd, bundleId, deployme
518
517
  });
519
518
  return configurationList;
520
519
  }
520
+ function getOrientationBuildSettings(orientation) {
521
+ // Try to align the orientation with the main app.
522
+ if (orientation === "landscape") {
523
+ return {
524
+ INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone: "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight",
525
+ INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad: "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight",
526
+ };
527
+ }
528
+ else if (orientation === "portrait") {
529
+ return {
530
+ INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone: "UIInterfaceOrientationPortrait",
531
+ INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad: "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown",
532
+ };
533
+ }
534
+ return {
535
+ INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone: "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight",
536
+ INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad: "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight",
537
+ };
538
+ }
539
+ function getDeviceFamilyBuildSettings(deviceFamilies) {
540
+ if (!deviceFamilies) {
541
+ return {
542
+ TARGETED_DEVICE_FAMILY: "1,2",
543
+ };
544
+ }
545
+ const families = [];
546
+ if (deviceFamilies.includes("phone")) {
547
+ families.push(1);
548
+ }
549
+ if (deviceFamilies.includes("tablet")) {
550
+ families.push(2);
551
+ }
552
+ return {
553
+ TARGETED_DEVICE_FAMILY: families.join(","),
554
+ };
555
+ }
521
556
  function createConfigurationList(project, { name, cwd, bundleId, deploymentTarget, currentProjectVersion, }) {
522
557
  const debugBuildConfig = xcode_1.XCBuildConfiguration.create(project, {
523
558
  name: "Debug",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bacons/apple-targets",
3
- "version": "0.1.16",
3
+ "version": "0.1.18",
4
4
  "description": "Generate Apple Targets with Expo Prebuild",
5
5
  "main": "build/ExtensionStorage.js",
6
6
  "types": "build/ExtensionStorage.d.ts",