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