| 1 | "use strict"; |
| 2 | |
| 3 | // Bundle the widget asset catalog into an app-extension (widget) target. |
| 4 | // |
| 5 | // expo-widgets generates the widget target without a Resources build phase, and |
| 6 | // hand-adding a PBXResourcesBuildPhase does NOT get picked up — xcodebuild's |
| 7 | // planner never schedules `actool` for it (verified: even a full re-plan skips |
| 8 | // it). So instead we add a shell-script phase that runs `actool` directly and |
| 9 | // drops the compiled Assets.car into the extension bundle. Marked |
| 10 | // alwaysOutOfDate so the build system always runs it. |
| 11 | // |
| 12 | // Idempotent across re-runs. Returns true when it added the phase, false when |
| 13 | // it was already present. Throws when the target does not exist — that means |
| 14 | // this ran before expo-widgets created the target (plugin ordering bug) and |
| 15 | // silently skipping would ship a widget without its assets. |
| 16 | |
| 17 | const PHASE_NAME = "Compile Widget Assets"; |
| 18 | |
| 19 | // Compiles ExpoWidgetsTarget/Assets.xcassets into the extension's resources dir. |
| 20 | // Uses only Xcode-provided build settings so it works for device + simulator. |
| 21 | const ACTOOL_SCRIPT = [ |
| 22 | "set -e", |
| 23 | 'CATALOG="${SRCROOT}/ExpoWidgetsTarget/Assets.xcassets"', |
| 24 | 'if [ ! -d "$CATALOG" ]; then', |
| 25 | ' echo "error: widget asset catalog not found at $CATALOG (expo-widgets wiped it? check plugin ordering in app.config.ts)"', |
| 26 | " exit 1", |
| 27 | "fi", |
| 28 | 'DEST="${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}"', |
| 29 | 'mkdir -p "$DEST"', |
| 30 | 'xcrun actool "$CATALOG" --compile "$DEST" --platform "${PLATFORM_NAME}" --minimum-deployment-target "${IPHONEOS_DEPLOYMENT_TARGET:-16.0}" --output-format human-readable-text', |
| 31 | ].join("\n"); |
| 32 | |
| 33 | function stripComments(map) { |
| 34 | const out = {}; |
| 35 | for (const key of Object.keys(map || {})) { |
| 36 | if (key.endsWith("_comment")) continue; |
| 37 | out[key] = map[key]; |
| 38 | } |
| 39 | return out; |
| 40 | } |
| 41 | |
| 42 | function findByName(map, name) { |
| 43 | for (const [uuid, value] of Object.entries(stripComments(map))) { |
| 44 | if (value && value.name === name) return { uuid, value }; |
| 45 | } |
| 46 | return null; |
| 47 | } |
| 48 | |
| 49 | /** |
| 50 | * @param {import('xcode').XcodeProject} proj |
| 51 | * @param {{ targetName: string }} opts |
| 52 | */ |
| 53 | function addWidgetAssetCatalog(proj, opts) { |
| 54 | const objects = proj.hash.project.objects; |
| 55 | const target = findByName(objects.PBXNativeTarget, opts.targetName); |
| 56 | if (!target) { |
| 57 | throw new Error( |
| 58 | `addWidgetAssetCatalog: target "${opts.targetName}" not found — ` + |
| 59 | "withWidgetLogoAsset must be registered before expo-widgets so its " + |
| 60 | "xcodeproj mod runs after the widget target is created.", |
| 61 | ); |
| 62 | } |
| 63 | |
| 64 | const phases = target.value.buildPhases || []; |
| 65 | const existing = objects.PBXShellScriptBuildPhase || {}; |
| 66 | const already = Object.entries(stripComments(existing)).some( |
| 67 | ([uuid, value]) => |
| 68 | value && value.name === `"${PHASE_NAME}"` && phases.some((p) => p.value === uuid), |
| 69 | ); |
| 70 | if (already) return false; |
| 71 | |
| 72 | const { uuid } = proj.addBuildPhase([], "PBXShellScriptBuildPhase", PHASE_NAME, target.uuid, { |
| 73 | shellPath: "/bin/sh", |
| 74 | shellScript: ACTOOL_SCRIPT, |
| 75 | }); |
| 76 | // Always run: input-analysis is exactly what skipped the Resources phase. |
| 77 | objects.PBXShellScriptBuildPhase[uuid].alwaysOutOfDate = 1; |
| 78 | return true; |
| 79 | } |
| 80 | |
| 81 | module.exports = { addWidgetAssetCatalog }; |