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