@bacons/apple-targets 0.1.17 → 0.1.19

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
@@ -178,8 +178,6 @@ The name of the target must match the name of the target directory.
178
178
 
179
179
  Some files are required to be linked to both your target and the main target. To support this, you can add a top-level `_shared` directory. Any file in this directory will be linked to both the main target and the sub-target. You'll need to re-run prebuild every time you add, rename, or remove a file in this directory.
180
180
 
181
- This is especially useful for Live Activities and Intents.
182
-
183
181
  ## `exportJs`
184
182
 
185
183
  The `exportJs` option should be used when the target uses React Native (App Clip, Share extension). It works by linking the main target's `Bundle React Native code and images` build phase to the target. This will ensure that production builds (`Release`) bundle the main JS entry file with Metro, and embed the bundle/assets for offline use.
@@ -319,6 +317,7 @@ If you experience issues building widgets, it might be because React Native is s
319
317
 
320
318
  Some workarounds:
321
319
 
320
+ - Clear the SwiftUI previews cache: `xcrun simctl --set previews delete all`
322
321
  - Prebuild without React Native: `npx expo prebuild --template node_modules/@bacons/apple-targets/prebuild-blank.tgz --clean`
323
322
  - If the widget doesn't show on the home screen when building the app, use iOS 18. You can long press the app icon and select the widget display options to transform the app icon into the widget.
324
323
 
@@ -401,47 +400,101 @@ let defaults = UserDefaults(suiteName:
401
400
  let index = defaults?.string(forKey: "myKey")
402
401
  ```
403
402
 
403
+ ### More data updates
404
+
405
+ For more advanced uses, I recommend the following resources:
406
+
407
+ - Updating widgets when the app is in the background: [Keeping A Widget Up-to-Date](https://developer.apple.com/documentation/widgetkit/keeping-a-widget-up-to-date).
408
+
404
409
  ## Xcode parsing
405
410
 
406
411
  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
412
 
408
- ## Generated App Intents
413
+ ## App Clips
409
414
 
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.
415
+ 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
416
 
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.
417
+ Here are a few notes from my experience building https://pillarvalley.expo.app (open on iOS to test).
413
418
 
414
- ```js
419
+ Build the app first, then the website. You can always instantly update the website if it's wrong. This includes the AASA, and metadata.
420
+
421
+ 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.
422
+
423
+ 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.
424
+
425
+ 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.
426
+
427
+ Ensure all the build numbers are the same across the `CURRENT_PROJECT_VERSION` and `CFBundleVersion` (Info.plist) otherwise the app will fail to build.
428
+
429
+ 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).
430
+
431
+ The value will be `<Apple Team ID>.<App Clip Bundle ID>`:
432
+
433
+ ```
415
434
  {
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
- ];
435
+ "appclips": {
436
+ "apps": ["QQ57RJ5UTD.com.evanbacon.pillarvalley.clip"]
437
+ }
429
438
  }
430
439
  ```
431
440
 
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:
441
+ Add the website URL to your App Clip entitlements (not the main entitlements). Here's an example with `https://pillarvalley.expo.app`:
433
442
 
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
- // }
443
+ ```xml
444
+ <key>com.apple.developer.associated-domains</key>
445
+ <array>
446
+ <string>appclips:pillarvalley.expo.app</string>
447
+ </array>
443
448
  ```
444
449
 
445
- You should copy the generated intents into your main `WidgetBundle` struct.
450
+ 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).
451
+
452
+ You should handle redirection from this default URL too with a [`app/+native-intent.ts`](https://docs.expo.dev/router/advanced/native-intent/) file:
453
+
454
+ ```ts
455
+ export function redirectSystemPath({ path }: { path: string }): string {
456
+ try {
457
+ // Handle App Clip default page redirection.
458
+ // If path matches https://appclip.apple.com/id?p=com.evanbacon.pillarvalley.clip (with any query parameters), then redirect to `/` path.
459
+ const url = new URL(path);
460
+ if (url.hostname === "appclip.apple.com") {
461
+ // Redirect to the root path and make the original URL available as a query parameter (optional).
462
+ return "/?ref=" + encodeURIComponent(path);
463
+ }
464
+ return path;
465
+ } catch {
466
+ return path;
467
+ }
468
+ }
469
+ ```
470
+
471
+ 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.
472
+
473
+ 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.
474
+
475
+ 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:
476
+
477
+ ```js
478
+ <meta
479
+ name="apple-itunes-app"
480
+ content={
481
+ "app-id=1336398804, app-clip-bundle-id=com.evanbacon.pillarvalley.clip, app-clip-display=card"
482
+ }
483
+ />
484
+ ```
485
+
486
+ 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)).
487
+
488
+ ```js
489
+ <Head>
490
+ {/* Required for app clips: */}
491
+ {/* https://developer.apple.com/documentation/appclip/supporting-invocations-from-your-website-and-the-messages-app */}
492
+ <meta property="og:image" content="https://pillarvalley.expo.app/og.png" />
493
+ </Head>
494
+ ```
495
+
496
+ You also need a `1800x1200` image for the App Store Connect image preview, so make both of these images around the same time.
497
+
498
+ 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
499
 
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.
500
+ 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",
@@ -554,7 +589,7 @@ function createConfigurationList(project, { name, cwd, bundleId, deploymentTarge
554
589
  SWIFT_ACTIVE_COMPILATION_CONDITIONS: "DEBUG",
555
590
  SWIFT_EMIT_LOC_STRINGS: "YES",
556
591
  SWIFT_OPTIMIZATION_LEVEL: "-Onone",
557
- SWIFT_VERSION: "5",
592
+ SWIFT_VERSION: "5.0",
558
593
  TARGETED_DEVICE_FAMILY: "1,2",
559
594
  },
560
595
  });
@@ -593,7 +628,7 @@ function createConfigurationList(project, { name, cwd, bundleId, deploymentTarge
593
628
  SWIFT_EMIT_LOC_STRINGS: "YES",
594
629
  SWIFT_COMPILATION_MODE: "wholemodule",
595
630
  SWIFT_OPTIMIZATION_LEVEL: "-O",
596
- SWIFT_VERSION: "5",
631
+ SWIFT_VERSION: "5.0",
597
632
  TARGETED_DEVICE_FAMILY: "1,2",
598
633
  },
599
634
  });
@@ -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.19",
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;