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