1use anyhow::{bail, Context, Result};
2use clap::ValueEnum;
3use serde::{Deserialize, Serialize};
4use std::collections::BTreeSet;
5use std::fs;
6use std::path::{Path, PathBuf};
7use toml_edit::{value, Array, DocumentMut, InlineTable, Item, Table, Value};
8
9const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION");
10const ANDROID_GRADLE_PLUGIN_VERSION: &str = "8.13.2";
11const DEFAULT_APP_ICON_PNG: &[u8] = include_bytes!("../assets/fission_logo.png");
12
13mod icons;
14mod splash;
15pub use icons::{copy_icon_for_bundle, normalized_extension, resolve_app_icon, ResolvedIcon};
16pub use splash::{SplashConfig, SplashResizeMode};
17
18#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, ValueEnum, Serialize, Deserialize)]
19#[serde(rename_all = "kebab-case")]
20pub enum Target {
21 Android,
22 Ios,
23 Linux,
24 Macos,
25 Server,
26 Site,
27 Web,
28 Windows,
29}
30
31#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, ValueEnum, Serialize, Deserialize)]
32#[serde(rename_all = "kebab-case")]
33pub enum PlatformCapability {
34 BarcodeScanner,
35 Biometric,
36 Bluetooth,
37 Camera,
38 Geolocation,
39 Haptics,
40 Microphone,
41 Nfc,
42 Notifications,
43 Passkeys,
44 VolumeControl,
45 Wifi,
46}
47
48impl PlatformCapability {
49 pub fn as_str(self) -> &'static str {
50 match self {
51 Self::BarcodeScanner => "barcode-scanner",
52 Self::Biometric => "biometric",
53 Self::Bluetooth => "bluetooth",
54 Self::Camera => "camera",
55 Self::Geolocation => "geolocation",
56 Self::Haptics => "haptics",
57 Self::Microphone => "microphone",
58 Self::Nfc => "nfc",
59 Self::Notifications => "notifications",
60 Self::Passkeys => "passkeys",
61 Self::VolumeControl => "volume-control",
62 Self::Wifi => "wifi",
63 }
64 }
65}
66
67impl Target {
68 pub fn as_str(self) -> &'static str {
69 match self {
70 Self::Android => "android",
71 Self::Ios => "ios",
72 Self::Linux => "linux",
73 Self::Macos => "macos",
74 Self::Server => "server",
75 Self::Site => "site",
76 Self::Web => "web",
77 Self::Windows => "windows",
78 }
79 }
80
81 pub fn scaffold_relative_path(self) -> &'static str {
82 match self {
83 Self::Android => "platforms/android/README.md",
84 Self::Ios => "platforms/ios/README.md",
85 Self::Linux => "platforms/linux/README.md",
86 Self::Macos => "platforms/macos/README.md",
87 Self::Server => "platforms/server/README.md",
88 Self::Site => "platforms/site/README.md",
89 Self::Web => "platforms/web/README.md",
90 Self::Windows => "platforms/windows/README.md",
91 }
92 }
93}
94
95#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
96pub enum DistributionProvider {
97 #[value(name = "app-store")]
98 AppStore,
99 #[value(name = "github-pages")]
100 GithubPages,
101 #[value(name = "github-releases")]
102 GithubReleases,
103 #[value(name = "cloudflare-pages")]
104 CloudflarePages,
105 #[value(name = "docker-registry")]
106 DockerRegistry,
107 Dropbox,
108 #[value(name = "google-drive")]
109 GoogleDrive,
110 #[value(name = "microsoft-store")]
111 MicrosoftStore,
112 Netlify,
113 #[value(name = "onedrive")]
114 OneDrive,
115 #[value(name = "play-store")]
116 PlayStore,
117 S3,
118}
119
120impl DistributionProvider {
121 pub fn as_str(self) -> &'static str {
122 match self {
123 Self::AppStore => "app-store",
124 Self::GithubPages => "github-pages",
125 Self::GithubReleases => "github-releases",
126 Self::CloudflarePages => "cloudflare-pages",
127 Self::DockerRegistry => "docker-registry",
128 Self::Dropbox => "dropbox",
129 Self::GoogleDrive => "google-drive",
130 Self::MicrosoftStore => "microsoft-store",
131 Self::Netlify => "netlify",
132 Self::OneDrive => "onedrive",
133 Self::PlayStore => "play-store",
134 Self::S3 => "s3",
135 }
136 }
137}
138
139#[derive(Debug, Serialize, Deserialize)]
140pub struct FissionProject {
141 pub app: AppConfig,
142 pub targets: BTreeSet<Target>,
143 #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
144 pub capabilities: BTreeSet<PlatformCapability>,
145 #[serde(default, skip_serializing_if = "NativeConfig::is_empty")]
146 pub native: NativeConfig,
147}
148
149#[derive(Debug, Serialize, Deserialize)]
150pub struct AppConfig {
151 pub name: String,
152 #[serde(alias = "identifier")]
153 pub app_id: String,
154 #[serde(default, skip_serializing_if = "Option::is_none")]
155 pub splash: Option<SplashConfig>,
156}
157
158#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
159pub struct NativeConfig {
160 #[serde(default, skip_serializing_if = "Vec::is_empty")]
161 pub modules: Vec<NativeModuleConfig>,
162}
163
164impl NativeConfig {
165 pub fn is_empty(&self) -> bool {
166 self.modules.is_empty()
167 }
168}
169
170#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
171pub struct NativeModuleConfig {
172 pub name: String,
173 #[serde(default, skip_serializing_if = "Option::is_none")]
174 pub path: Option<String>,
175 #[serde(default, skip_serializing_if = "NativeAndroidModuleConfig::is_empty")]
176 pub android: NativeAndroidModuleConfig,
177 #[serde(default, skip_serializing_if = "NativeIosModuleConfig::is_empty")]
178 pub ios: NativeIosModuleConfig,
179}
180
181#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
182pub struct NativeAndroidModuleConfig {
183 #[serde(default, skip_serializing_if = "Vec::is_empty")]
184 pub repositories: Vec<String>,
185 #[serde(default, skip_serializing_if = "Vec::is_empty")]
186 pub gradle_dependencies: Vec<String>,
187 #[serde(default, skip_serializing_if = "Vec::is_empty")]
188 pub source_dirs: Vec<String>,
189 #[serde(default, skip_serializing_if = "Vec::is_empty")]
190 pub permissions: Vec<String>,
191 #[serde(default, skip_serializing_if = "Vec::is_empty")]
192 pub manifest_application_entries: Vec<String>,
193}
194
195impl NativeAndroidModuleConfig {
196 pub fn is_empty(&self) -> bool {
197 self.repositories.is_empty()
198 && self.gradle_dependencies.is_empty()
199 && self.source_dirs.is_empty()
200 && self.permissions.is_empty()
201 && self.manifest_application_entries.is_empty()
202 }
203}
204
205#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
206pub struct NativeIosModuleConfig {
207 #[serde(default, skip_serializing_if = "Vec::is_empty")]
208 pub swift_packages: Vec<NativeIosSwiftPackageConfig>,
209 #[serde(default, skip_serializing_if = "Vec::is_empty")]
210 pub source_dirs: Vec<String>,
211 #[serde(default, skip_serializing_if = "Vec::is_empty")]
212 pub linked_frameworks: Vec<String>,
213}
214
215impl NativeIosModuleConfig {
216 pub fn is_empty(&self) -> bool {
217 self.swift_packages.is_empty()
218 && self.source_dirs.is_empty()
219 && self.linked_frameworks.is_empty()
220 }
221}
222
223#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
224pub struct NativeIosSwiftPackageConfig {
225 pub url: String,
226 pub product: String,
227 #[serde(default, skip_serializing_if = "Option::is_none")]
228 pub from: Option<String>,
229}
230
231#[derive(Debug, Deserialize)]
232struct CargoManifest {
233 package: Option<CargoPackage>,
234}
235
236#[derive(Debug, Deserialize)]
237struct CargoPackage {
238 pub name: String,
239}
240
241#[derive(Clone, Copy, Debug, Eq, PartialEq)]
242enum WritePolicy {
243 Overwrite,
244 PreserveExisting,
245}
246
247pub fn init_project(
248 root: &Path,
249 name: Option<String>,
250 app_id: Option<String>,
251 local_path: Option<PathBuf>,
252) -> Result<()> {
253 let existing_project = root.exists() && root.read_dir()?.next().is_some();
254 fs::create_dir_all(root.join("src"))?;
255
256 let write_policy = if existing_project {
257 WritePolicy::PreserveExisting
258 } else {
259 WritePolicy::Overwrite
260 };
261 let project = initial_project_config(root, name, app_id)?;
262
263 write_file_with_policy(
264 &root.join("Cargo.toml"),
265 &render_cargo_toml(&project, local_path.as_deref()),
266 write_policy,
267 )?;
268 write_file_with_policy(
269 &root.join("src/main.rs"),
270 &render_app_main(project.app.name.as_str()),
271 write_policy,
272 )?;
273 write_file_with_policy(&root.join("src/lib.rs"), APP_LIB, write_policy)?;
274 write_file_with_policy(&root.join("src/app.rs"), APP_RS, write_policy)?;
275 write_binary_file_with_policy(
276 &root.join("assets/app-icon.png"),
277 DEFAULT_APP_ICON_PNG,
278 write_policy,
279 )?;
280 write_file_with_policy(
281 &root.join("README.md"),
282 &render_project_readme(&project),
283 write_policy,
284 )?;
285 write_file_with_policy(
286 &root.join(".gitignore"),
287 "target/\nplatforms/*/build/\n",
288 write_policy,
289 )?;
290 write_project_config(root, &project)?;
291
292 let targets = project.targets.iter().copied().collect::<Vec<_>>();
293 for target in targets {
294 scaffold_target_with_policy(root, &project, target, write_policy)?;
295 }
296 sync_platform_config(root, &project)?;
297 sync_cargo_fission_dependency(root, &project, local_path.as_deref())?;
298
299 Ok(())
300}
301
302fn initial_project_config(
303 root: &Path,
304 name: Option<String>,
305 app_id: Option<String>,
306) -> Result<FissionProject> {
307 let existing = if root.join("fission.toml").exists() {
308 Some(read_project_config(root)?)
309 } else {
310 None
311 };
312 let cargo_name = cargo_package_name(root);
313 if let (Some(requested), Some(cargo_name)) = (&name, &cargo_name) {
314 let requested = normalize_crate_name(requested);
315 let cargo_name = normalize_crate_name(cargo_name);
316 if requested != cargo_name {
317 bail!(
318 "refusing to set app name `{requested}` for existing Cargo package `{cargo_name}`; rename the package in Cargo.toml first or omit --name"
319 );
320 }
321 }
322 let project_name = cargo_name
323 .or(name)
324 .or_else(|| existing.as_ref().map(|project| project.app.name.clone()))
325 .unwrap_or_else(|| {
326 root.file_name()
327 .and_then(|value| value.to_str())
328 .unwrap_or("fission-app")
329 .to_string()
330 });
331 let normalized_name = normalize_crate_name(&project_name);
332
333 let mut targets = existing
334 .as_ref()
335 .map(|project| project.targets.clone())
336 .unwrap_or_default();
337 targets.extend(detect_project_targets(root));
338 if targets.is_empty() {
339 targets.extend([Target::Windows, Target::Macos, Target::Linux]);
340 }
341
342 Ok(FissionProject {
343 app: AppConfig {
344 name: normalized_name.clone(),
345 app_id: app_id
346 .or_else(|| existing.as_ref().map(|project| project.app.app_id.clone()))
347 .unwrap_or_else(|| format!("com.example.{}", normalized_name.replace('-', "_"))),
348 splash: existing
349 .as_ref()
350 .and_then(|project| project.app.splash.clone()),
351 },
352 targets,
353 capabilities: existing
354 .as_ref()
355 .map(|project| project.capabilities.clone())
356 .unwrap_or_default(),
357 native: existing
358 .as_ref()
359 .map(|project| project.native.clone())
360 .unwrap_or_default(),
361 })
362}
363
364pub fn cargo_package_name(root: &Path) -> Option<String> {
365 let manifest = fs::read_to_string(root.join("Cargo.toml")).ok()?;
366 let manifest: CargoManifest = toml::from_str(&manifest).ok()?;
367 manifest.package.map(|package| package.name)
368}
369
370fn detect_project_targets(root: &Path) -> BTreeSet<Target> {
371 let mut targets = BTreeSet::new();
372 if root.join("src/main.rs").exists() || root.join("src/lib.rs").exists() {
373 targets.extend([Target::Windows, Target::Macos, Target::Linux]);
374 }
375 for (target, relative) in [
376 (Target::Android, "platforms/android"),
377 (Target::Ios, "platforms/ios"),
378 (Target::Linux, "platforms/linux"),
379 (Target::Macos, "platforms/macos"),
380 (Target::Server, "platforms/server"),
381 (Target::Site, "content"),
382 (Target::Web, "platforms/web"),
383 (Target::Windows, "platforms/windows"),
384 ] {
385 if root.join(relative).exists() {
386 targets.insert(target);
387 }
388 }
389 targets
390}
391
392pub fn add_targets(project_dir: &Path, targets: &[Target]) -> Result<()> {
393 if targets.is_empty() {
394 bail!("no targets provided");
395 }
396 let mut project = read_project_config(project_dir)?;
397 for target in targets {
398 let target_exists =
399 project.targets.contains(target) || target_scaffold_dir_exists(project_dir, *target);
400 project.targets.insert(*target);
401 let write_policy = if target_exists {
402 WritePolicy::PreserveExisting
403 } else {
404 WritePolicy::Overwrite
405 };
406 scaffold_target_with_policy(project_dir, &project, *target, write_policy)?;
407 }
408 sync_platform_config(project_dir, &project)?;
409 write_project_config(project_dir, &project)?;
410 update_cargo_fission_features(project_dir, &project)?;
411 write_file_with_policy(
412 &project_dir.join("README.md"),
413 &render_project_readme(&project),
414 WritePolicy::PreserveExisting,
415 )?;
416 Ok(())
417}
418
419pub fn add_capabilities(project_dir: &Path, capabilities: &[PlatformCapability]) -> Result<()> {
420 if capabilities.is_empty() {
421 bail!("no capabilities provided");
422 }
423 let mut project = read_project_config(project_dir)?;
424 for capability in capabilities {
425 project.capabilities.insert(*capability);
426 }
427 write_project_config(project_dir, &project)?;
428 sync_platform_config(project_dir, &project)?;
429 Ok(())
430}
431
432pub fn sync_platform_config(root: &Path, project: &FissionProject) -> Result<()> {
433 apply_platform_capability_config(root, project)?;
434 apply_native_module_config(root, project)?;
435 splash::apply_platform_splash_config(root, project)?;
436 icons::apply_platform_icon_config(root, project)?;
437 apply_mobile_run_script_hardening(root, project)?;
438 Ok(())
439}
440
441fn apply_native_module_config(root: &Path, project: &FissionProject) -> Result<()> {
442 if project.targets.contains(&Target::Android) {
443 write_file(
444 &root.join("platforms/android/native-modules.gradle"),
445 &render_android_native_modules_gradle(project),
446 )?;
447 apply_android_settings_gradle_hardening(root, project)?;
448 apply_android_native_manifest_entries(root, project)?;
449 }
450 if project.targets.contains(&Target::Ios) {
451 write_file(
452 &root.join("platforms/ios/NativeModules/Package.swift"),
453 &render_ios_native_modules_package(project),
454 )?;
455 write_file(
456 &root.join(
457 "platforms/ios/NativeModules/Sources/FissionNativeModules/FissionNativeCapabilities.swift",
458 ),
459 render_ios_native_capabilities_swift(),
460 )?;
461 sync_ios_native_module_sources(root, project)?;
462 }
463 Ok(())
464}
465
466fn apply_android_native_manifest_entries(root: &Path, project: &FissionProject) -> Result<()> {
467 let entries = render_android_native_application_entries(project);
468 if entries.trim().is_empty() {
469 return Ok(());
470 }
471 let path = root.join("platforms/android/AndroidManifest.xml");
472 if !path.exists() {
473 return Ok(());
474 }
475 let existing =
476 fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))?;
477 let missing = entries
478 .lines()
479 .filter(|entry| !entry.trim().is_empty() && !existing.contains(entry.trim()))
480 .collect::<Vec<_>>();
481 if missing.is_empty() {
482 return Ok(());
483 }
484
485 let insertion = format!("{}\n", missing.join("\n"));
486 let marker =
487 " <activity\n android:name=\"rs.fission.runtime.FissionActivity\"";
488 let updated = if let Some(index) = existing.find(marker) {
489 let mut updated = existing.clone();
490 updated.insert_str(index, &insertion);
491 updated
492 } else if let Some(index) = existing.find("</application>") {
493 let mut updated = existing.clone();
494 updated.insert_str(index, &insertion);
495 updated
496 } else {
497 existing
498 };
499
500 if updated != fs::read_to_string(&path)? {
501 fs::write(&path, updated).with_context(|| format!("failed to write {}", path.display()))?;
502 }
503 Ok(())
504}
505
506fn sync_ios_native_module_sources(root: &Path, project: &FissionProject) -> Result<()> {
507 let generated_root = root.join("platforms/ios/NativeModules/Sources/FissionNativeModules");
508 fs::create_dir_all(&generated_root)
509 .with_context(|| format!("failed to create {}", generated_root.display()))?;
510
511 for module in &project.native.modules {
512 let module_dir = generated_root.join(swift_module_source_dir_name(&module.name));
513 if module_dir.exists() {
514 fs::remove_dir_all(&module_dir)
515 .with_context(|| format!("failed to remove {}", module_dir.display()))?;
516 }
517 if module.ios.source_dirs.is_empty() {
518 continue;
519 }
520 fs::create_dir_all(&module_dir)
521 .with_context(|| format!("failed to create {}", module_dir.display()))?;
522 for source_dir in &module.ios.source_dirs {
523 let source_dir = source_dir.trim();
524 if source_dir.is_empty() {
525 continue;
526 }
527 let source = resolve_project_path(root, source_dir);
528 copy_dir_contents(&source, &module_dir).with_context(|| {
529 format!(
530 "failed to copy iOS native module source {} into {}",
531 source.display(),
532 module_dir.display()
533 )
534 })?;
535 }
536 }
537 Ok(())
538}
539
540fn resolve_project_path(root: &Path, value: &str) -> PathBuf {
541 let path = Path::new(value);
542 if path.is_absolute() {
543 path.to_path_buf()
544 } else {
545 root.join(path)
546 }
547}
548
549fn swift_module_source_dir_name(name: &str) -> String {
550 let mut output = String::new();
551 for ch in name.chars() {
552 if ch.is_ascii_alphanumeric() {
553 output.push(ch);
554 } else if !output.ends_with('_') {
555 output.push('_');
556 }
557 }
558 let output = output.trim_matches('_');
559 if output.is_empty() {
560 "module".to_string()
561 } else {
562 output.to_string()
563 }
564}
565
566fn copy_dir_contents(source: &Path, dest: &Path) -> Result<()> {
567 if source.is_file() {
568 let file_name = source
569 .file_name()
570 .ok_or_else(|| anyhow::anyhow!("source file has no file name"))?;
571 fs::create_dir_all(dest)?;
572 fs::copy(source, dest.join(file_name))?;
573 return Ok(());
574 }
575 fs::create_dir_all(dest)?;
576 for entry in fs::read_dir(source)
577 .with_context(|| format!("failed to read native source dir {}", source.display()))?
578 {
579 let entry = entry?;
580 let path = entry.path();
581 let target = dest.join(entry.file_name());
582 if path.is_dir() {
583 copy_dir_contents(&path, &target)?;
584 } else if path.is_file() {
585 fs::copy(&path, &target)
586 .with_context(|| format!("failed to copy {}", path.display()))?;
587 }
588 }
589 Ok(())
590}
591
592fn apply_mobile_run_script_hardening(root: &Path, project: &FissionProject) -> Result<()> {
593 if project.targets.contains(&Target::Ios) {
594 apply_ios_run_script_hardening(root)?;
595 apply_ios_package_script_hardening(root)?;
596 }
597 if project.targets.contains(&Target::Android) {
598 apply_android_run_script_hardening(root)?;
599 apply_android_package_script_hardening(root)?;
600 apply_android_manifest_hardening(root)?;
601 apply_android_root_build_gradle_hardening(root)?;
602 apply_android_app_build_gradle_hardening(root)?;
603 apply_android_gradle_properties_hardening(root)?;
604 }
605 Ok(())
606}
607
608fn apply_ios_run_script_hardening(root: &Path) -> Result<()> {
609 let path = root.join("platforms/ios/run-sim.sh");
610 if !path.exists() {
611 return Ok(());
612 }
613 let existing =
614 fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))?;
615 if existing.contains("IOS_SIM_UNINSTALL_BEFORE_INSTALL") {
616 return Ok(());
617 }
618 let marker = "xcrun simctl bootstatus \"$DEVICE_ID\" -b\n";
619 let insertion = "xcrun simctl bootstatus \"$DEVICE_ID\" -b\nif [[ \"${IOS_SIM_UNINSTALL_BEFORE_INSTALL:-1}\" == \"1\" ]]; then\n xcrun simctl uninstall \"$DEVICE_ID\" \"$BUNDLE_ID\" >/dev/null 2>&1 || true\nfi\n";
620 let updated = existing.replacen(marker, insertion, 1);
621 fs::write(&path, updated).with_context(|| format!("failed to write {}", path.display()))
622}
623
624fn apply_ios_package_script_hardening(root: &Path) -> Result<()> {
625 let path = root.join("platforms/ios/package-sim.sh");
626 if !path.exists() {
627 return Ok(());
628 }
629 let existing =
630 fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))?;
631 if !existing.contains("import plistlib") {
632 return Ok(());
633 }
634 let Some(start) = existing.find("python3 - <<'PY' \"$SCRIPT_DIR/Info.plist\"") else {
635 return Ok(());
636 };
637 let Some(relative_end) = existing[start..].find("\nPY") else {
638 return Ok(());
639 };
640 let end = start + relative_end + "\nPY\n".len();
641 let mut updated = existing;
642 updated.replace_range(start..end, IOS_INFO_PLIST_PLUTIL_PATCH);
643 fs::write(&path, updated).with_context(|| format!("failed to write {}", path.display()))
644}
645
646fn apply_android_run_script_hardening(root: &Path) -> Result<()> {
647 let path = root.join("platforms/android/run-emulator.sh");
648 if !path.exists() {
649 return Ok(());
650 }
651 let existing =
652 fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))?;
653 if existing.contains(":app:assemble") {
654 return Ok(());
655 }
656 let mut updated = existing.clone();
657 let wait_function = android_wait_for_boot_function();
658 if let Some(start) = updated.find("wait_for_android_boot() {") {
659 let marker = "\n}\n\nANDROID_EMULATOR_API_LEVEL=";
660 if let Some(relative_end) = updated[start..].find(marker) {
661 let end = start + relative_end + "\n}\n\n".len();
662 updated.replace_range(start..end, &format!("{wait_function}\n\n"));
663 }
664 } else {
665 updated = updated.replacen(
666 "\nANDROID_EMULATOR_API_LEVEL=",
667 &format!("\n{wait_function}\n\nANDROID_EMULATOR_API_LEVEL="),
668 1,
669 );
670 }
671 updated =
672 replace_android_boot_wait_after(updated, " disown || true\n", " wait_for_android_boot\n");
673 updated = replace_android_boot_wait_after(
674 updated,
675 " \"$EMULATOR_BIN\" \"${EMULATOR_ARGS[@]}\" >/tmp/fission-android-emulator.log 2>&1 &\n",
676 " wait_for_android_boot\n",
677 );
678 if !updated.contains(
679 "printf 'Using existing emulator %s\\n' \"$RUNNING_EMULATOR\"\n wait_for_android_boot\n",
680 ) {
681 updated = updated.replacen(
682 "printf 'Using existing emulator %s\\n' \"$RUNNING_EMULATOR\"\n",
683 "printf 'Using existing emulator %s\\n' \"$RUNNING_EMULATOR\"\n wait_for_android_boot\n",
684 1,
685 );
686 }
687 while updated.contains(" wait_for_android_boot\n wait_for_android_boot\n") {
688 updated = updated.replace(
689 " wait_for_android_boot\n wait_for_android_boot\n",
690 " wait_for_android_boot\n",
691 );
692 }
693 updated = updated.replace(
694 "\"$ADB\" install -r \"$APK\"",
695 "read -r -a ADB_INSTALL_FLAGS <<< \"${ADB_INSTALL_FLAGS:---no-streaming -r}\"\n\"$ADB\" install \"${ADB_INSTALL_FLAGS[@]}\" \"$APK\"",
696 );
697 if updated != existing {
698 fs::write(&path, updated).with_context(|| format!("failed to write {}", path.display()))?;
699 }
700 Ok(())
701}
702
703fn apply_android_package_script_hardening(root: &Path) -> Result<()> {
704 let path = root.join("platforms/android/package-apk.sh");
705 if !path.exists() {
706 return Ok(());
707 }
708 let existing =
709 fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))?;
710 let mut updated = existing.clone();
711 if updated.contains("import re\nimport sys\n") && !updated.contains("import pathlib\n") {
712 updated = updated.replace(
713 "import re\nimport sys\n",
714 "import pathlib\nimport re\nimport sys\n",
715 );
716 }
717 let has_code_line = r#"has_code = "true" if pathlib.Path(dest).with_name("apk-root").joinpath("classes.dex").exists() else "false"
718manifest = re.sub(r'android:hasCode="(?:true|false)"', f'android:hasCode="{has_code}"', manifest)
719"#;
720 if !updated.contains("android:hasCode=") || !updated.contains("with_name(\"apk-root\")") {
721 updated = updated.replace(
722 "manifest = re.sub(r'android:targetSdkVersion=\"\\d+\"', f'android:targetSdkVersion=\"{target_api}\"', manifest)\n",
723 &format!(
724 "manifest = re.sub(r'android:targetSdkVersion=\"\\d+\"', f'android:targetSdkVersion=\"{{target_api}}\"', manifest)\n{has_code_line}"
725 ),
726 );
727 }
728 if updated != existing {
729 fs::write(&path, updated).with_context(|| format!("failed to write {}", path.display()))?;
730 }
731 Ok(())
732}
733
734fn apply_android_manifest_hardening(root: &Path) -> Result<()> {
735 let path = root.join("platforms/android/AndroidManifest.xml");
736 if !path.exists() {
737 return Ok(());
738 }
739 let existing =
740 fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))?;
741 if existing.contains("rs.fission.runtime.FissionActivity") {
742 return Ok(());
743 }
744 let updated = existing.replace(r#"android:hasCode="true""#, r#"android:hasCode="false""#);
745 if updated != existing {
746 fs::write(&path, updated).with_context(|| format!("failed to write {}", path.display()))?;
747 }
748 Ok(())
749}
750
751fn apply_android_root_build_gradle_hardening(root: &Path) -> Result<()> {
752 let path = root.join("platforms/android/build.gradle.kts");
753 if !path.exists() {
754 return Ok(());
755 }
756 let existing =
757 fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))?;
758 let mut updated = String::new();
759 for line in existing.lines() {
760 if line
761 .trim_start()
762 .starts_with("id(\"com.android.application\") version ")
763 {
764 let indent = line
765 .chars()
766 .take_while(|ch| ch.is_whitespace())
767 .collect::<String>();
768 updated.push_str(&format!(
769 "{indent}id(\"com.android.application\") version \"{ANDROID_GRADLE_PLUGIN_VERSION}\" apply false\n"
770 ));
771 } else {
772 updated.push_str(line);
773 updated.push('\n');
774 }
775 }
776 if updated != existing {
777 fs::write(&path, updated).with_context(|| format!("failed to write {}", path.display()))?;
778 }
779 Ok(())
780}
781
782fn apply_android_app_build_gradle_hardening(root: &Path) -> Result<()> {
783 let path = root.join("platforms/android/app/build.gradle.kts");
784 if !path.exists() {
785 return Ok(());
786 }
787 let existing =
788 fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))?;
789 let mut updated = existing.replace("../native-modules.gradle.kts", "../native-modules.gradle");
790 if !updated.contains("../native-modules.gradle") {
791 updated.push_str("\napply(from = \"../native-modules.gradle\")\n");
792 }
793 if updated != existing {
794 fs::write(&path, updated).with_context(|| format!("failed to write {}", path.display()))?;
795 }
796 Ok(())
797}
798
799fn apply_android_gradle_properties_hardening(root: &Path) -> Result<()> {
800 let path = root.join("platforms/android/gradle.properties");
801 if !path.exists() {
802 return fs::write(&path, render_android_gradle_properties())
803 .with_context(|| format!("failed to write {}", path.display()));
804 }
805 let existing =
806 fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))?;
807 let mut saw_androidx = false;
808 let mut saw_jvmargs = false;
809 let mut saw_compile_warning = false;
810 let mut updated = String::new();
811 for line in existing.lines() {
812 let trimmed = line.trim_start();
813 if trimmed.starts_with("android.useAndroidX=") {
814 updated.push_str("android.useAndroidX=true\n");
815 saw_androidx = true;
816 } else if trimmed.starts_with("org.gradle.jvmargs=") {
817 updated.push_str(line);
818 updated.push('\n');
819 saw_jvmargs = true;
820 } else if trimmed.starts_with("android.javaCompile.suppressSourceTargetDeprecationWarning=")
821 {
822 updated.push_str(line);
823 updated.push('\n');
824 saw_compile_warning = true;
825 } else {
826 updated.push_str(line);
827 updated.push('\n');
828 }
829 }
830 if !saw_androidx {
831 if !updated.ends_with('\n') {
832 updated.push('\n');
833 }
834 updated.push_str("android.useAndroidX=true\n");
835 }
836 if !saw_jvmargs {
837 updated.push_str("org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8\n");
838 }
839 if !saw_compile_warning {
840 updated.push_str("android.javaCompile.suppressSourceTargetDeprecationWarning=true\n");
841 }
842 if updated != existing {
843 fs::write(&path, updated).with_context(|| format!("failed to write {}", path.display()))?;
844 }
845 Ok(())
846}
847
848fn apply_android_settings_gradle_hardening(root: &Path, project: &FissionProject) -> Result<()> {
849 let path = root.join("platforms/android/settings.gradle.kts");
850 if !path.exists() {
851 return Ok(());
852 }
853 let existing =
854 fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))?;
855 let missing = android_dependency_repositories(project)
856 .into_iter()
857 .filter(|repository| !existing.contains(repository))
858 .collect::<Vec<_>>();
859 if missing.is_empty() {
860 return Ok(());
861 }
862 let marker = " repositories {\n";
863 let Some(index) = existing.find(marker) else {
864 return Ok(());
865 };
866 let mut insertion = String::new();
867 for repository in missing {
868 insertion.push_str(" ");
869 insertion.push_str(&repository);
870 insertion.push('\n');
871 }
872 let mut updated = existing;
873 updated.insert_str(index + marker.len(), &insertion);
874 fs::write(&path, updated).with_context(|| format!("failed to write {}", path.display()))
875}
876
877fn android_wait_for_boot_function() -> &'static str {
878 r#"wait_for_android_boot() {
879 "$ADB" wait-for-device
880 until "$ADB" shell getprop sys.boot_completed 2>/dev/null | tr -d '\r' | grep -q '^1$'; do
881 sleep 1
882 done
883 local deadline=$((SECONDS + 180))
884 until "$ADB" shell cmd package list packages >/dev/null 2>&1; do
885 if (( SECONDS > deadline )); then
886 printf 'Android package manager did not become available. Restart the emulator with ANDROID_EMULATOR_RESTART=1 and try again.\n' >&2
887 exit 1
888 fi
889 sleep 1
890 done
891}"#
892}
893
894fn replace_android_boot_wait_after(mut text: String, marker: &str, replacement: &str) -> String {
895 let Some(start) = text.find(marker) else {
896 return text;
897 };
898 let wait_start = start + marker.len();
899 let old_wait = " \"$ADB\" wait-for-device\n until \"$ADB\" shell getprop sys.boot_completed 2>/dev/null | tr -d '\\r' | grep -q '^1$'; do\n sleep 1\n done\n";
900 if text[wait_start..].starts_with(old_wait) {
901 text.replace_range(wait_start..wait_start + old_wait.len(), replacement);
902 }
903 text
904}
905
906const IOS_INFO_PLIST_PLUTIL_PATCH: &str = r#"cp "$SCRIPT_DIR/Info.plist" "$BUNDLE_DIR/Info.plist"
907PLUTIL=$(xcrun --find plutil 2>/dev/null || command -v plutil || true)
908if [[ -z "$PLUTIL" ]]; then
909 printf 'plutil not found. Install Xcode command line tools to package the iOS simulator app.\n' >&2
910 exit 1
911fi
912"$PLUTIL" -replace CFBundleIdentifier -string "$BUNDLE_ID" "$BUNDLE_DIR/Info.plist"
913"$PLUTIL" -replace CFBundleDisplayName -string "$DISPLAY_NAME" "$BUNDLE_DIR/Info.plist"
914"$PLUTIL" -replace CFBundleName -string "$DISPLAY_NAME" "$BUNDLE_DIR/Info.plist"
915"$PLUTIL" -replace CFBundleExecutable -string "$EXECUTABLE_NAME" "$BUNDLE_DIR/Info.plist"
916"#;
917
918fn apply_platform_capability_config(root: &Path, project: &FissionProject) -> Result<()> {
919 if project.capabilities.is_empty() {
920 return Ok(());
921 }
922 if project.targets.contains(&Target::Android) {
923 ensure_android_capability_helper(root)?;
924 apply_android_capability_config(root, project)?;
925 }
926 if project.targets.contains(&Target::Ios) {
927 apply_ios_capability_config(root, project)?;
928 }
929 Ok(())
930}
931
932fn ensure_android_capability_helper(root: &Path) -> Result<()> {
933 write_file_with_policy(
934 &root.join("platforms/android/java/rs/fission/runtime/FissionAndroidCapabilities.java"),
935 render_android_capabilities_java(),
936 WritePolicy::PreserveExisting,
937 )
938}
939
940fn apply_android_capability_config(root: &Path, project: &FissionProject) -> Result<()> {
941 let path = root.join("platforms/android/AndroidManifest.xml");
942 if !path.exists() {
943 return Ok(());
944 }
945 let existing =
946 fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))?;
947 let mut capabilities = String::new();
948 if project.capabilities.contains(&PlatformCapability::Nfc)
949 && !existing.contains("android.permission.NFC")
950 {
951 capabilities.push_str(&render_android_nfc_manifest_entries());
952 }
953 if project
954 .capabilities
955 .contains(&PlatformCapability::Notifications)
956 && !existing.contains("android.permission.POST_NOTIFICATIONS")
957 {
958 capabilities.push_str(&render_android_notifications_manifest_entries());
959 }
960 if project
961 .capabilities
962 .contains(&PlatformCapability::Biometric)
963 && !existing.contains("android.permission.USE_BIOMETRIC")
964 {
965 capabilities.push_str(&render_android_biometric_manifest_entries());
966 }
967 if project
968 .capabilities
969 .contains(&PlatformCapability::Bluetooth)
970 {
971 capabilities.push_str(&render_missing_android_bluetooth_manifest_entries(
972 &existing,
973 ));
974 }
975 if project
976 .capabilities
977 .contains(&PlatformCapability::BarcodeScanner)
978 && !project.capabilities.contains(&PlatformCapability::Camera)
979 && !existing.contains("android.permission.CAMERA")
980 {
981 capabilities.push_str(&render_android_barcode_camera_manifest_entries());
982 }
983 if project.capabilities.contains(&PlatformCapability::Camera) {
984 capabilities.push_str(&render_missing_android_camera_manifest_entries(&existing));
985 }
986 if project
987 .capabilities
988 .contains(&PlatformCapability::Geolocation)
989 && !existing.contains("android.permission.ACCESS_FINE_LOCATION")
990 {
991 capabilities.push_str(&render_android_geolocation_manifest_entries());
992 }
993 if project.capabilities.contains(&PlatformCapability::Haptics)
994 && !existing.contains("android.permission.VIBRATE")
995 {
996 capabilities.push_str(&render_android_haptics_manifest_entries());
997 }
998 if project
999 .capabilities
1000 .contains(&PlatformCapability::Microphone)
1001 && !existing.contains("android.permission.RECORD_AUDIO")
1002 {
1003 capabilities.push_str(&render_android_microphone_manifest_entries());
1004 }
1005 if project.capabilities.contains(&PlatformCapability::Wifi) {
1006 capabilities.push_str(&render_missing_android_wifi_manifest_entries(&existing));
1007 }
1008 if project
1009 .capabilities
1010 .contains(&PlatformCapability::VolumeControl)
1011 && !existing.contains("android.permission.MODIFY_AUDIO_SETTINGS")
1012 {
1013 capabilities.push_str(&render_android_volume_manifest_entries());
1014 }
1015 if capabilities.is_empty() {
1016 return Ok(());
1017 }
1018 let marker = r#" <uses-permission android:name="android.permission.INTERNET" />"#;
1019 let updated = if existing.contains(marker) {
1020 existing.replacen(marker, &format!("{marker}\n{capabilities}"), 1)
1021 } else {
1022 existing.replacen("<uses-sdk", &format!("{capabilities}\n <uses-sdk"), 1)
1023 };
1024 fs::write(&path, updated).with_context(|| format!("failed to write {}", path.display()))
1025}
1026
1027fn apply_ios_capability_config(root: &Path, project: &FissionProject) -> Result<()> {
1028 let info_path = root.join("platforms/ios/Info.plist");
1029 if info_path.exists() {
1030 let existing = fs::read_to_string(&info_path)
1031 .with_context(|| format!("failed to read {}", info_path.display()))?;
1032 if project.capabilities.contains(&PlatformCapability::Nfc)
1033 && !existing.contains("NFCReaderUsageDescription")
1034 {
1035 let entry = " <key>NFCReaderUsageDescription</key>\n <string>This app uses NFC to scan nearby tags when you request it.</string>\n";
1036 let updated = existing.replacen("</dict>", &format!("{entry}</dict>"), 1);
1037 fs::write(&info_path, updated)
1038 .with_context(|| format!("failed to write {}", info_path.display()))?;
1039 }
1040 }
1041
1042 if project.capabilities.contains(&PlatformCapability::Nfc) {
1043 let entitlements_path = root.join("platforms/ios/Entitlements.plist");
1044 if entitlements_path.exists() {
1045 let existing = fs::read_to_string(&entitlements_path)
1046 .with_context(|| format!("failed to read {}", entitlements_path.display()))?;
1047 if !existing.contains("com.apple.developer.nfc.readersession.formats") {
1048 let entry = " <key>com.apple.developer.nfc.readersession.formats</key>\n <array>\n <string>NDEF</string>\n </array>\n";
1049 let updated = existing.replacen("</dict>", &format!("{entry}</dict>"), 1);
1050 fs::write(&entitlements_path, updated)
1051 .with_context(|| format!("failed to write {}", entitlements_path.display()))?;
1052 }
1053 } else {
1054 write_file_with_policy(
1055 &entitlements_path,
1056 IOS_NFC_ENTITLEMENTS_PLIST,
1057 WritePolicy::PreserveExisting,
1058 )?;
1059 }
1060 }
1061 if project
1062 .capabilities
1063 .contains(&PlatformCapability::Biometric)
1064 && info_path.exists()
1065 {
1066 let existing = fs::read_to_string(&info_path)
1067 .with_context(|| format!("failed to read {}", info_path.display()))?;
1068 if !existing.contains("NSFaceIDUsageDescription") {
1069 let entry = " <key>NSFaceIDUsageDescription</key>\n <string>This app uses biometrics to authenticate you when you request it.</string>\n";
1070 let updated = existing.replacen("</dict>", &format!("{entry}</dict>"), 1);
1071 fs::write(&info_path, updated)
1072 .with_context(|| format!("failed to write {}", info_path.display()))?;
1073 }
1074 }
1075 if project
1076 .capabilities
1077 .contains(&PlatformCapability::Bluetooth)
1078 && info_path.exists()
1079 {
1080 let existing = fs::read_to_string(&info_path)
1081 .with_context(|| format!("failed to read {}", info_path.display()))?;
1082 if !existing.contains("NSBluetoothAlwaysUsageDescription") {
1083 let entry = " <key>NSBluetoothAlwaysUsageDescription</key>\n <string>This app uses Bluetooth when you request nearby-device features.</string>\n";
1084 let updated = existing.replacen("</dict>", &format!("{entry}</dict>"), 1);
1085 fs::write(&info_path, updated)
1086 .with_context(|| format!("failed to write {}", info_path.display()))?;
1087 }
1088 }
1089 if project
1090 .capabilities
1091 .contains(&PlatformCapability::BarcodeScanner)
1092 && info_path.exists()
1093 {
1094 let existing = fs::read_to_string(&info_path)
1095 .with_context(|| format!("failed to read {}", info_path.display()))?;
1096 if !existing.contains("NSCameraUsageDescription") {
1097 let entry = " <key>NSCameraUsageDescription</key>\n <string>This app uses the camera to scan barcodes when you request it.</string>\n";
1098 let updated = existing.replacen("</dict>", &format!("{entry}</dict>"), 1);
1099 fs::write(&info_path, updated)
1100 .with_context(|| format!("failed to write {}", info_path.display()))?;
1101 }
1102 }
1103 if project.capabilities.contains(&PlatformCapability::Camera) && info_path.exists() {
1104 let existing = fs::read_to_string(&info_path)
1105 .with_context(|| format!("failed to read {}", info_path.display()))?;
1106 if !existing.contains("NSCameraUsageDescription") {
1107 let entry = " <key>NSCameraUsageDescription</key>\n <string>This app uses the camera when you request camera features.</string>\n";
1108 let updated = existing.replacen("</dict>", &format!("{entry}</dict>"), 1);
1109 fs::write(&info_path, updated)
1110 .with_context(|| format!("failed to write {}", info_path.display()))?;
1111 }
1112 }
1113 if project
1114 .capabilities
1115 .contains(&PlatformCapability::Geolocation)
1116 && info_path.exists()
1117 {
1118 let existing = fs::read_to_string(&info_path)
1119 .with_context(|| format!("failed to read {}", info_path.display()))?;
1120 if !existing.contains("NSLocationWhenInUseUsageDescription") {
1121 let entry = " <key>NSLocationWhenInUseUsageDescription</key>\n <string>This app uses your location when you request location-aware features.</string>\n";
1122 let updated = existing.replacen("</dict>", &format!("{entry}</dict>"), 1);
1123 fs::write(&info_path, updated)
1124 .with_context(|| format!("failed to write {}", info_path.display()))?;
1125 }
1126 }
1127 if project
1128 .capabilities
1129 .contains(&PlatformCapability::Microphone)
1130 && info_path.exists()
1131 {
1132 let existing = fs::read_to_string(&info_path)
1133 .with_context(|| format!("failed to read {}", info_path.display()))?;
1134 if !existing.contains("NSMicrophoneUsageDescription") {
1135 let entry = " <key>NSMicrophoneUsageDescription</key>\n <string>This app uses the microphone when you request audio capture.</string>\n";
1136 let updated = existing.replacen("</dict>", &format!("{entry}</dict>"), 1);
1137 fs::write(&info_path, updated)
1138 .with_context(|| format!("failed to write {}", info_path.display()))?;
1139 }
1140 }
1141 if project.capabilities.contains(&PlatformCapability::Wifi) && info_path.exists() {
1142 let existing = fs::read_to_string(&info_path)
1143 .with_context(|| format!("failed to read {}", info_path.display()))?;
1144 if !existing.contains("NSLocationWhenInUseUsageDescription") {
1145 let entry = " <key>NSLocationWhenInUseUsageDescription</key>\n <string>This app uses location permission where the platform requires it for Wi-Fi information.</string>\n";
1146 let updated = existing.replacen("</dict>", &format!("{entry}</dict>"), 1);
1147 fs::write(&info_path, updated)
1148 .with_context(|| format!("failed to write {}", info_path.display()))?;
1149 }
1150 }
1151 if project.capabilities.contains(&PlatformCapability::Wifi) {
1152 let entitlements_path = root.join("platforms/ios/Entitlements.plist");
1153 apply_ios_wifi_entitlements(&entitlements_path)?;
1154 }
1155 Ok(())
1156}
1157
1158fn apply_ios_wifi_entitlements(path: &Path) -> Result<()> {
1159 if path.exists() {
1160 let existing = fs::read_to_string(path)
1161 .with_context(|| format!("failed to read {}", path.display()))?;
1162 let mut entry = String::new();
1163 if !existing.contains("com.apple.developer.networking.wifi-info") {
1164 entry.push_str(" <key>com.apple.developer.networking.wifi-info</key>\n <true/>\n");
1165 }
1166 if !existing.contains("com.apple.developer.networking.HotspotConfiguration") {
1167 entry.push_str(
1168 " <key>com.apple.developer.networking.HotspotConfiguration</key>\n <true/>\n",
1169 );
1170 }
1171 if entry.is_empty() {
1172 return Ok(());
1173 }
1174 let updated = existing.replacen("</dict>", &format!("{entry}</dict>"), 1);
1175 fs::write(path, updated).with_context(|| format!("failed to write {}", path.display()))?;
1176 return Ok(());
1177 }
1178 write_file_with_policy(
1179 path,
1180 IOS_WIFI_ENTITLEMENTS_PLIST,
1181 WritePolicy::PreserveExisting,
1182 )
1183}
1184
1185fn target_scaffold_dir_exists(project_dir: &Path, target: Target) -> bool {
1186 Path::new(target.scaffold_relative_path())
1187 .parent()
1188 .is_some_and(|relative| project_dir.join(relative).exists())
1189}
1190
1191fn write_project_config(root: &Path, project: &FissionProject) -> Result<()> {
1192 let path = root.join("fission.toml");
1193 if path.exists() {
1194 let existing = fs::read_to_string(&path)
1195 .with_context(|| format!("failed to read {}", path.display()))?;
1196 let mut doc = existing
1197 .parse::<DocumentMut>()
1198 .with_context(|| format!("failed to parse {}", path.display()))?;
1199 update_project_config_document(&mut doc, project);
1200 write_file(&path, &doc.to_string())?;
1201 return Ok(());
1202 }
1203 let data = toml::to_string_pretty(project)?;
1204 write_file(&path, &(data + "\n"))
1205}
1206
1207fn update_project_config_document(doc: &mut DocumentMut, project: &FissionProject) {
1208 doc["targets"] = value(string_array(
1209 project.targets.iter().map(|target| target.as_str()),
1210 ));
1211 if project.capabilities.is_empty() {
1212 doc.as_table_mut().remove("capabilities");
1213 } else {
1214 doc["capabilities"] = value(string_array(
1215 project
1216 .capabilities
1217 .iter()
1218 .map(|capability| capability.as_str()),
1219 ));
1220 }
1221
1222 if !doc["app"].is_table() {
1223 doc["app"] = Item::Table(Table::new());
1224 }
1225 doc["app"]["name"] = value(project.app.name.clone());
1226 doc["app"]["app_id"] = value(project.app.app_id.clone());
1227 if let Some(splash) = &project.app.splash {
1228 if !doc["app"]["splash"].is_table() {
1229 doc["app"]["splash"] = Item::Table(Table::new());
1230 }
1231 let splash_item = &mut doc["app"]["splash"];
1232 if let Some(background_color) = &splash.background_color {
1233 splash_item["background_color"] = value(background_color.clone());
1234 }
1235 if let Some(image) = &splash.image {
1236 splash_item["image"] = value(image.clone());
1237 }
1238 if let Some(resize_mode) = splash.resize_mode {
1239 splash_item["resize_mode"] = value(match resize_mode {
1240 SplashResizeMode::Center => "center",
1241 SplashResizeMode::Contain => "contain",
1242 SplashResizeMode::Cover => "cover",
1243 });
1244 }
1245 if let Some(animated_icon) = &splash.android_animated_icon {
1246 splash_item["android_animated_icon"] = value(animated_icon.clone());
1247 }
1248 if let Some(duration) = splash.android_animation_duration_ms {
1249 splash_item["android_animation_duration_ms"] = value(i64::from(duration));
1250 }
1251 } else if let Some(app) = doc["app"].as_table_like_mut() {
1252 app.remove("splash");
1253 }
1254}
1255
1256fn string_array<'a>(values: impl Iterator<Item = &'a str>) -> Array {
1257 let mut array = Array::new();
1258 for value in values {
1259 let mut value = Value::from(value);
1260 value.decor_mut().set_prefix("\n ");
1261 array.push_formatted(value);
1262 }
1263 array.set_trailing("\n");
1264 array.set_trailing_comma(true);
1265 array
1266}
1267
1268pub fn read_project_config(root: &Path) -> Result<FissionProject> {
1269 let path = root.join("fission.toml");
1270 let data = fs::read_to_string(&path).with_context(|| {
1271 format!(
1272 "failed to read {}; run `fission init {}` to register this project without overwriting existing files",
1273 path.display(),
1274 root.display()
1275 )
1276 })?;
1277 toml::from_str(&data).with_context(|| format!("failed to parse {}", path.display()))
1278}
1279
1280fn update_cargo_fission_features(root: &Path, project: &FissionProject) -> Result<()> {
1281 sync_cargo_fission_dependency(root, project, None)
1282}
1283
1284fn sync_cargo_fission_dependency(
1285 root: &Path,
1286 project: &FissionProject,
1287 local_path: Option<&Path>,
1288) -> Result<()> {
1289 let path = root.join("Cargo.toml");
1290 let Ok(text) = fs::read_to_string(&path) else {
1291 return Ok(());
1292 };
1293
1294 let mut doc = text
1295 .parse::<DocumentMut>()
1296 .with_context(|| format!("failed to parse {}", path.display()))?;
1297 let features = fission_features_for_targets(&project.targets);
1298 let mut changed = false;
1299
1300 if !doc.get("dependencies").is_some_and(Item::is_table_like) {
1301 doc["dependencies"] = Item::Table(Table::new());
1302 changed = true;
1303 }
1304
1305 let use_workspace_fission = local_path.is_none()
1306 && workspace_has_fission_dependency(&doc)
1307 && doc
1308 .get("dependencies")
1309 .and_then(Item::as_table_like)
1310 .is_none_or(|dependencies| !dependencies.contains_key("fission"));
1311 let deps = doc["dependencies"]
1312 .as_table_like_mut()
1313 .expect("dependencies table was just created");
1314 let dep = deps.entry("fission").or_insert(Item::None);
1315 changed |= sync_fission_dependency_item(dep, &features, local_path, use_workspace_fission)?;
1316
1317 if changed {
1318 fs::write(&path, doc.to_string())
1319 .with_context(|| format!("failed to update {}", path.display()))?;
1320 }
1321 Ok(())
1322}
1323
1324fn workspace_has_fission_dependency(doc: &DocumentMut) -> bool {
1325 doc.get("workspace")
1326 .and_then(Item::as_table_like)
1327 .and_then(|workspace| workspace.get("dependencies"))
1328 .and_then(Item::as_table_like)
1329 .is_some_and(|dependencies| dependencies.contains_key("fission"))
1330}
1331
1332fn sync_fission_dependency_item(
1333 item: &mut Item,
1334 features: &[&'static str],
1335 local_path: Option<&Path>,
1336 use_workspace_fission: bool,
1337) -> Result<bool> {
1338 match item {
1339 Item::None => {
1340 *item = Item::Value(Value::InlineTable(new_fission_dependency_table(
1341 features,
1342 local_path,
1343 use_workspace_fission,
1344 )));
1345 Ok(true)
1346 }
1347 Item::Value(Value::String(version)) => {
1348 let mut table = InlineTable::new();
1349 table.insert("version", Value::String(version.clone()));
1350 sync_fission_inline_table(&mut table, features, local_path, use_workspace_fission);
1351 *item = Item::Value(Value::InlineTable(table));
1352 Ok(true)
1353 }
1354 Item::Value(Value::InlineTable(table)) => Ok(sync_fission_inline_table(
1355 table,
1356 features,
1357 local_path,
1358 use_workspace_fission,
1359 )),
1360 Item::Table(table) => Ok(sync_fission_table(
1361 table,
1362 features,
1363 local_path,
1364 use_workspace_fission,
1365 )),
1366 _ => bail!("unsupported fission dependency format in Cargo.toml"),
1367 }
1368}
1369
1370fn new_fission_dependency_table(
1371 features: &[&'static str],
1372 local_path: Option<&Path>,
1373 use_workspace_fission: bool,
1374) -> InlineTable {
1375 let mut table = InlineTable::new();
1376 if let Some(root) = local_path {
1377 table.insert(
1378 "path",
1379 Value::from(
1380 root.join("crates/authoring/fission")
1381 .to_string_lossy()
1382 .to_string(),
1383 ),
1384 );
1385 } else if use_workspace_fission {
1386 table.insert("workspace", Value::from(true));
1387 } else {
1388 table.insert("version", Value::from(CURRENT_VERSION));
1389 }
1390 table.insert("default-features", Value::from(false));
1391 table.insert("features", cargo_feature_array_value(features));
1392 table
1393}
1394
1395fn sync_fission_inline_table(
1396 table: &mut InlineTable,
1397 features: &[&'static str],
1398 local_path: Option<&Path>,
1399 use_workspace_fission: bool,
1400) -> bool {
1401 let before = table.to_string();
1402 if let Some(root) = local_path {
1403 table.insert(
1404 "path",
1405 Value::from(
1406 root.join("crates/authoring/fission")
1407 .to_string_lossy()
1408 .to_string(),
1409 ),
1410 );
1411 table.remove("version");
1412 table.remove("workspace");
1413 } else if use_workspace_fission
1414 && !table.contains_key("path")
1415 && !table.contains_key("version")
1416 && !table.contains_key("git")
1417 {
1418 table.insert("workspace", Value::from(true));
1419 } else if !table.contains_key("path")
1420 && !table.contains_key("version")
1421 && !table.contains_key("workspace")
1422 && !table.contains_key("git")
1423 {
1424 table.insert("version", Value::from(CURRENT_VERSION));
1425 }
1426 table.insert("default-features", Value::from(false));
1427 table.insert("features", cargo_feature_array_value(features));
1428 table.to_string() != before
1429}
1430
1431fn sync_fission_table(
1432 table: &mut Table,
1433 features: &[&'static str],
1434 local_path: Option<&Path>,
1435 use_workspace_fission: bool,
1436) -> bool {
1437 let before = table.to_string();
1438 if let Some(root) = local_path {
1439 table["path"] = value(
1440 root.join("crates/authoring/fission")
1441 .to_string_lossy()
1442 .to_string(),
1443 );
1444 table.remove("version");
1445 table.remove("workspace");
1446 } else if use_workspace_fission
1447 && !table.contains_key("path")
1448 && !table.contains_key("version")
1449 && !table.contains_key("git")
1450 {
1451 table["workspace"] = value(true);
1452 } else if !table.contains_key("path")
1453 && !table.contains_key("version")
1454 && !table.contains_key("workspace")
1455 && !table.contains_key("git")
1456 {
1457 table["version"] = value(CURRENT_VERSION);
1458 }
1459 table["default-features"] = value(false);
1460 table["features"] = Item::Value(cargo_feature_array_value(features));
1461 table.to_string() != before
1462}
1463
1464fn cargo_feature_array_value(features: &[&'static str]) -> Value {
1465 let mut array = Array::new();
1466 for feature in features {
1467 array.push(*feature);
1468 }
1469 Value::Array(array)
1470}
1471
1472fn scaffold_target_with_policy(
1473 root: &Path,
1474 project: &FissionProject,
1475 target: Target,
1476 write_policy: WritePolicy,
1477) -> Result<()> {
1478 let relative = Path::new(target.scaffold_relative_path());
1479 let text = match target {
1480 Target::Android => {
1481 scaffold_android_bundle(root, project, write_policy)?;
1482 platform_readme(
1483 "Android",
1484 "Runnable emulator target. The CLI generates a Gradle Android project shell plus scripts that build, install, and launch the Fission app on an Android emulator.",
1485 &[
1486 "Install the Rust target: `rustup target add aarch64-linux-android`.",
1487 "Run `fission doctor android --project-dir .` to check SDK, NDK, emulator, and Rust target setup.",
1488 "Run `fission devices --project-dir .` to list connected Android devices and configured emulators.",
1489 "Run `fission run --target android --project-dir .` to build, install, launch, and attach to logs.",
1490 "Run `fission run --target android --device <adb-serial> --project-dir .` to launch on a specific device.",
1491 "Run `fission test --target android --project-dir .` for an emulator launch plus test-control health check.",
1492 "Run `./platforms/android/run-emulator.sh` from the project root to build, package, install, and launch the app on the configured emulator.",
1493 "Override `ANDROID_HOME`, `ANDROID_NDK`, `ANDROID_MIN_API_LEVEL`, `ANDROID_TARGET_API_LEVEL`, `ANDROID_AVD_NAME`, or `ANDROID_SYSTEM_IMAGE` if your local SDK setup differs.",
1494 "Set `ANDROID_EMULATOR_HEADLESS=1` for background/CI runs, or `ANDROID_EMULATOR_RESTART=1` to relaunch a hidden emulator visibly.",
1495 "The generated package uses `assets/app-icon.png` as its default launcher icon.",
1496 "Configure `[app.splash]` in `fission.toml` to generate the native Android launch theme, splash background, static image, and optional Android animated drawable.",
1497 "Run `fission add-capability nfc --project-dir .` to add NFC manifest permission and feature declarations.",
1498 "Run `fission add-capability notifications --project-dir .` to add Android notification permission for API 33 and newer.",
1499 "Run `fission add-capability biometric --project-dir .` to add biometric manifest permissions.",
1500 "Run `fission add-capability passkeys --project-dir .` to record passkey/WebAuthn use. Android passkeys also require Digital Asset Links and host Credential Manager integration for production sign-in.",
1501 "Run `fission add-capability bluetooth --project-dir .` to add Bluetooth permissions and optional hardware feature declarations.",
1502 "Run `fission add-capability barcode-scanner --project-dir .` to add camera permission for barcode scanning.",
1503 "Run `fission add-capability camera --project-dir .` to add camera permission and optional camera/flash hardware feature declarations.",
1504 "Run `fission add-capability geolocation --project-dir .` to add location permissions.",
1505 "Run `fission add-capability haptics --project-dir .` to add the vibration permission.",
1506 "Run `fission add-capability microphone --project-dir .` to add audio recording permission.",
1507 "Run `fission add-capability volume-control --project-dir .` to add Android audio settings permission.",
1508 "Run `fission add-capability wifi --project-dir .` to add Wi-Fi permissions and optional hardware feature declarations.",
1509 "Set `FISSION_TEST_CONTROL_PORT=<host-port>` before `run-emulator.sh`; the script forwards it to the fixed in-app device port.",
1510 ],
1511 )
1512 }
1513 Target::Ios => {
1514 scaffold_ios_bundle(root, project, write_policy)?;
1515 platform_readme(
1516 "iOS",
1517 "Simulator target. The CLI generates a simulator app bundle template plus shell scripts that build, install, launch, and smoke-test the Fission app with `simctl`.",
1518 &[
1519 "Install the Rust targets: `rustup target add aarch64-apple-ios aarch64-apple-ios-sim`.",
1520 "Run `fission doctor ios --project-dir .` to check Xcode, simulator, and Rust target setup.",
1521 "Confirm the simulator SDK path with `xcrun --sdk iphonesimulator --show-sdk-path`.",
1522 "Run `fission devices --project-dir .` to list available iOS simulators.",
1523 "Run `fission run --target ios --project-dir .` to build, install, launch, and attach to simulator logs.",
1524 "Run `fission run --target ios --device <simulator-udid> --project-dir .` to launch on a specific simulator.",
1525 "Run `fission test --target ios --project-dir .` for a simulator launch plus test-control health check.",
1526 "Run `./platforms/ios/run-sim.sh` from the project root to build, install, and launch the app on the first available iPhone simulator.",
1527 "The generated bundle uses `assets/app-icon.png` as its default app icon.",
1528 "Configure `[app.splash]` in `fission.toml` to generate the native iOS launch storyboard and splash image copied into the simulator bundle.",
1529 "Run `fission add-capability nfc --project-dir .` to add the NFC usage description and entitlements file.",
1530 "Run `fission add-capability notifications --project-dir .` to record local-notification use. iOS prompts at runtime and does not require an Info.plist usage key for local notifications.",
1531 "Run `fission add-capability biometric --project-dir .` to add the Face ID usage description.",
1532 "Run `fission add-capability passkeys --project-dir .` to record passkey/WebAuthn use. iOS production passkeys require associated domains such as `webcredentials:example.com` in the app entitlements.",
1533 "Run `fission add-capability bluetooth --project-dir .` to add the Bluetooth usage description.",
1534 "Run `fission add-capability barcode-scanner --project-dir .` to add the camera usage description for barcode scanning.",
1535 "Run `fission add-capability camera --project-dir .` to add the camera usage description.",
1536 "Run `fission add-capability geolocation --project-dir .` to add the location usage description.",
1537 "Run `fission add-capability microphone --project-dir .` to add the microphone usage description.",
1538 "Run `fission add-capability wifi --project-dir .` to add Wi-Fi entitlements and the location usage description required by current-network information APIs.",
1539 "Volume control does not require an iOS Info.plist key in the generated scaffold.",
1540 "Haptics do not require an iOS Info.plist key in the generated scaffold.",
1541 "Set `FISSION_TEST_CONTROL_PORT=<port>` before `run-sim.sh` to expose the in-app test control server on the host.",
1542 "Set `IOS_SIM_DEVICE_ID=<udid>` if you want a specific simulator device.",
1543 "Set `IOS_SIM_HEADLESS=1` for CI or background-only simulator runs; otherwise the script opens Simulator visibly.",
1544 ],
1545 )
1546 }
1547 Target::Web => {
1548 scaffold_web_bundle(root, project, write_policy)?;
1549 platform_readme(
1550 "Web",
1551 "Runnable browser target. The CLI generates a WASM host page plus helper scripts that build the app with `wasm-pack` and serve it locally.",
1552 &[
1553 "Install the Rust target: `rustup target add wasm32-unknown-unknown`.",
1554 "Install `wasm-pack` once: `cargo install wasm-pack`.",
1555 "Install Node.js 22+ so the smoke test can inspect Chrome/Chromium CDP runtime and console output.",
1556 "Run `fission doctor web --project-dir .` to check wasm-pack, generated JavaScript glue, Chrome/Chromium, and Rust target setup.",
1557 "Run `fission devices --project-dir .` to confirm Chrome/Chromium detection.",
1558 "Run `fission run --target web --project-dir .` to build, serve, open, and attach to the local server.",
1559 "Run `fission run --target web --detach --project-dir .` to keep the local server running in the background.",
1560 "Run `fission test --target web --project-dir .` for a headless Chrome/Chromium CDP smoke test.",
1561 "Run `./platforms/web/run-browser.sh` from the project root to build the wasm package and serve the app locally.",
1562 "Set `FISSION_WEB_PORT=<port>` or `FISSION_WEB_HOST=<host>` if the default `127.0.0.1:8123` does not suit your machine.",
1563 "Set `FISSION_WEB_OPEN=1` if you want the helper script to open a browser tab automatically.",
1564 "The generated page uses `assets/app-icon.png` as its default favicon/app icon seed.",
1565 ],
1566 )
1567 }
1568 Target::Server => platform_readme(
1569 "Server",
1570 "Server-rendered Fission target. The CLI runs the app through the server shell for dynamic HTML, revalidated pages, server jobs, signed actions, worker artifacts, and focused browser islands.",
1571 &[
1572 "Configure `[server].entry` in `fission.toml` so the CLI can invoke the server app.",
1573 "Run `fission server check --project-dir .` to render all declared server routes.",
1574 "Run `fission server serve --project-dir .` to serve the app locally.",
1575 "Run `fission server artifacts --project-dir .` to generate browser worker and island WASM shims.",
1576 "Run `fission package --target server --format docker-image --release --project-dir .` to package the server app as an OCI/Docker image.",
1577 ],
1578 ),
1579 Target::Site => {
1580 write_file_with_policy(
1581 &root.join("content/getting-started.md"),
1582 "---\ntitle: Site content\ndescription: Static site content rendered by the Fission static site shell.\n---\n\n# Site content\n\nAdd Markdown files under `content/`. `fission site build` renders them through real Fission widgets, lowers the nodes to Core IR, and emits static HTML.\n",
1583 write_policy,
1584 )?;
1585 platform_readme(
1586 "Static site",
1587 "Static multi-page website target. The site shell renders Markdown content through real Fission widgets, lowers nodes to Core IR, and emits semantic static HTML.",
1588 &[
1589 "Add Markdown or MDX content under `content/`.",
1590 "Run `fission site routes --project-dir .` to list generated routes.",
1591 "Run `fission site build --project-dir .` to render HTML into `target/fission/site`.",
1592 "Run `fission site serve --project-dir .` to build and serve the generated site locally.",
1593 "Unsupported interactive widgets fail during the static render instead of silently falling back to JavaScript.",
1594 ],
1595 )
1596 }
1597 Target::Linux | Target::Macos | Target::Windows => platform_readme(
1598 match target {
1599 Target::Linux => "Linux",
1600 Target::Macos => "macOS",
1601 Target::Windows => "Windows",
1602 _ => unreachable!(),
1603 },
1604 "Runnable target. Desktop platforms share the default `src/main.rs` entrypoint through `DesktopApp`.",
1605 &[
1606 "Run `fission run --project-dir .` from the project root to launch the desktop app and attach output.",
1607 "Run `fission build --project-dir . --release` for a release desktop build.",
1608 "Run `fission test --project-dir .` for the app crate's Rust tests.",
1609 "This target uses the default Vello desktop shell path.",
1610 ],
1611 ),
1612 };
1613 write_file_with_policy(&root.join(relative), &text, write_policy)
1614}
1615
1616fn scaffold_ios_bundle(
1617 root: &Path,
1618 project: &FissionProject,
1619 write_policy: WritePolicy,
1620) -> Result<()> {
1621 let executable = ios_executable_name(project);
1622 let bundle_name = ios_bundle_name(project);
1623 let plist = render_ios_plist(project, &executable);
1624 let package_script = render_ios_package_script(project, &bundle_name, &executable);
1625 let run_script = render_ios_run_script(project);
1626 let test_script = render_ios_test_script();
1627
1628 write_file_with_policy(&root.join("platforms/ios/Info.plist"), &plist, write_policy)?;
1629 write_file_with_policy(
1630 &root.join("platforms/ios/Package.swift"),
1631 &render_ios_host_package(project),
1632 write_policy,
1633 )?;
1634 write_file_with_policy(
1635 &root.join("platforms/ios/Sources/FissionHost/FissionNativeCapabilities.swift"),
1636 render_ios_host_native_capabilities_swift(),
1637 write_policy,
1638 )?;
1639 write_file_with_policy(
1640 &root.join("platforms/ios/NativeModules/README.md"),
1641 IOS_NATIVE_MODULES_README,
1642 write_policy,
1643 )?;
1644 write_file_with_policy(
1645 &root.join("platforms/ios/NativeModules/Package.swift"),
1646 &render_ios_native_modules_package(project),
1647 write_policy,
1648 )?;
1649 write_file_with_policy(
1650 &root.join(
1651 "platforms/ios/NativeModules/Sources/FissionNativeModules/FissionNativeCapabilities.swift",
1652 ),
1653 render_ios_native_capabilities_swift(),
1654 write_policy,
1655 )?;
1656 sync_ios_native_module_sources(root, project)?;
1657 if project.capabilities.contains(&PlatformCapability::Nfc)
1658 || project.capabilities.contains(&PlatformCapability::Wifi)
1659 {
1660 write_file_with_policy(
1661 &root.join("platforms/ios/Entitlements.plist"),
1662 &render_ios_entitlements_plist(project),
1663 write_policy,
1664 )?;
1665 }
1666 write_file_with_policy(
1667 &root.join("platforms/ios/package-sim.sh"),
1668 &package_script,
1669 write_policy,
1670 )?;
1671 write_file_with_policy(
1672 &root.join("platforms/ios/run-sim.sh"),
1673 &run_script,
1674 write_policy,
1675 )?;
1676 write_file_with_policy(
1677 &root.join("platforms/ios/test-sim.sh"),
1678 &test_script,
1679 write_policy,
1680 )?;
1681 #[cfg(unix)]
1682 {
1683 use std::os::unix::fs::PermissionsExt;
1684 for relative in [
1685 "platforms/ios/package-sim.sh",
1686 "platforms/ios/run-sim.sh",
1687 "platforms/ios/test-sim.sh",
1688 ] {
1689 let path = root.join(relative);
1690 if path.exists() {
1691 fs::set_permissions(path, fs::Permissions::from_mode(0o755))?;
1692 }
1693 }
1694 }
1695 Ok(())
1696}
1697
1698fn scaffold_android_bundle(
1699 root: &Path,
1700 project: &FissionProject,
1701 write_policy: WritePolicy,
1702) -> Result<()> {
1703 let manifest = render_android_manifest(project);
1704 let package_script = render_android_package_script(project);
1705 let run_script = render_android_run_script(project);
1706 let test_script = render_android_test_script();
1707
1708 write_file_with_policy(
1709 &root.join("platforms/android/settings.gradle.kts"),
1710 &render_android_settings_gradle(project),
1711 write_policy,
1712 )?;
1713 write_file_with_policy(
1714 &root.join("platforms/android/build.gradle.kts"),
1715 &render_android_root_build_gradle(),
1716 write_policy,
1717 )?;
1718 write_file_with_policy(
1719 &root.join("platforms/android/gradle.properties"),
1720 render_android_gradle_properties(),
1721 write_policy,
1722 )?;
1723 write_file_with_policy(
1724 &root.join("platforms/android/app/build.gradle.kts"),
1725 &render_android_app_build_gradle(project),
1726 write_policy,
1727 )?;
1728 write_file_with_policy(
1729 &root.join("platforms/android/native-modules.gradle"),
1730 &render_android_native_modules_gradle(project),
1731 write_policy,
1732 )?;
1733 write_file_with_policy(
1734 &root.join("platforms/android/AndroidManifest.xml"),
1735 &manifest,
1736 write_policy,
1737 )?;
1738 write_file_with_policy(
1739 &root.join("platforms/android/package-apk.sh"),
1740 &package_script,
1741 write_policy,
1742 )?;
1743 write_file_with_policy(
1744 &root.join("platforms/android/run-emulator.sh"),
1745 &run_script,
1746 write_policy,
1747 )?;
1748 write_file_with_policy(
1749 &root.join("platforms/android/test-emulator.sh"),
1750 &test_script,
1751 write_policy,
1752 )?;
1753 write_file_with_policy(
1754 &root.join("platforms/android/java/rs/fission/runtime/FissionActivity.java"),
1755 render_android_activity_java(),
1756 write_policy,
1757 )?;
1758 write_file_with_policy(
1759 &root.join("platforms/android/native-modules/README.md"),
1760 ANDROID_NATIVE_MODULES_README,
1761 write_policy,
1762 )?;
1763 #[cfg(unix)]
1764 {
1765 use std::os::unix::fs::PermissionsExt;
1766 for relative in [
1767 "platforms/android/package-apk.sh",
1768 "platforms/android/run-emulator.sh",
1769 "platforms/android/test-emulator.sh",
1770 ] {
1771 let path = root.join(relative);
1772 if path.exists() {
1773 fs::set_permissions(path, fs::Permissions::from_mode(0o755))?;
1774 }
1775 }
1776 }
1777 Ok(())
1778}
1779
1780fn scaffold_web_bundle(
1781 root: &Path,
1782 project: &FissionProject,
1783 write_policy: WritePolicy,
1784) -> Result<()> {
1785 let index_html = render_web_index(project);
1786 let bootstrap = render_web_bootstrap(project);
1787 let build_script = render_web_build_script();
1788 let run_script = render_web_run_script(project);
1789 let test_script = render_web_test_script(project);
1790
1791 write_file_with_policy(
1792 &root.join("platforms/web/index.html"),
1793 &index_html,
1794 write_policy,
1795 )?;
1796 write_file_with_policy(
1797 &root.join("platforms/web/bootstrap.mjs"),
1798 &bootstrap,
1799 write_policy,
1800 )?;
1801 write_file_with_policy(
1802 &root.join("platforms/web/build-wasm.sh"),
1803 &build_script,
1804 write_policy,
1805 )?;
1806 write_file_with_policy(
1807 &root.join("platforms/web/run-browser.sh"),
1808 &run_script,
1809 write_policy,
1810 )?;
1811 write_file_with_policy(
1812 &root.join("platforms/web/test-browser.sh"),
1813 &test_script,
1814 write_policy,
1815 )?;
1816
1817 #[cfg(unix)]
1818 {
1819 use std::os::unix::fs::PermissionsExt;
1820 for relative in [
1821 "platforms/web/build-wasm.sh",
1822 "platforms/web/run-browser.sh",
1823 "platforms/web/test-browser.sh",
1824 ] {
1825 let path = root.join(relative);
1826 if path.exists() {
1827 let mut perms = fs::metadata(&path)?.permissions();
1828 perms.set_mode(0o755);
1829 fs::set_permissions(path, perms)?;
1830 }
1831 }
1832 }
1833
1834 Ok(())
1835}
1836
1837pub(crate) fn write_file(path: &Path, contents: &str) -> Result<()> {
1838 write_file_with_policy(path, contents, WritePolicy::Overwrite)
1839}
1840
1841fn write_file_with_policy(path: &Path, contents: &str, write_policy: WritePolicy) -> Result<()> {
1842 if write_policy == WritePolicy::PreserveExisting && path.exists() {
1843 return Ok(());
1844 }
1845 if let Some(parent) = path.parent() {
1846 fs::create_dir_all(parent)?;
1847 }
1848 fs::write(path, contents).with_context(|| format!("failed to write {}", path.display()))
1849}
1850
1851fn write_binary_file_with_policy(
1852 path: &Path,
1853 contents: &[u8],
1854 write_policy: WritePolicy,
1855) -> Result<()> {
1856 if write_policy == WritePolicy::PreserveExisting && path.exists() {
1857 return Ok(());
1858 }
1859 if let Some(parent) = path.parent() {
1860 fs::create_dir_all(parent)?;
1861 }
1862 fs::write(path, contents).with_context(|| format!("failed to write {}", path.display()))
1863}
1864
1865fn render_cargo_toml(project: &FissionProject, local_path: Option<&Path>) -> String {
1866 let feature_list = render_fission_feature_list(&project.targets);
1867 let deps = if let Some(root) = local_path {
1868 let fission_path = root.join("crates/authoring/fission");
1869 format!(
1870 "fission = {{ path = {:?}, default-features = false, features = [{}] }}\n",
1871 fission_path.to_string_lossy().to_string(),
1872 feature_list
1873 )
1874 } else {
1875 format!(
1876 "fission = {{ version = \"{}\", default-features = false, features = [{}] }}\n",
1877 CURRENT_VERSION, feature_list
1878 )
1879 };
1880 let lib_name = project.app.name.replace('-', "_");
1881
1882 format!(
1883 "[package]\nname = \"{}\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n[lib]\nname = \"{}\"\ncrate-type = [\"cdylib\", \"rlib\"]\n\n[dependencies]\nanyhow = \"1\"\nserde = {{ version = \"1\", features = [\"derive\"] }}\n{}\n[target.'cfg(target_arch = \"wasm32\")'.dependencies]\nconsole_error_panic_hook = \"0.1\"\nwasm-bindgen = \"0.2\"\n",
1884 project.app.name, lib_name, deps
1885 )
1886}
1887
1888fn render_fission_feature_list(targets: &BTreeSet<Target>) -> String {
1889 fission_features_for_targets(targets)
1890 .into_iter()
1891 .map(|feature| format!("\"{feature}\""))
1892 .collect::<Vec<_>>()
1893 .join(", ")
1894}
1895
1896fn fission_features_for_targets(targets: &BTreeSet<Target>) -> Vec<&'static str> {
1897 let mut features = Vec::new();
1898 if targets
1899 .iter()
1900 .any(|target| matches!(target, Target::Linux | Target::Macos | Target::Windows))
1901 {
1902 features.push("desktop");
1903 }
1904 if targets.contains(&Target::Web) {
1905 features.push("web");
1906 }
1907 if targets.contains(&Target::Android) {
1908 features.push("android");
1909 }
1910 if targets.contains(&Target::Ios) {
1911 features.push("ios");
1912 }
1913 if targets.contains(&Target::Site) {
1914 features.push("site");
1915 }
1916 if targets.contains(&Target::Server) {
1917 features.push("server");
1918 }
1919 features
1920}
1921
1922fn render_project_readme(project: &FissionProject) -> String {
1923 let mut targets = String::new();
1924 for target in &project.targets {
1925 targets.push_str(&format!("- `{}`\n", target.as_str()));
1926 }
1927 format!(
1928 "# {}\n\nGenerated by `fission init`.\n\n## Targets\n\n{}\n## Commands\n\n- `fission doctor --project-dir .` -- check local SDKs, browsers, emulators, and Rust targets\n- `fission devices --project-dir .` -- list runnable desktop, browser, simulator, emulator, and device targets\n- `fission run --project-dir .` -- launch the desktop app and attach to output\n- `fission run --target web --project-dir .` -- launch the web app and attach to the local server\n- `fission run --target ios --project-dir .` -- build, install, launch, and attach to simulator logs\n- `fission run --target android --project-dir .` -- build, install, launch, and attach to Android logs\n- `fission run --target <target> --device <id> --detach --project-dir .` -- launch without attaching\n- `fission logs --target <target> --device <id> --project-dir . --follow` -- attach later where supported\n- `fission build --target <target> --project-dir . --release` -- build a target without launching it\n- `fission test --target <target> --project-dir .` -- run the generated platform smoke test\n- `fission add-target web ios android --project-dir .` -- scaffold more targets\n- `fission add-capability nfc notifications biometric passkeys bluetooth barcode-scanner camera geolocation haptics microphone volume-control wifi --project-dir .` -- declare host capabilities and update platform config where possible\n- `cat platforms/<target>/README.md` -- inspect target-specific prerequisites and environment variables\n\n## Assets\n\n- `assets/app-icon.png` is the default app icon seed copied from Fission's `docs/fission_logo.png`\n\n## Status\n\nDesktop, web, iOS simulator, and Android emulator workflows are runnable through `fission run`. The platform scripts remain checked in so CI and advanced users can call the lower-level build, run, and smoke-test steps directly when needed.\n",
1929 project.app.name, targets
1930 )
1931}
1932
1933fn platform_readme(title: &str, summary: &str, bullets: &[&str]) -> String {
1934 let mut out = format!("# {} target\n\n{}\n", title, summary);
1935 for bullet in bullets {
1936 out.push_str(&format!("\n- {}", bullet));
1937 }
1938 out.push('\n');
1939 out
1940}
1941
1942fn normalize_crate_name(name: &str) -> String {
1943 name.chars()
1944 .map(|ch| match ch {
1945 'A'..='Z' => ch.to_ascii_lowercase(),
1946 'a'..='z' | '0'..='9' => ch,
1947 _ => '-',
1948 })
1949 .collect::<String>()
1950 .trim_matches('-')
1951 .to_string()
1952}
1953
1954pub fn ios_executable_name(project: &FissionProject) -> String {
1955 project.app.name.replace('-', "_")
1956}
1957
1958fn ios_bundle_name(project: &FissionProject) -> String {
1959 let mut out = String::new();
1960 let mut uppercase_next = true;
1961 for ch in project.app.name.chars() {
1962 match ch {
1963 '-' | '_' | ' ' => uppercase_next = true,
1964 _ if uppercase_next => {
1965 out.extend(ch.to_uppercase());
1966 uppercase_next = false;
1967 }
1968 _ => out.push(ch),
1969 }
1970 }
1971 if out.is_empty() {
1972 "FissionApp".to_string()
1973 } else {
1974 out
1975 }
1976}
1977
1978fn android_library_name(project: &FissionProject) -> String {
1979 project.app.name.replace('-', "_")
1980}
1981
1982fn android_root_project_name(project: &FissionProject) -> String {
1983 project.app.name.replace('-', "_")
1984}
1985
1986fn render_android_settings_gradle(project: &FissionProject) -> String {
1987 let repositories = android_dependency_repositories(project)
1988 .into_iter()
1989 .map(|repository| format!(" {repository}\n"))
1990 .collect::<String>();
1991 format!(
1992 r#"pluginManagement {{
1993 repositories {{
1994 google()
1995 mavenCentral()
1996 gradlePluginPortal()
1997 }}
1998}}
1999
2000dependencyResolutionManagement {{
2001 repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
2002 repositories {{
2003{repositories}
2004 }}
2005}}
2006
2007rootProject.name = "{name}-android"
2008include(":app")
2009"#,
2010 name = android_root_project_name(project),
2011 )
2012}
2013
2014fn render_android_root_build_gradle() -> String {
2015 format!(
2016 r#"plugins {{
2017 id("com.android.application") version "{ANDROID_GRADLE_PLUGIN_VERSION}" apply false
2018}}
2019"#
2020 )
2021}
2022
2023fn render_android_gradle_properties() -> &'static str {
2024 "android.useAndroidX=true\norg.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8\nandroid.javaCompile.suppressSourceTargetDeprecationWarning=true\n"
2025}
2026
2027fn render_android_app_build_gradle(project: &FissionProject) -> String {
2028 format!(
2029 r#"plugins {{
2030 id("com.android.application")
2031}}
2032
2033android {{
2034 namespace = "{app_id}"
2035 compileSdk = (System.getenv("ANDROID_TARGET_API_LEVEL") ?: "35").toInt()
2036
2037 defaultConfig {{
2038 applicationId = "{app_id}"
2039 minSdk = (System.getenv("ANDROID_MIN_API_LEVEL") ?: "24").toInt()
2040 targetSdk = (System.getenv("ANDROID_TARGET_API_LEVEL") ?: "35").toInt()
2041 versionCode = 1
2042 versionName = "0.1.0"
2043 }}
2044
2045 sourceSets {{
2046 getByName("main") {{
2047 manifest.srcFile("../AndroidManifest.xml")
2048 java.srcDirs("../java")
2049 res.srcDirs("../res", "src/main/res")
2050 jniLibs.srcDirs("src/main/jniLibs")
2051 }}
2052 }}
2053}}
2054
2055apply(from = "../native-modules.gradle")
2056"#,
2057 app_id = project.app.app_id,
2058 )
2059}
2060
2061fn render_android_native_modules_gradle(project: &FissionProject) -> String {
2062 let mut dependencies = Vec::new();
2063 let mut source_dirs = Vec::new();
2064 for module in &project.native.modules {
2065 for dependency in &module.android.gradle_dependencies {
2066 if let Some(dependency) = normalize_gradle_dependency(dependency) {
2067 dependencies.push((module.name.as_str(), dependency));
2068 }
2069 }
2070 for source_dir in &module.android.source_dirs {
2071 let source_dir = source_dir.trim();
2072 if !source_dir.is_empty() {
2073 source_dirs.push((module.name.as_str(), source_dir.to_string()));
2074 }
2075 }
2076 }
2077
2078 let mut out = String::from(
2079 "// Generated by Fission. Native capability modules append Android SDK wiring here.\n",
2080 );
2081 if dependencies.is_empty() && source_dirs.is_empty() {
2082 out.push_str("// No Android native modules are configured in fission.toml.\n");
2083 return out;
2084 }
2085 if !source_dirs.is_empty() {
2086 out.push_str("\ndef fissionProjectDir = rootProject.projectDir.toPath().resolve('../..').normalize().toFile()\n");
2087 out.push_str("android {\n");
2088 out.push_str(" sourceSets {\n");
2089 out.push_str(" main {\n");
2090 for (module, source_dir) in &source_dirs {
2091 out.push_str(" // ");
2092 out.push_str(module);
2093 out.push('\n');
2094 out.push_str(" java.srcDir(new File(fissionProjectDir, ");
2095 out.push_str(&groovy_string_literal(source_dir));
2096 out.push_str("))\n");
2097 }
2098 out.push_str(" }\n");
2099 out.push_str(" }\n");
2100 out.push_str("}\n");
2101 }
2102 if !dependencies.is_empty() {
2103 out.push_str("\ndependencies {\n");
2104 for (module, dependency) in dependencies {
2105 out.push_str(" // ");
2106 out.push_str(module);
2107 out.push('\n');
2108 out.push_str(" ");
2109 out.push_str(&dependency);
2110 out.push('\n');
2111 }
2112 out.push_str("}\n");
2113 }
2114 out
2115}
2116
2117fn android_dependency_repositories(project: &FissionProject) -> BTreeSet<String> {
2118 let mut repositories = BTreeSet::new();
2119 repositories.insert("google()".to_string());
2120 repositories.insert("mavenCentral()".to_string());
2121 for module in &project.native.modules {
2122 for repository in &module.android.repositories {
2123 if let Some(repository) = normalize_gradle_repository(repository) {
2124 repositories.insert(repository);
2125 }
2126 }
2127 }
2128 repositories
2129}
2130
2131fn normalize_gradle_repository(value: &str) -> Option<String> {
2132 let value = value.trim();
2133 if value.is_empty() {
2134 return None;
2135 }
2136 match value {
2137 "google" | "google()" => Some("google()".to_string()),
2138 "mavenCentral" | "mavenCentral()" => Some("mavenCentral()".to_string()),
2139 "gradlePluginPortal" | "gradlePluginPortal()" => Some("gradlePluginPortal()".to_string()),
2140 _ if value.contains('(') => Some(value.to_string()),
2141 _ => Some(format!("maven(\"{value}\")")),
2142 }
2143}
2144
2145fn normalize_gradle_dependency(value: &str) -> Option<String> {
2146 let value = value.trim();
2147 if value.is_empty() {
2148 return None;
2149 }
2150 if let Some((configuration, dependency)) = split_gradle_dependency_invocation(value) {
2151 Some(format!("{configuration} {}", dependency.trim()))
2152 } else if value.contains('(') {
2153 Some(format!("implementation {value}"))
2154 } else {
2155 Some(format!("implementation {}", groovy_string_literal(value)))
2156 }
2157}
2158
2159fn split_gradle_dependency_invocation(value: &str) -> Option<(&str, &str)> {
2160 let open = value.find('(')?;
2161 if !value.ends_with(')') {
2162 return None;
2163 }
2164 let configuration = value[..open].trim();
2165 if !is_gradle_dependency_configuration(configuration) {
2166 return None;
2167 }
2168 let dependency = value[open + 1..value.len() - 1].trim();
2169 if dependency.is_empty() {
2170 return None;
2171 }
2172 Some((configuration, dependency))
2173}
2174
2175fn is_gradle_dependency_configuration(value: &str) -> bool {
2176 matches!(
2177 value,
2178 "implementation"
2179 | "api"
2180 | "compileOnly"
2181 | "runtimeOnly"
2182 | "testImplementation"
2183 | "testCompileOnly"
2184 | "testRuntimeOnly"
2185 | "androidTestImplementation"
2186 | "androidTestCompileOnly"
2187 | "androidTestRuntimeOnly"
2188 | "debugImplementation"
2189 | "debugCompileOnly"
2190 | "debugRuntimeOnly"
2191 | "releaseImplementation"
2192 | "releaseCompileOnly"
2193 | "releaseRuntimeOnly"
2194 | "kapt"
2195 | "ksp"
2196 )
2197}
2198
2199fn groovy_string_literal(value: &str) -> String {
2200 format!("'{}'", value.replace('\\', "\\\\").replace('\'', "\\'"))
2201}
2202
2203fn render_android_activity_java() -> &'static str {
2204 r#"package rs.fission.runtime;
2205
2206public final class FissionActivity extends android.app.NativeActivity {
2207}
2208"#
2209}
2210
2211const ANDROID_NATIVE_MODULES_README: &str = r#"# Android native modules
2212
2213This directory is reserved for native capability module sources copied or owned by the app shell.
2214
2215Generic dependency and repository wiring is generated into `../native-modules.gradle` from
2216`fission.toml` `[native]` module declarations. Fission does not ship payment, camera-addon,
2217scanner-addon, or other app-specific modules in core; those crates provide their native adapters.
2218"#;
2219
2220fn render_ios_host_package(project: &FissionProject) -> String {
2221 format!(
2222 r#"// swift-tools-version: 5.9
2223import PackageDescription
2224
2225let package = Package(
2226 name: "{name}FissionHost",
2227 platforms: [
2228 .iOS(.v16),
2229 ],
2230 products: [
2231 .library(name: "FissionHost", targets: ["FissionHost"]),
2232 ],
2233 dependencies: [
2234 .package(path: "NativeModules"),
2235 ],
2236 targets: [
2237 .target(
2238 name: "FissionHost",
2239 dependencies: [
2240 .product(name: "FissionNativeModules", package: "NativeModules"),
2241 ],
2242 path: "Sources/FissionHost"
2243 ),
2244 ]
2245)
2246"#,
2247 name = ios_bundle_name(project),
2248 )
2249}
2250
2251fn render_ios_native_modules_package(project: &FissionProject) -> String {
2252 let package_dependencies = project
2253 .native
2254 .modules
2255 .iter()
2256 .flat_map(|module| module.ios.swift_packages.iter())
2257 .map(render_ios_swift_package_dependency)
2258 .collect::<Vec<_>>();
2259 let target_dependencies = project
2260 .native
2261 .modules
2262 .iter()
2263 .flat_map(|module| module.ios.swift_packages.iter())
2264 .map(render_ios_swift_product_dependency)
2265 .collect::<Vec<_>>();
2266
2267 let dependencies = if package_dependencies.is_empty() {
2268 String::new()
2269 } else {
2270 format!(
2271 "\n {}\n ",
2272 package_dependencies.join(",\n ")
2273 )
2274 };
2275 let target_dependencies = if target_dependencies.is_empty() {
2276 String::new()
2277 } else {
2278 format!(
2279 "\n {}\n ",
2280 target_dependencies.join(",\n ")
2281 )
2282 };
2283
2284 format!(
2285 r#"// swift-tools-version: 5.9
2286import PackageDescription
2287
2288let package = Package(
2289 name: "NativeModules",
2290 platforms: [
2291 .iOS(.v16),
2292 ],
2293 products: [
2294 .library(name: "FissionNativeModules", targets: ["FissionNativeModules"]),
2295 ],
2296 dependencies: [{dependencies}],
2297 targets: [
2298 .target(
2299 name: "FissionNativeModules",
2300 dependencies: [{target_dependencies}],
2301 path: "Sources/FissionNativeModules"
2302 ),
2303 ]
2304)
2305"#
2306 )
2307}
2308
2309fn render_ios_swift_package_dependency(package: &NativeIosSwiftPackageConfig) -> String {
2310 let version = package
2311 .from
2312 .as_deref()
2313 .filter(|value| !value.trim().is_empty())
2314 .unwrap_or("0.0.0");
2315 format!(".package(url: {:?}, from: {:?})", package.url, version)
2316}
2317
2318fn render_ios_swift_product_dependency(package: &NativeIosSwiftPackageConfig) -> String {
2319 let package_name = package
2320 .url
2321 .trim_end_matches('/')
2322 .rsplit('/')
2323 .next()
2324 .unwrap_or(package.product.as_str())
2325 .trim_end_matches(".git");
2326 format!(
2327 ".product(name: {:?}, package: {:?})",
2328 package.product, package_name
2329 )
2330}
2331
2332fn render_ios_host_native_capabilities_swift() -> &'static str {
2333 r#"import Foundation
2334import FissionNativeModules
2335
2336public enum FissionHostNativeCapabilities {
2337 public static func present(name: String, requestID: UInt64, payload: Data, completion: @escaping (Result<Data, Error>) -> Void) -> Bool {
2338 FissionNativeCapabilityRegistry.shared.present(name: name, requestID: requestID, payload: payload, completion: completion)
2339 }
2340}
2341"#
2342}
2343
2344fn render_ios_native_capabilities_swift() -> &'static str {
2345 r#"import Foundation
2346
2347public protocol FissionNativeCapability {
2348 var name: String { get }
2349 func present(requestID: UInt64, payload: Data, completion: @escaping (Result<Data, Error>) -> Void)
2350}
2351
2352public final class FissionNativeCapabilityRegistry {
2353 public static let shared = FissionNativeCapabilityRegistry()
2354 private var capabilities: [String: FissionNativeCapability] = [:]
2355
2356 private init() {}
2357
2358 public func register(_ capability: FissionNativeCapability) {
2359 capabilities[capability.name] = capability
2360 }
2361
2362 public func present(name: String, requestID: UInt64, payload: Data, completion: @escaping (Result<Data, Error>) -> Void) -> Bool {
2363 guard let capability = capabilities[name] else {
2364 return false
2365 }
2366 capability.present(requestID: requestID, payload: payload, completion: completion)
2367 return true
2368 }
2369}
2370"#
2371}
2372
2373const IOS_NATIVE_MODULES_README: &str = r#"# iOS native modules
2374
2375This Swift package is the app-owned integration point for native capability modules.
2376
2377Fission generates `Package.swift` from `fission.toml` `[native]` module declarations. Capability
2378crates can provide Swift sources or package dependencies here without adding product-specific
2379logic to Fission itself.
2380"#;
2381
2382fn render_ios_plist(project: &FissionProject, executable: &str) -> String {
2383 let capability_entries = render_ios_info_plist_capability_entries(project);
2384 format!(
2385 r#"<?xml version="1.0" encoding="UTF-8"?>
2386<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
2387<plist version="1.0">
2388<dict>
2389 <key>CFBundleDevelopmentRegion</key>
2390 <string>en</string>
2391 <key>CFBundleDisplayName</key>
2392 <string>{display_name}</string>
2393 <key>CFBundleExecutable</key>
2394 <string>{executable}</string>
2395 <key>CFBundleIdentifier</key>
2396 <string>{bundle_id}</string>
2397 <key>CFBundleInfoDictionaryVersion</key>
2398 <string>6.0</string>
2399 <key>CFBundleName</key>
2400 <string>{display_name}</string>
2401 <key>CFBundlePackageType</key>
2402 <string>APPL</string>
2403 <key>CFBundleShortVersionString</key>
2404 <string>0.1.0</string>
2405 <key>CFBundleVersion</key>
2406 <string>1</string>
2407 <key>CFBundleIconFile</key>
2408 <string>AppIcon</string>
2409 <key>UILaunchStoryboardName</key>
2410 <string>LaunchScreen</string>
2411 <key>LSRequiresIPhoneOS</key>
2412 <true/>
2413 <key>MinimumOSVersion</key>
2414 <string>18.0</string>
2415{capability_entries}
2416 <key>UIDeviceFamily</key>
2417 <array>
2418 <integer>1</integer>
2419 <integer>2</integer>
2420 </array>
2421</dict>
2422</plist>
2423"#,
2424 display_name = ios_bundle_name(project),
2425 executable = executable,
2426 bundle_id = project.app.app_id,
2427 capability_entries = capability_entries,
2428 )
2429}
2430
2431fn render_ios_info_plist_capability_entries(project: &FissionProject) -> String {
2432 let mut out = String::new();
2433 if project.capabilities.contains(&PlatformCapability::Nfc) {
2434 out.push_str(" <key>NFCReaderUsageDescription</key>\n <string>This app uses NFC to scan nearby tags when you request it.</string>\n");
2435 }
2436 if project
2437 .capabilities
2438 .contains(&PlatformCapability::Biometric)
2439 {
2440 out.push_str(" <key>NSFaceIDUsageDescription</key>\n <string>This app uses biometrics to authenticate you when you request it.</string>\n");
2441 }
2442 if project
2443 .capabilities
2444 .contains(&PlatformCapability::Bluetooth)
2445 {
2446 out.push_str(" <key>NSBluetoothAlwaysUsageDescription</key>\n <string>This app uses Bluetooth when you request nearby-device features.</string>\n");
2447 }
2448 if project
2449 .capabilities
2450 .contains(&PlatformCapability::BarcodeScanner)
2451 {
2452 out.push_str(" <key>NSCameraUsageDescription</key>\n <string>This app uses the camera to scan barcodes when you request it.</string>\n");
2453 }
2454 if project.capabilities.contains(&PlatformCapability::Camera)
2455 && !project
2456 .capabilities
2457 .contains(&PlatformCapability::BarcodeScanner)
2458 {
2459 out.push_str(" <key>NSCameraUsageDescription</key>\n <string>This app uses the camera when you request camera features.</string>\n");
2460 }
2461 if project
2462 .capabilities
2463 .contains(&PlatformCapability::Geolocation)
2464 {
2465 out.push_str(" <key>NSLocationWhenInUseUsageDescription</key>\n <string>This app uses your location when you request location-aware features.</string>\n");
2466 }
2467 if project
2468 .capabilities
2469 .contains(&PlatformCapability::Microphone)
2470 {
2471 out.push_str(" <key>NSMicrophoneUsageDescription</key>\n <string>This app uses the microphone when you request audio capture.</string>\n");
2472 }
2473 if project.capabilities.contains(&PlatformCapability::Wifi)
2474 && !project
2475 .capabilities
2476 .contains(&PlatformCapability::Geolocation)
2477 {
2478 out.push_str(" <key>NSLocationWhenInUseUsageDescription</key>\n <string>This app uses location permission where the platform requires it for Wi-Fi information.</string>\n");
2479 }
2480 out
2481}
2482
2483fn render_ios_package_script(
2484 project: &FissionProject,
2485 bundle_name: &str,
2486 executable: &str,
2487) -> String {
2488 format!(
2489 r#"#!/usr/bin/env bash
2490set -euo pipefail
2491
2492SCRIPT_DIR=$(cd -- "$(dirname "${{BASH_SOURCE[0]}}")" && pwd)
2493PROJECT_DIR=$(cd -- "$SCRIPT_DIR/../.." && pwd)
2494TARGET="${{IOS_SIM_TARGET:-aarch64-apple-ios-sim}}"
2495PROFILE="${{IOS_SIM_PROFILE:-debug}}"
2496PACKAGE_NAME="{package_name}"
2497BUNDLE_ID="${{IOS_BUNDLE_ID:-{bundle_id}}}"
2498DISPLAY_NAME="${{IOS_DISPLAY_NAME:-{bundle_name}}}"
2499EXECUTABLE_NAME="${{IOS_EXECUTABLE_NAME:-{executable}}}"
2500BUNDLE_NAME="${{IOS_BUNDLE_NAME:-$DISPLAY_NAME.app}}"
2501BUILD_DIR="$SCRIPT_DIR/build/$PROFILE"
2502BUNDLE_DIR="$BUILD_DIR/$BUNDLE_NAME"
2503
2504BUILD_ARGS=(build --manifest-path "$PROJECT_DIR/Cargo.toml" --target "$TARGET" --package "$PACKAGE_NAME")
2505ARTIFACT_DIR=debug
2506if [[ "$PROFILE" == "release" ]]; then
2507 BUILD_ARGS+=(--release)
2508 ARTIFACT_DIR=release
2509fi
2510
2511cargo "${{BUILD_ARGS[@]}}"
2512TARGET_DIR=$(python3 - <<'PY' "$PROJECT_DIR/Cargo.toml"
2513import json
2514import subprocess
2515import sys
2516
2517manifest = sys.argv[1]
2518metadata = json.loads(
2519 subprocess.check_output(
2520 ["cargo", "metadata", "--manifest-path", manifest, "--format-version", "1", "--no-deps"]
2521 )
2522)
2523print(metadata["target_directory"])
2524PY
2525)
2526
2527rm -rf "$BUNDLE_DIR"
2528mkdir -p "$BUNDLE_DIR"
2529cp "$TARGET_DIR/$TARGET/$ARTIFACT_DIR/$PACKAGE_NAME" "$BUNDLE_DIR/$EXECUTABLE_NAME"
2530chmod +x "$BUNDLE_DIR/$EXECUTABLE_NAME"
2531{plist_patch}
2532shopt -s nullglob
2533PLATFORM_APP_ICONS=("$SCRIPT_DIR"/AppIcon.*)
2534if (( ${{#PLATFORM_APP_ICONS[@]}} == 0 )); then
2535 cp "$PROJECT_DIR/assets/app-icon.png" "$BUNDLE_DIR/AppIcon.png"
2536else
2537 app_icon="${{PLATFORM_APP_ICONS[0]}}"
2538 cp "$app_icon" "$BUNDLE_DIR/$(basename "$app_icon")"
2539fi
2540shopt -u nullglob
2541shopt -s nullglob
2542SPLASH_IMAGES=("$SCRIPT_DIR"/SplashImage.*)
2543if (( ${{#SPLASH_IMAGES[@]}} == 0 )); then
2544 cp "$PROJECT_DIR/assets/app-icon.png" "$BUNDLE_DIR/SplashImage.png"
2545else
2546 for splash_image in "${{SPLASH_IMAGES[@]}}"; do
2547 cp "$splash_image" "$BUNDLE_DIR/"
2548 done
2549fi
2550shopt -u nullglob
2551if [[ -f "$SCRIPT_DIR/LaunchScreen.storyboard" ]]; then
2552 IBTOOL=$(xcrun --find ibtool 2>/dev/null || true)
2553 if [[ -z "$IBTOOL" ]]; then
2554 printf 'ibtool not found. Install Xcode command line tools to compile the iOS launch screen storyboard.\n' >&2
2555 exit 1
2556 fi
2557 "$IBTOOL" \
2558 --errors \
2559 --warnings \
2560 --notices \
2561 --target-device iphone \
2562 --target-device ipad \
2563 --minimum-deployment-target 18.0 \
2564 --output-format human-readable-text \
2565 --compile "$BUNDLE_DIR/LaunchScreen.storyboardc" \
2566 "$SCRIPT_DIR/LaunchScreen.storyboard"
2567fi
2568printf 'APPL????' > "$BUNDLE_DIR/PkgInfo"
2569printf '%s\n' "$BUNDLE_DIR"
2570"#,
2571 package_name = project.app.name,
2572 bundle_id = project.app.app_id,
2573 bundle_name = bundle_name,
2574 executable = executable,
2575 plist_patch = IOS_INFO_PLIST_PLUTIL_PATCH,
2576 )
2577}
2578
2579fn render_ios_run_script(project: &FissionProject) -> String {
2580 format!(
2581 r#"#!/usr/bin/env bash
2582set -euo pipefail
2583
2584SCRIPT_DIR=$(cd -- "$(dirname "${{BASH_SOURCE[0]}}")" && pwd)
2585BUNDLE_DIR=$("$SCRIPT_DIR/package-sim.sh")
2586BUNDLE_ID="${{IOS_BUNDLE_ID:-{bundle_id}}}"
2587DEVICE_ID="${{IOS_SIM_DEVICE_ID:-}}"
2588
2589if [[ -z "$DEVICE_ID" ]]; then
2590 DEVICE_ID=$(python3 - <<'PY'
2591import json
2592import subprocess
2593payload = json.loads(subprocess.check_output(["xcrun", "simctl", "list", "devices", "available", "-j"]))
2594for runtime, devices in payload["devices"].items():
2595 if not runtime.startswith("com.apple.CoreSimulator.SimRuntime.iOS-"):
2596 continue
2597 for device in devices:
2598 if device.get("isAvailable") and "iPhone" in device["name"]:
2599 print(device["udid"])
2600 raise SystemExit(0)
2601raise SystemExit("no available iPhone simulator found")
2602PY
2603)
2604fi
2605
2606if [[ "${{IOS_SIM_HEADLESS:-0}}" != "1" ]] && command -v open >/dev/null 2>&1; then
2607 open -a Simulator --args -CurrentDeviceUDID "$DEVICE_ID" >/dev/null 2>&1 \
2608 || open -a Simulator >/dev/null 2>&1 \
2609 || true
2610fi
2611
2612xcrun simctl boot "$DEVICE_ID" >/dev/null 2>&1 || true
2613xcrun simctl bootstatus "$DEVICE_ID" -b
2614if [[ "${{IOS_SIM_UNINSTALL_BEFORE_INSTALL:-1}}" == "1" ]]; then
2615 xcrun simctl uninstall "$DEVICE_ID" "$BUNDLE_ID" >/dev/null 2>&1 || true
2616fi
2617xcrun simctl install "$DEVICE_ID" "$BUNDLE_DIR"
2618
2619if [[ -n "${{FISSION_TEST_CONTROL_PORT:-}}" ]]; then
2620 SIMCTL_CHILD_FISSION_TEST_CONTROL_PORT="${{FISSION_TEST_CONTROL_PORT}}" \
2621 xcrun simctl launch --terminate-running-process "$DEVICE_ID" "$BUNDLE_ID"
2622else
2623 xcrun simctl launch --terminate-running-process "$DEVICE_ID" "$BUNDLE_ID"
2624fi
2625"#,
2626 bundle_id = project.app.app_id,
2627 )
2628}
2629
2630fn render_ios_test_script() -> String {
2631 r#"#!/usr/bin/env bash
2632set -euo pipefail
2633
2634SCRIPT_DIR=$(cd -- "$(dirname "${BASH_SOURCE[0]}")" && pwd)
2635export FISSION_TEST_CONTROL_PORT="${FISSION_TEST_CONTROL_PORT:-48711}"
2636
2637"$SCRIPT_DIR/run-sim.sh"
2638
2639python3 - <<'PY' "$FISSION_TEST_CONTROL_PORT"
2640import sys
2641import time
2642import urllib.request
2643
2644port = sys.argv[1]
2645url = f"http://127.0.0.1:{port}/health"
2646deadline = time.time() + 90
2647last_error = None
2648while time.time() < deadline:
2649 try:
2650 with urllib.request.urlopen(url, timeout=1) as response:
2651 body = response.read().decode("utf-8", "replace")
2652 if response.status == 200 and '"status":"ok"' in body:
2653 print(f"iOS simulator test control is healthy on {url}")
2654 raise SystemExit(0)
2655 except Exception as error:
2656 last_error = error
2657 time.sleep(1)
2658raise SystemExit(f"iOS simulator test control did not become healthy on {url}: {last_error}")
2659PY
2660"#
2661 .to_string()
2662}
2663
2664fn render_android_manifest(project: &FissionProject) -> String {
2665 let capability_entries = render_android_capability_manifest_entries(project);
2666 let native_application_entries = render_android_native_application_entries(project);
2667 format!(
2668 r#"<?xml version="1.0" encoding="utf-8"?>
2669<manifest xmlns:android="http://schemas.android.com/apk/res/android"
2670 package="{app_id}">
2671
2672 <uses-permission android:name="android.permission.INTERNET" />
2673{capability_entries}
2674
2675 <uses-sdk
2676 android:minSdkVersion="24"
2677 android:targetSdkVersion="35" />
2678
2679 <application
2680 android:debuggable="true"
2681 android:extractNativeLibs="true"
2682 android:hasCode="true"
2683 android:icon="@drawable/app_icon"
2684 android:label="{label}">
2685{native_application_entries}
2686 <activity
2687 android:name="rs.fission.runtime.FissionActivity"
2688 android:configChanges="orientation|keyboardHidden|screenSize|screenLayout|smallestScreenSize|uiMode|density"
2689 android:exported="true"
2690 android:launchMode="singleTask"
2691 android:theme="@style/FissionLaunchTheme">
2692 <meta-data
2693 android:name="android.app.lib_name"
2694 android:value="{lib_name}" />
2695 <intent-filter>
2696 <action android:name="android.intent.action.MAIN" />
2697 <category android:name="android.intent.category.LAUNCHER" />
2698 </intent-filter>
2699 </activity>
2700 </application>
2701
2702</manifest>
2703"#,
2704 app_id = project.app.app_id,
2705 label = ios_bundle_name(project),
2706 lib_name = android_library_name(project),
2707 capability_entries = capability_entries,
2708 native_application_entries = native_application_entries,
2709 )
2710}
2711
2712fn render_android_native_application_entries(project: &FissionProject) -> String {
2713 let mut out = String::new();
2714 for module in &project.native.modules {
2715 for entry in &module.android.manifest_application_entries {
2716 let entry = entry.trim();
2717 if entry.is_empty() {
2718 continue;
2719 }
2720 out.push_str(" ");
2721 out.push_str(entry);
2722 if !entry.ends_with('\n') {
2723 out.push('\n');
2724 }
2725 }
2726 }
2727 out
2728}
2729
2730fn render_android_capability_manifest_entries(project: &FissionProject) -> String {
2731 let mut out = String::new();
2732 if project.capabilities.contains(&PlatformCapability::Nfc) {
2733 out.push_str(&render_android_nfc_manifest_entries());
2734 }
2735 if project
2736 .capabilities
2737 .contains(&PlatformCapability::Notifications)
2738 {
2739 out.push_str(&render_android_notifications_manifest_entries());
2740 }
2741 if project
2742 .capabilities
2743 .contains(&PlatformCapability::Biometric)
2744 {
2745 out.push_str(&render_android_biometric_manifest_entries());
2746 }
2747 if project
2748 .capabilities
2749 .contains(&PlatformCapability::Bluetooth)
2750 {
2751 out.push_str(&render_android_bluetooth_manifest_entries());
2752 }
2753 if project.capabilities.contains(&PlatformCapability::Camera) {
2754 out.push_str(&render_android_camera_manifest_entries());
2755 } else if project
2756 .capabilities
2757 .contains(&PlatformCapability::BarcodeScanner)
2758 {
2759 out.push_str(&render_android_barcode_camera_manifest_entries());
2760 }
2761 if project
2762 .capabilities
2763 .contains(&PlatformCapability::Geolocation)
2764 {
2765 out.push_str(&render_android_geolocation_manifest_entries());
2766 }
2767 if project.capabilities.contains(&PlatformCapability::Haptics) {
2768 out.push_str(&render_android_haptics_manifest_entries());
2769 }
2770 if project
2771 .capabilities
2772 .contains(&PlatformCapability::Microphone)
2773 {
2774 out.push_str(&render_android_microphone_manifest_entries());
2775 }
2776 if project
2777 .capabilities
2778 .contains(&PlatformCapability::VolumeControl)
2779 {
2780 out.push_str(&render_android_volume_manifest_entries());
2781 }
2782 if project.capabilities.contains(&PlatformCapability::Wifi) {
2783 out.push_str(&render_android_wifi_manifest_entries());
2784 }
2785 for permission in android_native_module_permissions(project) {
2786 out.push_str(&format!(
2787 " <uses-permission android:name=\"{}\" />\n",
2788 permission
2789 ));
2790 }
2791 out
2792}
2793
2794fn android_native_module_permissions(project: &FissionProject) -> BTreeSet<String> {
2795 project
2796 .native
2797 .modules
2798 .iter()
2799 .flat_map(|module| module.android.permissions.iter())
2800 .map(|permission| permission.trim().to_string())
2801 .filter(|permission| !permission.is_empty())
2802 .collect()
2803}
2804
2805fn render_android_nfc_manifest_entries() -> String {
2806 let mut out = String::new();
2807 out.push_str(" <uses-permission android:name=\"android.permission.NFC\" />\n");
2808 out.push_str(
2809 " <uses-feature android:name=\"android.hardware.nfc\" android:required=\"false\" />\n",
2810 );
2811 out
2812}
2813
2814fn render_android_notifications_manifest_entries() -> String {
2815 " <uses-permission android:name=\"android.permission.POST_NOTIFICATIONS\" />\n".to_string()
2816}
2817
2818fn render_android_biometric_manifest_entries() -> String {
2819 let mut out = String::new();
2820 out.push_str(" <uses-permission android:name=\"android.permission.USE_BIOMETRIC\" />\n");
2821 out.push_str(" <uses-permission android:name=\"android.permission.USE_FINGERPRINT\" android:maxSdkVersion=\"28\" />\n");
2822 out
2823}
2824
2825fn render_android_bluetooth_manifest_entries() -> String {
2826 let mut out = String::new();
2827 out.push_str(" <uses-permission android:name=\"android.permission.BLUETOOTH\" android:maxSdkVersion=\"30\" />\n");
2828 out.push_str(" <uses-permission android:name=\"android.permission.BLUETOOTH_ADMIN\" android:maxSdkVersion=\"30\" />\n");
2829 out.push_str(" <uses-permission android:name=\"android.permission.BLUETOOTH_SCAN\" android:usesPermissionFlags=\"neverForLocation\" />\n");
2830 out.push_str(" <uses-permission android:name=\"android.permission.BLUETOOTH_CONNECT\" />\n");
2831 out.push_str(
2832 " <uses-permission android:name=\"android.permission.BLUETOOTH_ADVERTISE\" />\n",
2833 );
2834 out.push_str(
2835 " <uses-feature android:name=\"android.hardware.bluetooth\" android:required=\"false\" />\n",
2836 );
2837 out.push_str(
2838 " <uses-feature android:name=\"android.hardware.bluetooth_le\" android:required=\"false\" />\n",
2839 );
2840 out
2841}
2842
2843fn render_missing_android_bluetooth_manifest_entries(existing: &str) -> String {
2844 let mut out = String::new();
2845 if !existing.contains("android.permission.BLUETOOTH\"") {
2846 out.push_str(" <uses-permission android:name=\"android.permission.BLUETOOTH\" android:maxSdkVersion=\"30\" />\n");
2847 }
2848 if !existing.contains("android.permission.BLUETOOTH_ADMIN") {
2849 out.push_str(" <uses-permission android:name=\"android.permission.BLUETOOTH_ADMIN\" android:maxSdkVersion=\"30\" />\n");
2850 }
2851 if !existing.contains("android.permission.BLUETOOTH_SCAN") {
2852 out.push_str(" <uses-permission android:name=\"android.permission.BLUETOOTH_SCAN\" android:usesPermissionFlags=\"neverForLocation\" />\n");
2853 }
2854 if !existing.contains("android.permission.BLUETOOTH_CONNECT") {
2855 out.push_str(
2856 " <uses-permission android:name=\"android.permission.BLUETOOTH_CONNECT\" />\n",
2857 );
2858 }
2859 if !existing.contains("android.permission.BLUETOOTH_ADVERTISE") {
2860 out.push_str(
2861 " <uses-permission android:name=\"android.permission.BLUETOOTH_ADVERTISE\" />\n",
2862 );
2863 }
2864 if !existing.contains("android.hardware.bluetooth\"") {
2865 out.push_str(
2866 " <uses-feature android:name=\"android.hardware.bluetooth\" android:required=\"false\" />\n",
2867 );
2868 }
2869 if !existing.contains("android.hardware.bluetooth_le") {
2870 out.push_str(
2871 " <uses-feature android:name=\"android.hardware.bluetooth_le\" android:required=\"false\" />\n",
2872 );
2873 }
2874 out
2875}
2876
2877fn render_android_barcode_camera_manifest_entries() -> String {
2878 let mut out = String::new();
2879 out.push_str(" <uses-permission android:name=\"android.permission.CAMERA\" />\n");
2880 out.push_str(
2881 " <uses-feature android:name=\"android.hardware.camera.any\" android:required=\"false\" />\n",
2882 );
2883 out
2884}
2885
2886fn render_android_camera_manifest_entries() -> String {
2887 let mut out = String::new();
2888 out.push_str(" <uses-permission android:name=\"android.permission.CAMERA\" />\n");
2889 out.push_str(
2890 " <uses-feature android:name=\"android.hardware.camera.any\" android:required=\"false\" />\n",
2891 );
2892 out.push_str(
2893 " <uses-feature android:name=\"android.hardware.camera\" android:required=\"false\" />\n",
2894 );
2895 out.push_str(
2896 " <uses-feature android:name=\"android.hardware.camera.front\" android:required=\"false\" />\n",
2897 );
2898 out.push_str(
2899 " <uses-feature android:name=\"android.hardware.camera.flash\" android:required=\"false\" />\n",
2900 );
2901 out
2902}
2903
2904fn render_missing_android_camera_manifest_entries(existing: &str) -> String {
2905 let mut out = String::new();
2906 if !existing.contains("android.permission.CAMERA") {
2907 out.push_str(" <uses-permission android:name=\"android.permission.CAMERA\" />\n");
2908 }
2909 if !existing.contains("android.hardware.camera.any") {
2910 out.push_str(
2911 " <uses-feature android:name=\"android.hardware.camera.any\" android:required=\"false\" />\n",
2912 );
2913 }
2914 if !existing.contains("android.hardware.camera\"") {
2915 out.push_str(
2916 " <uses-feature android:name=\"android.hardware.camera\" android:required=\"false\" />\n",
2917 );
2918 }
2919 if !existing.contains("android.hardware.camera.front") {
2920 out.push_str(
2921 " <uses-feature android:name=\"android.hardware.camera.front\" android:required=\"false\" />\n",
2922 );
2923 }
2924 if !existing.contains("android.hardware.camera.flash") {
2925 out.push_str(
2926 " <uses-feature android:name=\"android.hardware.camera.flash\" android:required=\"false\" />\n",
2927 );
2928 }
2929 out
2930}
2931
2932fn render_android_geolocation_manifest_entries() -> String {
2933 let mut out = String::new();
2934 out.push_str(
2935 " <uses-permission android:name=\"android.permission.ACCESS_COARSE_LOCATION\" />\n",
2936 );
2937 out.push_str(
2938 " <uses-permission android:name=\"android.permission.ACCESS_FINE_LOCATION\" />\n",
2939 );
2940 out
2941}
2942
2943fn render_android_haptics_manifest_entries() -> String {
2944 " <uses-permission android:name=\"android.permission.VIBRATE\" />\n".to_string()
2945}
2946
2947fn render_android_microphone_manifest_entries() -> String {
2948 " <uses-permission android:name=\"android.permission.RECORD_AUDIO\" />\n".to_string()
2949}
2950
2951fn render_android_volume_manifest_entries() -> String {
2952 " <uses-permission android:name=\"android.permission.MODIFY_AUDIO_SETTINGS\" />\n"
2953 .to_string()
2954}
2955
2956fn render_android_wifi_manifest_entries() -> String {
2957 let mut out = String::new();
2958 out.push_str(" <uses-permission android:name=\"android.permission.ACCESS_WIFI_STATE\" />\n");
2959 out.push_str(" <uses-permission android:name=\"android.permission.CHANGE_WIFI_STATE\" />\n");
2960 out.push_str(
2961 " <uses-permission android:name=\"android.permission.ACCESS_NETWORK_STATE\" />\n",
2962 );
2963 out.push_str(
2964 " <uses-permission android:name=\"android.permission.CHANGE_NETWORK_STATE\" />\n",
2965 );
2966 out.push_str(" <uses-permission android:name=\"android.permission.NEARBY_WIFI_DEVICES\" android:usesPermissionFlags=\"neverForLocation\" />\n");
2967 out.push_str(" <uses-permission android:name=\"android.permission.ACCESS_FINE_LOCATION\" android:maxSdkVersion=\"32\" />\n");
2968 out.push_str(
2969 " <uses-feature android:name=\"android.hardware.wifi\" android:required=\"false\" />\n",
2970 );
2971 out.push_str(
2972 " <uses-feature android:name=\"android.hardware.wifi.direct\" android:required=\"false\" />\n",
2973 );
2974 out
2975}
2976
2977fn render_missing_android_wifi_manifest_entries(existing: &str) -> String {
2978 let mut out = String::new();
2979 if !existing.contains("android.permission.ACCESS_WIFI_STATE") {
2980 out.push_str(
2981 " <uses-permission android:name=\"android.permission.ACCESS_WIFI_STATE\" />\n",
2982 );
2983 }
2984 if !existing.contains("android.permission.CHANGE_WIFI_STATE") {
2985 out.push_str(
2986 " <uses-permission android:name=\"android.permission.CHANGE_WIFI_STATE\" />\n",
2987 );
2988 }
2989 if !existing.contains("android.permission.ACCESS_NETWORK_STATE") {
2990 out.push_str(
2991 " <uses-permission android:name=\"android.permission.ACCESS_NETWORK_STATE\" />\n",
2992 );
2993 }
2994 if !existing.contains("android.permission.CHANGE_NETWORK_STATE") {
2995 out.push_str(
2996 " <uses-permission android:name=\"android.permission.CHANGE_NETWORK_STATE\" />\n",
2997 );
2998 }
2999 if !existing.contains("android.permission.NEARBY_WIFI_DEVICES") {
3000 out.push_str(" <uses-permission android:name=\"android.permission.NEARBY_WIFI_DEVICES\" android:usesPermissionFlags=\"neverForLocation\" />\n");
3001 }
3002 if !existing.contains("android.permission.ACCESS_FINE_LOCATION") {
3003 out.push_str(" <uses-permission android:name=\"android.permission.ACCESS_FINE_LOCATION\" android:maxSdkVersion=\"32\" />\n");
3004 }
3005 if !existing.contains("android.hardware.wifi\"") {
3006 out.push_str(
3007 " <uses-feature android:name=\"android.hardware.wifi\" android:required=\"false\" />\n",
3008 );
3009 }
3010 if !existing.contains("android.hardware.wifi.direct") {
3011 out.push_str(
3012 " <uses-feature android:name=\"android.hardware.wifi.direct\" android:required=\"false\" />\n",
3013 );
3014 }
3015 out
3016}
3017
3018fn render_ios_entitlements_plist(project: &FissionProject) -> String {
3019 let mut entries = String::new();
3020 if project.capabilities.contains(&PlatformCapability::Nfc) {
3021 entries.push_str(" <key>com.apple.developer.nfc.readersession.formats</key>\n <array>\n <string>NDEF</string>\n </array>\n");
3022 }
3023 if project.capabilities.contains(&PlatformCapability::Wifi) {
3024 entries.push_str(" <key>com.apple.developer.networking.wifi-info</key>\n <true/>\n");
3025 entries.push_str(
3026 " <key>com.apple.developer.networking.HotspotConfiguration</key>\n <true/>\n",
3027 );
3028 }
3029 format!(
3030 "<?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{entries}</dict>\n</plist>\n"
3031 )
3032}
3033
3034const IOS_NFC_ENTITLEMENTS_PLIST: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
3035<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3036<plist version="1.0">
3037<dict>
3038 <key>com.apple.developer.nfc.readersession.formats</key>
3039 <array>
3040 <string>NDEF</string>
3041 </array>
3042</dict>
3043</plist>
3044"#;
3045
3046const IOS_WIFI_ENTITLEMENTS_PLIST: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
3047<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3048<plist version="1.0">
3049<dict>
3050 <key>com.apple.developer.networking.wifi-info</key>
3051 <true/>
3052 <key>com.apple.developer.networking.HotspotConfiguration</key>
3053 <true/>
3054</dict>
3055</plist>
3056"#;
3057
3058fn render_android_capabilities_java() -> &'static str {
3059 include_str!("../assets/android/rs/fission/runtime/FissionAndroidCapabilities.java")
3060}
3061
3062fn render_android_package_script(project: &FissionProject) -> String {
3063 let lib_name = android_library_name(project);
3064 format!(
3065 r#"#!/usr/bin/env bash
3066set -euo pipefail
3067
3068SCRIPT_DIR=$(cd -- "$(dirname "${{BASH_SOURCE[0]}}")" && pwd)
3069PROJECT_DIR=$(cd -- "$SCRIPT_DIR/../.." && pwd)
3070TARGET="${{ANDROID_TARGET_TRIPLE:-aarch64-linux-android}}"
3071PACKAGE_NAME="{package_name}"
3072LIB_NAME="{lib_name}"
3073PROFILE="${{ANDROID_PROFILE:-debug}}"
3074ANDROID_HOME="${{ANDROID_HOME:-${{ANDROID_SDK_ROOT:-$HOME/Library/Android/sdk}}}}"
3075ANDROID_MIN_API_LEVEL="${{ANDROID_MIN_API_LEVEL:-${{ANDROID_API_LEVEL:-24}}}}"
3076
3077find_android_ndk() {{
3078 if [[ -n "${{ANDROID_NDK:-}}" ]]; then
3079 printf '%s\n' "$ANDROID_NDK"
3080 return
3081 fi
3082 local ndk_root="$ANDROID_HOME/ndk"
3083 if [[ ! -d "$ndk_root" ]]; then
3084 printf 'Android NDK not found. Set ANDROID_NDK or install one under %s.\n' "$ndk_root" >&2
3085 return 1
3086 fi
3087 local ndk
3088 ndk=$(find "$ndk_root" -maxdepth 1 -mindepth 1 -type d | sort -V | tail -1)
3089 if [[ -z "$ndk" ]]; then
3090 printf 'Android NDK not found. Set ANDROID_NDK or install one under %s.\n' "$ndk_root" >&2
3091 return 1
3092 fi
3093 printf '%s\n' "$ndk"
3094}}
3095
3096detect_android_toolchain() {{
3097 local prebuilt_root="$ANDROID_NDK/toolchains/llvm/prebuilt"
3098 local host
3099 for host in darwin-aarch64 darwin-x86_64 linux-x86_64 windows-x86_64; do
3100 if [[ -d "$prebuilt_root/$host/bin" ]]; then
3101 printf '%s\n' "$prebuilt_root/$host/bin"
3102 return
3103 fi
3104 done
3105 local fallback
3106 fallback=$(find "$prebuilt_root" -maxdepth 1 -mindepth 1 -type d 2>/dev/null | sort | head -1 || true)
3107 if [[ -n "$fallback" && -d "$fallback/bin" ]]; then
3108 printf '%s\n' "$fallback/bin"
3109 return
3110 fi
3111 printf 'No Android NDK LLVM prebuilt toolchain found under %s. Expected a prebuilt host directory such as darwin-x86_64 or linux-x86_64.\n' "$prebuilt_root" >&2
3112 return 1
3113}}
3114
3115detect_latest_android_api() {{
3116 find "$ANDROID_HOME/platforms" -maxdepth 1 -type d -name 'android-*' 2>/dev/null \
3117 | sed 's#.*android-##' \
3118 | sort -n \
3119 | tail -1
3120}}
3121
3122ANDROID_TARGET_API_LEVEL="${{ANDROID_TARGET_API_LEVEL:-$(detect_latest_android_api)}}"
3123if [[ -z "$ANDROID_TARGET_API_LEVEL" ]]; then
3124 printf 'No Android platform found under %s/platforms. Install one with sdkmanager "platforms;android-35" or newer.\n' "$ANDROID_HOME" >&2
3125 exit 1
3126fi
3127
3128ANDROID_NDK=$(find_android_ndk)
3129ANDROID_TOOLCHAIN="${{ANDROID_TOOLCHAIN:-$(detect_android_toolchain)}}"
3130CC_aarch64_linux_android="${{CC_aarch64_linux_android:-$ANDROID_TOOLCHAIN/aarch64-linux-android${{ANDROID_MIN_API_LEVEL}}-clang}}"
3131AR_aarch64_linux_android="${{AR_aarch64_linux_android:-$ANDROID_TOOLCHAIN/llvm-ar}}"
3132CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER="${{CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER:-$CC_aarch64_linux_android}}"
3133CARGO_TARGET_AARCH64_LINUX_ANDROID_AR="${{CARGO_TARGET_AARCH64_LINUX_ANDROID_AR:-$AR_aarch64_linux_android}}"
3134export ANDROID_HOME ANDROID_NDK ANDROID_MIN_API_LEVEL ANDROID_TARGET_API_LEVEL ANDROID_TOOLCHAIN CC_aarch64_linux_android AR_aarch64_linux_android
3135export CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER CARGO_TARGET_AARCH64_LINUX_ANDROID_AR
3136
3137if [[ -n "${{FISSION_GRADLE:-}}" ]]; then
3138 read -r -a GRADLE_CMD <<< "$FISSION_GRADLE"
3139elif [[ -x "$SCRIPT_DIR/gradlew" ]]; then
3140 GRADLE_CMD=("$SCRIPT_DIR/gradlew")
3141else
3142 if ! command -v gradle >/dev/null 2>&1; then
3143 printf 'Gradle is required for the generated Android project shell. Install Gradle or add a wrapper under %s.\n' "$SCRIPT_DIR" >&2
3144 exit 1
3145 fi
3146 GRADLE_CMD=(gradle)
3147fi
3148
3149BUILD_ARGS=(build --manifest-path "$PROJECT_DIR/Cargo.toml" --lib --target "$TARGET" --package "$PACKAGE_NAME")
3150ARTIFACT_DIR=debug
3151GRADLE_VARIANT=Debug
3152GRADLE_OUTPUT_DIR=debug
3153if [[ "$PROFILE" == "release" ]]; then
3154 BUILD_ARGS+=(--release)
3155 ARTIFACT_DIR=release
3156 GRADLE_VARIANT=Release
3157 GRADLE_OUTPUT_DIR=release
3158fi
3159
3160cargo "${{BUILD_ARGS[@]}}"
3161TARGET_DIR=$(python3 - <<'PY' "$PROJECT_DIR/Cargo.toml"
3162import json
3163import subprocess
3164import sys
3165
3166manifest = sys.argv[1]
3167metadata = json.loads(
3168 subprocess.check_output(
3169 ["cargo", "metadata", "--manifest-path", manifest, "--format-version", "1", "--no-deps"]
3170 )
3171)
3172print(metadata["target_directory"])
3173PY
3174)
3175
3176SO_PATH="$TARGET_DIR/$TARGET/$ARTIFACT_DIR/lib$LIB_NAME.so"
3177JNI_DIR="$SCRIPT_DIR/app/src/main/jniLibs/arm64-v8a"
3178GENERATED_RES_DIR="$SCRIPT_DIR/app/src/main/res/drawable-nodpi"
3179mkdir -p "$JNI_DIR" "$GENERATED_RES_DIR"
3180cp "$SO_PATH" "$JNI_DIR/lib$LIB_NAME.so"
3181shopt -s nullglob
3182APP_ICONS=("$SCRIPT_DIR"/res/drawable-nodpi/app_icon.* "$SCRIPT_DIR"/res/drawable/app_icon.*)
3183if (( ${{#APP_ICONS[@]}} == 0 )); then
3184 cp "$PROJECT_DIR/assets/app-icon.png" "$GENERATED_RES_DIR/app_icon.png"
3185fi
3186shopt -u nullglob
3187shopt -s nullglob
3188SPLASH_IMAGES=("$SCRIPT_DIR"/res/drawable-nodpi/fission_splash_image.*)
3189if (( ${{#SPLASH_IMAGES[@]}} == 0 )); then
3190 cp "$PROJECT_DIR/assets/app-icon.png" "$GENERATED_RES_DIR/fission_splash_image.png"
3191fi
3192shopt -u nullglob
3193
3194"${{GRADLE_CMD[@]}}" -p "$SCRIPT_DIR" ":app:assemble$GRADLE_VARIANT"
3195
3196APK="$SCRIPT_DIR/app/build/outputs/apk/$GRADLE_OUTPUT_DIR/app-$GRADLE_OUTPUT_DIR.apk"
3197if [[ ! -f "$APK" ]]; then
3198 printf 'Gradle did not produce the expected APK: %s\n' "$APK" >&2
3199 exit 1
3200fi
3201printf '%s\n' "$APK"
3202"#,
3203 package_name = project.app.name,
3204 lib_name = lib_name,
3205 )
3206}
3207
3208fn render_android_run_script(project: &FissionProject) -> String {
3209 format!(
3210 r#"#!/usr/bin/env bash
3211set -euo pipefail
3212
3213SCRIPT_DIR=$(cd -- "$(dirname "${{BASH_SOURCE[0]}}")" && pwd)
3214ANDROID_HOME="${{ANDROID_HOME:-${{ANDROID_SDK_ROOT:-$HOME/Library/Android/sdk}}}}"
3215ADB="$ANDROID_HOME/platform-tools/adb"
3216EMULATOR_BIN="$ANDROID_HOME/emulator/emulator"
3217AVDMANAGER="${{ANDROID_AVDMANAGER:-$ANDROID_HOME/cmdline-tools/latest/bin/avdmanager}}"
3218
3219detect_latest_emulator_api() {{
3220 find "$ANDROID_HOME/system-images" -path '*/google_apis/arm64-v8a' -type d 2>/dev/null \
3221 | sed -n 's#.*system-images/android-\([0-9][0-9]*\)/google_apis/arm64-v8a#\1#p' \
3222 | sort -n \
3223 | tail -1
3224}}
3225
3226android_system_image_path() {{
3227 local image="$1"
3228 image="${{image#system-images;}}"
3229 printf '%s/system-images/%s\n' "$ANDROID_HOME" "${{image//;/\/}}"
3230}}
3231
3232wait_for_android_boot() {{
3233 "$ADB" wait-for-device
3234 until "$ADB" shell getprop sys.boot_completed 2>/dev/null | tr -d '\r' | grep -q '^1$'; do
3235 sleep 1
3236 done
3237 local deadline=$((SECONDS + 180))
3238 until "$ADB" shell cmd package list packages >/dev/null 2>&1; do
3239 if (( SECONDS > deadline )); then
3240 printf 'Android package manager did not become available. Restart the emulator with ANDROID_EMULATOR_RESTART=1 and try again.\n' >&2
3241 exit 1
3242 fi
3243 sleep 1
3244 done
3245}}
3246
3247ANDROID_EMULATOR_API_LEVEL="${{ANDROID_EMULATOR_API_LEVEL:-$(detect_latest_emulator_api)}}"
3248if [[ -z "$ANDROID_EMULATOR_API_LEVEL" ]]; then
3249 printf 'No Android arm64 google_apis emulator image found under %s/system-images.\nInstall one with sdkmanager "system-images;android-35;google_apis;arm64-v8a" or set ANDROID_SYSTEM_IMAGE.\n' "$ANDROID_HOME" >&2
3250 exit 1
3251fi
3252AVD_NAME="${{ANDROID_AVD_NAME:-FissionApi${{ANDROID_EMULATOR_API_LEVEL}}Arm64}}"
3253SYSTEM_IMAGE="${{ANDROID_SYSTEM_IMAGE:-system-images;android-${{ANDROID_EMULATOR_API_LEVEL}};google_apis;arm64-v8a}}"
3254DEVICE_PORT="${{ANDROID_TEST_CONTROL_DEVICE_PORT:-48761}}"
3255HOST_PORT="${{FISSION_TEST_CONTROL_PORT:-48761}}"
3256HEADLESS="${{ANDROID_EMULATOR_HEADLESS:-0}}"
3257RESTART_EMULATOR="${{ANDROID_EMULATOR_RESTART:-0}}"
3258
3259for tool in "$ADB" "$EMULATOR_BIN" "$AVDMANAGER"; do
3260 if [[ ! -x "$tool" ]]; then
3261 printf 'Required Android tool is missing or not executable: %s\nRun `fission doctor android --project-dir .` for setup help.\n' "$tool" >&2
3262 exit 1
3263 fi
3264done
3265
3266if ! "$AVDMANAGER" list avd | grep -q "Name: $AVD_NAME"; then
3267 if [[ ! -d "$(android_system_image_path "$SYSTEM_IMAGE")" ]]; then
3268 printf 'Android system image is not installed: %s\nInstall it with sdkmanager "%s" or set ANDROID_SYSTEM_IMAGE.\n' "$SYSTEM_IMAGE" "$SYSTEM_IMAGE" >&2
3269 exit 1
3270 fi
3271 echo "no" | "$AVDMANAGER" create avd -n "$AVD_NAME" -k "$SYSTEM_IMAGE" --abi "google_apis/arm64-v8a" --device "pixel_5"
3272fi
3273
3274RUNNING_EMULATOR=$("$ADB" devices | awk '/^emulator-.*device$/ {{ print $1; exit }}')
3275if [[ -n "$RUNNING_EMULATOR" && "$RESTART_EMULATOR" == "1" ]]; then
3276 "$ADB" -s "$RUNNING_EMULATOR" emu kill >/dev/null || true
3277 until ! "$ADB" devices | grep -q '^emulator-'; do
3278 sleep 1
3279 done
3280 RUNNING_EMULATOR=""
3281fi
3282
3283if [[ -z "$RUNNING_EMULATOR" ]]; then
3284 EMULATOR_ARGS=(-avd "$AVD_NAME" -gpu "${{ANDROID_EMULATOR_GPU:-swiftshader_indirect}}" -no-audio)
3285 if [[ "$HEADLESS" == "1" ]]; then
3286 EMULATOR_ARGS+=(-no-window)
3287 fi
3288 printf 'Launching emulator %s (%s)\n' "$AVD_NAME" "$([[ "$HEADLESS" == "1" ]] && echo headless || echo visible)"
3289 nohup "$EMULATOR_BIN" "${{EMULATOR_ARGS[@]}}" >/tmp/fission-android-emulator.log 2>&1 &
3290 disown || true
3291 wait_for_android_boot
3292else
3293 printf 'Using existing emulator %s\n' "$RUNNING_EMULATOR"
3294 wait_for_android_boot
3295 if [[ "$HEADLESS" != "1" ]]; then
3296 printf 'If the window is not visible, restart with ANDROID_EMULATOR_RESTART=1 to relaunch a visible emulator.\n'
3297 fi
3298fi
3299
3300APK=$("$SCRIPT_DIR/package-apk.sh")
3301read -r -a ADB_INSTALL_FLAGS <<< "${{ADB_INSTALL_FLAGS:---no-streaming -r}}"
3302"$ADB" install "${{ADB_INSTALL_FLAGS[@]}}" "$APK"
3303"$ADB" forward "tcp:$HOST_PORT" "tcp:$DEVICE_PORT"
3304"$ADB" shell am start -n {app_id}/rs.fission.runtime.FissionActivity >/dev/null
3305printf 'APK=%s\n' "$APK"
3306"#,
3307 app_id = project.app.app_id,
3308 )
3309}
3310
3311fn render_android_test_script() -> String {
3312 r#"#!/usr/bin/env bash
3313set -euo pipefail
3314
3315SCRIPT_DIR=$(cd -- "$(dirname "${BASH_SOURCE[0]}")" && pwd)
3316export FISSION_TEST_CONTROL_PORT="${FISSION_TEST_CONTROL_PORT:-48761}"
3317
3318"$SCRIPT_DIR/run-emulator.sh"
3319
3320python3 - <<'PY' "$FISSION_TEST_CONTROL_PORT"
3321import sys
3322import time
3323import urllib.request
3324
3325port = sys.argv[1]
3326url = f"http://127.0.0.1:{port}/health"
3327deadline = time.time() + 90
3328last_error = None
3329while time.time() < deadline:
3330 try:
3331 with urllib.request.urlopen(url, timeout=1) as response:
3332 body = response.read().decode("utf-8", "replace")
3333 if response.status == 200 and '"status":"ok"' in body:
3334 print(f"Android emulator test control is healthy on {url}")
3335 raise SystemExit(0)
3336 except Exception as error:
3337 last_error = error
3338 time.sleep(1)
3339raise SystemExit(f"Android emulator test control did not become healthy on {url}: {last_error}")
3340PY
3341"#
3342 .to_string()
3343}
3344
3345fn render_web_index(project: &FissionProject) -> String {
3346 let title = ios_bundle_name(project);
3347 format!(
3348 r#"<!doctype html>
3349<html lang="en">
3350 <head>
3351 <meta charset="utf-8" />
3352 <meta name="viewport" content="width=device-width, initial-scale=1" />
3353 <title>{title}</title>
3354 <link rel="icon" type="image/png" href="../../assets/app-icon.png" />
3355 <style>
3356 :root {{
3357 color-scheme: dark;
3358 background: #14171f;
3359 }}
3360 html, body {{
3361 margin: 0;
3362 width: 100%;
3363 height: 100%;
3364 overflow: hidden;
3365 overscroll-behavior: none;
3366 background: #14171f;
3367 }}
3368 body, #fission-web-mount {{
3369 width: 100vw;
3370 height: 100vh;
3371 }}
3372 canvas {{
3373 display: block;
3374 width: 100vw;
3375 height: 100vh;
3376 border: 0;
3377 outline: none;
3378 user-select: none;
3379 -webkit-user-drag: none;
3380 touch-action: none;
3381 -webkit-tap-highlight-color: transparent;
3382 }}
3383 canvas:focus, canvas:focus-visible {{
3384 outline: none;
3385 }}
3386 </style>
3387 </head>
3388 <body>
3389 <main id="fission-web-mount" aria-label="{title}"></main>
3390 <script type="module" src="./bootstrap.mjs"></script>
3391 </body>
3392</html>
3393"#,
3394 title = title,
3395 )
3396}
3397
3398fn render_web_bootstrap(project: &FissionProject) -> String {
3399 let module_name = project.app.name.replace('-', "_");
3400 format!(
3401 "import init from \"./pkg/{}.js\";\n\nawait init();\n",
3402 module_name
3403 )
3404}
3405
3406fn render_web_build_script() -> String {
3407 r#"#!/usr/bin/env bash
3408set -euo pipefail
3409
3410SCRIPT_DIR=$(cd -- "$(dirname "${BASH_SOURCE[0]}")" && pwd)
3411PROJECT_DIR=$(cd -- "$SCRIPT_DIR/../.." && pwd)
3412PROFILE="${FISSION_WEB_PROFILE:-dev}"
3413BUILD_ARGS=(build "$PROJECT_DIR" --target web --out-dir "$SCRIPT_DIR/pkg")
3414
3415if [[ "$PROFILE" == "release" ]]; then
3416 BUILD_ARGS+=(--release)
3417else
3418 BUILD_ARGS+=(--dev)
3419fi
3420
3421wasm-pack "${BUILD_ARGS[@]}"
3422"#
3423 .to_string()
3424}
3425
3426fn render_web_run_script(_project: &FissionProject) -> String {
3427 format!(
3428 r#"#!/usr/bin/env bash
3429set -euo pipefail
3430
3431SCRIPT_DIR=$(cd -- "$(dirname "${{BASH_SOURCE[0]}}")" && pwd)
3432PROJECT_DIR=$(cd -- "$SCRIPT_DIR/../.." && pwd)
3433HOST="${{FISSION_WEB_HOST:-127.0.0.1}}"
3434REQUESTED_PORT="${{FISSION_WEB_PORT:-8123}}"
3435PORT="$REQUESTED_PORT"
3436if [[ -z "${{FISSION_WEB_PORT:-}}" ]]; then
3437 PORT=$(python3 - "$HOST" "$REQUESTED_PORT" <<'PY'
3438import socket
3439import sys
3440
3441host = sys.argv[1]
3442start = int(sys.argv[2])
3443for port in range(start, start + 51):
3444 with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe:
3445 probe.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
3446 try:
3447 probe.bind((host, port))
3448 except OSError:
3449 continue
3450 print(port)
3451 raise SystemExit(0)
3452raise SystemExit(f"no free web port found from {{host}}:{{start}}")
3453PY
3454)
3455 if [[ "$PORT" != "$REQUESTED_PORT" ]]; then
3456 printf 'Port %s:%s is already in use; using %s:%s.\n' "$HOST" "$REQUESTED_PORT" "$HOST" "$PORT"
3457 fi
3458fi
3459URL="http://${{HOST}}:${{PORT}}/platforms/web/"
3460
3461"$SCRIPT_DIR/build-wasm.sh"
3462
3463printf 'Serving %s\n' "$URL"
3464printf 'Press Ctrl+C to stop the local server.\n'
3465if [[ "${{FISSION_WEB_OPEN:-0}}" == "1" ]]; then
3466 if command -v open >/dev/null 2>&1; then
3467 open "$URL"
3468 elif command -v xdg-open >/dev/null 2>&1; then
3469 xdg-open "$URL"
3470 elif command -v cmd.exe >/dev/null 2>&1; then
3471 cmd.exe /C start "$URL"
3472 else
3473 printf 'No browser opener found. Open %s manually.\n' "$URL"
3474 fi
3475fi
3476
3477cd "$PROJECT_DIR"
3478python3 -m http.server "$PORT" --bind "$HOST"
3479"#
3480 )
3481}
3482
3483fn render_web_test_script(_project: &FissionProject) -> String {
3484 r#"#!/usr/bin/env bash
3485set -euo pipefail
3486
3487SCRIPT_DIR=$(cd -- "$(dirname "${BASH_SOURCE[0]}")" && pwd)
3488PROJECT_DIR=$(cd -- "$SCRIPT_DIR/../.." && pwd)
3489HOST="${FISSION_WEB_HOST:-127.0.0.1}"
3490REQUESTED_PORT="${FISSION_WEB_PORT:-8123}"
3491PORT="$REQUESTED_PORT"
3492if [[ -z "${FISSION_WEB_PORT:-}" ]]; then
3493 PORT=$(python3 - "$HOST" "$REQUESTED_PORT" <<'PY'
3494import socket
3495import sys
3496
3497host = sys.argv[1]
3498start = int(sys.argv[2])
3499for port in range(start, start + 51):
3500 with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe:
3501 probe.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
3502 try:
3503 probe.bind((host, port))
3504 except OSError:
3505 continue
3506 print(port)
3507 raise SystemExit(0)
3508raise SystemExit(f"no free web port found from {host}:{start}")
3509PY
3510)
3511 if [[ "$PORT" != "$REQUESTED_PORT" ]]; then
3512 printf 'Port %s:%s is already in use; using %s:%s.\n' "$HOST" "$REQUESTED_PORT" "$HOST" "$PORT"
3513 fi
3514fi
3515REQUESTED_CDP_PORT="${FISSION_WEB_CDP_PORT:-9222}"
3516CDP_PORT="$REQUESTED_CDP_PORT"
3517if [[ -z "${FISSION_WEB_CDP_PORT:-}" ]]; then
3518 CDP_PORT=$(python3 - "127.0.0.1" "$REQUESTED_CDP_PORT" <<'PY'
3519import socket
3520import sys
3521
3522host = sys.argv[1]
3523start = int(sys.argv[2])
3524for port in range(start, start + 51):
3525 with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe:
3526 probe.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
3527 try:
3528 probe.bind((host, port))
3529 except OSError:
3530 continue
3531 print(port)
3532 raise SystemExit(0)
3533raise SystemExit(f"no free CDP port found from {host}:{start}")
3534PY
3535)
3536 if [[ "$CDP_PORT" != "$REQUESTED_CDP_PORT" ]]; then
3537 printf 'CDP port 127.0.0.1:%s is already in use; using 127.0.0.1:%s.\n' "$REQUESTED_CDP_PORT" "$CDP_PORT"
3538 fi
3539fi
3540URL="http://${HOST}:${PORT}/platforms/web/"
3541PROFILE_DIR="$SCRIPT_DIR/build/chrome-profile"
3542
3543require_node_websocket() {
3544 if ! command -v node >/dev/null 2>&1; then
3545 printf 'Node.js was not found. Install Node 22+ so the generated browser smoke test can inspect Chrome CDP console/runtime errors.\n' >&2
3546 exit 1
3547 fi
3548 if ! node -e 'process.exit(typeof WebSocket === "function" ? 0 : 1)' >/dev/null 2>&1; then
3549 printf 'Node.js is available but does not expose the built-in WebSocket client. Install Node 22+ for Chrome CDP smoke tests.\n' >&2
3550 exit 1
3551 fi
3552}
3553
3554detect_chrome() {
3555 if [[ -n "${FISSION_CHROME:-}" && -x "$FISSION_CHROME" ]]; then
3556 printf '%s\n' "$FISSION_CHROME"
3557 return
3558 fi
3559 local candidate
3560 for candidate in \
3561 "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" \
3562 "/Applications/Chromium.app/Contents/MacOS/Chromium" \
3563 "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge"; do
3564 if [[ -x "$candidate" ]]; then
3565 printf '%s\n' "$candidate"
3566 return
3567 fi
3568 done
3569 for candidate in google-chrome chromium chromium-browser chrome; do
3570 if command -v "$candidate" >/dev/null 2>&1; then
3571 command -v "$candidate"
3572 return
3573 fi
3574 done
3575 return 1
3576}
3577
3578require_node_websocket
3579"$SCRIPT_DIR/build-wasm.sh"
3580
3581mkdir -p "$SCRIPT_DIR/build"
3582cd "$PROJECT_DIR"
3583python3 -m http.server "$PORT" --bind "$HOST" >"$SCRIPT_DIR/build/web-server.log" 2>&1 &
3584SERVER_PID=$!
3585
3586cleanup() {
3587 if [[ -n "${CHROME_PID:-}" ]]; then
3588 kill "$CHROME_PID" >/dev/null 2>&1 || true
3589 fi
3590 kill "$SERVER_PID" >/dev/null 2>&1 || true
3591}
3592trap cleanup EXIT
3593
3594printf 'Running transient web smoke test at %s\n' "$URL"
3595printf 'The local server is stopped automatically when this script exits.\n'
3596
3597python3 - <<'PY' "$URL"
3598import sys
3599import time
3600import urllib.request
3601
3602url = sys.argv[1]
3603deadline = time.time() + 30
3604last_error = None
3605while time.time() < deadline:
3606 try:
3607 with urllib.request.urlopen(url, timeout=1) as response:
3608 if response.status == 200:
3609 raise SystemExit(0)
3610 except Exception as error:
3611 last_error = error
3612 time.sleep(0.5)
3613raise SystemExit(f"web server did not serve {url}: {last_error}")
3614PY
3615
3616CHROME=$(detect_chrome) || {
3617 printf 'Chrome/Chromium was not found. Set FISSION_CHROME=/path/to/chrome or run `fission doctor web --project-dir .`.\n' >&2
3618 exit 1
3619}
3620
3621rm -rf "$PROFILE_DIR"
3622"$CHROME" \
3623 --headless=new \
3624 --enable-unsafe-webgpu \
3625 --no-first-run \
3626 --no-default-browser-check \
3627 --remote-debugging-port="$CDP_PORT" \
3628 --user-data-dir="$PROFILE_DIR" \
3629 "$URL" >"$SCRIPT_DIR/build/chrome.log" 2>&1 &
3630CHROME_PID=$!
3631
3632CDP_PORT="$CDP_PORT" FISSION_WEB_URL="$URL" node <<'NODE'
3633const cdpPort = process.env.CDP_PORT;
3634const expectedUrl = process.env.FISSION_WEB_URL;
3635const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
3636
3637async function waitForTarget() {
3638 const deadline = Date.now() + 60_000;
3639 let lastError = null;
3640 while (Date.now() < deadline) {
3641 try {
3642 const response = await fetch(`http://127.0.0.1:${cdpPort}/json/list`);
3643 const targets = await response.json();
3644 const target = targets.find((entry) => entry.type === 'page' && entry.url.startsWith(expectedUrl));
3645 if (target?.webSocketDebuggerUrl) {
3646 return target.webSocketDebuggerUrl;
3647 }
3648 } catch (error) {
3649 lastError = error;
3650 }
3651 await sleep(250);
3652 }
3653 throw new Error(`Chrome CDP target did not become ready for ${expectedUrl}: ${lastError?.message ?? lastError}`);
3654}
3655
3656class CdpClient {
3657 constructor(url) {
3658 this.url = url;
3659 this.ws = null;
3660 this.nextId = 1;
3661 this.pending = new Map();
3662 this.errors = [];
3663 }
3664
3665 async open() {
3666 await new Promise((resolve, reject) => {
3667 const ws = new WebSocket(this.url);
3668 this.ws = ws;
3669 ws.addEventListener('open', resolve, { once: true });
3670 ws.addEventListener('error', (event) => reject(new Error(`CDP websocket error: ${event.message ?? 'unknown error'}`)), { once: true });
3671 ws.addEventListener('message', (event) => this.onMessage(event.data));
3672 ws.addEventListener('close', () => {
3673 for (const { reject: rejectPending } of this.pending.values()) {
3674 rejectPending(new Error('CDP websocket closed'));
3675 }
3676 this.pending.clear();
3677 });
3678 });
3679 }
3680
3681 send(method, params = {}) {
3682 const id = this.nextId++;
3683 const message = { id, method, params };
3684 return new Promise((resolve, reject) => {
3685 const timeout = setTimeout(() => {
3686 this.pending.delete(id);
3687 reject(new Error(`CDP command timed out: ${method}`));
3688 }, 10_000);
3689 this.pending.set(id, { resolve, reject, timeout, method });
3690 this.ws.send(JSON.stringify(message));
3691 });
3692 }
3693
3694 onMessage(raw) {
3695 const message = JSON.parse(raw);
3696 if (message.id) {
3697 const pending = this.pending.get(message.id);
3698 if (!pending) return;
3699 clearTimeout(pending.timeout);
3700 this.pending.delete(message.id);
3701 if (message.error) {
3702 pending.reject(new Error(`${pending.method}: ${message.error.message}`));
3703 } else {
3704 pending.resolve(message.result ?? {});
3705 }
3706 return;
3707 }
3708
3709 if (message.method === 'Runtime.exceptionThrown') {
3710 this.errors.push(formatException(message.params?.exceptionDetails));
3711 } else if (message.method === 'Runtime.consoleAPICalled') {
3712 const type = message.params?.type;
3713 if (type === 'error' || type === 'assert') {
3714 this.errors.push(`console.${type}: ${(message.params?.args ?? []).map(formatRemoteObject).join(' ')}`);
3715 }
3716 } else if (message.method === 'Log.entryAdded') {
3717 const entry = message.params?.entry;
3718 if (entry?.level === 'error') {
3719 if ((entry.url ?? '').endsWith('/__fission/renderer')) {
3720 return;
3721 }
3722 this.errors.push(`browser log error: ${entry.text}${entry.url ? ` (${entry.url}:${entry.lineNumber ?? 0})` : ''}`);
3723 }
3724 }
3725 }
3726
3727 close() {
3728 this.ws?.close();
3729 }
3730}
3731
3732function formatRemoteObject(value) {
3733 if (!value) return '<missing>';
3734 if (Object.prototype.hasOwnProperty.call(value, 'value')) return JSON.stringify(value.value);
3735 return value.description ?? value.unserializableValue ?? value.type ?? '<unknown>';
3736}
3737
3738function formatException(details) {
3739 if (!details) return 'runtime exception: <missing details>';
3740 const exception = details.exception?.description ?? details.exception?.value ?? details.text ?? 'unknown exception';
3741 const location = details.url ? ` at ${details.url}:${details.lineNumber ?? 0}:${details.columnNumber ?? 0}` : '';
3742 return `runtime exception: ${exception}${location}`;
3743}
3744
3745function errorBlock(errors) {
3746 return errors.slice(0, 10).map((error, index) => `${index + 1}. ${error}`).join('\n');
3747}
3748
3749async function readRuntimeStatus(client) {
3750 const expression = `(() => {
3751 const canvas = document.querySelector('canvas');
3752 if (!canvas) return { ready: false, reason: 'no canvas element' };
3753 const rect = canvas.getBoundingClientRect();
3754 const perf = globalThis.__FISSION_PERF ?? { frames: [], inputLatencies: [] };
3755 return {
3756 ready: rect.width > 0 && rect.height > 0,
3757 width: Math.round(rect.width),
3758 height: Math.round(rect.height),
3759 gpu: typeof navigator.gpu !== 'undefined',
3760 renderer: globalThis.__FISSION_RENDERER_INFO ?? null,
3761 frames: Array.isArray(perf.frames) ? perf.frames.slice(-120) : [],
3762 inputLatencies: Array.isArray(perf.inputLatencies) ? perf.inputLatencies.slice(-30) : [],
3763 title: document.title,
3764 };
3765 })()`;
3766 const result = await client.send('Runtime.evaluate', { expression, returnByValue: true });
3767 if (result.exceptionDetails) {
3768 throw new Error(formatException(result.exceptionDetails));
3769 }
3770 return result.result?.value ?? { ready: false, reason: 'evaluation returned no value' };
3771}
3772
3773function average(values) {
3774 if (!values.length) return 0;
3775 return values.reduce((sum, value) => sum + value, 0) / values.length;
3776}
3777
3778async function clickCanvasCenter(client, status) {
3779 const x = Math.max(1, Math.floor(status.width / 2));
3780 const y = Math.max(1, Math.floor(status.height / 2));
3781 await client.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x, y, button: 'none' });
3782 await client.send('Input.dispatchMouseEvent', { type: 'mousePressed', x, y, button: 'left', clickCount: 1 });
3783 await client.send('Input.dispatchMouseEvent', { type: 'mouseReleased', x, y, button: 'left', clickCount: 1 });
3784}
3785
3786async function main() {
3787 const wsUrl = await waitForTarget();
3788 const client = new CdpClient(wsUrl);
3789 await client.open();
3790 try {
3791 await Promise.all([
3792 client.send('Runtime.enable'),
3793 client.send('Log.enable'),
3794 client.send('Page.enable'),
3795 ]);
3796
3797 const deadline = Date.now() + 60_000;
3798 let readySince = null;
3799 let lastStatus = null;
3800 while (Date.now() < deadline) {
3801 if (client.errors.length > 0) {
3802 throw new Error(`browser reported runtime/console errors:\n${errorBlock(client.errors)}`);
3803 }
3804 lastStatus = await readRuntimeStatus(client);
3805 if (lastStatus.ready && lastStatus.renderer) {
3806 readySince ??= Date.now();
3807 if (Date.now() - readySince >= 1_500) {
3808 const renderer = lastStatus.renderer.active;
3809 if (lastStatus.gpu && renderer === 'canvas2d-software' && !lastStatus.renderer.fallback_reason && process.env.FISSION_ALLOW_WEBGPU_FALLBACK !== '1') {
3810 throw new Error(`WebGPU is exposed but Fission used canvas2d-software without a fallback reason: ${JSON.stringify(lastStatus.renderer)}`);
3811 }
3812 await clickCanvasCenter(client, lastStatus);
3813 const inputDeadline = Date.now() + 10_000;
3814 while (Date.now() < inputDeadline) {
3815 lastStatus = await readRuntimeStatus(client);
3816 if ((lastStatus.inputLatencies ?? []).length > 0) break;
3817 await sleep(100);
3818 }
3819 const frames = lastStatus.frames ?? [];
3820 const latencies = lastStatus.inputLatencies ?? [];
3821 if (frames.length < 2) {
3822 throw new Error(`web perf smoke did not capture enough frame samples: ${JSON.stringify(lastStatus)}`);
3823 }
3824 if (latencies.length < 1) {
3825 throw new Error(`web perf smoke did not capture input latency samples: ${JSON.stringify(lastStatus)}`);
3826 }
3827 const avgFrame = average(frames.slice(-30));
3828 const avgLatency = average(latencies.slice(-10));
3829 if (avgFrame > Number(process.env.FISSION_WEB_MAX_AVG_FRAME_MS ?? 80)) {
3830 throw new Error(`web average frame time ${avgFrame.toFixed(2)}ms exceeded smoke threshold`);
3831 }
3832 if (avgLatency > Number(process.env.FISSION_WEB_MAX_INPUT_LATENCY_MS ?? 180)) {
3833 throw new Error(`web input latency ${avgLatency.toFixed(2)}ms exceeded smoke threshold`);
3834 }
3835 console.log(`Web app renderer ${renderer}; canvas ${lastStatus.width}x${lastStatus.height}; avg frame ${avgFrame.toFixed(2)}ms; avg input latency ${avgLatency.toFixed(2)}ms.`);
3836 return;
3837 }
3838 } else {
3839 readySince = null;
3840 }
3841 await sleep(250);
3842 }
3843 throw new Error(`web app did not render a non-empty canvas with renderer diagnostics. Last state: ${JSON.stringify(lastStatus)}`);
3844 } finally {
3845 client.close();
3846 }
3847}
3848
3849main().catch((error) => {
3850 console.error(error.stack ?? error.message ?? String(error));
3851 process.exit(1);
3852});
3853NODE
3854"#
3855 .to_string()
3856}
3857fn render_app_main(package_name: &str) -> String {
3858 let lib_name = package_name.replace('-', "_");
3859 format!(
3860 r#"#[cfg(target_os = "android")]
3861fn main() {{}}
3862
3863#[cfg(target_arch = "wasm32")]
3864fn main() {{}}
3865
3866#[cfg(target_os = "ios")]
3867fn main() -> anyhow::Result<()> {{
3868 {lib_name}::run_mobile()
3869}}
3870
3871#[cfg(not(any(target_arch = "wasm32", target_os = "ios", target_os = "android")))]
3872fn main() -> anyhow::Result<()> {{
3873 {lib_name}::run_desktop()
3874}}
3875"#
3876 )
3877}
3878
3879const APP_LIB: &str = r#"pub mod app;
3880
3881use crate::app::CounterApp;
3882use fission::prelude::*;
3883
3884#[cfg(target_os = "android")]
3885const ANDROID_TEST_CONTROL_PORT: u16 = 48761;
3886
3887#[cfg(any(target_os = "android", target_os = "ios"))]
3888fn mobile_app() -> MobileApp<crate::app::CounterState, CounterApp> {
3889 let app = MobileApp::<crate::app::CounterState, _>::new(CounterApp).with_title("Fission App");
3890 #[cfg(target_os = "android")]
3891 let app = app.with_test_control_port(ANDROID_TEST_CONTROL_PORT);
3892 app
3893}
3894
3895#[cfg(target_arch = "wasm32")]
3896fn web_app() -> WebApp<crate::app::CounterState, CounterApp> {
3897 WebApp::<crate::app::CounterState, _>::new(CounterApp).with_title("Fission App")
3898}
3899
3900#[cfg(not(any(target_arch = "wasm32", target_os = "android", target_os = "ios")))]
3901pub fn run_desktop() -> anyhow::Result<()> {
3902 DesktopApp::<crate::app::CounterState, _>::new(CounterApp).run()
3903}
3904
3905#[cfg(any(target_os = "android", target_os = "ios"))]
3906pub fn run_mobile() -> anyhow::Result<()> {
3907 mobile_app().run()
3908}
3909
3910#[cfg(target_os = "android")]
3911#[no_mangle]
3912fn android_main(app_handle: AndroidApp) {
3913 let _ = mobile_app().run_with_android_app(app_handle);
3914}
3915
3916#[cfg(target_arch = "wasm32")]
3917#[wasm_bindgen::prelude::wasm_bindgen(start)]
3918pub fn run_web() -> Result<(), wasm_bindgen::JsValue> {
3919 console_error_panic_hook::set_once();
3920 web_app()
3921 .run()
3922 .map_err(|error| wasm_bindgen::JsValue::from_str(&error.to_string()))
3923}
3924"#;
3925
3926const APP_RS: &str = r#"use fission::prelude::*;
3927
3928#[derive(Default, Debug, Clone, PartialEq)]
3929pub struct CounterState {
3930 pub count: i32,
3931}
3932
3933impl GlobalState for CounterState {}
3934
3935#[fission_reducer(Increment)]
3936fn on_increment(state: &mut CounterState) {
3937 state.count += 1;
3938}
3939
3940#[derive(Clone)]
3941pub struct CounterApp;
3942
3943impl From<CounterApp> for Widget {
3944 fn from(component: CounterApp) -> Self {
3945 let (ctx, view) = fission::build::current::<CounterState>();
3946 let increment = with_reducer!(ctx, Increment, on_increment);
3947
3948 Column {
3949 gap: Some(16.0),
3950 children: vec![
3951 Text::new(format!("Count: {}", view.state().count)).size(28.0).into(),
3952 Button {
3953 on_press: Some(increment),
3954 child: Some(Text::new("Increment").into()),
3955 ..Default::default()
3956 }
3957 .into(),
3958 ],
3959 ..Default::default()
3960 }
3961 .into()
3962
3963 }
3964}
3965"#;