Skip to main content

rustferry_apple/
project.rs

1use std::{collections::BTreeMap, fmt::Write as _, fs};
2
3use camino::{Utf8Component, Utf8Path, Utf8PathBuf};
4use rustferry_codegen::{
5    GeneratedAssetPlatform, GeneratedAssetSet, read_generated_platform_assets,
6    render_platform_assets_for,
7};
8use rustferry_core::{
9    FerryConfig, Orientation, ProjectAssets, Theme, validate_application_identifier,
10};
11use serde::{Deserialize, Serialize};
12
13use crate::{AppleError, error::io_error};
14
15/// How generated iOS image assets are packaged into a Simulator application.
16#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
17#[serde(rename_all = "kebab-case")]
18pub enum IosAssetPackaging {
19    /// Compile `Assets.xcassets` with Xcode when an iOS Simulator runtime is available.
20    #[default]
21    CompiledCatalog,
22    /// Preserve SDK-only builds with validated PNG resources when no runtime is installed.
23    SdkOnlyResources,
24}
25
26/// Inputs for deterministic hidden Xcode project generation.
27#[derive(Clone, Debug, PartialEq)]
28pub struct IosProjectSpec {
29    /// Validated application configuration.
30    pub config: FerryConfig,
31    /// Cargo binary copied to the app's executable location.
32    pub binary_name: String,
33    /// Validated project icon and splash inputs, when generating a build host.
34    pub assets: Option<ProjectAssets>,
35    /// Asset representation emitted into the hidden Xcode project.
36    pub asset_packaging: IosAssetPackaging,
37}
38
39/// Apple SDK and signing behavior encoded in a generated Xcode project.
40#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
41#[serde(rename_all = "kebab-case")]
42pub enum IosProjectPlatform {
43    /// Apple Silicon iOS Simulator with local ad-hoc signing.
44    #[default]
45    Simulator,
46    /// Physical iOS device compilation with all code signing disabled.
47    DeviceUnsigned,
48}
49
50impl IosProjectPlatform {
51    pub(crate) const fn rust_target(self) -> &'static str {
52        match self {
53            Self::Simulator => "aarch64-apple-ios-sim",
54            Self::DeviceUnsigned => "aarch64-apple-ios",
55        }
56    }
57}
58
59impl IosProjectSpec {
60    /// Construct a project specification.
61    pub fn new(config: FerryConfig, binary_name: impl Into<String>) -> Self {
62        Self {
63            config,
64            binary_name: binary_name.into(),
65            assets: None,
66            asset_packaging: IosAssetPackaging::CompiledCatalog,
67        }
68    }
69
70    /// Attach validated project image assets to the generated host.
71    #[must_use]
72    pub fn with_assets(mut self, assets: ProjectAssets) -> Self {
73        self.assets = Some(assets);
74        self
75    }
76
77    /// Select the generated Xcode project's asset representation.
78    #[must_use]
79    pub fn with_asset_packaging(mut self, asset_packaging: IosAssetPackaging) -> Self {
80        self.asset_packaging = asset_packaging;
81        self
82    }
83}
84
85/// Deterministic generated files, keyed by paths relative to a generated root.
86#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
87pub struct GeneratedAppleProject {
88    /// Generated UTF-8 or binary file contents.
89    pub files: BTreeMap<Utf8PathBuf, Vec<u8>>,
90}
91
92impl GeneratedAppleProject {
93    /// Read one generated UTF-8 file by relative path.
94    pub fn text(&self, path: &Utf8Path) -> Option<&str> {
95        self.files
96            .get(path)
97            .and_then(|contents| std::str::from_utf8(contents).ok())
98    }
99}
100
101/// Render an Xcode app target and any requested extension target without touching disk.
102///
103/// # Errors
104///
105/// Returns [`AppleError`] when configuration, bundle identifiers, versions,
106/// binary names, application groups, or generated metadata are invalid.
107#[allow(clippy::too_many_lines)]
108pub fn generate_ios_project(spec: &IosProjectSpec) -> Result<GeneratedAppleProject, AppleError> {
109    generate_ios_project_for_platform(spec, IosProjectPlatform::Simulator)
110}
111
112/// Render an Xcode app target for an explicit simulator or physical-device platform.
113///
114/// The physical-device variant is compile-only: its generated project disables
115/// signing and therefore cannot produce an installable application on its own.
116///
117/// # Errors
118///
119/// Returns [`AppleError`] when configuration, bundle identifiers, versions,
120/// binary names, application groups, or generated metadata are invalid.
121#[allow(clippy::too_many_lines)]
122pub fn generate_ios_project_for_platform(
123    spec: &IosProjectSpec,
124    platform: IosProjectPlatform,
125) -> Result<GeneratedAppleProject, AppleError> {
126    let asset_catalog = if spec.asset_packaging == IosAssetPackaging::CompiledCatalog {
127        spec.assets.as_ref().map(rendered_ios_catalog).transpose()?
128    } else {
129        None
130    };
131    generate_ios_project_with_catalog(spec, asset_catalog, platform)
132}
133
134pub(crate) fn generate_ios_project_from_asset_set(
135    spec: &IosProjectSpec,
136    generated: &GeneratedAssetSet,
137) -> Result<GeneratedAppleProject, AppleError> {
138    if spec.asset_packaging != IosAssetPackaging::CompiledCatalog {
139        return Err(AppleError::InvalidRequest(
140            "a generated iOS asset set can only back compiled-catalog packaging".to_owned(),
141        ));
142    }
143    let assets = spec.assets.as_ref().ok_or_else(|| {
144        AppleError::InvalidRequest(
145            "an iOS generated asset set requires a validated project asset snapshot".to_owned(),
146        )
147    })?;
148    if generated.fingerprint != assets.fingerprint() {
149        return Err(AppleError::InvalidRequest(format!(
150            "generated iOS asset cache {} differs from the planned source snapshot",
151            generated.root
152        )));
153    }
154    let expected = rendered_ios_catalog(assets)?;
155    let cached = read_generated_platform_assets(generated, GeneratedAssetPlatform::Ios)?;
156    if cached != expected {
157        return Err(AppleError::InvalidRequest(format!(
158            "generated iOS asset cache {} differs from deterministic catalog output",
159            generated.root
160        )));
161    }
162    generate_ios_project_with_catalog(spec, Some(cached), IosProjectPlatform::Simulator)
163}
164
165fn rendered_ios_catalog(assets: &ProjectAssets) -> Result<Vec<(Utf8PathBuf, Vec<u8>)>, AppleError> {
166    let mut catalog = render_platform_assets_for(assets, GeneratedAssetPlatform::Ios)?
167        .files
168        .into_iter()
169        .filter_map(|(path, bytes)| {
170            path.strip_prefix("ios")
171                .ok()
172                .map(|relative| (relative.to_owned(), bytes))
173        })
174        .collect::<Vec<_>>();
175    catalog.sort_by(|left, right| left.0.cmp(&right.0));
176    Ok(catalog)
177}
178
179fn generate_ios_project_with_catalog(
180    spec: &IosProjectSpec,
181    asset_catalog: Option<Vec<(Utf8PathBuf, Vec<u8>)>>,
182    platform: IosProjectPlatform,
183) -> Result<GeneratedAppleProject, AppleError> {
184    validate_spec(spec)?;
185    let mut files = BTreeMap::new();
186    insert_text(
187        &mut files,
188        "FerryHost.xcodeproj/project.pbxproj",
189        render_pbxproj(spec, platform),
190    );
191    insert_text(
192        &mut files,
193        "FerryHost.xcodeproj/xcshareddata/xcschemes/FerryApp.xcscheme",
194        render_scheme(&spec.binary_name),
195    );
196    insert_text(&mut files, "Info.plist", render_app_info_plist(spec));
197    insert_app_assets(&mut files, spec, asset_catalog)?;
198    insert_text(
199        &mut files,
200        "FerryResources.json",
201        render_resource_metadata(spec, platform)?,
202    );
203    insert_text(
204        &mut files,
205        "RuntimeBridge/FerryRuntimeBridge.swift",
206        render_runtime_bridge_source(spec),
207    );
208    insert_text(
209        &mut files,
210        "RuntimeBridge/Info.plist",
211        include_str!("../templates/FerryRuntimeBridge-Info.plist").replace(
212            "{{minimum_version}}",
213            &xml_escape(&spec.config.ios.min_version),
214        ),
215    );
216
217    insert_extension_sources(&mut files, spec)?;
218    Ok(GeneratedAppleProject { files })
219}
220
221fn insert_extension_sources(
222    files: &mut BTreeMap<Utf8PathBuf, Vec<u8>>,
223    spec: &IosProjectSpec,
224) -> Result<(), AppleError> {
225    if spec.config.extensions.live_activity.enabled {
226        insert_text(
227            files,
228            "ActivityModel/FerryActivityAttributes.swift",
229            render_activity_model_source(),
230        );
231        insert_text(
232            files,
233            "ActivityModel/Info.plist",
234            include_str!("../templates/FerryActivityModel-Info.plist").replace(
235                "{{minimum_version}}",
236                &xml_escape(&spec.config.ios.min_version),
237            ),
238        );
239    }
240    if spec.config.extensions.widget.enabled {
241        let Some(app_group) = spec.config.extensions.widget.app_group.as_deref() else {
242            return Err(AppleError::InvalidConfig(
243                "extensions.widget.app_group is required when the widget is enabled".to_owned(),
244            ));
245        };
246        insert_text(files, "App.entitlements", render_entitlements(app_group));
247        insert_text(
248            files,
249            "WidgetExtension/Widget.swift",
250            render_widget_source(app_group),
251        );
252        insert_text(
253            files,
254            "WidgetExtension/Info.plist",
255            render_extension_info_plist(
256                &format!("{}.widget", spec.config.app.identifier),
257                "FerryWidgetExtension",
258                &spec.config.app.display_version,
259                &format!(
260                    "{}.{}.{}",
261                    spec.config.app.version.major,
262                    spec.config.app.version.minor,
263                    spec.config.app.version.patch
264                ),
265            ),
266        );
267        insert_text(
268            files,
269            "WidgetExtension/Widget.entitlements",
270            render_entitlements(app_group),
271        );
272    }
273
274    if spec.config.extensions.live_activity.enabled {
275        insert_text(
276            files,
277            "LiveActivityExtension/LiveActivity.swift",
278            render_live_activity_source(),
279        );
280        insert_text(
281            files,
282            "LiveActivityExtension/Info.plist",
283            render_extension_info_plist(
284                &format!("{}.liveactivity", spec.config.app.identifier),
285                "FerryLiveActivityExtension",
286                &spec.config.app.display_version,
287                &format!(
288                    "{}.{}.{}",
289                    spec.config.app.version.major,
290                    spec.config.app.version.minor,
291                    spec.config.app.version.patch
292                ),
293            ),
294        );
295    }
296    Ok(())
297}
298
299fn insert_app_assets(
300    files: &mut BTreeMap<Utf8PathBuf, Vec<u8>>,
301    spec: &IosProjectSpec,
302    asset_catalog: Option<Vec<(Utf8PathBuf, Vec<u8>)>>,
303) -> Result<(), AppleError> {
304    if let Some(asset_catalog) = asset_catalog {
305        for (relative, bytes) in asset_catalog {
306            validate_relative_path(&relative)?;
307            files.insert(relative, bytes);
308        }
309    } else if spec.asset_packaging == IosAssetPackaging::SdkOnlyResources
310        && let Some(assets) = &spec.assets
311    {
312        files.insert(Utf8PathBuf::from("FerryIcon.png"), assets.icon().to_vec());
313        files.insert(
314            Utf8PathBuf::from("FerrySplash.png"),
315            assets.splash().to_vec(),
316        );
317    }
318    Ok(())
319}
320
321/// Write generated files below an internal root, rejecting absolute paths, traversal, and symlinks.
322///
323/// # Errors
324///
325/// Returns [`AppleError`] for unsafe relative paths, symlink traversal, or filesystem failures.
326pub fn write_ios_project(
327    project: &GeneratedAppleProject,
328    root: &Utf8Path,
329) -> Result<(), AppleError> {
330    reject_symlink(root)?;
331    fs::create_dir_all(root)
332        .map_err(|source| io_error("create generated iOS root", root, source))?;
333    for (relative, contents) in &project.files {
334        validate_relative_path(relative)?;
335        let destination = root.join(relative);
336        let parent = destination
337            .parent()
338            .ok_or_else(|| AppleError::UnsafeGeneratedPath {
339                path: destination.clone(),
340                reason: "generated file has no parent directory".to_owned(),
341            })?;
342        create_directories_without_symlinks(root, parent)?;
343        reject_symlink(&destination)?;
344        fs::write(&destination, contents)
345            .map_err(|source| io_error("write generated iOS file", &destination, source))?;
346    }
347    Ok(())
348}
349
350fn validate_spec(spec: &IosProjectSpec) -> Result<(), AppleError> {
351    let issues = spec.config.validate();
352    if !issues.is_empty() {
353        let summary = issues
354            .iter()
355            .map(|issue| format!("{}: {} ({})", issue.field, issue.message, issue.help))
356            .collect::<Vec<_>>()
357            .join("; ");
358        return Err(AppleError::InvalidConfig(summary));
359    }
360    validate_binary_name(&spec.binary_name)?;
361    validate_apple_version("app.display_version", &spec.config.app.display_version, 3)?;
362    validate_apple_version("ios.min_version", &spec.config.ios.min_version, 2)?;
363    if let Some(app_group) = spec.config.extensions.widget.app_group.as_deref()
364        && (!app_group.starts_with("group.") || validate_application_identifier(app_group).is_err())
365    {
366        return Err(AppleError::InvalidRequest(format!(
367            "widget application group `{app_group}` must start with `group.` and use bundle-identifier characters"
368        )));
369    }
370    for suffix in ["widget", "liveactivity"] {
371        let identifier = format!("{}.{}", spec.config.app.identifier, suffix);
372        if identifier.len() > 255 {
373            return Err(AppleError::InvalidRequest(format!(
374                "extension bundle identifier `{identifier}` exceeds 255 bytes"
375            )));
376        }
377    }
378    Ok(())
379}
380
381pub(crate) fn validate_binary_name(name: &str) -> Result<(), AppleError> {
382    if name.is_empty()
383        || name == "."
384        || name == ".."
385        || !name
386            .chars()
387            .all(|character| character.is_ascii_alphanumeric() || matches!(character, '-' | '_'))
388    {
389        return Err(AppleError::InvalidRequest(format!(
390            "binary name `{name}` must contain only ASCII letters, digits, `-`, or `_`"
391        )));
392    }
393    Ok(())
394}
395
396fn validate_apple_version(
397    field: &str,
398    value: &str,
399    max_components: usize,
400) -> Result<(), AppleError> {
401    let components = value.split('.').collect::<Vec<_>>();
402    if components.is_empty()
403        || components.len() > max_components
404        || components.iter().any(|component| {
405            component.is_empty() || !component.chars().all(|ch| ch.is_ascii_digit())
406        })
407    {
408        return Err(AppleError::InvalidRequest(format!(
409            "{field} `{value}` must contain one to {max_components} dot-separated numeric components"
410        )));
411    }
412    Ok(())
413}
414
415fn insert_text(files: &mut BTreeMap<Utf8PathBuf, Vec<u8>>, path: &str, contents: String) {
416    files.insert(Utf8PathBuf::from(path), contents.into_bytes());
417}
418
419fn render_app_info_plist(spec: &IosProjectSpec) -> String {
420    let app = &spec.config.app;
421    let mut extra = String::new();
422    let launch_screen = match (spec.assets.is_some(), spec.asset_packaging) {
423        (true, IosAssetPackaging::CompiledCatalog) => {
424            extra.push_str("  <key>CFBundleIconName</key>\n  <string>AppIcon</string>\n");
425            "  <dict>\n    <key>UIImageName</key>\n    <string>FerryLaunch</string>\n  </dict>"
426        }
427        (true, IosAssetPackaging::SdkOnlyResources) => {
428            extra.push_str("  <key>CFBundleIconFile</key>\n  <string>FerryIcon</string>\n  <key>UILaunchImageFile</key>\n  <string>FerrySplash</string>\n");
429            "  <dict>\n    <key>UIImageName</key>\n    <string>FerrySplash</string>\n  </dict>"
430        }
431        (false, _) => "  <dict/>",
432    };
433    if !spec.config.capabilities.deep_links.schemes.is_empty() {
434        extra.push_str("  <key>CFBundleURLTypes</key>\n  <array>\n    <dict>\n      <key>CFBundleURLName</key>\n      <string>");
435        extra.push_str(&xml_escape(&app.identifier));
436        extra.push_str("</string>\n      <key>CFBundleURLSchemes</key>\n      <array>\n");
437        for scheme in &spec.config.capabilities.deep_links.schemes {
438            let _ = writeln!(extra, "        <string>{}</string>", xml_escape(scheme));
439        }
440        extra.push_str("      </array>\n    </dict>\n  </array>\n");
441    }
442    if spec.config.extensions.live_activity.enabled {
443        extra.push_str("  <key>NSSupportsLiveActivities</key>\n  <true/>\n");
444    }
445    for (key, permission) in [
446        ("NSCameraUsageDescription", &spec.config.permissions.camera),
447        (
448            "NSPhotoLibraryUsageDescription",
449            &spec.config.permissions.photos,
450        ),
451        (
452            "NSMicrophoneUsageDescription",
453            &spec.config.permissions.microphone,
454        ),
455        (
456            "NSLocationWhenInUseUsageDescription",
457            &spec.config.permissions.location_when_in_use,
458        ),
459        (
460            "NSLocalNetworkUsageDescription",
461            &spec.config.permissions.local_network,
462        ),
463    ] {
464        if permission.enabled {
465            let purpose = permission
466                .purpose
467                .as_deref()
468                .expect("validated enabled permission purpose");
469            let _ = writeln!(
470                extra,
471                "  <key>{key}</key>\n  <string>{}</string>",
472                xml_escape(purpose)
473            );
474        }
475    }
476    match app.window.theme {
477        Theme::System => {}
478        Theme::Light => {
479            extra.push_str("  <key>UIUserInterfaceStyle</key>\n  <string>Light</string>\n");
480        }
481        Theme::Dark => {
482            extra.push_str("  <key>UIUserInterfaceStyle</key>\n  <string>Dark</string>\n");
483        }
484    }
485    let orientations = match app.window.orientation {
486        Orientation::Automatic => [
487            "UIInterfaceOrientationPortrait",
488            "UIInterfaceOrientationLandscapeLeft",
489            "UIInterfaceOrientationLandscapeRight",
490        ]
491        .as_slice(),
492        Orientation::Portrait => ["UIInterfaceOrientationPortrait"].as_slice(),
493        Orientation::Landscape => [
494            "UIInterfaceOrientationLandscapeLeft",
495            "UIInterfaceOrientationLandscapeRight",
496        ]
497        .as_slice(),
498    };
499    let orientation_values = orientations
500        .iter()
501        .map(|orientation| format!("    <string>{orientation}</string>"))
502        .collect::<Vec<_>>()
503        .join("\n");
504    let build_version = format!(
505        "{}.{}.{}",
506        app.version.major, app.version.minor, app.version.patch
507    );
508    format!(
509        "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n<plist version=\"1.0\">\n<dict>\n  <key>CFBundleDevelopmentRegion</key>\n  <string>en</string>\n  <key>CFBundleDisplayName</key>\n  <string>{display_name}</string>\n  <key>CFBundleExecutable</key>\n  <string>{executable}</string>\n  <key>CFBundleIdentifier</key>\n  <string>{identifier}</string>\n  <key>CFBundleInfoDictionaryVersion</key>\n  <string>6.0</string>\n  <key>CFBundleName</key>\n  <string>{display_name}</string>\n  <key>CFBundlePackageType</key>\n  <string>APPL</string>\n  <key>CFBundleShortVersionString</key>\n  <string>{display_version}</string>\n  <key>CFBundleVersion</key>\n  <string>{build_version}</string>\n  <key>LSRequiresIPhoneOS</key>\n  <true/>\n  <key>MinimumOSVersion</key>\n  <string>{minimum_version}</string>\n  <key>UILaunchScreen</key>\n{launch_screen}\n  <key>UIRequiredDeviceCapabilities</key>\n  <array>\n    <string>arm64</string>\n  </array>\n  <key>UISupportedInterfaceOrientations</key>\n  <array>\n{orientation_values}\n  </array>\n  <key>UIApplicationSupportsIndirectInputEvents</key>\n  <true/>\n{extra}</dict>\n</plist>\n",
510        display_name = xml_escape(&app.name),
511        executable = xml_escape(&spec.binary_name),
512        identifier = xml_escape(&app.identifier),
513        display_version = xml_escape(&app.display_version),
514        build_version = build_version,
515        minimum_version = xml_escape(&spec.config.ios.min_version),
516        launch_screen = launch_screen,
517    )
518}
519
520fn render_extension_info_plist(
521    identifier: &str,
522    executable: &str,
523    display_version: &str,
524    build_version: &str,
525) -> String {
526    format!(
527        "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n<plist version=\"1.0\">\n<dict>\n  <key>CFBundleDisplayName</key>\n  <string>{executable}</string>\n  <key>CFBundleExecutable</key>\n  <string>{executable}</string>\n  <key>CFBundleIdentifier</key>\n  <string>{identifier}</string>\n  <key>CFBundleInfoDictionaryVersion</key>\n  <string>6.0</string>\n  <key>CFBundlePackageType</key>\n  <string>XPC!</string>\n  <key>CFBundleShortVersionString</key>\n  <string>{display_version}</string>\n  <key>CFBundleVersion</key>\n  <string>{build_version}</string>\n  <key>NSExtension</key>\n  <dict>\n    <key>NSExtensionPointIdentifier</key>\n    <string>com.apple.widgetkit-extension</string>\n  </dict>\n</dict>\n</plist>\n",
528        executable = xml_escape(executable),
529        identifier = xml_escape(identifier),
530        display_version = xml_escape(display_version),
531        build_version = xml_escape(build_version),
532    )
533}
534
535fn render_entitlements(app_group: &str) -> String {
536    format!(
537        "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n<plist version=\"1.0\">\n<dict>\n  <key>com.apple.security.application-groups</key>\n  <array>\n    <string>{}</string>\n  </array>\n</dict>\n</plist>\n",
538        xml_escape(app_group)
539    )
540}
541
542fn render_widget_source(app_group: &str) -> String {
543    include_str!("../templates/FerryWidget.swift.tpl")
544        .replace("{{widget_app_group}}", &swift_string_literal(app_group))
545}
546
547fn render_live_activity_source() -> String {
548    "import ActivityKit\nimport FerryActivityModel\nimport SwiftUI\nimport WidgetKit\n\n@main\nstruct FerryLiveActivity: Widget {\n    var body: some WidgetConfiguration {\n        ActivityConfiguration(for: FerryActivityAttributes.self) { context in\n            VStack(alignment: .leading) {\n                Text(context.state.title).font(.headline)\n                Text(context.state.status)\n                ProgressView(value: context.state.progress)\n            }\n            .widgetURL(context.state.deepLink.flatMap(URL.init(string:)))\n        } dynamicIsland: { context in\n            DynamicIsland {\n                DynamicIslandExpandedRegion(.leading) { Text(context.state.leadingText) }\n                DynamicIslandExpandedRegion(.trailing) { Text(context.state.trailingText) }\n                DynamicIslandExpandedRegion(.bottom) { ProgressView(value: context.state.progress) }\n            } compactLeading: {\n                Text(context.state.leadingText)\n            } compactTrailing: {\n                Text(context.state.trailingText)\n            } minimal: {\n                Text(context.state.status.prefix(1))\n            }\n            .widgetURL(context.state.deepLink.flatMap(URL.init(string:)))\n        }\n    }\n}\n"
549        .to_owned()
550}
551
552fn render_activity_model_source() -> String {
553    format!(
554        "import ActivityKit\nimport Foundation\n\n{}\n",
555        include_str!("../templates/FerryActivityAttributes.swift.tpl").trim_end()
556    )
557}
558
559fn render_runtime_bridge_source(spec: &IosProjectSpec) -> String {
560    let enabled = |value: bool| if value { "true" } else { "false" };
561    let live_activity = spec.config.extensions.live_activity.enabled;
562    let model_import = if live_activity {
563        "import FerryActivityModel\n"
564    } else {
565        ""
566    };
567    let attributes_declaration = if live_activity {
568        String::new()
569    } else {
570        format!(
571            "{}\n\n",
572            include_str!("../templates/FerryActivityAttributes.swift.tpl").trim_end()
573        )
574    };
575    let widget_group = spec
576        .config
577        .extensions
578        .widget
579        .app_group
580        .as_deref()
581        .map_or_else(|| "nil".to_owned(), swift_string_literal);
582    include_str!("../templates/FerryRuntimeBridge.swift.tpl")
583        .replace("{{activity_model_import}}\n", model_import)
584        .replace(
585            "{{activity_attributes_declaration}}\n\n",
586            &attributes_declaration,
587        )
588        .replace(
589            "{{storage_enabled}}",
590            enabled(spec.config.capabilities.storage.enabled),
591        )
592        .replace(
593            "{{network_enabled}}",
594            enabled(spec.config.capabilities.network.mode != rustferry_core::NetworkMode::None),
595        )
596        .replace(
597            "{{network_probe_enabled}}",
598            enabled(matches!(
599                spec.config.capabilities.network.mode,
600                rustferry_core::NetworkMode::Optional | rustferry_core::NetworkMode::Required
601            )),
602        )
603        .replace(
604            "{{haptics_enabled}}",
605            enabled(spec.config.capabilities.haptics.enabled),
606        )
607        .replace(
608            "{{notifications_enabled}}",
609            enabled(spec.config.capabilities.notifications.local),
610        )
611        .replace(
612            "{{clipboard_enabled}}",
613            enabled(spec.config.capabilities.clipboard.enabled),
614        )
615        .replace(
616            "{{share_enabled}}",
617            enabled(spec.config.capabilities.share.enabled),
618        )
619        .replace(
620            "{{photos_enabled}}",
621            enabled(spec.config.permissions.photos.enabled),
622        )
623        .replace(
624            "{{camera_enabled}}",
625            enabled(spec.config.permissions.camera.enabled),
626        )
627        .replace(
628            "{{microphone_enabled}}",
629            enabled(spec.config.permissions.microphone.enabled),
630        )
631        .replace(
632            "{{location_enabled}}",
633            enabled(spec.config.permissions.location_when_in_use.enabled),
634        )
635        .replace("{{widget_app_group}}", &widget_group)
636        .replace(
637            "{{live_activity_enabled}}",
638            enabled(spec.config.extensions.live_activity.enabled),
639        )
640        .replace(
641            "{{deep_link_schemes}}",
642            &swift_string_set(&spec.config.capabilities.deep_links.schemes),
643        )
644        .replace(
645            "{{deep_link_hosts}}",
646            &swift_string_set(&spec.config.capabilities.deep_links.allowed_hosts),
647        )
648        .replace(
649            "{{deep_link_actions}}",
650            &swift_string_set(&spec.config.capabilities.deep_links.allowed_actions),
651        )
652}
653
654fn render_resource_metadata(
655    spec: &IosProjectSpec,
656    platform: IosProjectPlatform,
657) -> Result<String, AppleError> {
658    let value = serde_json::json!({
659        "schema_version": 1,
660        "generator": "cargo-ferry",
661        "ui_backend": "slint-1.17.1",
662        "rust_target": platform.rust_target(),
663        "bundle_identifier": spec.config.app.identifier,
664    });
665    serde_json::to_string_pretty(&value)
666        .map(|mut output| {
667            output.push('\n');
668            output
669        })
670        .map_err(|error| {
671            AppleError::InvalidRequest(format!("could not encode iOS resource metadata: {error}"))
672        })
673}
674
675fn render_scheme(binary_name: &str) -> String {
676    format!(
677        "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<Scheme LastUpgradeVersion=\"2660\" version=\"1.7\">\n  <BuildAction parallelizeBuildables=\"YES\" buildImplicitDependencies=\"YES\">\n    <BuildActionEntries>\n      <BuildActionEntry buildForTesting=\"YES\" buildForRunning=\"YES\" buildForProfiling=\"YES\" buildForArchiving=\"YES\" buildForAnalyzing=\"YES\">\n        <BuildableReference BuildableIdentifier=\"primary\" BlueprintIdentifier=\"{}\" BuildableName=\"{}.app\" BlueprintName=\"FerryApp\" ReferencedContainer=\"container:FerryHost.xcodeproj\"/>\n      </BuildActionEntry>\n    </BuildActionEntries>\n  </BuildAction>\n  <TestAction buildConfiguration=\"Debug\" selectedDebuggerIdentifier=\"Xcode.DebuggerFoundation.Debugger.LLDB\" selectedLauncherIdentifier=\"Xcode.DebuggerFoundation.Launcher.LLDB\" shouldUseLaunchSchemeArgsEnv=\"YES\"/>\n  <LaunchAction buildConfiguration=\"Debug\" selectedDebuggerIdentifier=\"Xcode.DebuggerFoundation.Debugger.LLDB\" selectedLauncherIdentifier=\"Xcode.DebuggerFoundation.Launcher.LLDB\" launchStyle=\"0\" useCustomWorkingDirectory=\"NO\" ignoresPersistentStateOnLaunch=\"NO\" debugDocumentVersioning=\"YES\" debugServiceExtension=\"internal\" allowLocationSimulation=\"YES\">\n    <BuildableProductRunnable runnableDebuggingMode=\"0\">\n      <BuildableReference BuildableIdentifier=\"primary\" BlueprintIdentifier=\"{}\" BuildableName=\"{}.app\" BlueprintName=\"FerryApp\" ReferencedContainer=\"container:FerryHost.xcodeproj\"/>\n    </BuildableProductRunnable>\n  </LaunchAction>\n  <ProfileAction buildConfiguration=\"Release\" shouldUseLaunchSchemeArgsEnv=\"YES\" savedToolIdentifier=\"\" useCustomWorkingDirectory=\"NO\" debugDocumentVersioning=\"YES\"/>\n  <AnalyzeAction buildConfiguration=\"Debug\"/>\n  <ArchiveAction buildConfiguration=\"Release\" revealArchiveInOrganizer=\"YES\"/>\n</Scheme>\n",
678        id(15),
679        xml_escape(binary_name),
680        id(15),
681        xml_escape(binary_name),
682    )
683}
684
685fn render_pbxproj(spec: &IosProjectSpec, platform: IosProjectPlatform) -> String {
686    let project = render_simulator_pbxproj(spec);
687    match platform {
688        IosProjectPlatform::Simulator => project,
689        IosProjectPlatform::DeviceUnsigned => project
690            .replace("SDKROOT = iphonesimulator", "SDKROOT = iphoneos")
691            .replace(
692                "SUPPORTED_PLATFORMS = iphonesimulator",
693                "SUPPORTED_PLATFORMS = iphoneos",
694            )
695            .replace(
696                "AD_HOC_CODE_SIGNING_ALLOWED = YES",
697                "AD_HOC_CODE_SIGNING_ALLOWED = NO",
698            )
699            .replace("CODE_SIGN_IDENTITY = \"-\"", "CODE_SIGN_IDENTITY = \"\"")
700            .replace("CODE_SIGNING_ALLOWED = YES", "CODE_SIGNING_ALLOWED = NO")
701            .replace("CODE_SIGNING_REQUIRED = YES", "CODE_SIGNING_REQUIRED = NO")
702            .replace(
703                "ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, );",
704                "ATTRIBUTES = (RemoveHeadersOnCopy, );",
705            ),
706    }
707}
708
709#[allow(clippy::too_many_lines, clippy::write_with_newline)]
710fn render_simulator_pbxproj(spec: &IosProjectSpec) -> String {
711    let binary = pbx_quote(&spec.binary_name);
712    let app_product = pbx_quote(&format!("{}.app", spec.binary_name));
713    let bundle = pbx_quote(&spec.config.app.identifier);
714    let min_version = pbx_quote(&spec.config.ios.min_version);
715    let marketing = pbx_quote(&spec.config.app.display_version);
716    let build_version = pbx_quote(&format!(
717        "{}.{}.{}",
718        spec.config.app.version.major, spec.config.app.version.minor, spec.config.app.version.patch
719    ));
720    let widget = spec.config.extensions.widget.enabled;
721    let live_activity = spec.config.extensions.live_activity.enabled;
722    let assets = spec.assets.is_some();
723    let compiled_assets = assets && spec.asset_packaging == IosAssetPackaging::CompiledCatalog;
724    let sdk_only_assets = assets && spec.asset_packaging == IosAssetPackaging::SdkOnlyResources;
725    let mut output = String::from(
726        "// !$*UTF8*$!\n{\n\tarchiveVersion = 1;\n\tclasses = {};\n\tobjectVersion = 77;\n\tobjects = {\n\n",
727    );
728
729    output.push_str("/* Begin PBXBuildFile section */\n");
730    let _ = writeln!(
731        output,
732        "\t\t{} /* {} in Install Rust Executable */ = {{isa = PBXBuildFile; fileRef = {} /* {} */; }};",
733        id(10),
734        spec.binary_name,
735        id(11),
736        spec.binary_name
737    );
738    let _ = writeln!(
739        output,
740        "\t\t{} /* FerryResources.json in Resources */ = {{isa = PBXBuildFile; fileRef = {} /* FerryResources.json */; }};",
741        id(12),
742        id(6)
743    );
744    if compiled_assets {
745        let _ = writeln!(
746            output,
747            "\t\t{} /* Assets.xcassets in Resources */ = {{isa = PBXBuildFile; fileRef = {} /* Assets.xcassets */; }};",
748            id(9),
749            id(5)
750        );
751    } else if sdk_only_assets {
752        let _ = writeln!(
753            output,
754            "\t\t{} /* FerryIcon.png in Resources */ = {{isa = PBXBuildFile; fileRef = {} /* FerryIcon.png */; }};",
755            id(9),
756            id(5)
757        );
758        let _ = writeln!(
759            output,
760            "\t\t{} /* FerrySplash.png in Resources */ = {{isa = PBXBuildFile; fileRef = {} /* FerrySplash.png */; }};",
761            id(18),
762            id(19)
763        );
764    }
765    let _ = writeln!(
766        output,
767        "\t\t{} /* FerryRuntimeBridge.swift in Sources */ = {{isa = PBXBuildFile; fileRef = {} /* FerryRuntimeBridge.swift */; }};",
768        id(80),
769        id(81)
770    );
771    let _ = writeln!(
772        output,
773        "\t\t{} /* FerryRuntimeBridge.framework in Embed Frameworks */ = {{isa = PBXBuildFile; fileRef = {} /* FerryRuntimeBridge.framework */; settings = {{ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }}; }};",
774        id(84),
775        id(82)
776    );
777    if widget {
778        let _ = writeln!(
779            output,
780            "\t\t{} /* Widget.swift in Sources */ = {{isa = PBXBuildFile; fileRef = {} /* Widget.swift */; }};",
781            id(34),
782            id(30)
783        );
784        let _ = writeln!(
785            output,
786            "\t\t{} /* FerryWidgetExtension.appex in Embed App Extensions */ = {{isa = PBXBuildFile; fileRef = {} /* FerryWidgetExtension.appex */; settings = {{ATTRIBUTES = (RemoveHeadersOnCopy, ); }}; }};",
787            id(40),
788            id(33)
789        );
790    }
791    if live_activity {
792        let _ = writeln!(
793            output,
794            "\t\t{} /* FerryActivityAttributes.swift in Sources */ = {{isa = PBXBuildFile; fileRef = {} /* FerryActivityAttributes.swift */; }};",
795            id(100),
796            id(101)
797        );
798        let _ = writeln!(
799            output,
800            "\t\t{} /* FerryActivityModel.framework in Embed Frameworks */ = {{isa = PBXBuildFile; fileRef = {} /* FerryActivityModel.framework */; settings = {{ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }}; }};",
801            id(103),
802            id(102)
803        );
804        let _ = writeln!(
805            output,
806            "\t\t{} /* FerryActivityModel.framework in RuntimeBridge Frameworks */ = {{isa = PBXBuildFile; fileRef = {} /* FerryActivityModel.framework */; }};",
807            id(104),
808            id(102)
809        );
810        let _ = writeln!(
811            output,
812            "\t\t{} /* FerryActivityModel.framework in Live Activity Frameworks */ = {{isa = PBXBuildFile; fileRef = {} /* FerryActivityModel.framework */; }};",
813            id(105),
814            id(102)
815        );
816        let _ = writeln!(
817            output,
818            "\t\t{} /* LiveActivity.swift in Sources */ = {{isa = PBXBuildFile; fileRef = {} /* LiveActivity.swift */; }};",
819            id(54),
820            id(50)
821        );
822        let _ = writeln!(
823            output,
824            "\t\t{} /* FerryLiveActivityExtension.appex in Embed App Extensions */ = {{isa = PBXBuildFile; fileRef = {} /* FerryLiveActivityExtension.appex */; settings = {{ATTRIBUTES = (RemoveHeadersOnCopy, ); }}; }};",
825            id(60),
826            id(53)
827        );
828    }
829    output.push_str("/* End PBXBuildFile section */\n\n");
830
831    output.push_str("/* Begin PBXContainerItemProxy section */\n");
832    let _ = writeln!(
833        output,
834        "\t\t{} /* PBXContainerItemProxy */ = {{isa = PBXContainerItemProxy; containerPortal = {} /* Project object */; proxyType = 1; remoteGlobalIDString = {}; remoteInfo = FerryRuntimeBridge; }};",
835        id(86),
836        id(1),
837        id(90)
838    );
839    if widget {
840        let _ = writeln!(
841            output,
842            "\t\t{} /* PBXContainerItemProxy */ = {{isa = PBXContainerItemProxy; containerPortal = {} /* Project object */; proxyType = 1; remoteGlobalIDString = {}; remoteInfo = FerryWidgetExtension; }};",
843            id(41),
844            id(1),
845            id(36)
846        );
847    }
848    if live_activity {
849        let _ = writeln!(
850            output,
851            "\t\t{} /* PBXContainerItemProxy */ = {{isa = PBXContainerItemProxy; containerPortal = {} /* Project object */; proxyType = 1; remoteGlobalIDString = {}; remoteInfo = FerryLiveActivityExtension; }};",
852            id(61),
853            id(1),
854            id(56)
855        );
856        for (proxy, target, owner) in [
857            (114, 108, "FerryActivityModel"),
858            (116, 108, "FerryActivityModel"),
859            (118, 108, "FerryActivityModel"),
860        ] {
861            let _ = writeln!(
862                output,
863                "\t\t{} /* PBXContainerItemProxy */ = {{isa = PBXContainerItemProxy; containerPortal = {} /* Project object */; proxyType = 1; remoteGlobalIDString = {}; remoteInfo = {}; }};",
864                id(proxy),
865                id(1),
866                id(target),
867                owner
868            );
869        }
870    }
871    output.push_str("/* End PBXContainerItemProxy section */\n\n");
872
873    output.push_str("/* Begin PBXCopyFilesBuildPhase section */\n");
874    let _ = write!(
875        output,
876        "\t\t{} /* Install Rust Executable */ = {{\n\t\t\tisa = PBXCopyFilesBuildPhase;\n\t\t\tbuildActionMask = 2147483647;\n\t\t\tdstPath = \"\";\n\t\t\tdstSubfolderSpec = 1;\n\t\t\tfiles = ({} /* {} in Install Rust Executable */,);\n\t\t\tname = \"Install Rust Executable\";\n\t\t\trunOnlyForDeploymentPostprocessing = 0;\n\t\t}};\n",
877        id(14),
878        id(10),
879        spec.binary_name
880    );
881    let _ = write!(
882        output,
883        "\t\t{} /* Embed Frameworks */ = {{\n\t\t\tisa = PBXCopyFilesBuildPhase;\n\t\t\tbuildActionMask = 2147483647;\n\t\t\tdstPath = \"\";\n\t\t\tdstSubfolderSpec = 10;\n\t\t\tfiles = ({} /* FerryRuntimeBridge.framework in Embed Frameworks */,",
884        id(93),
885        id(84)
886    );
887    if live_activity {
888        let _ = write!(
889            output,
890            " {} /* FerryActivityModel.framework in Embed Frameworks */,",
891            id(103)
892        );
893    }
894    output.push_str(
895        ");\n\t\t\tname = \"Embed Frameworks\";\n\t\t\trunOnlyForDeploymentPostprocessing = 0;\n\t\t};\n",
896    );
897    if widget || live_activity {
898        let _ = write!(
899            output,
900            "\t\t{} /* Embed App Extensions */ = {{\n\t\t\tisa = PBXCopyFilesBuildPhase;\n\t\t\tbuildActionMask = 2147483647;\n\t\t\tdstPath = \"\";\n\t\t\tdstSubfolderSpec = 13;\n\t\t\tfiles = (\n",
901            id(70)
902        );
903        if widget {
904            let _ = writeln!(
905                output,
906                "\t\t\t\t{} /* FerryWidgetExtension.appex in Embed App Extensions */,",
907                id(40)
908            );
909        }
910        if live_activity {
911            let _ = writeln!(
912                output,
913                "\t\t\t\t{} /* FerryLiveActivityExtension.appex in Embed App Extensions */,",
914                id(60)
915            );
916        }
917        output.push_str("\t\t\t);\n\t\t\tname = \"Embed App Extensions\";\n\t\t\trunOnlyForDeploymentPostprocessing = 0;\n\t\t};\n");
918    }
919    output.push_str("/* End PBXCopyFilesBuildPhase section */\n\n");
920
921    output.push_str("/* Begin PBXFileReference section */\n");
922    let _ = writeln!(
923        output,
924        "\t\t{} /* {} */ = {{isa = PBXFileReference; explicitFileType = \"compiled.mach-o.executable\"; path = {}; sourceTree = \"<group>\"; }};",
925        id(11),
926        spec.binary_name,
927        binary
928    );
929    let _ = writeln!(
930        output,
931        "\t\t{} /* {}.app */ = {{isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = {}; sourceTree = BUILT_PRODUCTS_DIR; }};",
932        id(4),
933        spec.binary_name,
934        app_product
935    );
936    if compiled_assets {
937        let _ = writeln!(
938            output,
939            "\t\t{} /* Assets.xcassets */ = {{isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = \"<group>\"; }};",
940            id(5)
941        );
942    } else if sdk_only_assets {
943        let _ = writeln!(
944            output,
945            "\t\t{} /* FerryIcon.png */ = {{isa = PBXFileReference; lastKnownFileType = image.png; path = FerryIcon.png; sourceTree = \"<group>\"; }};",
946            id(5)
947        );
948        let _ = writeln!(
949            output,
950            "\t\t{} /* FerrySplash.png */ = {{isa = PBXFileReference; lastKnownFileType = image.png; path = FerrySplash.png; sourceTree = \"<group>\"; }};",
951            id(19)
952        );
953    }
954    let _ = writeln!(
955        output,
956        "\t\t{} /* FerryResources.json */ = {{isa = PBXFileReference; lastKnownFileType = text.json; path = FerryResources.json; sourceTree = \"<group>\"; }};",
957        id(6)
958    );
959    let _ = writeln!(
960        output,
961        "\t\t{} /* Info.plist */ = {{isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = \"<group>\"; }};",
962        id(7)
963    );
964    let _ = writeln!(
965        output,
966        "\t\t{} /* FerryRuntimeBridge.swift */ = {{isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FerryRuntimeBridge.swift; sourceTree = \"<group>\"; }};",
967        id(81)
968    );
969    let _ = writeln!(
970        output,
971        "\t\t{} /* FerryRuntimeBridge.framework */ = {{isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = FerryRuntimeBridge.framework; sourceTree = BUILT_PRODUCTS_DIR; }};",
972        id(82)
973    );
974    let _ = writeln!(
975        output,
976        "\t\t{} /* Info.plist */ = {{isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = \"<group>\"; }};",
977        id(83)
978    );
979    if widget {
980        let _ = writeln!(
981            output,
982            "\t\t{} /* App.entitlements */ = {{isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = App.entitlements; sourceTree = \"<group>\"; }};",
983            id(8)
984        );
985        let _ = writeln!(
986            output,
987            "\t\t{} /* Widget.swift */ = {{isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Widget.swift; sourceTree = \"<group>\"; }};",
988            id(30)
989        );
990        let _ = writeln!(
991            output,
992            "\t\t{} /* Info.plist */ = {{isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = \"<group>\"; }};",
993            id(31)
994        );
995        let _ = writeln!(
996            output,
997            "\t\t{} /* Widget.entitlements */ = {{isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Widget.entitlements; sourceTree = \"<group>\"; }};",
998            id(32)
999        );
1000        let _ = writeln!(
1001            output,
1002            "\t\t{} /* FerryWidgetExtension.appex */ = {{isa = PBXFileReference; explicitFileType = \"wrapper.app-extension\"; includeInIndex = 0; path = FerryWidgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }};",
1003            id(33)
1004        );
1005    }
1006    if live_activity {
1007        let _ = writeln!(
1008            output,
1009            "\t\t{} /* FerryActivityAttributes.swift */ = {{isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FerryActivityAttributes.swift; sourceTree = \"<group>\"; }};",
1010            id(101)
1011        );
1012        let _ = writeln!(
1013            output,
1014            "\t\t{} /* FerryActivityModel.framework */ = {{isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = FerryActivityModel.framework; sourceTree = BUILT_PRODUCTS_DIR; }};",
1015            id(102)
1016        );
1017        let _ = writeln!(
1018            output,
1019            "\t\t{} /* Info.plist */ = {{isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = \"<group>\"; }};",
1020            id(106)
1021        );
1022        let _ = writeln!(
1023            output,
1024            "\t\t{} /* LiveActivity.swift */ = {{isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveActivity.swift; sourceTree = \"<group>\"; }};",
1025            id(50)
1026        );
1027        let _ = writeln!(
1028            output,
1029            "\t\t{} /* Info.plist */ = {{isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = \"<group>\"; }};",
1030            id(51)
1031        );
1032        let _ = writeln!(
1033            output,
1034            "\t\t{} /* FerryLiveActivityExtension.appex */ = {{isa = PBXFileReference; explicitFileType = \"wrapper.app-extension\"; includeInIndex = 0; path = FerryLiveActivityExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }};",
1035            id(53)
1036        );
1037    }
1038    output.push_str("/* End PBXFileReference section */\n\n");
1039
1040    output.push_str("/* Begin PBXGroup section */\n");
1041    let _ = write!(
1042        output,
1043        "\t\t{} = {{\n\t\t\tisa = PBXGroup;\n\t\t\tchildren = (\n\t\t\t\t{} /* {} */,\n\t\t\t\t{} /* Info.plist */,\n\t\t\t\t{} /* FerryResources.json */,\n",
1044        id(2),
1045        id(11),
1046        spec.binary_name,
1047        id(7),
1048        id(6)
1049    );
1050    if compiled_assets {
1051        let _ = writeln!(output, "\t\t\t\t{} /* Assets.xcassets */,", id(5));
1052    } else if sdk_only_assets {
1053        let _ = writeln!(output, "\t\t\t\t{} /* FerryIcon.png */,", id(5));
1054        let _ = writeln!(output, "\t\t\t\t{} /* FerrySplash.png */,", id(19));
1055    }
1056    let _ = writeln!(output, "\t\t\t\t{} /* RuntimeBridge */,", id(91));
1057    if widget {
1058        let _ = writeln!(output, "\t\t\t\t{} /* App.entitlements */,", id(8));
1059        let _ = writeln!(output, "\t\t\t\t{} /* WidgetExtension */,", id(38));
1060    }
1061    if live_activity {
1062        let _ = writeln!(output, "\t\t\t\t{} /* ActivityModel */,", id(107));
1063        let _ = writeln!(output, "\t\t\t\t{} /* LiveActivityExtension */,", id(58));
1064    }
1065    let _ = write!(
1066        output,
1067        "\t\t\t\t{} /* Products */,\n\t\t\t);\n\t\t\tsourceTree = \"<group>\";\n\t\t}};\n",
1068        id(3)
1069    );
1070    let _ = write!(
1071        output,
1072        "\t\t{} /* Products */ = {{\n\t\t\tisa = PBXGroup;\n\t\t\tchildren = (\n\t\t\t\t{} /* {}.app */,\n",
1073        id(3),
1074        id(4),
1075        spec.binary_name
1076    );
1077    let _ = writeln!(
1078        output,
1079        "\t\t\t\t{} /* FerryRuntimeBridge.framework */,",
1080        id(82)
1081    );
1082    if widget {
1083        let _ = writeln!(
1084            output,
1085            "\t\t\t\t{} /* FerryWidgetExtension.appex */,",
1086            id(33)
1087        );
1088    }
1089    if live_activity {
1090        let _ = writeln!(
1091            output,
1092            "\t\t\t\t{} /* FerryActivityModel.framework */,",
1093            id(102)
1094        );
1095        let _ = writeln!(
1096            output,
1097            "\t\t\t\t{} /* FerryLiveActivityExtension.appex */,",
1098            id(53)
1099        );
1100    }
1101    output.push_str("\t\t\t);\n\t\t\tname = Products;\n\t\t\tsourceTree = \"<group>\";\n\t\t};\n");
1102    let _ = write!(
1103        output,
1104        "\t\t{} /* RuntimeBridge */ = {{\n\t\t\tisa = PBXGroup;\n\t\t\tchildren = (\n\t\t\t\t{} /* FerryRuntimeBridge.swift */,\n\t\t\t\t{} /* Info.plist */,\n\t\t\t);\n\t\t\tpath = RuntimeBridge;\n\t\t\tsourceTree = \"<group>\";\n\t\t}};\n",
1105        id(91),
1106        id(81),
1107        id(83)
1108    );
1109    if widget {
1110        let _ = write!(
1111            output,
1112            "\t\t{} /* WidgetExtension */ = {{\n\t\t\tisa = PBXGroup;\n\t\t\tchildren = (\n\t\t\t\t{} /* Widget.swift */,\n\t\t\t\t{} /* Info.plist */,\n\t\t\t\t{} /* Widget.entitlements */,\n\t\t\t);\n\t\t\tpath = WidgetExtension;\n\t\t\tsourceTree = \"<group>\";\n\t\t}};\n",
1113            id(38),
1114            id(30),
1115            id(31),
1116            id(32)
1117        );
1118    }
1119    if live_activity {
1120        let _ = write!(
1121            output,
1122            "\t\t{} /* ActivityModel */ = {{\n\t\t\tisa = PBXGroup;\n\t\t\tchildren = (\n\t\t\t\t{} /* FerryActivityAttributes.swift */,\n\t\t\t\t{} /* Info.plist */,\n\t\t\t);\n\t\t\tpath = ActivityModel;\n\t\t\tsourceTree = \"<group>\";\n\t\t}};\n",
1123            id(107),
1124            id(101),
1125            id(106)
1126        );
1127        let _ = write!(
1128            output,
1129            "\t\t{} /* LiveActivityExtension */ = {{\n\t\t\tisa = PBXGroup;\n\t\t\tchildren = (\n\t\t\t\t{} /* LiveActivity.swift */,\n\t\t\t\t{} /* Info.plist */,\n\t\t\t);\n\t\t\tpath = LiveActivityExtension;\n\t\t\tsourceTree = \"<group>\";\n\t\t}};\n",
1130            id(58),
1131            id(50),
1132            id(51)
1133        );
1134    }
1135    output.push_str("/* End PBXGroup section */\n\n");
1136
1137    output.push_str("/* Begin PBXNativeTarget section */\n");
1138    let _ = write!(
1139        output,
1140        "\t\t{} /* FerryApp */ = {{\n\t\t\tisa = PBXNativeTarget;\n\t\t\tbuildConfigurationList = {} /* Build configuration list for PBXNativeTarget \"FerryApp\" */;\n\t\t\tbuildPhases = (\n\t\t\t\t{} /* Resources */,\n\t\t\t\t{} /* Install Rust Executable */,\n\t\t\t\t{} /* Embed Frameworks */,\n",
1141        id(15),
1142        id(25),
1143        id(13),
1144        id(14),
1145        id(93)
1146    );
1147    if widget || live_activity {
1148        let _ = writeln!(output, "\t\t\t\t{} /* Embed App Extensions */,", id(70));
1149    }
1150    let _ = writeln!(
1151        output,
1152        "\t\t\t);\n\t\t\tbuildRules = ();\n\t\t\tdependencies = (\n\t\t\t\t{} /* PBXTargetDependency */,",
1153        id(87)
1154    );
1155    if widget {
1156        let _ = writeln!(output, "\t\t\t\t{} /* PBXTargetDependency */,", id(42));
1157    }
1158    if live_activity {
1159        let _ = writeln!(output, "\t\t\t\t{} /* PBXTargetDependency */,", id(115));
1160        let _ = writeln!(output, "\t\t\t\t{} /* PBXTargetDependency */,", id(62));
1161    }
1162    let _ = write!(
1163        output,
1164        "\t\t\t);\n\t\t\tname = FerryApp;\n\t\t\tproductName = {};\n\t\t\tproductReference = {} /* {}.app */;\n\t\t\tproductType = \"com.apple.product-type.application\";\n\t\t}};\n",
1165        binary,
1166        id(4),
1167        spec.binary_name
1168    );
1169    let _ = write!(
1170        output,
1171        "\t\t{} /* FerryRuntimeBridge */ = {{\n\t\t\tisa = PBXNativeTarget;\n\t\t\tbuildConfigurationList = {} /* Build configuration list for PBXNativeTarget \"FerryRuntimeBridge\" */;\n\t\t\tbuildPhases = ({} /* Sources */,",
1172        id(90),
1173        id(95),
1174        id(92)
1175    );
1176    if live_activity {
1177        let _ = write!(output, " {} /* Frameworks */,", id(113));
1178    }
1179    output.push_str(");\n\t\t\tbuildRules = ();\n\t\t\tdependencies = (");
1180    if live_activity {
1181        let _ = write!(output, "{} /* PBXTargetDependency */,", id(117));
1182    }
1183    let _ = write!(
1184        output,
1185        ");\n\t\t\tname = FerryRuntimeBridge;\n\t\t\tproductName = FerryRuntimeBridge;\n\t\t\tproductReference = {} /* FerryRuntimeBridge.framework */;\n\t\t\tproductType = \"com.apple.product-type.framework\";\n\t\t}};\n",
1186        id(82)
1187    );
1188    if widget {
1189        let _ = write!(
1190            output,
1191            "\t\t{} /* FerryWidgetExtension */ = {{\n\t\t\tisa = PBXNativeTarget;\n\t\t\tbuildConfigurationList = {} /* Build configuration list for PBXNativeTarget \"FerryWidgetExtension\" */;\n\t\t\tbuildPhases = ({} /* Sources */,);\n\t\t\tbuildRules = ();\n\t\t\tdependencies = ();\n\t\t\tname = FerryWidgetExtension;\n\t\t\tproductName = FerryWidgetExtension;\n\t\t\tproductReference = {} /* FerryWidgetExtension.appex */;\n\t\t\tproductType = \"com.apple.product-type.app-extension\";\n\t\t}};\n",
1192            id(36),
1193            id(39),
1194            id(35),
1195            id(33)
1196        );
1197    }
1198    if live_activity {
1199        let _ = write!(
1200            output,
1201            "\t\t{} /* FerryActivityModel */ = {{\n\t\t\tisa = PBXNativeTarget;\n\t\t\tbuildConfigurationList = {} /* Build configuration list for PBXNativeTarget \"FerryActivityModel\" */;\n\t\t\tbuildPhases = ({} /* Sources */,);\n\t\t\tbuildRules = ();\n\t\t\tdependencies = ();\n\t\t\tname = FerryActivityModel;\n\t\t\tproductName = FerryActivityModel;\n\t\t\tproductReference = {} /* FerryActivityModel.framework */;\n\t\t\tproductType = \"com.apple.product-type.framework\";\n\t\t}};\n",
1202            id(108),
1203            id(112),
1204            id(109),
1205            id(102)
1206        );
1207        let _ = write!(
1208            output,
1209            "\t\t{} /* FerryLiveActivityExtension */ = {{\n\t\t\tisa = PBXNativeTarget;\n\t\t\tbuildConfigurationList = {} /* Build configuration list for PBXNativeTarget \"FerryLiveActivityExtension\" */;\n\t\t\tbuildPhases = ({} /* Sources */, {} /* Frameworks */,);\n\t\t\tbuildRules = ();\n\t\t\tdependencies = ({} /* PBXTargetDependency */,);\n\t\t\tname = FerryLiveActivityExtension;\n\t\t\tproductName = FerryLiveActivityExtension;\n\t\t\tproductReference = {} /* FerryLiveActivityExtension.appex */;\n\t\t\tproductType = \"com.apple.product-type.app-extension\";\n\t\t}};\n",
1210            id(56),
1211            id(59),
1212            id(55),
1213            id(94),
1214            id(119),
1215            id(53)
1216        );
1217    }
1218    output.push_str("/* End PBXNativeTarget section */\n\n");
1219
1220    output.push_str("/* Begin PBXProject section */\n");
1221    let _ = write!(
1222        output,
1223        "\t\t{} /* Project object */ = {{\n\t\t\tisa = PBXProject;\n\t\t\tattributes = {{\n\t\t\t\tBuildIndependentTargetsInParallel = YES;\n\t\t\t\tLastSwiftUpdateCheck = 2660;\n\t\t\t\tLastUpgradeCheck = 2660;\n\t\t\t}};\n\t\t\tbuildConfigurationList = {} /* Build configuration list for PBXProject \"FerryHost\" */;\n\t\t\tcompatibilityVersion = \"Xcode 15.0\";\n\t\t\tdevelopmentRegion = en;\n\t\t\thasScannedForEncodings = 0;\n\t\t\tknownRegions = (en, Base,);\n\t\t\tmainGroup = {};\n\t\t\tproductRefGroup = {} /* Products */;\n\t\t\tprojectDirPath = \"\";\n\t\t\tprojectRoot = \"\";\n\t\t\ttargets = (\n\t\t\t\t{} /* FerryApp */,\n",
1224        id(1),
1225        id(22),
1226        id(2),
1227        id(3),
1228        id(15)
1229    );
1230    let _ = writeln!(output, "\t\t\t\t{} /* FerryRuntimeBridge */,", id(90));
1231    if widget {
1232        let _ = writeln!(output, "\t\t\t\t{} /* FerryWidgetExtension */,", id(36));
1233    }
1234    if live_activity {
1235        let _ = writeln!(output, "\t\t\t\t{} /* FerryActivityModel */,", id(108));
1236        let _ = writeln!(
1237            output,
1238            "\t\t\t\t{} /* FerryLiveActivityExtension */,",
1239            id(56)
1240        );
1241    }
1242    output.push_str("\t\t\t);\n\t\t};\n/* End PBXProject section */\n\n");
1243
1244    output.push_str("/* Begin PBXResourcesBuildPhase section */\n");
1245    if compiled_assets {
1246        let _ = write!(
1247            output,
1248            "\t\t{} /* Resources */ = {{isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ({} /* Assets.xcassets in Resources */, {} /* FerryResources.json in Resources */,); runOnlyForDeploymentPostprocessing = 0; }};\n",
1249            id(13),
1250            id(9),
1251            id(12)
1252        );
1253    } else if sdk_only_assets {
1254        let _ = write!(
1255            output,
1256            "\t\t{} /* Resources */ = {{isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ({} /* FerryIcon.png in Resources */, {} /* FerrySplash.png in Resources */, {} /* FerryResources.json in Resources */,); runOnlyForDeploymentPostprocessing = 0; }};\n",
1257            id(13),
1258            id(9),
1259            id(18),
1260            id(12)
1261        );
1262    } else {
1263        let _ = write!(
1264            output,
1265            "\t\t{} /* Resources */ = {{isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ({} /* FerryResources.json in Resources */,); runOnlyForDeploymentPostprocessing = 0; }};\n",
1266            id(13),
1267            id(12)
1268        );
1269    }
1270    output.push_str("/* End PBXResourcesBuildPhase section */\n\n");
1271
1272    if live_activity {
1273        output.push_str("/* Begin PBXFrameworksBuildPhase section */\n");
1274        let _ = write!(
1275            output,
1276            "\t\t{} /* Frameworks */ = {{isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ({} /* FerryActivityModel.framework in RuntimeBridge Frameworks */,); runOnlyForDeploymentPostprocessing = 0; }};\n",
1277            id(113),
1278            id(104)
1279        );
1280        let _ = write!(
1281            output,
1282            "\t\t{} /* Frameworks */ = {{isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ({} /* FerryActivityModel.framework in Live Activity Frameworks */,); runOnlyForDeploymentPostprocessing = 0; }};\n",
1283            id(94),
1284            id(105)
1285        );
1286        output.push_str("/* End PBXFrameworksBuildPhase section */\n\n");
1287    }
1288
1289    output.push_str("/* Begin PBXSourcesBuildPhase section */\n");
1290    let _ = write!(
1291        output,
1292        "\t\t{} /* Sources */ = {{isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ({} /* FerryRuntimeBridge.swift in Sources */,); runOnlyForDeploymentPostprocessing = 0; }};\n",
1293        id(92),
1294        id(80)
1295    );
1296    if widget {
1297        let _ = write!(
1298            output,
1299            "\t\t{} /* Sources */ = {{isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ({} /* Widget.swift in Sources */,); runOnlyForDeploymentPostprocessing = 0; }};\n",
1300            id(35),
1301            id(34)
1302        );
1303    }
1304    if live_activity {
1305        let _ = write!(
1306            output,
1307            "\t\t{} /* Sources */ = {{isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ({} /* FerryActivityAttributes.swift in Sources */,); runOnlyForDeploymentPostprocessing = 0; }};\n",
1308            id(109),
1309            id(100)
1310        );
1311        let _ = write!(
1312            output,
1313            "\t\t{} /* Sources */ = {{isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ({} /* LiveActivity.swift in Sources */,); runOnlyForDeploymentPostprocessing = 0; }};\n",
1314            id(55),
1315            id(54)
1316        );
1317    }
1318    output.push_str("/* End PBXSourcesBuildPhase section */\n\n");
1319
1320    output.push_str("/* Begin PBXTargetDependency section */\n");
1321    let _ = writeln!(
1322        output,
1323        "\t\t{} /* PBXTargetDependency */ = {{isa = PBXTargetDependency; target = {} /* FerryRuntimeBridge */; targetProxy = {} /* PBXContainerItemProxy */; }};",
1324        id(87),
1325        id(90),
1326        id(86)
1327    );
1328    if widget {
1329        let _ = writeln!(
1330            output,
1331            "\t\t{} /* PBXTargetDependency */ = {{isa = PBXTargetDependency; target = {} /* FerryWidgetExtension */; targetProxy = {} /* PBXContainerItemProxy */; }};",
1332            id(42),
1333            id(36),
1334            id(41)
1335        );
1336    }
1337    if live_activity {
1338        let _ = writeln!(
1339            output,
1340            "\t\t{} /* PBXTargetDependency */ = {{isa = PBXTargetDependency; target = {} /* FerryLiveActivityExtension */; targetProxy = {} /* PBXContainerItemProxy */; }};",
1341            id(62),
1342            id(56),
1343            id(61)
1344        );
1345        let _ = writeln!(
1346            output,
1347            "\t\t{} /* PBXTargetDependency */ = {{isa = PBXTargetDependency; target = {} /* FerryActivityModel */; targetProxy = {} /* PBXContainerItemProxy */; }};",
1348            id(115),
1349            id(108),
1350            id(114)
1351        );
1352        let _ = writeln!(
1353            output,
1354            "\t\t{} /* PBXTargetDependency */ = {{isa = PBXTargetDependency; target = {} /* FerryActivityModel */; targetProxy = {} /* PBXContainerItemProxy */; }};",
1355            id(117),
1356            id(108),
1357            id(116)
1358        );
1359        let _ = writeln!(
1360            output,
1361            "\t\t{} /* PBXTargetDependency */ = {{isa = PBXTargetDependency; target = {} /* FerryActivityModel */; targetProxy = {} /* PBXContainerItemProxy */; }};",
1362            id(119),
1363            id(108),
1364            id(118)
1365        );
1366    }
1367    output.push_str("/* End PBXTargetDependency section */\n\n");
1368
1369    output.push_str("/* Begin XCBuildConfiguration section */\n");
1370    for (config_id, name) in [(20, "Debug"), (21, "Release")] {
1371        let _ = write!(
1372            output,
1373            "\t\t{} /* {} */ = {{isa = XCBuildConfiguration; buildSettings = {{CLANG_ENABLE_MODULES = YES; IPHONEOS_DEPLOYMENT_TARGET = {}; SDKROOT = iphonesimulator; }}; name = {}; }};\n",
1374            id(config_id),
1375            name,
1376            min_version,
1377            name
1378        );
1379    }
1380    for (config_id, name) in [(23, "Debug"), (24, "Release")] {
1381        let code_sign_entitlements = if widget {
1382            "CODE_SIGN_ENTITLEMENTS = App.entitlements; "
1383        } else {
1384            ""
1385        };
1386        let asset_catalog_settings = if compiled_assets {
1387            "ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; "
1388        } else {
1389            ""
1390        };
1391        let _ = write!(
1392            output,
1393            "\t\t{} /* {} */ = {{isa = XCBuildConfiguration; buildSettings = {{AD_HOC_CODE_SIGNING_ALLOWED = YES; ARCHS = arm64; {}ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = NO; CODE_SIGN_IDENTITY = \"-\"; CODE_SIGNING_ALLOWED = YES; CODE_SIGNING_REQUIRED = YES; {}COMPRESS_PNG_FILES = NO; CURRENT_PROJECT_VERSION = {}; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = Info.plist; IPHONEOS_DEPLOYMENT_TARGET = {}; LD_RUNPATH_SEARCH_PATHS = (\"$(inherited)\", \"@executable_path/Frameworks\",); MARKETING_VERSION = {}; ONLY_ACTIVE_ARCH = NO; PRODUCT_BUNDLE_IDENTIFIER = {}; PRODUCT_NAME = {}; SDKROOT = iphonesimulator; SKIP_INSTALL = NO; STRIP_PNG_TEXT = NO; SUPPORTED_PLATFORMS = iphonesimulator; TARGETED_DEVICE_FAMILY = \"1,2\"; }}; name = {}; }};\n",
1394            id(config_id),
1395            name,
1396            asset_catalog_settings,
1397            code_sign_entitlements,
1398            build_version,
1399            min_version,
1400            marketing,
1401            bundle,
1402            binary,
1403            name
1404        );
1405    }
1406    for (config_id, name) in [(96, "Debug"), (97, "Release")] {
1407        let activity_model_runpath = if live_activity {
1408            "LD_RUNPATH_SEARCH_PATHS = (\"$(inherited)\", \"@loader_path\",); "
1409        } else {
1410            ""
1411        };
1412        let _ = write!(
1413            output,
1414            "\t\t{} /* {} */ = {{isa = XCBuildConfiguration; buildSettings = {{AD_HOC_CODE_SIGNING_ALLOWED = YES; APPLICATION_EXTENSION_API_ONLY = NO; ARCHS = arm64; CODE_SIGN_IDENTITY = \"-\"; CODE_SIGNING_ALLOWED = YES; CODE_SIGNING_REQUIRED = YES; CURRENT_PROJECT_VERSION = 1; DEFINES_MODULE = YES; DYLIB_INSTALL_NAME_BASE = \"@rpath\"; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = RuntimeBridge/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = {}; {}MARKETING_VERSION = 1.0; ONLY_ACTIVE_ARCH = NO; PRODUCT_BUNDLE_IDENTIFIER = org.rustferry.runtime-bridge; PRODUCT_NAME = FerryRuntimeBridge; SDKROOT = iphonesimulator; SKIP_INSTALL = YES; SUPPORTED_PLATFORMS = iphonesimulator; SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_INSTALL_OBJC_HEADER = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = \"1,2\"; }}; name = {}; }};\n",
1415            id(config_id),
1416            name,
1417            min_version,
1418            activity_model_runpath,
1419            name
1420        );
1421    }
1422    if live_activity {
1423        for (config_id, name) in [(110, "Debug"), (111, "Release")] {
1424            let _ = write!(
1425                output,
1426                "\t\t{} /* {} */ = {{isa = XCBuildConfiguration; buildSettings = {{AD_HOC_CODE_SIGNING_ALLOWED = YES; APPLICATION_EXTENSION_API_ONLY = YES; ARCHS = arm64; CODE_SIGN_IDENTITY = \"-\"; CODE_SIGNING_ALLOWED = YES; CODE_SIGNING_REQUIRED = YES; CURRENT_PROJECT_VERSION = 1; DEFINES_MODULE = YES; DYLIB_INSTALL_NAME_BASE = \"@rpath\"; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = ActivityModel/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = {}; MARKETING_VERSION = 1.0; ONLY_ACTIVE_ARCH = NO; PRODUCT_BUNDLE_IDENTIFIER = org.rustferry.activity-model; PRODUCT_NAME = FerryActivityModel; SDKROOT = iphonesimulator; SKIP_INSTALL = YES; SUPPORTED_PLATFORMS = iphonesimulator; SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_INSTALL_OBJC_HEADER = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = \"1,2\"; }}; name = {}; }};\n",
1427                id(config_id),
1428                name,
1429                min_version,
1430                name
1431            );
1432        }
1433    }
1434    if widget {
1435        render_extension_build_configs(
1436            &mut output,
1437            37,
1438            38_000,
1439            "WidgetExtension/Info.plist",
1440            Some("WidgetExtension/Widget.entitlements"),
1441            &format!("{}.widget", spec.config.app.identifier),
1442            "FerryWidgetExtension",
1443            &spec.config.ios.min_version,
1444        );
1445    }
1446    if live_activity {
1447        render_extension_build_configs(
1448            &mut output,
1449            57,
1450            58_000,
1451            "LiveActivityExtension/Info.plist",
1452            None,
1453            &format!("{}.liveactivity", spec.config.app.identifier),
1454            "FerryLiveActivityExtension",
1455            &spec.config.ios.min_version,
1456        );
1457    }
1458    output.push_str("/* End XCBuildConfiguration section */\n\n");
1459
1460    output.push_str("/* Begin XCConfigurationList section */\n");
1461    let _ = write!(
1462        output,
1463        "\t\t{} /* Build configuration list for PBXProject \"FerryHost\" */ = {{isa = XCConfigurationList; buildConfigurations = ({} /* Debug */, {} /* Release */,); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }};\n",
1464        id(22),
1465        id(20),
1466        id(21)
1467    );
1468    let _ = write!(
1469        output,
1470        "\t\t{} /* Build configuration list for PBXNativeTarget \"FerryApp\" */ = {{isa = XCConfigurationList; buildConfigurations = ({} /* Debug */, {} /* Release */,); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }};\n",
1471        id(25),
1472        id(23),
1473        id(24)
1474    );
1475    let _ = write!(
1476        output,
1477        "\t\t{} /* Build configuration list for PBXNativeTarget \"FerryRuntimeBridge\" */ = {{isa = XCConfigurationList; buildConfigurations = ({} /* Debug */, {} /* Release */,); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }};\n",
1478        id(95),
1479        id(96),
1480        id(97)
1481    );
1482    if live_activity {
1483        let _ = write!(
1484            output,
1485            "\t\t{} /* Build configuration list for PBXNativeTarget \"FerryActivityModel\" */ = {{isa = XCConfigurationList; buildConfigurations = ({} /* Debug */, {} /* Release */,); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }};\n",
1486            id(112),
1487            id(110),
1488            id(111)
1489        );
1490    }
1491    if widget {
1492        let _ = write!(
1493            output,
1494            "\t\t{} /* Build configuration list for PBXNativeTarget \"FerryWidgetExtension\" */ = {{isa = XCConfigurationList; buildConfigurations = ({} /* Debug */, {} /* Release */,); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }};\n",
1495            id(39),
1496            id(37),
1497            id(38_000)
1498        );
1499    }
1500    if live_activity {
1501        let _ = write!(
1502            output,
1503            "\t\t{} /* Build configuration list for PBXNativeTarget \"FerryLiveActivityExtension\" */ = {{isa = XCConfigurationList; buildConfigurations = ({} /* Debug */, {} /* Release */,); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }};\n",
1504            id(59),
1505            id(57),
1506            id(58_000)
1507        );
1508    }
1509    output.push_str("/* End XCConfigurationList section */\n\n");
1510    let _ = write!(
1511        output,
1512        "\t}};\n\trootObject = {} /* Project object */;\n}}\n",
1513        id(1)
1514    );
1515    output
1516}
1517
1518#[allow(clippy::too_many_arguments)]
1519#[allow(clippy::write_with_newline)]
1520fn render_extension_build_configs(
1521    output: &mut String,
1522    debug_id: u64,
1523    release_id: u64,
1524    info_plist: &str,
1525    entitlements: Option<&str>,
1526    identifier: &str,
1527    product_name: &str,
1528    min_version: &str,
1529) {
1530    for (config_id, name) in [(debug_id, "Debug"), (release_id, "Release")] {
1531        let entitlement_setting = entitlements
1532            .map(|path| format!("CODE_SIGN_ENTITLEMENTS = {}; ", pbx_quote(path)))
1533            .unwrap_or_default();
1534        let _ = write!(
1535            output,
1536            "\t\t{} /* {} */ = {{isa = XCBuildConfiguration; buildSettings = {{AD_HOC_CODE_SIGNING_ALLOWED = YES; APPLICATION_EXTENSION_API_ONLY = YES; ARCHS = arm64; CODE_SIGN_IDENTITY = \"-\"; CODE_SIGNING_ALLOWED = YES; CODE_SIGNING_REQUIRED = YES; {}GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = {}; IPHONEOS_DEPLOYMENT_TARGET = {}; LD_RUNPATH_SEARCH_PATHS = (\"$(inherited)\", \"@executable_path/Frameworks\", \"@executable_path/../../Frameworks\",); ONLY_ACTIVE_ARCH = NO; PRODUCT_BUNDLE_IDENTIFIER = {}; PRODUCT_NAME = {}; SDKROOT = iphonesimulator; SKIP_INSTALL = YES; SUPPORTED_PLATFORMS = iphonesimulator; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = \"1,2\"; }}; name = {}; }};\n",
1537            id(config_id),
1538            name,
1539            entitlement_setting,
1540            pbx_quote(info_plist),
1541            pbx_quote(min_version),
1542            pbx_quote(identifier),
1543            pbx_quote(product_name),
1544            name
1545        );
1546    }
1547}
1548
1549fn id(number: u64) -> String {
1550    format!("AA{number:022X}")
1551}
1552
1553fn pbx_quote(value: &str) -> String {
1554    let escaped = value
1555        .replace('\\', "\\\\")
1556        .replace('"', "\\\"")
1557        .replace('\n', "\\n")
1558        .replace('\r', "\\r");
1559    format!("\"{escaped}\"")
1560}
1561
1562fn xml_escape(value: &str) -> String {
1563    value
1564        .replace('&', "&amp;")
1565        .replace('<', "&lt;")
1566        .replace('>', "&gt;")
1567        .replace('"', "&quot;")
1568        .replace('\'', "&apos;")
1569}
1570
1571fn swift_escape(value: &str) -> String {
1572    let mut escaped = String::new();
1573    for character in value.chars() {
1574        match character {
1575            '\\' => escaped.push_str("\\\\"),
1576            '"' => escaped.push_str("\\\""),
1577            '\n' => escaped.push_str("\\n"),
1578            '\r' => escaped.push_str("\\r"),
1579            '\t' => escaped.push_str("\\t"),
1580            character if character.is_control() => {
1581                let _ = write!(escaped, "\\u{{{:X}}}", u32::from(character));
1582            }
1583            character => escaped.push(character),
1584        }
1585    }
1586    escaped
1587}
1588
1589fn swift_string_literal(value: &str) -> String {
1590    format!("\"{}\"", swift_escape(value))
1591}
1592
1593fn swift_string_set(values: &[String]) -> String {
1594    format!(
1595        "[{}]",
1596        values
1597            .iter()
1598            .map(|value| swift_string_literal(value))
1599            .collect::<Vec<_>>()
1600            .join(", ")
1601    )
1602}
1603
1604fn validate_relative_path(path: &Utf8Path) -> Result<(), AppleError> {
1605    if path.is_absolute() || path.as_str().is_empty() {
1606        return Err(AppleError::UnsafeGeneratedPath {
1607            path: path.to_owned(),
1608            reason: "generated paths must be non-empty and relative".to_owned(),
1609        });
1610    }
1611    if path
1612        .components()
1613        .any(|component| !matches!(component, Utf8Component::Normal(_)))
1614    {
1615        return Err(AppleError::UnsafeGeneratedPath {
1616            path: path.to_owned(),
1617            reason: "generated paths cannot contain parent, root, or prefix components".to_owned(),
1618        });
1619    }
1620    Ok(())
1621}
1622
1623fn reject_symlink(path: &Utf8Path) -> Result<(), AppleError> {
1624    match fs::symlink_metadata(path) {
1625        Ok(metadata) if metadata.file_type().is_symlink() => Err(AppleError::UnsafeGeneratedPath {
1626            path: path.to_owned(),
1627            reason: "generated output cannot traverse a symbolic link".to_owned(),
1628        }),
1629        Ok(_) => Ok(()),
1630        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
1631        Err(source) => Err(io_error("inspect generated path", path, source)),
1632    }
1633}
1634
1635fn create_directories_without_symlinks(
1636    root: &Utf8Path,
1637    destination: &Utf8Path,
1638) -> Result<(), AppleError> {
1639    let relative = destination
1640        .strip_prefix(root)
1641        .map_err(|_| AppleError::UnsafeGeneratedPath {
1642            path: destination.to_owned(),
1643            reason: format!("path is outside generated root `{root}`"),
1644        })?;
1645    let mut current = root.to_owned();
1646    for component in relative.components() {
1647        let Utf8Component::Normal(component) = component else {
1648            return Err(AppleError::UnsafeGeneratedPath {
1649                path: destination.to_owned(),
1650                reason: "directory path contains traversal components".to_owned(),
1651            });
1652        };
1653        current.push(component);
1654        reject_symlink(&current)?;
1655        fs::create_dir_all(&current)
1656            .map_err(|source| io_error("create generated iOS directory", &current, source))?;
1657    }
1658    Ok(())
1659}
1660
1661#[cfg(test)]
1662mod tests {
1663    use super::*;
1664
1665    mod png_fixture {
1666        include!(concat!(
1667            env!("CARGO_MANIFEST_DIR"),
1668            "/tests/fixtures/opaque_png.rs"
1669        ));
1670    }
1671
1672    use png_fixture::OPAQUE_1024_PNG as PNG;
1673
1674    fn starter() -> IosProjectSpec {
1675        IosProjectSpec::new(
1676            FerryConfig::starter("Weather & Wind", "com.example.weather"),
1677            "weather-app",
1678        )
1679    }
1680
1681    #[test]
1682    fn base_project_is_deterministic_and_escapes_plist_values() {
1683        let first = generate_ios_project(&starter()).unwrap();
1684        let second = generate_ios_project(&starter()).unwrap();
1685        assert_eq!(first, second);
1686        let plist = first.text(Utf8Path::new("Info.plist")).unwrap();
1687        assert!(plist.contains("Weather &amp; Wind"));
1688        let pbx = first
1689            .text(Utf8Path::new("FerryHost.xcodeproj/project.pbxproj"))
1690            .unwrap();
1691        assert!(pbx.contains("Install Rust Executable"));
1692        assert!(pbx.contains("dstSubfolderSpec = 1"));
1693        assert!(!pbx.contains("PBXShellScriptBuildPhase"));
1694        assert!(pbx.contains("AD_HOC_CODE_SIGNING_ALLOWED = YES"));
1695        assert!(pbx.contains("CODE_SIGN_IDENTITY = \"-\""));
1696        assert!(pbx.contains("CODE_SIGNING_ALLOWED = YES"));
1697        assert!(pbx.contains("CODE_SIGNING_REQUIRED = YES"));
1698        assert!(!pbx.contains("CODE_SIGNING_ALLOWED = NO"));
1699        assert!(!pbx.contains("CODE_SIGNING_REQUIRED = NO"));
1700        assert!(pbx.contains("aarch64") || pbx.contains("ARCHS = arm64"));
1701        assert!(!pbx.contains("FerryWidgetExtension"));
1702        assert!(!plist.contains("UIApplicationDelegateClassName"));
1703        let bridge = first
1704            .text(Utf8Path::new("RuntimeBridge/FerryRuntimeBridge.swift"))
1705            .unwrap();
1706        assert!(bridge.contains("installApplicationDelegateHook()"));
1707        assert!(bridge.contains("@_cdecl(\"ferry_bridge_init\")"));
1708        assert!(bridge.contains("method_setImplementation"));
1709        assert!(!bridge.contains("UIApplication.shared"));
1710        assert!(!bridge.contains("import FerryActivityModel"));
1711        assert!(bridge.contains("public struct FerryActivityAttributes"));
1712        assert!(
1713            !first
1714                .files
1715                .contains_key(Utf8Path::new("ActivityModel/FerryActivityAttributes.swift"))
1716        );
1717    }
1718
1719    #[test]
1720    fn project_assets_emit_a_real_catalog_and_build_settings() {
1721        let temporary = tempfile::tempdir().unwrap();
1722        let root = Utf8Path::from_path(temporary.path()).unwrap();
1723        fs::create_dir(root.join("assets")).unwrap();
1724        fs::write(root.join("assets/icon.png"), PNG).unwrap();
1725        fs::write(root.join("assets/splash.png"), PNG).unwrap();
1726        let generated =
1727            generate_ios_project(&starter().with_assets(ProjectAssets::load(root).unwrap()))
1728                .unwrap();
1729        assert!(generated.files.contains_key(Utf8Path::new(
1730            "Assets.xcassets/AppIcon.appiconset/AppIcon-1024-1x.png"
1731        )));
1732        assert!(generated.files.contains_key(Utf8Path::new(
1733            "Assets.xcassets/AppIcon.appiconset/Contents.json"
1734        )));
1735        assert!(generated.files.contains_key(Utf8Path::new(
1736            "Assets.xcassets/FerryLaunch.imageset/Contents.json"
1737        )));
1738        let plist = generated.text(Utf8Path::new("Info.plist")).unwrap();
1739        assert!(plist.contains("<string>AppIcon</string>"));
1740        assert!(plist.contains("<string>FerryLaunch</string>"));
1741        assert!(!plist.contains("UILaunchImageFile"));
1742        let pbx = generated
1743            .text(Utf8Path::new("FerryHost.xcodeproj/project.pbxproj"))
1744            .unwrap();
1745        assert!(pbx.contains("Assets.xcassets in Resources"));
1746        assert!(pbx.contains("folder.assetcatalog"));
1747        assert!(pbx.contains("ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon"));
1748        assert!(pbx.contains("COMPRESS_PNG_FILES = NO"));
1749    }
1750
1751    #[test]
1752    fn sdk_only_assets_preserve_runtime_free_xcode_builds() {
1753        let temporary = tempfile::tempdir().unwrap();
1754        let root = Utf8Path::from_path(temporary.path()).unwrap();
1755        fs::create_dir(root.join("assets")).unwrap();
1756        fs::write(root.join("assets/icon.png"), PNG).unwrap();
1757        fs::write(root.join("assets/splash.png"), PNG).unwrap();
1758        let generated = generate_ios_project(
1759            &starter()
1760                .with_assets(ProjectAssets::load(root).unwrap())
1761                .with_asset_packaging(IosAssetPackaging::SdkOnlyResources),
1762        )
1763        .unwrap();
1764
1765        assert_eq!(generated.files[Utf8Path::new("FerryIcon.png")], PNG);
1766        assert_eq!(generated.files[Utf8Path::new("FerrySplash.png")], PNG);
1767        assert!(
1768            !generated
1769                .files
1770                .keys()
1771                .any(|path| path.starts_with("Assets.xcassets"))
1772        );
1773        let plist = generated.text(Utf8Path::new("Info.plist")).unwrap();
1774        assert!(plist.contains("<key>CFBundleIconFile</key>"));
1775        assert!(plist.contains("<string>FerryIcon</string>"));
1776        assert!(plist.contains("<key>UILaunchImageFile</key>"));
1777        assert!(plist.contains("<string>FerrySplash</string>"));
1778        assert!(!plist.contains("CFBundleIconName"));
1779        let pbx = generated
1780            .text(Utf8Path::new("FerryHost.xcodeproj/project.pbxproj"))
1781            .unwrap();
1782        assert!(pbx.contains("FerryIcon.png in Resources"));
1783        assert!(pbx.contains("FerrySplash.png in Resources"));
1784        assert!(!pbx.contains("Assets.xcassets in Resources"));
1785        assert!(!pbx.contains("ASSETCATALOG_COMPILER_APPICON_NAME"));
1786    }
1787
1788    #[test]
1789    fn cached_catalog_is_consumed_and_tampering_is_rejected() {
1790        let temporary = tempfile::tempdir().unwrap();
1791        let root = Utf8Path::from_path(temporary.path()).unwrap();
1792        fs::create_dir(root.join("assets")).unwrap();
1793        fs::write(root.join("assets/icon.png"), PNG).unwrap();
1794        fs::write(root.join("assets/splash.png"), PNG).unwrap();
1795        let assets = ProjectAssets::load(root).unwrap();
1796        let generated_assets = rustferry_codegen::generate_platform_assets(root, None).unwrap();
1797        let spec = starter().with_assets(assets);
1798        let generated = generate_ios_project_from_asset_set(&spec, &generated_assets).unwrap();
1799        let catalog_path = Utf8Path::new("Assets.xcassets/AppIcon.appiconset/AppIcon-1024-1x.png");
1800        assert_eq!(
1801            generated.files.get(catalog_path),
1802            Some(&fs::read(generated_assets.root.join("ios").join(catalog_path)).unwrap())
1803        );
1804
1805        fs::write(
1806            generated_assets
1807                .root
1808                .join("ios/Assets.xcassets/AppIcon.appiconset/AppIcon-1024-1x.png"),
1809            b"tampered",
1810        )
1811        .unwrap();
1812        assert!(generate_ios_project_from_asset_set(&spec, &generated_assets).is_err());
1813    }
1814
1815    #[test]
1816    fn extensions_are_real_separate_xcode_targets() {
1817        let mut spec = starter();
1818        spec.config.extensions.widget.enabled = true;
1819        spec.config.extensions.widget.app_group = Some("group.com.example.weather".into());
1820        spec.config.extensions.live_activity.enabled = true;
1821        spec.config.ios.min_version = "16.1".into();
1822        let generated = generate_ios_project(&spec).unwrap();
1823        let pbx = generated
1824            .text(Utf8Path::new("FerryHost.xcodeproj/project.pbxproj"))
1825            .unwrap();
1826        assert!(pbx.contains("com.apple.product-type.app-extension"));
1827        assert!(pbx.contains("FerryWidgetExtension.appex in Embed App Extensions"));
1828        assert!(pbx.contains("FerryLiveActivityExtension.appex in Embed App Extensions"));
1829        assert!(
1830            generated
1831                .files
1832                .contains_key(Utf8Path::new("WidgetExtension/Widget.swift"))
1833        );
1834        assert!(
1835            generated
1836                .files
1837                .contains_key(Utf8Path::new("LiveActivityExtension/LiveActivity.swift"))
1838        );
1839        let activity_model = generated
1840            .text(Utf8Path::new("ActivityModel/FerryActivityAttributes.swift"))
1841            .unwrap();
1842        assert!(activity_model.contains("public struct FerryActivityAttributes"));
1843        assert!(activity_model.contains("import ActivityKit"));
1844        assert!(!activity_model.contains("UIKit"));
1845        assert!(!activity_model.contains("UIApplication"));
1846        assert!(!activity_model.contains("FerryBridge"));
1847        let bridge = generated
1848            .text(Utf8Path::new("RuntimeBridge/FerryRuntimeBridge.swift"))
1849            .unwrap();
1850        assert!(bridge.contains("import FerryActivityModel"));
1851        assert!(!bridge.contains("public struct FerryActivityAttributes"));
1852        assert!(bridge.contains("UIApplicationDelegate"));
1853        let live_activity = generated
1854            .text(Utf8Path::new("LiveActivityExtension/LiveActivity.swift"))
1855            .unwrap();
1856        assert!(live_activity.contains("import FerryActivityModel"));
1857        assert!(!live_activity.contains("FerryRuntimeBridge"));
1858        assert!(pbx.contains("FerryActivityModel.framework in RuntimeBridge Frameworks"));
1859        assert!(pbx.contains("FerryActivityModel.framework in Live Activity Frameworks"));
1860        assert!(pbx.contains("LD_RUNPATH_SEARCH_PATHS = (\"$(inherited)\", \"@loader_path\",)"));
1861        assert!(pbx.contains("@executable_path/../../Frameworks"));
1862        assert!(!pbx.contains("FerryRuntimeBridge.framework in Frameworks"));
1863        assert_eq!(
1864            pbx.matches("APPLICATION_EXTENSION_API_ONLY = NO").count(),
1865            2
1866        );
1867        assert_eq!(
1868            pbx.matches("APPLICATION_EXTENSION_API_ONLY = YES").count(),
1869            6
1870        );
1871        let widget = generated
1872            .text(Utf8Path::new("WidgetExtension/Widget.swift"))
1873            .unwrap();
1874        assert!(widget.contains("snapshot[\"caption\"]"));
1875        assert!(widget.contains("ProgressView(value: progress)"));
1876        assert!(widget.contains("Link(action.label, destination: action.destination)"));
1877        assert!(widget.contains(".widgetURL(entry.deepLink)"));
1878    }
1879
1880    #[test]
1881    fn device_live_activity_model_is_extension_safe_and_iphoneos_only() {
1882        let mut spec = starter();
1883        spec.config.extensions.live_activity.enabled = true;
1884        spec.config.ios.min_version = "16.1".into();
1885        let generated =
1886            generate_ios_project_for_platform(&spec, IosProjectPlatform::DeviceUnsigned).unwrap();
1887        let pbx = generated
1888            .text(Utf8Path::new("FerryHost.xcodeproj/project.pbxproj"))
1889            .unwrap();
1890
1891        assert!(!pbx.contains("iphonesimulator"));
1892        assert!(pbx.contains("SDKROOT = iphoneos"));
1893        assert!(pbx.contains("SUPPORTED_PLATFORMS = iphoneos"));
1894        assert!(pbx.contains("PRODUCT_NAME = FerryActivityModel"));
1895        assert_eq!(
1896            pbx.matches("APPLICATION_EXTENSION_API_ONLY = NO").count(),
1897            2
1898        );
1899        assert_eq!(
1900            pbx.matches("APPLICATION_EXTENSION_API_ONLY = YES").count(),
1901            4
1902        );
1903        assert!(!pbx.contains("FerryRuntimeBridge.framework in Frameworks"));
1904    }
1905
1906    #[cfg(target_os = "macos")]
1907    #[test]
1908    #[ignore = "requires full Xcode"]
1909    fn xcode_builds_the_safe_activity_model_dependency_graph() {
1910        let mut spec = starter();
1911        spec.config.extensions.live_activity.enabled = true;
1912        spec.config.ios.min_version = "16.1".into();
1913        let generated = generate_ios_project(&spec).unwrap();
1914        let temporary = tempfile::tempdir().unwrap();
1915        let root = Utf8Path::from_path(temporary.path()).unwrap();
1916        write_ios_project(&generated, root).unwrap();
1917
1918        let output = std::process::Command::new("/usr/bin/xcrun")
1919            .args(["xcodebuild", "-project"])
1920            .arg(root.join("FerryHost.xcodeproj"))
1921            .args(["-list", "-json"])
1922            .current_dir(root)
1923            .output()
1924            .unwrap();
1925        assert!(
1926            output.status.success(),
1927            "xcodebuild failed: {}",
1928            String::from_utf8_lossy(&output.stderr)
1929        );
1930        let listing: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
1931        let targets = listing["project"]["targets"].as_array().unwrap();
1932        for expected in [
1933            "FerryApp",
1934            "FerryRuntimeBridge",
1935            "FerryActivityModel",
1936            "FerryLiveActivityExtension",
1937        ] {
1938            assert!(targets.iter().any(|target| target == expected));
1939        }
1940
1941        let products = root.join("Products");
1942        let intermediates = root.join("Intermediates");
1943        let build = std::process::Command::new("/usr/bin/xcrun")
1944            .args(["xcodebuild", "-project"])
1945            .arg(root.join("FerryHost.xcodeproj"))
1946            .args([
1947                "-target",
1948                "FerryRuntimeBridge",
1949                "-target",
1950                "FerryLiveActivityExtension",
1951                "-configuration",
1952                "Debug",
1953                "-sdk",
1954                "iphonesimulator",
1955                "AD_HOC_CODE_SIGNING_ALLOWED=YES",
1956                "CODE_SIGN_IDENTITY=-",
1957                "CODE_SIGNING_ALLOWED=YES",
1958                "CODE_SIGNING_REQUIRED=YES",
1959                "ARCHS=arm64",
1960                "ONLY_ACTIVE_ARCH=NO",
1961            ])
1962            .arg(format!("SYMROOT={products}"))
1963            .arg(format!("OBJROOT={intermediates}"))
1964            .arg(format!("CONFIGURATION_BUILD_DIR={products}"))
1965            .arg("build")
1966            .current_dir(root)
1967            .output()
1968            .unwrap();
1969        assert!(
1970            build.status.success(),
1971            "xcodebuild failed: {}",
1972            String::from_utf8_lossy(&build.stderr)
1973        );
1974
1975        let executable =
1976            products.join("FerryLiveActivityExtension.appex/FerryLiveActivityExtension");
1977        assert!(executable.is_file());
1978        assert!(
1979            products
1980                .join("FerryActivityModel.framework/FerryActivityModel")
1981                .is_file()
1982        );
1983        let linked_libraries = |executable: &Utf8Path| {
1984            let output = std::process::Command::new("/usr/bin/xcrun")
1985                .args(["otool", "-L"])
1986                .arg(executable)
1987                .output()
1988                .unwrap();
1989            assert!(
1990                output.status.success(),
1991                "otool failed: {}",
1992                String::from_utf8_lossy(&output.stderr)
1993            );
1994            String::from_utf8(output.stdout).unwrap()
1995        };
1996        let dependencies = linked_libraries(&executable);
1997        assert!(dependencies.contains("@rpath/FerryActivityModel.framework/FerryActivityModel"));
1998        assert!(!dependencies.contains("FerryRuntimeBridge"));
1999        let runtime_dependencies =
2000            linked_libraries(&products.join("FerryRuntimeBridge.framework/FerryRuntimeBridge"));
2001        assert!(
2002            runtime_dependencies.contains("@rpath/FerryActivityModel.framework/FerryActivityModel")
2003        );
2004    }
2005
2006    #[test]
2007    fn emits_only_enabled_permission_purpose_strings() {
2008        let mut spec = starter();
2009        spec.config.permissions.camera.enabled = true;
2010        spec.config.permissions.camera.purpose = Some("Scan <labels> & receipts".into());
2011        let generated = generate_ios_project(&spec).unwrap();
2012        let plist = generated.text(Utf8Path::new("Info.plist")).unwrap();
2013        assert!(plist.contains("NSCameraUsageDescription"));
2014        assert!(plist.contains("Scan &lt;labels&gt; &amp; receipts"));
2015        assert!(!plist.contains("NSMicrophoneUsageDescription"));
2016        assert!(!plist.contains("NSLocationWhenInUseUsageDescription"));
2017    }
2018
2019    #[test]
2020    fn writes_only_relative_generated_files() {
2021        let mut generated = GeneratedAppleProject {
2022            files: BTreeMap::new(),
2023        };
2024        generated
2025            .files
2026            .insert(Utf8PathBuf::from("../escape"), Vec::new());
2027        let root = tempfile::tempdir().unwrap();
2028        let root = Utf8Path::from_path(root.path()).unwrap();
2029        assert!(matches!(
2030            write_ios_project(&generated, root),
2031            Err(AppleError::UnsafeGeneratedPath { .. })
2032        ));
2033    }
2034
2035    #[cfg(unix)]
2036    #[test]
2037    fn rejects_symlinked_generated_directories() {
2038        use std::os::unix::fs::symlink;
2039
2040        let temporary = tempfile::tempdir().unwrap();
2041        let temporary = Utf8Path::from_path(temporary.path()).unwrap();
2042        fs::create_dir(temporary.join("outside")).unwrap();
2043        fs::create_dir(temporary.join("generated")).unwrap();
2044        symlink(
2045            temporary.join("outside"),
2046            temporary.join("generated/FerryHost.xcodeproj"),
2047        )
2048        .unwrap();
2049        assert!(matches!(
2050            write_ios_project(
2051                &generate_ios_project(&starter()).unwrap(),
2052                &temporary.join("generated")
2053            ),
2054            Err(AppleError::UnsafeGeneratedPath { .. })
2055        ));
2056    }
2057}