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