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