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#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
17#[serde(rename_all = "kebab-case")]
18pub enum IosAssetPackaging {
19 #[default]
21 CompiledCatalog,
22 SdkOnlyResources,
24}
25
26#[derive(Clone, Debug, PartialEq)]
28pub struct IosProjectSpec {
29 pub config: FerryConfig,
31 pub binary_name: String,
33 pub assets: Option<ProjectAssets>,
35 pub asset_packaging: IosAssetPackaging,
37}
38
39#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
41#[serde(rename_all = "kebab-case")]
42pub enum IosProjectPlatform {
43 #[default]
45 Simulator,
46 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 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 #[must_use]
72 pub fn with_assets(mut self, assets: ProjectAssets) -> Self {
73 self.assets = Some(assets);
74 self
75 }
76
77 #[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#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
87pub struct GeneratedAppleProject {
88 pub files: BTreeMap<Utf8PathBuf, Vec<u8>>,
90}
91
92impl GeneratedAppleProject {
93 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#[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#[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
321pub 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('&', "&")
1565 .replace('<', "<")
1566 .replace('>', ">")
1567 .replace('"', """)
1568 .replace('\'', "'")
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(¤t)?;
1655 fs::create_dir_all(¤t)
1656 .map_err(|source| io_error("create generated iOS directory", ¤t, 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 & 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 <labels> & 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}