Skip to main content

fission_command_core/
lib.rs

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"#;