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