@bacons/apple-targets 0.1.17 → 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).
@@ -20,7 +20,7 @@ const withTargetsDir = (config, _props) => {
20
20
  hasWarned = true;
21
21
  console.warn((0, chalk_1.default) `{yellow [bacons/apple-targets]} Expo config is missing required {cyan ios.appleTeamId} property. Find this in Xcode and add to the Expo Config to correct. iOS builds may fail until this is corrected.`);
22
22
  }
23
- const targets = (0, glob_1.globSync)(`${root}/${match}/expo-target.config.@(json|js)`, {
23
+ const targets = (0, glob_1.sync)(`${root}/${match}/expo-target.config.@(json|js)`, {
24
24
  // const targets = globSync(`./targets/action/expo-target.config.@(json|js)`, {
25
25
  cwd: projectRoot,
26
26
  absolute: true,
package/build/config.d.ts CHANGED
@@ -3,17 +3,6 @@ export type DynamicColor = {
3
3
  light: string;
4
4
  dark?: string;
5
5
  };
6
- export type AppIntent = {
7
- /** Main text to show in the control */
8
- label: string;
9
- /** SF Symbol name */
10
- icon: string;
11
- displayName: string;
12
- description: string;
13
- kind?: string;
14
- /** URL to open when the intent is triggered. Should be a universal link. */
15
- url: string;
16
- };
17
6
  export type Entitlements = Partial<{
18
7
  "com.apple.developer.healthkit": boolean;
19
8
  "com.apple.developer.healthkit.access": string[];
@@ -70,6 +59,7 @@ export type Entitlements = Partial<{
70
59
  "com.apple.developer.driverkit.transport.hid": boolean;
71
60
  "com.apple.developer.driverkit.family.audio": boolean;
72
61
  "com.apple.developer.shared-with-you": boolean;
62
+ "com.apple.developer.associated-domains": string[];
73
63
  }>;
74
64
  export type Config = {
75
65
  /**
@@ -119,7 +109,5 @@ export type Config = {
119
109
  }>;
120
110
  /** Should the release build export the JS bundle and embed. Intended for App Clips and Share Extensions where you may want to use React Native. */
121
111
  exportJs?: boolean;
122
- /** App Intents to generate. */
123
- intents?: AppIntent[];
124
112
  };
125
113
  export type ConfigFunction = (config: import("expo/config").ExpoConfig) => Config;
@@ -27,7 +27,7 @@ exports.generateWatchIconsInternalAsync = exports.generateIconsInternalAsync = e
27
27
  const config_plugins_1 = require("@expo/config-plugins");
28
28
  const image_utils_1 = require("@expo/image-utils");
29
29
  const AssetContents_1 = require("@expo/prebuild-config/build/plugins/icons/AssetContents");
30
- const fs = __importStar(require("fs"));
30
+ const fs = __importStar(require("fs-extra"));
31
31
  const path_1 = __importStar(require("path"));
32
32
  const withImageAsset = (config, { cwd, name, image }) => {
33
33
  return (0, config_plugins_1.withDangerousMod)(config, [
@@ -37,9 +37,7 @@ const withImageAsset = (config, { cwd, name, image }) => {
37
37
  const iosNamedProjectRoot = (0, path_1.join)(projectRoot, cwd);
38
38
  const imgPath = `Assets.xcassets/${name}.imageset`;
39
39
  // Ensure the Images.xcassets/AppIcon.appiconset path exists
40
- await fs.promises.mkdir((0, path_1.join)(iosNamedProjectRoot, imgPath), {
41
- recursive: true,
42
- });
40
+ await fs.ensureDir((0, path_1.join)(iosNamedProjectRoot, imgPath));
43
41
  const userDefinedIcon = typeof image === "string"
44
42
  ? { "1x": image, "2x": undefined, "3x": undefined }
45
43
  : image;
@@ -122,9 +120,7 @@ exports.ICON_CONTENTS = [
122
120
  ];
123
121
  async function setIconsAsync(icon, projectRoot, iosNamedProjectRoot, cacheComponent) {
124
122
  // Ensure the Images.xcassets/AppIcon.appiconset path exists
125
- await fs.promises.mkdir((0, path_1.join)(iosNamedProjectRoot, IMAGESET_PATH), {
126
- recursive: true,
127
- });
123
+ await fs.ensureDir((0, path_1.join)(iosNamedProjectRoot, IMAGESET_PATH));
128
124
  // Finally, write the Config.json
129
125
  await (0, AssetContents_1.writeContentsJsonAsync)((0, path_1.join)(iosNamedProjectRoot, IMAGESET_PATH), {
130
126
  images: await generateIconsInternalAsync(icon, projectRoot, iosNamedProjectRoot, cacheComponent),
@@ -158,7 +154,7 @@ async function generateResizedImageAsync(icon, name, projectRoot, iosNamedProjec
158
154
  });
159
155
  // Write image buffer to the file system.
160
156
  const assetPath = (0, path_1.join)(iosNamedProjectRoot, `Assets.xcassets/${name}.imageset`, filename);
161
- await fs.promises.writeFile(assetPath, source);
157
+ await fs.writeFile(assetPath, source);
162
158
  if (filename) {
163
159
  imgEntry.filename = filename;
164
160
  }
@@ -200,7 +196,7 @@ async function generateIconsInternalAsync(icon, projectRoot, iosNamedProjectRoot
200
196
  });
201
197
  // Write image buffer to the file system.
202
198
  const assetPath = (0, path_1.join)(iosNamedProjectRoot, IMAGESET_PATH, filename);
203
- await fs.promises.writeFile(assetPath, source);
199
+ await fs.writeFile(assetPath, source);
204
200
  // Save a reference to the generated image so we don't create a duplicate.
205
201
  generatedIcons[filename] = true;
206
202
  }
@@ -238,7 +234,7 @@ async function generateWatchIconsInternalAsync(icon, projectRoot, iosNamedProjec
238
234
  });
239
235
  // Write image buffer to the file system.
240
236
  const assetPath = (0, path_1.join)(iosNamedProjectRoot, IMAGESET_PATH, filename);
241
- await fs.promises.writeFile(assetPath, source);
237
+ await fs.writeFile(assetPath, source);
242
238
  imagesJson.push({
243
239
  filename: getAppleIconName(size, 1),
244
240
  idiom: "universal",
@@ -27,7 +27,7 @@ exports.generateWatchIconsInternalAsync = exports.generateIconsInternalAsync = e
27
27
  const config_plugins_1 = require("@expo/config-plugins");
28
28
  const image_utils_1 = require("@expo/image-utils");
29
29
  const AssetContents_1 = require("@expo/prebuild-config/build/plugins/icons/AssetContents");
30
- const fs = __importStar(require("fs"));
30
+ const fs = __importStar(require("fs-extra"));
31
31
  const path_1 = require("path");
32
32
  // TODO: support dark, tinted, and universal icons for widgets.
33
33
  const withIosIcon = (config, { cwd, type, iconFilePath, isTransparent = false }) => {
@@ -38,9 +38,7 @@ const withIosIcon = (config, { cwd, type, iconFilePath, isTransparent = false })
38
38
  const namedProjectRoot = (0, path_1.join)(projectRoot, cwd);
39
39
  if (type === "watch") {
40
40
  // Ensure the Images.xcassets/AppIcon.appiconset path exists
41
- await fs.promises.mkdir((0, path_1.join)(namedProjectRoot, IMAGESET_PATH), {
42
- recursive: true,
43
- });
41
+ await fs.ensureDir((0, path_1.join)(namedProjectRoot, IMAGESET_PATH));
44
42
  // Finally, write the Config.json
45
43
  await (0, AssetContents_1.writeContentsJsonAsync)((0, path_1.join)(namedProjectRoot, IMAGESET_PATH), {
46
44
  images: await generateWatchIconsInternalAsync(iconFilePath, projectRoot, namedProjectRoot, cwd, isTransparent),
@@ -121,9 +119,7 @@ exports.ICON_CONTENTS = [
121
119
  ];
122
120
  async function setIconsAsync(icon, projectRoot, iosNamedProjectRoot, cacheComponent, isTransparent) {
123
121
  // Ensure the Images.xcassets/AppIcon.appiconset path exists
124
- await fs.promises.mkdir((0, path_1.join)(iosNamedProjectRoot, IMAGESET_PATH), {
125
- recursive: true,
126
- });
122
+ await fs.ensureDir((0, path_1.join)(iosNamedProjectRoot, IMAGESET_PATH));
127
123
  // Finally, write the Config.json
128
124
  await (0, AssetContents_1.writeContentsJsonAsync)((0, path_1.join)(iosNamedProjectRoot, IMAGESET_PATH), {
129
125
  images: await generateIconsInternalAsync(icon, projectRoot, iosNamedProjectRoot, cacheComponent, isTransparent),
@@ -162,7 +158,7 @@ async function generateIconsInternalAsync(icon, projectRoot, iosNamedProjectRoot
162
158
  });
163
159
  // Write image buffer to the file system.
164
160
  const assetPath = (0, path_1.join)(iosNamedProjectRoot, IMAGESET_PATH, filename);
165
- await fs.promises.writeFile(assetPath, source);
161
+ await fs.writeFile(assetPath, source);
166
162
  // Save a reference to the generated image so we don't create a duplicate.
167
163
  generatedIcons[filename] = true;
168
164
  }
@@ -200,7 +196,7 @@ async function generateWatchIconsInternalAsync(icon, projectRoot, iosNamedProjec
200
196
  });
201
197
  // Write image buffer to the file system.
202
198
  const assetPath = (0, path_1.join)(iosNamedProjectRoot, IMAGESET_PATH, filename);
203
- await fs.promises.writeFile(assetPath, source);
199
+ await fs.writeFile(assetPath, source);
204
200
  imagesJson.push({
205
201
  filename: getAppleIconName(size, 1),
206
202
  idiom: "universal",
@@ -15,7 +15,6 @@ const withIosIcon_1 = require("./icon/withIosIcon");
15
15
  const target_1 = require("./target");
16
16
  const withEasCredentials_1 = require("./withEasCredentials");
17
17
  const withXcodeChanges_1 = require("./withXcodeChanges");
18
- const withLinkAppIntent_1 = __importDefault(require("./withLinkAppIntent"));
19
18
  const DEFAULT_DEPLOYMENT_TARGET = "18.0";
20
19
  function memoize(fn) {
21
20
  const cache = new Map();
@@ -52,7 +51,7 @@ function kebabToCamelCase(str) {
52
51
  });
53
52
  }
54
53
  const withWidget = (config, props) => {
55
- var _a, _b, _c, _d, _e, _f, _g, _h, _j;
54
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l;
56
55
  prebuildLogQueue.add(() => warnOnce((0, chalk_1.default) `\nUsing experimental Config Plugin {bold @bacons/apple-targets} that is subject to breaking changes.`));
57
56
  // TODO: Magically based on the top-level folders in the `ios-widgets/` folder
58
57
  if (props.icon && !/https?:\/\//.test(props.icon)) {
@@ -64,7 +63,7 @@ const withWidget = (config, props) => {
64
63
  .replace(/^\/+/, "");
65
64
  const widget = kebabToCamelCase(widgetDir);
66
65
  const widgetFolderAbsolutePath = path_1.default.join((_b = (_a = config._internal) === null || _a === void 0 ? void 0 : _a.projectRoot) !== null && _b !== void 0 ? _b : "", props.directory);
67
- const entitlementsFiles = (0, glob_1.globSync)("*.entitlements", {
66
+ const entitlementsFiles = (0, glob_1.sync)("*.entitlements", {
68
67
  absolute: true,
69
68
  cwd: widgetFolderAbsolutePath,
70
69
  });
@@ -75,11 +74,54 @@ const withWidget = (config, props) => {
75
74
  if (entitlementsJson) {
76
75
  // Apply default entitlements that must be present for a target to work.
77
76
  const applyDefaultEntitlements = (entitlements) => {
78
- var _a, _b;
77
+ var _a, _b, _c, _d, _e, _f;
79
78
  if (props.type === "clip") {
80
79
  entitlements["com.apple.developer.parent-application-identifiers"] = [
81
80
  `$(AppIdentifierPrefix)${config.ios.bundleIdentifier}`,
82
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
+ }
83
125
  // NOTE: This doesn't seem to be required anymore (Oct 12 2024):
84
126
  // entitlements["com.apple.developer.on-demand-install-capable"] = true;
85
127
  }
@@ -90,7 +132,7 @@ const withWidget = (config, props) => {
90
132
  !hasDefinedAppGroupsManually &&
91
133
  // And the target is part of a predefined list of types that benefit from app groups that match the main app...
92
134
  target_1.SHOULD_USE_APP_GROUPS_BY_DEFAULT[props.type]) {
93
- 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];
94
136
  if (Array.isArray(mainAppGroups) && mainAppGroups.length > 0) {
95
137
  // Then set the target app groups to match the main app.
96
138
  entitlements[APP_GROUP_KEY] = mainAppGroups;
@@ -117,29 +159,6 @@ const withWidget = (config, props) => {
117
159
  };
118
160
  entitlementsJson = applyDefaultEntitlements(entitlementsJson);
119
161
  }
120
- if (props.intents) {
121
- // Add local images:
122
- // TODO: Apple only supports custom SF Symbols for intents, so we'll need to create a system to generate these from an SVG or accept .SFSymbol.svg files.
123
- // props.intents.forEach((intent, index) => {
124
- // // Is local image?
125
- // if (intent.icon.match(/^\./)) {
126
- // props.images ??= {};
127
- // const intentIconName = "generated_expo_intent_icon_" + index;
128
- // props.images[intentIconName] = intent.icon;
129
- // props.intents![index].icon = intentIconName;
130
- // }
131
- // });
132
- props.intents.forEach((intent) => {
133
- if (intent.icon.match(/^\./)) {
134
- throw new Error("Local images are not supported for intents. Use an SF Symbol name. From: " +
135
- intent.icon);
136
- }
137
- });
138
- (0, withLinkAppIntent_1.default)(config, {
139
- intents: props.intents,
140
- targetRoot: widgetFolderAbsolutePath,
141
- });
142
- }
143
162
  // If the user defined entitlements, then overwrite any existing entitlements file
144
163
  if (entitlementsJson) {
145
164
  (0, config_plugins_1.withDangerousMod)(config, [
@@ -201,22 +220,32 @@ const withWidget = (config, props) => {
201
220
  const mainAppBundleId = config.ios.bundleIdentifier;
202
221
  const bundleId = ((_d = props.bundleIdentifier) === null || _d === void 0 ? void 0 : _d.startsWith("."))
203
222
  ? mainAppBundleId + props.bundleIdentifier
204
- : (_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"];
205
232
  (0, withXcodeChanges_1.withXcodeChanges)(config, {
206
233
  configPath: props.configPath,
207
234
  name: targetName,
208
235
  cwd: "../" +
209
236
  path_1.default.relative(config._internal.projectRoot, path_1.default.resolve(props.directory)),
210
- 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,
211
238
  bundleId,
212
239
  icon: props.icon,
213
- 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,
214
243
  // @ts-expect-error: who cares
215
- 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,
216
245
  frameworks: (0, target_1.getFrameworksForType)(props.type).concat(props.frameworks || []),
217
246
  type: props.type,
218
247
  teamId: props.appleTeamId,
219
- exportJs: (_j = props.exportJs) !== null && _j !== void 0 ? _j :
248
+ exportJs: (_l = props.exportJs) !== null && _l !== void 0 ? _l :
220
249
  // Assume App Clips are used for React Native.
221
250
  props.type === "clip",
222
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",
@@ -697,7 +732,7 @@ async function applyXcodeChanges(config, project, props) {
697
732
  }
698
733
  }
699
734
  function configureTargetWithEntitlements(target) {
700
- const entitlements = (0, glob_1.globSync)("*.entitlements", {
735
+ const entitlements = (0, glob_1.sync)("*.entitlements", {
701
736
  absolute: false,
702
737
  cwd: magicCwd,
703
738
  });
@@ -719,7 +754,7 @@ async function applyXcodeChanges(config, project, props) {
719
754
  });
720
755
  }
721
756
  function configureTargetWithPreview(target) {
722
- const assets = (0, glob_1.globSync)("preview/*.xcassets", {
757
+ const assets = (0, glob_1.sync)("preview/*.xcassets", {
723
758
  absolute: true,
724
759
  cwd: magicCwd,
725
760
  })[0];
@@ -839,7 +874,7 @@ async function applyXcodeChanges(config, project, props) {
839
874
  fs_1.default.statSync(path_1.default.join(assetsDir, file)).isDirectory())
840
875
  .map((file) => path_1.default.join("assets", file));
841
876
  const protectedGroup = ensureProtectedGroup(project, path_1.default.dirname(props.cwd));
842
- const sharedAssets = (0, glob_1.globSync)("_shared/*", {
877
+ const sharedAssets = (0, glob_1.sync)("_shared/*", {
843
878
  absolute: false,
844
879
  cwd: magicCwd,
845
880
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bacons/apple-targets",
3
- "version": "0.1.17",
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",
@@ -33,13 +33,15 @@
33
33
  "author": "Evan Bacon",
34
34
  "license": "MIT",
35
35
  "dependencies": {
36
- "@bacons/xcode": "1.0.0-alpha.24",
37
36
  "@react-native/normalize-colors": "^0.76.1",
38
- "glob": "^10.4.2",
37
+ "glob": "^10.2.6",
38
+ "@bacons/xcode": "1.0.0-alpha.24",
39
+ "fs-extra": "^11.2.0",
39
40
  "debug": "^4.3.4"
40
41
  },
41
42
  "devDependencies": {
42
43
  "@types/debug": "^4.1.7",
44
+ "@types/fs-extra": "^11.0.4",
43
45
  "@types/glob": "^8.1.0",
44
46
  "@expo/babel-preset-cli": "^0.3.1",
45
47
  "chalk": "^4.0.0",
@@ -1,7 +0,0 @@
1
- import { ConfigPlugin } from "expo/config-plugins";
2
- import { AppIntent } from "./config";
3
- declare const withLinkAppIntent: ConfigPlugin<{
4
- intents: AppIntent[];
5
- targetRoot: string;
6
- }>;
7
- export default withLinkAppIntent;
@@ -1,86 +0,0 @@
1
- "use strict";
2
- var __importDefault = (this && this.__importDefault) || function (mod) {
3
- return (mod && mod.__esModule) ? mod : { "default": mod };
4
- };
5
- Object.defineProperty(exports, "__esModule", { value: true });
6
- const config_plugins_1 = require("expo/config-plugins");
7
- function generateSwiftModule(config) {
8
- const { intents } = config;
9
- const widgetBundleBody = intents
10
- .map((intent, index) => `// widgetControl${index}()`)
11
- .join("\n");
12
- const widgetDefinitions = intents
13
- .map((intent, index) => {
14
- return `
15
- @available(iOS 18.0, *)
16
- struct widgetControl${index}: ControlWidget {
17
- static let kind: String = "${intent.kind}"
18
- var body: some ControlWidgetConfiguration {
19
- StaticControlConfiguration(kind: Self.kind) {
20
- ControlWidgetButton(action: OpenAppIntent${index}()) {
21
- Label("${intent.label}", ${
22
- // If the icon is generated, use the image system
23
- intent.icon.startsWith("generated_expo_intent_icon_")
24
- ? "image"
25
- : "systemImage"}: "${intent.icon}")
26
- }
27
- }
28
- .displayName("${intent.displayName}")
29
- .description("${intent.description}")
30
- }
31
- }
32
-
33
- // This must be in both targets when \`openAppWhenRun = true\`
34
- // https://developer.apple.com/forums/thread/763851
35
- @available(iOS 18.0, *)
36
- struct OpenAppIntent${index}: ControlConfigurationIntent {
37
- static let title: LocalizedStringResource = "${intent.displayName}"
38
- static let description = IntentDescription(stringLiteral: "${intent.description}")
39
- static let isDiscoverable = true
40
- static let openAppWhenRun: Bool = true
41
-
42
- @MainActor
43
- func perform() async throws -> some IntentResult & OpensIntent {
44
- return .result(opensIntent: OpenURLIntent(URL(string: "${intent.url}")!))
45
- }
46
- }`;
47
- })
48
- .join("\n");
49
- return `
50
- // Generated by @bacons/apple-targets via the appIntents option in the config file.
51
- import AppIntents
52
- import SwiftUI
53
- import WidgetKit
54
-
55
- // TODO: These must be added to the WidgetBundle manually. They need to be linked outside of the _shared folder.
56
- // @main
57
- // struct exportWidgets: WidgetBundle {
58
- // var body: some Widget {
59
- ${widgetBundleBody}
60
- // }
61
- // }
62
- ${widgetDefinitions}
63
- `;
64
- }
65
- const fs_1 = __importDefault(require("fs"));
66
- const withLinkAppIntent = (config, { targetRoot, intents }) => {
67
- (0, config_plugins_1.withDangerousMod)(config, [
68
- "ios",
69
- async (config) => {
70
- const sharedFile = `${targetRoot}/_shared/generated-intents.swift`;
71
- await fs_1.default.promises.mkdir(`${targetRoot}/_shared`, { recursive: true });
72
- await fs_1.default.promises.writeFile(sharedFile, generateSwiftModule({
73
- intents: intents.map((intent, index) => {
74
- var _a;
75
- return ({
76
- ...intent,
77
- kind: (_a = intent.kind) !== null && _a !== void 0 ? _a : `${config.ios.bundleIdentifier}.${index}`,
78
- });
79
- }),
80
- }), "utf-8");
81
- return config;
82
- },
83
- ]);
84
- return config;
85
- };
86
- exports.default = withLinkAppIntent;