Skip to main content

fission_command_core/
lib.rs

1use anyhow::{bail, Context, Result};
2use clap::ValueEnum;
3use serde::{Deserialize, Serialize};
4use std::collections::BTreeSet;
5use std::fs;
6use std::path::{Path, PathBuf};
7use toml_edit::{value, Array, DocumentMut, InlineTable, Item, Table, Value};
8
9const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION");
10const ANDROID_GRADLE_PLUGIN_VERSION: &str = "8.13.2";
11const DEFAULT_APP_ICON_PNG: &[u8] = include_bytes!("../assets/fission_logo.png");
12const GENERATED_APP_AGENTS_MARKER: &str = "<!-- fission-cli-generated-agents:v1 -->";
13const GENERATED_APP_AGENTS_MD: &str = include_str!("../assets/AGENTS.md");
14
15mod desktop_features;
16mod icons;
17mod linux_native;
18mod macos_native;
19mod macos_signing;
20mod native_cargo;
21mod native_variant;
22mod splash;
23mod windows_native;
24pub use desktop_features::{read_desktop_cargo_options, DesktopCargoOptions};
25pub use icons::{copy_icon_for_bundle, normalized_extension, resolve_app_icon, ResolvedIcon};
26pub use linux_native::{
27    build_linux_native_modules, stage_linux_native_products, test_linux_native_modules,
28    BuiltLinuxNativeProduct, NativeLinuxModuleConfig, NativeLinuxProductConfig,
29    NativeLinuxProductKind,
30};
31pub use macos_native::{
32    build_macos_native_modules, embed_and_sign_macos_native_modules, test_macos_native_modules,
33    MacosNativeBundleMode, NativeMacosModuleConfig, NativeMacosProductConfig,
34    NativeMacosProductKind, NativeMacosProductSigningConfig,
35};
36pub use macos_signing::{
37    read_macos_package_config, read_macos_package_config_for_profile,
38    read_macos_package_config_for_profile_and_variant, read_macos_run_config,
39    read_macos_run_config_for_profile, sign_macos_app_if_configured, MacosPackageConfig,
40};
41pub use native_variant::{ensure_native_variant_target, variant_output_path, NativeVariant};
42pub use splash::{SplashConfig, SplashResizeMode};
43pub use windows_native::{
44    build_windows_native_modules, stage_windows_runtime_products, test_windows_native_modules,
45    BuiltWindowsNativeProduct, NativeWindowsModuleConfig, NativeWindowsProductConfig,
46    NativeWindowsProductKind,
47};
48
49#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, ValueEnum, Serialize, Deserialize)]
50#[serde(rename_all = "kebab-case")]
51pub enum Target {
52    Android,
53    Ios,
54    Linux,
55    Macos,
56    #[value(name = "ssr", alias = "server")]
57    #[serde(rename = "ssr", alias = "server")]
58    Server,
59    #[value(name = "static-site", alias = "site")]
60    #[serde(rename = "static-site", alias = "site")]
61    Site,
62    Terminal,
63    Web,
64    Windows,
65}
66
67#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, ValueEnum, Serialize, Deserialize)]
68#[serde(rename_all = "kebab-case")]
69pub enum PlatformCapability {
70    BarcodeScanner,
71    Biometric,
72    Bluetooth,
73    Camera,
74    Geolocation,
75    Haptics,
76    Microphone,
77    Nfc,
78    Notifications,
79    Passkeys,
80    VolumeControl,
81    Wifi,
82}
83
84impl PlatformCapability {
85    pub fn as_str(self) -> &'static str {
86        match self {
87            Self::BarcodeScanner => "barcode-scanner",
88            Self::Biometric => "biometric",
89            Self::Bluetooth => "bluetooth",
90            Self::Camera => "camera",
91            Self::Geolocation => "geolocation",
92            Self::Haptics => "haptics",
93            Self::Microphone => "microphone",
94            Self::Nfc => "nfc",
95            Self::Notifications => "notifications",
96            Self::Passkeys => "passkeys",
97            Self::VolumeControl => "volume-control",
98            Self::Wifi => "wifi",
99        }
100    }
101}
102
103impl Target {
104    pub fn as_str(self) -> &'static str {
105        match self {
106            Self::Android => "android",
107            Self::Ios => "ios",
108            Self::Linux => "linux",
109            Self::Macos => "macos",
110            Self::Server => "ssr",
111            Self::Site => "static-site",
112            Self::Terminal => "terminal",
113            Self::Web => "web",
114            Self::Windows => "windows",
115        }
116    }
117
118    pub fn scaffold_relative_path(self) -> &'static str {
119        match self {
120            Self::Android => "platforms/android/README.md",
121            Self::Ios => "platforms/ios/README.md",
122            Self::Linux => "platforms/linux/README.md",
123            Self::Macos => "platforms/macos/README.md",
124            Self::Server => "platforms/ssr/README.md",
125            Self::Site => "platforms/site/README.md",
126            Self::Terminal => "platforms/terminal/README.md",
127            Self::Web => "platforms/web/README.md",
128            Self::Windows => "platforms/windows/README.md",
129        }
130    }
131}
132
133#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
134pub enum DistributionProvider {
135    #[value(name = "app-store")]
136    AppStore,
137    #[value(name = "github-pages")]
138    GithubPages,
139    #[value(name = "github-releases")]
140    GithubReleases,
141    #[value(name = "cloudflare-pages")]
142    CloudflarePages,
143    #[value(name = "docker-registry")]
144    DockerRegistry,
145    Dropbox,
146    #[value(name = "google-drive")]
147    GoogleDrive,
148    #[value(name = "microsoft-store")]
149    MicrosoftStore,
150    Netlify,
151    #[value(name = "onedrive")]
152    OneDrive,
153    #[value(name = "play-store")]
154    PlayStore,
155    S3,
156}
157
158impl DistributionProvider {
159    pub fn as_str(self) -> &'static str {
160        match self {
161            Self::AppStore => "app-store",
162            Self::GithubPages => "github-pages",
163            Self::GithubReleases => "github-releases",
164            Self::CloudflarePages => "cloudflare-pages",
165            Self::DockerRegistry => "docker-registry",
166            Self::Dropbox => "dropbox",
167            Self::GoogleDrive => "google-drive",
168            Self::MicrosoftStore => "microsoft-store",
169            Self::Netlify => "netlify",
170            Self::OneDrive => "onedrive",
171            Self::PlayStore => "play-store",
172            Self::S3 => "s3",
173        }
174    }
175}
176
177#[derive(Debug, Serialize, Deserialize)]
178pub struct FissionProject {
179    pub app: AppConfig,
180    pub targets: BTreeSet<Target>,
181    #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
182    pub capabilities: BTreeSet<PlatformCapability>,
183    #[serde(default, skip_serializing_if = "NativeConfig::is_empty")]
184    pub native: NativeConfig,
185}
186
187#[derive(Debug, Serialize, Deserialize)]
188pub struct AppConfig {
189    pub name: String,
190    #[serde(alias = "identifier", alias = "id")]
191    pub app_id: String,
192    #[serde(default, skip_serializing_if = "Option::is_none")]
193    pub splash: Option<SplashConfig>,
194}
195
196#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
197pub struct NativeConfig {
198    #[serde(default, skip_serializing_if = "Vec::is_empty")]
199    pub modules: Vec<NativeModuleConfig>,
200}
201
202#[derive(Clone, Debug, Default, PartialEq, Eq)]
203pub struct ReleaseVersionConfig {
204    pub version: Option<String>,
205    pub build: Option<u64>,
206}
207
208#[derive(Debug, Deserialize, Default)]
209struct ReleaseVersionToml {
210    app: Option<AppReleaseVersionConfig>,
211    package: Option<PackageReleaseVersionConfig>,
212    release: Option<ReleaseRootVersionConfig>,
213    #[serde(default)]
214    releases: Vec<ReleaseEntryVersionConfig>,
215}
216
217#[derive(Debug, Deserialize, Default)]
218struct AppReleaseVersionConfig {
219    version: Option<String>,
220    build: Option<u64>,
221}
222
223#[derive(Debug, Deserialize, Default)]
224struct PackageReleaseVersionConfig {
225    android: Option<AndroidReleaseVersionConfig>,
226    ios: Option<IosReleaseVersionConfig>,
227    macos: Option<MacosReleaseVersionConfig>,
228    windows: Option<WindowsReleaseVersionConfig>,
229}
230
231#[derive(Debug, Deserialize, Default)]
232struct AndroidReleaseVersionConfig {
233    version_code: Option<u64>,
234    version_name: Option<String>,
235}
236
237#[derive(Debug, Deserialize, Default)]
238struct IosReleaseVersionConfig {
239    marketing_version: Option<String>,
240    build_number: Option<String>,
241}
242
243#[derive(Debug, Deserialize, Default)]
244struct MacosReleaseVersionConfig {
245    marketing_version: Option<String>,
246    build_number: Option<String>,
247}
248
249#[derive(Debug, Deserialize, Default)]
250struct WindowsReleaseVersionConfig {
251    version: Option<String>,
252    identity_name: Option<String>,
253    publisher: Option<String>,
254}
255
256#[derive(Debug, Deserialize, Default)]
257struct ReleaseRootVersionConfig {
258    active_release: Option<String>,
259}
260
261#[derive(Debug, Deserialize, Default)]
262struct ReleaseEntryVersionConfig {
263    id: Option<String>,
264    version: Option<String>,
265    build: Option<u64>,
266}
267
268impl NativeConfig {
269    pub fn is_empty(&self) -> bool {
270        self.modules.is_empty()
271    }
272}
273
274#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
275pub struct NativeModuleConfig {
276    pub name: String,
277    #[serde(default, skip_serializing_if = "Option::is_none")]
278    pub path: Option<String>,
279    #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
280    pub variants: BTreeSet<NativeVariant>,
281    #[serde(default, skip_serializing_if = "NativeAndroidModuleConfig::is_empty")]
282    pub android: NativeAndroidModuleConfig,
283    #[serde(default, skip_serializing_if = "NativeIosModuleConfig::is_empty")]
284    pub ios: NativeIosModuleConfig,
285    #[serde(default, skip_serializing_if = "NativeLinuxModuleConfig::is_empty")]
286    pub linux: NativeLinuxModuleConfig,
287    #[serde(default, skip_serializing_if = "NativeMacosModuleConfig::is_empty")]
288    pub macos: NativeMacosModuleConfig,
289    #[serde(default, skip_serializing_if = "NativeWindowsModuleConfig::is_empty")]
290    pub windows: NativeWindowsModuleConfig,
291}
292
293#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
294pub struct NativeAndroidModuleConfig {
295    #[serde(default, skip_serializing_if = "Vec::is_empty")]
296    pub repositories: Vec<String>,
297    #[serde(default, skip_serializing_if = "Vec::is_empty")]
298    pub gradle_dependencies: Vec<String>,
299    #[serde(default, skip_serializing_if = "Vec::is_empty")]
300    pub source_dirs: Vec<String>,
301    #[serde(default, skip_serializing_if = "Vec::is_empty")]
302    pub permissions: Vec<String>,
303    #[serde(default, skip_serializing_if = "Vec::is_empty")]
304    pub manifest_application_entries: Vec<String>,
305}
306
307impl NativeAndroidModuleConfig {
308    pub fn is_empty(&self) -> bool {
309        self.repositories.is_empty()
310            && self.gradle_dependencies.is_empty()
311            && self.source_dirs.is_empty()
312            && self.permissions.is_empty()
313            && self.manifest_application_entries.is_empty()
314    }
315}
316
317#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
318pub struct NativeIosModuleConfig {
319    #[serde(default, skip_serializing_if = "Vec::is_empty")]
320    pub swift_packages: Vec<NativeIosSwiftPackageConfig>,
321    #[serde(default, skip_serializing_if = "Vec::is_empty")]
322    pub source_dirs: Vec<String>,
323    #[serde(default, skip_serializing_if = "Vec::is_empty")]
324    pub linked_frameworks: Vec<String>,
325}
326
327impl NativeIosModuleConfig {
328    pub fn is_empty(&self) -> bool {
329        self.swift_packages.is_empty()
330            && self.source_dirs.is_empty()
331            && self.linked_frameworks.is_empty()
332    }
333}
334
335#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
336pub struct NativeIosSwiftPackageConfig {
337    pub url: String,
338    pub product: String,
339    #[serde(default, skip_serializing_if = "Option::is_none")]
340    pub from: Option<String>,
341}
342
343#[derive(Debug, Deserialize)]
344struct CargoManifest {
345    package: Option<CargoPackage>,
346}
347
348#[derive(Debug, Deserialize)]
349struct CargoPackage {
350    pub name: String,
351}
352
353#[derive(Clone, Copy, Debug, Eq, PartialEq)]
354enum WritePolicy {
355    Overwrite,
356    PreserveExisting,
357}
358
359pub fn init_project(
360    root: &Path,
361    name: Option<String>,
362    app_id: Option<String>,
363    local_path: Option<PathBuf>,
364) -> Result<()> {
365    let existing_project = root.exists() && root.read_dir()?.next().is_some();
366    fs::create_dir_all(root.join("src"))?;
367
368    let write_policy = if existing_project {
369        WritePolicy::PreserveExisting
370    } else {
371        WritePolicy::Overwrite
372    };
373    let project = initial_project_config(root, name, app_id)?;
374
375    write_file_with_policy(
376        &root.join("Cargo.toml"),
377        &render_cargo_toml(&project, local_path.as_deref()),
378        write_policy,
379    )?;
380    write_file_with_policy(
381        &root.join("src/main.rs"),
382        &render_app_main(project.app.name.as_str()),
383        write_policy,
384    )?;
385    write_file_with_policy(&root.join("src/lib.rs"), APP_LIB, write_policy)?;
386    write_file_with_policy(&root.join("src/app.rs"), APP_RS, write_policy)?;
387    write_binary_file_with_policy(
388        &root.join("assets/app-icon.png"),
389        DEFAULT_APP_ICON_PNG,
390        write_policy,
391    )?;
392    write_file_with_policy(
393        &root.join("README.md"),
394        &render_project_readme(&project),
395        write_policy,
396    )?;
397    write_generated_app_agents(root)?;
398    write_file_with_policy(
399        &root.join(".gitignore"),
400        "target/\nplatforms/*/build/\n",
401        write_policy,
402    )?;
403    write_project_config(root, &project)?;
404
405    let targets = project.targets.iter().copied().collect::<Vec<_>>();
406    for target in targets {
407        scaffold_target_with_policy(root, &project, target, write_policy)?;
408    }
409    sync_platform_config(root, &project)?;
410    sync_cargo_fission_dependency(root, &project, local_path.as_deref())?;
411
412    Ok(())
413}
414
415fn initial_project_config(
416    root: &Path,
417    name: Option<String>,
418    app_id: Option<String>,
419) -> Result<FissionProject> {
420    let existing = if root.join("fission.toml").exists() {
421        Some(read_project_config(root)?)
422    } else {
423        None
424    };
425    let cargo_name = cargo_package_name(root);
426    if let (Some(requested), Some(cargo_name)) = (&name, &cargo_name) {
427        let requested = normalize_crate_name(requested);
428        let cargo_name = normalize_crate_name(cargo_name);
429        if requested != cargo_name {
430            bail!(
431                "refusing to set app name `{requested}` for existing Cargo package `{cargo_name}`; rename the package in Cargo.toml first or omit --name"
432            );
433        }
434    }
435    let project_name = cargo_name
436        .or(name)
437        .or_else(|| existing.as_ref().map(|project| project.app.name.clone()))
438        .unwrap_or_else(|| {
439            root.file_name()
440                .and_then(|value| value.to_str())
441                .unwrap_or("fission-app")
442                .to_string()
443        });
444    let normalized_name = normalize_crate_name(&project_name);
445
446    let mut targets = existing
447        .as_ref()
448        .map(|project| project.targets.clone())
449        .unwrap_or_default();
450    targets.extend(detect_project_targets(root));
451    if targets.is_empty() {
452        targets.extend([Target::Windows, Target::Macos, Target::Linux]);
453    }
454
455    Ok(FissionProject {
456        app: AppConfig {
457            name: normalized_name.clone(),
458            app_id: app_id
459                .or_else(|| existing.as_ref().map(|project| project.app.app_id.clone()))
460                .unwrap_or_else(|| format!("com.example.{}", normalized_name.replace('-', "_"))),
461            splash: existing
462                .as_ref()
463                .and_then(|project| project.app.splash.clone()),
464        },
465        targets,
466        capabilities: existing
467            .as_ref()
468            .map(|project| project.capabilities.clone())
469            .unwrap_or_default(),
470        native: existing
471            .as_ref()
472            .map(|project| project.native.clone())
473            .unwrap_or_default(),
474    })
475}
476
477pub fn cargo_package_name(root: &Path) -> Option<String> {
478    let manifest = fs::read_to_string(root.join("Cargo.toml")).ok()?;
479    let manifest: CargoManifest = toml::from_str(&manifest).ok()?;
480    manifest.package.map(|package| package.name)
481}
482
483pub fn cargo_package_version(root: &Path) -> Option<String> {
484    let manifest = fs::read_to_string(root.join("Cargo.toml")).ok()?;
485    let value: toml::Value = toml::from_str(&manifest).ok()?;
486    value
487        .get("package")
488        .and_then(|package| package.get("version"))
489        .and_then(toml::Value::as_str)
490        .map(str::to_string)
491}
492
493fn detect_project_targets(root: &Path) -> BTreeSet<Target> {
494    let mut targets = BTreeSet::new();
495    if root.join("src/main.rs").exists() || root.join("src/lib.rs").exists() {
496        targets.extend([Target::Windows, Target::Macos, Target::Linux]);
497    }
498    for (target, relative) in [
499        (Target::Android, "platforms/android"),
500        (Target::Ios, "platforms/ios"),
501        (Target::Linux, "platforms/linux"),
502        (Target::Macos, "platforms/macos"),
503        (Target::Server, "platforms/ssr"),
504        (Target::Site, "content"),
505        (Target::Terminal, "platforms/terminal"),
506        (Target::Web, "platforms/web"),
507        (Target::Windows, "platforms/windows"),
508    ] {
509        if root.join(relative).exists() {
510            targets.insert(target);
511        }
512    }
513    for (target, relative) in [
514        (Target::Server, "platforms/server"),
515        (Target::Site, "platforms/site"),
516    ] {
517        if root.join(relative).exists() {
518            targets.insert(target);
519        }
520    }
521    targets
522}
523
524pub fn add_targets(project_dir: &Path, targets: &[Target]) -> Result<()> {
525    if targets.is_empty() {
526        bail!("no targets provided");
527    }
528    let mut project = read_project_config(project_dir)?;
529    for target in targets {
530        let target_exists =
531            project.targets.contains(target) || target_scaffold_dir_exists(project_dir, *target);
532        project.targets.insert(*target);
533        let write_policy = if target_exists {
534            WritePolicy::PreserveExisting
535        } else {
536            WritePolicy::Overwrite
537        };
538        scaffold_target_with_policy(project_dir, &project, *target, write_policy)?;
539    }
540    sync_platform_config(project_dir, &project)?;
541    write_project_config(project_dir, &project)?;
542    update_cargo_fission_features(project_dir, &project)?;
543    write_file_with_policy(
544        &project_dir.join("README.md"),
545        &render_project_readme(&project),
546        WritePolicy::PreserveExisting,
547    )?;
548    Ok(())
549}
550
551pub fn add_capabilities(project_dir: &Path, capabilities: &[PlatformCapability]) -> Result<()> {
552    if capabilities.is_empty() {
553        bail!("no capabilities provided");
554    }
555    let mut project = read_project_config(project_dir)?;
556    for capability in capabilities {
557        project.capabilities.insert(*capability);
558    }
559    write_project_config(project_dir, &project)?;
560    sync_platform_config(project_dir, &project)?;
561    Ok(())
562}
563
564pub fn sync_platform_config(root: &Path, project: &FissionProject) -> Result<()> {
565    apply_platform_capability_config(root, project)?;
566    apply_native_module_config(root, project)?;
567    splash::apply_platform_splash_config(root, project)?;
568    icons::apply_platform_icon_config(root, project)?;
569    apply_mobile_run_script_hardening(root, project)?;
570    Ok(())
571}
572
573pub fn resolve_release_version_config(
574    project_dir: &Path,
575    target: Option<Target>,
576) -> Result<ReleaseVersionConfig> {
577    let path = project_dir.join("fission.toml");
578    let data =
579        fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))?;
580    let manifest: ReleaseVersionToml = toml::from_str(&data).unwrap_or_default();
581    let active = manifest
582        .release
583        .as_ref()
584        .and_then(|release| release.active_release.as_deref())
585        .and_then(|id| {
586            manifest
587                .releases
588                .iter()
589                .find(|release| release.id.as_deref() == Some(id))
590        });
591
592    let mut version = active
593        .and_then(|release| release.version.clone())
594        .or_else(|| manifest.app.as_ref().and_then(|app| app.version.clone()));
595    let mut build = active
596        .and_then(|release| release.build)
597        .or_else(|| manifest.app.as_ref().and_then(|app| app.build));
598
599    match target {
600        Some(Target::Android) => {
601            if let Some(android) = manifest
602                .package
603                .as_ref()
604                .and_then(|package| package.android.as_ref())
605            {
606                version = android.version_name.clone().or(version);
607                build = android.version_code.or(build);
608            }
609        }
610        Some(Target::Ios) => {
611            if let Some(ios) = manifest
612                .package
613                .as_ref()
614                .and_then(|package| package.ios.as_ref())
615            {
616                version = ios.marketing_version.clone().or(version);
617                build = ios
618                    .build_number
619                    .as_deref()
620                    .and_then(|value| value.parse::<u64>().ok())
621                    .or(build);
622            }
623        }
624        Some(Target::Macos) => {
625            if let Some(macos) = manifest
626                .package
627                .as_ref()
628                .and_then(|package| package.macos.as_ref())
629            {
630                version = macos.marketing_version.clone().or(version);
631                build = macos
632                    .build_number
633                    .as_deref()
634                    .and_then(|value| value.parse::<u64>().ok())
635                    .or(build);
636            }
637        }
638        Some(Target::Windows) => {
639            if let Some(windows) = manifest
640                .package
641                .as_ref()
642                .and_then(|package| package.windows.as_ref())
643            {
644                version = windows.version.clone().or(version);
645            }
646        }
647        _ => {}
648    }
649
650    if version.is_none() {
651        version = cargo_package_version(project_dir);
652    }
653    Ok(ReleaseVersionConfig { version, build })
654}
655
656pub fn sync_release_platform_config(
657    project_dir: &Path,
658    target: Target,
659    release: &ReleaseVersionConfig,
660) -> Result<()> {
661    match target {
662        Target::Android => sync_android_release_config(project_dir, release),
663        Target::Ios => sync_ios_release_config(project_dir, release),
664        Target::Macos => sync_macos_release_config(project_dir, release),
665        Target::Windows => sync_windows_release_config(project_dir, release),
666        _ => Ok(()),
667    }
668}
669
670pub fn sync_resolved_release_platform_config(
671    project_dir: &Path,
672    target: Target,
673) -> Result<ReleaseVersionConfig> {
674    let release = resolve_release_version_config(project_dir, Some(target))?;
675    sync_release_platform_config(project_dir, target, &release)?;
676    Ok(release)
677}
678
679fn sync_android_release_config(project_dir: &Path, release: &ReleaseVersionConfig) -> Result<()> {
680    let path = project_dir.join("platforms/android/app/build.gradle.kts");
681    if !path.exists() {
682        return Ok(());
683    }
684    let version = release.version.as_deref().unwrap_or("0.1.0");
685    let build = release.build.unwrap_or(1);
686    rewrite_file_lines(&path, |trimmed| {
687        if trimmed.starts_with("versionCode =") {
688            Some(format!(
689                "        versionCode = (System.getenv(\"ANDROID_VERSION_CODE\") ?: \"{build}\").toInt()"
690            ))
691        } else if trimmed.starts_with("versionName =") {
692            Some(format!(
693                "        versionName = System.getenv(\"ANDROID_VERSION_NAME\") ?: \"{version}\""
694            ))
695        } else {
696            None
697        }
698    })
699}
700
701fn sync_ios_release_config(project_dir: &Path, release: &ReleaseVersionConfig) -> Result<()> {
702    let path = project_dir.join("platforms/ios/package-sim.sh");
703    if path.exists() {
704        let version = release.version.as_deref().unwrap_or("0.1.0");
705        let build = release.build.unwrap_or(1);
706        let existing = fs::read_to_string(&path)?;
707        let mut data = existing.clone();
708        if !data.contains("IOS_MARKETING_VERSION") {
709            data = data.replace(
710                "BUNDLE_NAME=\"${IOS_BUNDLE_NAME:-$DISPLAY_NAME.app}\"\n",
711                &format!(
712                    "BUNDLE_NAME=\"${{IOS_BUNDLE_NAME:-$DISPLAY_NAME.app}}\"\nIOS_MARKETING_VERSION=\"${{IOS_MARKETING_VERSION:-{version}}}\"\nIOS_BUILD_NUMBER=\"${{IOS_BUILD_NUMBER:-{build}}}\"\n"
713                ),
714            );
715        } else {
716            data = data
717                .lines()
718                .map(|line| {
719                    if line.starts_with("IOS_MARKETING_VERSION=") {
720                        format!("IOS_MARKETING_VERSION=\"${{IOS_MARKETING_VERSION:-{version}}}\"")
721                    } else if line.starts_with("IOS_BUILD_NUMBER=") {
722                        format!("IOS_BUILD_NUMBER=\"${{IOS_BUILD_NUMBER:-{build}}}\"")
723                    } else {
724                        line.to_string()
725                    }
726                })
727                .collect::<Vec<_>>()
728                .join("\n");
729            data.push('\n');
730        }
731        if data != existing {
732            fs::write(&path, data)?;
733        }
734    }
735    let plist = project_dir.join("platforms/ios/Info.plist");
736    if plist.exists() {
737        let version = release.version.as_deref().unwrap_or("0.1.0");
738        let build = release.build.unwrap_or(1).to_string();
739        rewrite_plist_string(&plist, "CFBundleShortVersionString", version)?;
740        rewrite_plist_string(&plist, "CFBundleVersion", &build)?;
741    }
742    Ok(())
743}
744
745fn sync_macos_release_config(project_dir: &Path, release: &ReleaseVersionConfig) -> Result<()> {
746    let plist = project_dir.join("platforms/macos/Info.plist");
747    if plist.exists() {
748        let version = release.version.as_deref().unwrap_or("0.1.0");
749        let build = release.build.unwrap_or(1).to_string();
750        rewrite_plist_string(&plist, "CFBundleShortVersionString", version)?;
751        rewrite_plist_string(&plist, "CFBundleVersion", &build)?;
752    }
753    Ok(())
754}
755
756fn sync_windows_release_config(project_dir: &Path, release: &ReleaseVersionConfig) -> Result<()> {
757    let config = read_windows_release_config(project_dir)?;
758    let manifests = [
759        project_dir.join("platforms/windows/Package.appxmanifest"),
760        project_dir.join("platforms/windows/AppxManifest.xml"),
761        project_dir.join("platforms/windows/appxmanifest.xml"),
762    ];
763    let has_manifest = manifests.iter().any(|path| path.exists());
764    if !has_manifest {
765        return Ok(());
766    }
767
768    let version = normalized_windows_package_version(release)?;
769    for path in manifests.into_iter().filter(|path| path.exists()) {
770        rewrite_windows_appx_manifest(
771            &path,
772            &version,
773            config.identity_name.as_deref(),
774            config.publisher.as_deref(),
775        )?;
776    }
777    Ok(())
778}
779
780fn read_windows_release_config(project_dir: &Path) -> Result<WindowsReleaseVersionConfig> {
781    let path = project_dir.join("fission.toml");
782    let data = fs::read_to_string(&path).unwrap_or_default();
783    let manifest: ReleaseVersionToml = toml::from_str(&data).unwrap_or_default();
784    Ok(manifest
785        .package
786        .and_then(|package| package.windows)
787        .unwrap_or_default())
788}
789
790pub fn normalize_windows_package_version(
791    version: Option<&str>,
792    build: Option<u64>,
793) -> Result<String> {
794    let version = version.unwrap_or("0.1.0");
795    let parts = version.split('.').collect::<Vec<_>>();
796    if parts.is_empty() || parts.len() > 4 {
797        bail!("Windows package version `{version}` must have one to four numeric components");
798    }
799    let mut normalized = Vec::with_capacity(4);
800    for part in &parts {
801        let value = part
802            .parse::<u16>()
803            .with_context(|| format!("Windows package version `{version}` must be numeric"))?;
804        normalized.push(value.to_string());
805    }
806    while normalized.len() < 3 {
807        normalized.push("0".to_string());
808    }
809    if normalized.len() == 3 {
810        let build = build.unwrap_or(0);
811        if build > u16::MAX as u64 {
812            bail!("Windows package build `{build}` must fit in a 16-bit version component");
813        }
814        normalized.push(build.to_string());
815    }
816    Ok(normalized.join("."))
817}
818
819fn normalized_windows_package_version(release: &ReleaseVersionConfig) -> Result<String> {
820    normalize_windows_package_version(release.version.as_deref(), release.build)
821}
822
823fn rewrite_windows_appx_manifest(
824    path: &Path,
825    version: &str,
826    identity_name: Option<&str>,
827    publisher: Option<&str>,
828) -> Result<()> {
829    let existing = fs::read_to_string(path)?;
830    let mut updated = rewrite_xml_attribute_on_tag(&existing, "Identity", "Version", version);
831    if let Some(identity_name) = identity_name.filter(|value| !value.trim().is_empty()) {
832        updated = rewrite_xml_attribute_on_tag(&updated, "Identity", "Name", identity_name.trim());
833    }
834    if let Some(publisher) = publisher.filter(|value| !value.trim().is_empty()) {
835        updated = rewrite_xml_attribute_on_tag(&updated, "Identity", "Publisher", publisher.trim());
836    }
837    if updated != existing {
838        fs::write(path, updated)?;
839    }
840    Ok(())
841}
842
843fn rewrite_xml_attribute_on_tag(input: &str, tag: &str, attribute: &str, value: &str) -> String {
844    let Some(tag_start) = input.find(&format!("<{tag}")) else {
845        return input.to_string();
846    };
847    let Some(relative_end) = input[tag_start..].find('>') else {
848        return input.to_string();
849    };
850    let tag_end = tag_start + relative_end;
851    let mut output = input.to_string();
852    let tag_text = &input[tag_start..=tag_end];
853    let escaped = escape_xml_attribute(value);
854    let updated_tag = if let Some(attribute_start) = tag_text.find(&format!("{attribute}=\"")) {
855        let value_start = attribute_start + attribute.len() + 2;
856        if let Some(relative_quote) = tag_text[value_start..].find('"') {
857            let value_end = value_start + relative_quote;
858            let mut tag_output = tag_text.to_string();
859            tag_output.replace_range(value_start..value_end, &escaped);
860            tag_output
861        } else {
862            tag_text.to_string()
863        }
864    } else {
865        let insert_at = tag_text
866            .rfind('/')
867            .filter(|slash| *slash + 1 == tag_text.len() - 1)
868            .unwrap_or(tag_text.len() - 1);
869        let mut tag_output = tag_text.to_string();
870        tag_output.insert_str(insert_at, &format!(" {attribute}=\"{escaped}\""));
871        tag_output
872    };
873    output.replace_range(tag_start..=tag_end, &updated_tag);
874    output
875}
876
877fn escape_xml_attribute(value: &str) -> String {
878    value
879        .replace('&', "&amp;")
880        .replace('"', "&quot;")
881        .replace('<', "&lt;")
882        .replace('>', "&gt;")
883}
884
885fn rewrite_file_lines<F>(path: &Path, mut replacement: F) -> Result<()>
886where
887    F: FnMut(&str) -> Option<String>,
888{
889    let existing = fs::read_to_string(path)?;
890    let mut updated = String::new();
891    for line in existing.lines() {
892        if let Some(new_line) = replacement(line.trim_start()) {
893            updated.push_str(&new_line);
894            updated.push('\n');
895        } else {
896            updated.push_str(line);
897            updated.push('\n');
898        }
899    }
900    if updated != existing {
901        fs::write(path, updated)?;
902    }
903    Ok(())
904}
905
906fn rewrite_plist_string(path: &Path, key: &str, value: &str) -> Result<()> {
907    let existing = fs::read_to_string(path)?;
908    let mut lines = existing.lines().peekable();
909    let mut updated = String::new();
910    while let Some(line) = lines.next() {
911        updated.push_str(line);
912        updated.push('\n');
913        if line.trim() == format!("<key>{key}</key>") {
914            let _ = lines.next();
915            updated.push_str(&format!("  <string>{value}</string>\n"));
916        }
917    }
918    if updated != existing {
919        fs::write(path, updated)?;
920    }
921    Ok(())
922}
923
924fn apply_native_module_config(root: &Path, project: &FissionProject) -> Result<()> {
925    if project.targets.contains(&Target::Android) {
926        write_file(
927            &root.join("platforms/android/native-modules.gradle"),
928            &render_android_native_modules_gradle(project),
929        )?;
930        apply_android_settings_gradle_hardening(root, project)?;
931        apply_android_native_manifest_entries(root, project)?;
932    }
933    if project.targets.contains(&Target::Ios) {
934        write_file(
935            &root.join("platforms/ios/NativeModules/Package.swift"),
936            &render_ios_native_modules_package(project),
937        )?;
938        write_file(
939            &root.join(
940                "platforms/ios/NativeModules/Sources/FissionNativeModules/FissionNativeCapabilities.swift",
941            ),
942            render_ios_native_capabilities_swift(),
943        )?;
944        sync_ios_native_module_sources(root, project)?;
945    }
946    Ok(())
947}
948
949fn apply_android_native_manifest_entries(root: &Path, project: &FissionProject) -> Result<()> {
950    let entries = render_android_native_application_entries(project);
951    if entries.trim().is_empty() {
952        return Ok(());
953    }
954    let path = root.join("platforms/android/AndroidManifest.xml");
955    if !path.exists() {
956        return Ok(());
957    }
958    let existing =
959        fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))?;
960    let missing = entries
961        .lines()
962        .filter(|entry| !entry.trim().is_empty() && !existing.contains(entry.trim()))
963        .collect::<Vec<_>>();
964    if missing.is_empty() {
965        return Ok(());
966    }
967
968    let insertion = format!("{}\n", missing.join("\n"));
969    let marker =
970        "        <activity\n            android:name=\"rs.fission.runtime.FissionActivity\"";
971    let updated = if let Some(index) = existing.find(marker) {
972        let mut updated = existing.clone();
973        updated.insert_str(index, &insertion);
974        updated
975    } else if let Some(index) = existing.find("</application>") {
976        let mut updated = existing.clone();
977        updated.insert_str(index, &insertion);
978        updated
979    } else {
980        existing
981    };
982
983    if updated != fs::read_to_string(&path)? {
984        fs::write(&path, updated).with_context(|| format!("failed to write {}", path.display()))?;
985    }
986    Ok(())
987}
988
989fn sync_ios_native_module_sources(root: &Path, project: &FissionProject) -> Result<()> {
990    let generated_root = root.join("platforms/ios/NativeModules/Sources/FissionNativeModules");
991    fs::create_dir_all(&generated_root)
992        .with_context(|| format!("failed to create {}", generated_root.display()))?;
993
994    for module in &project.native.modules {
995        let module_dir = generated_root.join(swift_module_source_dir_name(&module.name));
996        if module_dir.exists() {
997            fs::remove_dir_all(&module_dir)
998                .with_context(|| format!("failed to remove {}", module_dir.display()))?;
999        }
1000        if module.ios.source_dirs.is_empty() {
1001            continue;
1002        }
1003        fs::create_dir_all(&module_dir)
1004            .with_context(|| format!("failed to create {}", module_dir.display()))?;
1005        for source_dir in &module.ios.source_dirs {
1006            let source_dir = source_dir.trim();
1007            if source_dir.is_empty() {
1008                continue;
1009            }
1010            let source = resolve_project_path(root, source_dir);
1011            copy_dir_contents(&source, &module_dir).with_context(|| {
1012                format!(
1013                    "failed to copy iOS native module source {} into {}",
1014                    source.display(),
1015                    module_dir.display()
1016                )
1017            })?;
1018        }
1019    }
1020    Ok(())
1021}
1022
1023fn resolve_project_path(root: &Path, value: &str) -> PathBuf {
1024    let path = Path::new(value);
1025    if path.is_absolute() {
1026        path.to_path_buf()
1027    } else {
1028        root.join(path)
1029    }
1030}
1031
1032fn swift_module_source_dir_name(name: &str) -> String {
1033    let mut output = String::new();
1034    for ch in name.chars() {
1035        if ch.is_ascii_alphanumeric() {
1036            output.push(ch);
1037        } else if !output.ends_with('_') {
1038            output.push('_');
1039        }
1040    }
1041    let output = output.trim_matches('_');
1042    if output.is_empty() {
1043        "module".to_string()
1044    } else {
1045        output.to_string()
1046    }
1047}
1048
1049fn copy_dir_contents(source: &Path, dest: &Path) -> Result<()> {
1050    if source.is_file() {
1051        let file_name = source
1052            .file_name()
1053            .ok_or_else(|| anyhow::anyhow!("source file has no file name"))?;
1054        fs::create_dir_all(dest)?;
1055        fs::copy(source, dest.join(file_name))?;
1056        return Ok(());
1057    }
1058    fs::create_dir_all(dest)?;
1059    for entry in fs::read_dir(source)
1060        .with_context(|| format!("failed to read native source dir {}", source.display()))?
1061    {
1062        let entry = entry?;
1063        let path = entry.path();
1064        let target = dest.join(entry.file_name());
1065        if path.is_dir() {
1066            copy_dir_contents(&path, &target)?;
1067        } else if path.is_file() {
1068            fs::copy(&path, &target)
1069                .with_context(|| format!("failed to copy {}", path.display()))?;
1070        }
1071    }
1072    Ok(())
1073}
1074
1075/// Stages the project's `assets` directory as an application resource tree.
1076///
1077/// Desktop run and package commands use the same `assets` layout on every
1078/// platform so applications can ship large resources without compiling them
1079/// into the executable. A project without an `assets` directory is valid.
1080pub fn stage_project_assets(
1081    project_dir: &Path,
1082    destination_root: &Path,
1083) -> Result<Option<PathBuf>> {
1084    let source = project_dir.join("assets");
1085    if !source.exists() {
1086        return Ok(None);
1087    }
1088    if !source.is_dir() {
1089        bail!(
1090            "project assets path {} must be a directory",
1091            source.display()
1092        );
1093    }
1094    let destination = destination_root.join("assets");
1095    if destination.exists() {
1096        fs::remove_dir_all(&destination).with_context(|| {
1097            format!(
1098                "failed to clear staged project assets {}",
1099                destination.display()
1100            )
1101        })?;
1102    }
1103    copy_dir_contents(&source, &destination).with_context(|| {
1104        format!(
1105            "failed to stage project assets from {} to {}",
1106            source.display(),
1107            destination.display()
1108        )
1109    })?;
1110    Ok(Some(destination))
1111}
1112
1113fn apply_mobile_run_script_hardening(root: &Path, project: &FissionProject) -> Result<()> {
1114    if project.targets.contains(&Target::Ios) {
1115        apply_ios_run_script_hardening(root)?;
1116        apply_ios_package_script_hardening(root)?;
1117    }
1118    if project.targets.contains(&Target::Android) {
1119        apply_android_run_script_hardening(root)?;
1120        apply_android_package_script_hardening(root)?;
1121        apply_android_manifest_hardening(root)?;
1122        apply_android_root_build_gradle_hardening(root)?;
1123        apply_android_app_build_gradle_hardening(root)?;
1124        apply_android_gradle_properties_hardening(root)?;
1125    }
1126    Ok(())
1127}
1128
1129fn apply_ios_run_script_hardening(root: &Path) -> Result<()> {
1130    let path = root.join("platforms/ios/run-sim.sh");
1131    if !path.exists() {
1132        return Ok(());
1133    }
1134    let existing =
1135        fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))?;
1136    if existing.contains("IOS_SIM_UNINSTALL_BEFORE_INSTALL") {
1137        return Ok(());
1138    }
1139    let marker = "xcrun simctl bootstatus \"$DEVICE_ID\" -b\n";
1140    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";
1141    let updated = existing.replacen(marker, insertion, 1);
1142    fs::write(&path, updated).with_context(|| format!("failed to write {}", path.display()))
1143}
1144
1145fn apply_ios_package_script_hardening(root: &Path) -> Result<()> {
1146    let path = root.join("platforms/ios/package-sim.sh");
1147    if !path.exists() {
1148        return Ok(());
1149    }
1150    let existing =
1151        fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))?;
1152    let mut updated = existing.clone();
1153    if updated.contains("import plistlib") {
1154        let Some(start) = updated.find("python3 - <<'PY' \"$SCRIPT_DIR/Info.plist\"") else {
1155            return Ok(());
1156        };
1157        let Some(relative_end) = updated[start..].find("\nPY") else {
1158            return Ok(());
1159        };
1160        let end = start + relative_end + "\nPY\n".len();
1161        updated.replace_range(start..end, IOS_INFO_PLIST_PLUTIL_PATCH);
1162    }
1163    if !updated.contains("IOS_MARKETING_VERSION") {
1164        updated = updated.replacen(
1165            "BUNDLE_NAME=\"${IOS_BUNDLE_NAME:-$DISPLAY_NAME.app}\"\n",
1166            "BUNDLE_NAME=\"${IOS_BUNDLE_NAME:-$DISPLAY_NAME.app}\"\nIOS_MARKETING_VERSION=\"${IOS_MARKETING_VERSION:-0.1.0}\"\nIOS_BUILD_NUMBER=\"${IOS_BUILD_NUMBER:-1}\"\n",
1167            1,
1168        );
1169    }
1170    if updated != existing {
1171        fs::write(&path, updated).with_context(|| format!("failed to write {}", path.display()))?;
1172    }
1173    Ok(())
1174}
1175
1176fn apply_android_run_script_hardening(root: &Path) -> Result<()> {
1177    let path = root.join("platforms/android/run-emulator.sh");
1178    if !path.exists() {
1179        return Ok(());
1180    }
1181    let existing =
1182        fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))?;
1183    if existing.contains(":app:assemble") {
1184        return Ok(());
1185    }
1186    let mut updated = existing.clone();
1187    let wait_function = android_wait_for_boot_function();
1188    if let Some(start) = updated.find("wait_for_android_boot() {") {
1189        let marker = "\n}\n\nANDROID_EMULATOR_API_LEVEL=";
1190        if let Some(relative_end) = updated[start..].find(marker) {
1191            let end = start + relative_end + "\n}\n\n".len();
1192            updated.replace_range(start..end, &format!("{wait_function}\n\n"));
1193        }
1194    } else {
1195        updated = updated.replacen(
1196            "\nANDROID_EMULATOR_API_LEVEL=",
1197            &format!("\n{wait_function}\n\nANDROID_EMULATOR_API_LEVEL="),
1198            1,
1199        );
1200    }
1201    updated =
1202        replace_android_boot_wait_after(updated, "  disown || true\n", "  wait_for_android_boot\n");
1203    updated = replace_android_boot_wait_after(
1204        updated,
1205        "  \"$EMULATOR_BIN\" \"${EMULATOR_ARGS[@]}\" >/tmp/fission-android-emulator.log 2>&1 &\n",
1206        "  wait_for_android_boot\n",
1207    );
1208    if !updated.contains(
1209        "printf 'Using existing emulator %s\\n' \"$RUNNING_EMULATOR\"\n  wait_for_android_boot\n",
1210    ) {
1211        updated = updated.replacen(
1212            "printf 'Using existing emulator %s\\n' \"$RUNNING_EMULATOR\"\n",
1213            "printf 'Using existing emulator %s\\n' \"$RUNNING_EMULATOR\"\n  wait_for_android_boot\n",
1214            1,
1215        );
1216    }
1217    while updated.contains("  wait_for_android_boot\n  wait_for_android_boot\n") {
1218        updated = updated.replace(
1219            "  wait_for_android_boot\n  wait_for_android_boot\n",
1220            "  wait_for_android_boot\n",
1221        );
1222    }
1223    updated = updated.replace(
1224        "\"$ADB\" install -r \"$APK\"",
1225        "read -r -a ADB_INSTALL_FLAGS <<< \"${ADB_INSTALL_FLAGS:---no-streaming -r}\"\n\"$ADB\" install \"${ADB_INSTALL_FLAGS[@]}\" \"$APK\"",
1226    );
1227    if updated != existing {
1228        fs::write(&path, updated).with_context(|| format!("failed to write {}", path.display()))?;
1229    }
1230    Ok(())
1231}
1232
1233fn apply_android_package_script_hardening(root: &Path) -> Result<()> {
1234    let path = root.join("platforms/android/package-apk.sh");
1235    if !path.exists() {
1236        return Ok(());
1237    }
1238    let existing =
1239        fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))?;
1240    let mut updated = existing.clone();
1241    if updated.contains("import re\nimport sys\n") && !updated.contains("import pathlib\n") {
1242        updated = updated.replace(
1243            "import re\nimport sys\n",
1244            "import pathlib\nimport re\nimport sys\n",
1245        );
1246    }
1247    let has_code_line = r#"has_code = "true" if pathlib.Path(dest).with_name("apk-root").joinpath("classes.dex").exists() else "false"
1248manifest = re.sub(r'android:hasCode="(?:true|false)"', f'android:hasCode="{has_code}"', manifest)
1249"#;
1250    if !updated.contains("android:hasCode=") || !updated.contains("with_name(\"apk-root\")") {
1251        updated = updated.replace(
1252            "manifest = re.sub(r'android:targetSdkVersion=\"\\d+\"', f'android:targetSdkVersion=\"{target_api}\"', manifest)\n",
1253            &format!(
1254                "manifest = re.sub(r'android:targetSdkVersion=\"\\d+\"', f'android:targetSdkVersion=\"{{target_api}}\"', manifest)\n{has_code_line}"
1255            ),
1256        );
1257    }
1258    if updated != existing {
1259        fs::write(&path, updated).with_context(|| format!("failed to write {}", path.display()))?;
1260    }
1261    Ok(())
1262}
1263
1264fn apply_android_manifest_hardening(root: &Path) -> Result<()> {
1265    let path = root.join("platforms/android/AndroidManifest.xml");
1266    if !path.exists() {
1267        return Ok(());
1268    }
1269    let existing =
1270        fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))?;
1271    if existing.contains("rs.fission.runtime.FissionActivity") {
1272        return Ok(());
1273    }
1274    let updated = existing.replace(r#"android:hasCode="true""#, r#"android:hasCode="false""#);
1275    if updated != existing {
1276        fs::write(&path, updated).with_context(|| format!("failed to write {}", path.display()))?;
1277    }
1278    Ok(())
1279}
1280
1281fn apply_android_root_build_gradle_hardening(root: &Path) -> Result<()> {
1282    let path = root.join("platforms/android/build.gradle.kts");
1283    if !path.exists() {
1284        return Ok(());
1285    }
1286    let existing =
1287        fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))?;
1288    let mut updated = String::new();
1289    for line in existing.lines() {
1290        if line
1291            .trim_start()
1292            .starts_with("id(\"com.android.application\") version ")
1293        {
1294            let indent = line
1295                .chars()
1296                .take_while(|ch| ch.is_whitespace())
1297                .collect::<String>();
1298            updated.push_str(&format!(
1299                "{indent}id(\"com.android.application\") version \"{ANDROID_GRADLE_PLUGIN_VERSION}\" apply false\n"
1300            ));
1301        } else {
1302            updated.push_str(line);
1303            updated.push('\n');
1304        }
1305    }
1306    if updated != existing {
1307        fs::write(&path, updated).with_context(|| format!("failed to write {}", path.display()))?;
1308    }
1309    Ok(())
1310}
1311
1312fn apply_android_app_build_gradle_hardening(root: &Path) -> Result<()> {
1313    let path = root.join("platforms/android/app/build.gradle.kts");
1314    if !path.exists() {
1315        return Ok(());
1316    }
1317    let existing =
1318        fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))?;
1319    let mut updated = existing.replace("../native-modules.gradle.kts", "../native-modules.gradle");
1320    updated = updated.replace(
1321        "versionCode = 1",
1322        "versionCode = (System.getenv(\"ANDROID_VERSION_CODE\") ?: \"1\").toInt()",
1323    );
1324    updated = updated.replace(
1325        "versionName = \"0.1.0\"",
1326        "versionName = System.getenv(\"ANDROID_VERSION_NAME\") ?: \"0.1.0\"",
1327    );
1328    if !updated.contains("../native-modules.gradle") {
1329        updated.push_str("\napply(from = \"../native-modules.gradle\")\n");
1330    }
1331    if updated != existing {
1332        fs::write(&path, updated).with_context(|| format!("failed to write {}", path.display()))?;
1333    }
1334    Ok(())
1335}
1336
1337fn apply_android_gradle_properties_hardening(root: &Path) -> Result<()> {
1338    let path = root.join("platforms/android/gradle.properties");
1339    if !path.exists() {
1340        return fs::write(&path, render_android_gradle_properties())
1341            .with_context(|| format!("failed to write {}", path.display()));
1342    }
1343    let existing =
1344        fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))?;
1345    let mut saw_androidx = false;
1346    let mut saw_jvmargs = false;
1347    let mut saw_compile_warning = false;
1348    let mut updated = String::new();
1349    for line in existing.lines() {
1350        let trimmed = line.trim_start();
1351        if trimmed.starts_with("android.useAndroidX=") {
1352            updated.push_str("android.useAndroidX=true\n");
1353            saw_androidx = true;
1354        } else if trimmed.starts_with("org.gradle.jvmargs=") {
1355            updated.push_str(line);
1356            updated.push('\n');
1357            saw_jvmargs = true;
1358        } else if trimmed.starts_with("android.javaCompile.suppressSourceTargetDeprecationWarning=")
1359        {
1360            updated.push_str(line);
1361            updated.push('\n');
1362            saw_compile_warning = true;
1363        } else {
1364            updated.push_str(line);
1365            updated.push('\n');
1366        }
1367    }
1368    if !saw_androidx {
1369        if !updated.ends_with('\n') {
1370            updated.push('\n');
1371        }
1372        updated.push_str("android.useAndroidX=true\n");
1373    }
1374    if !saw_jvmargs {
1375        updated.push_str("org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8\n");
1376    }
1377    if !saw_compile_warning {
1378        updated.push_str("android.javaCompile.suppressSourceTargetDeprecationWarning=true\n");
1379    }
1380    if updated != existing {
1381        fs::write(&path, updated).with_context(|| format!("failed to write {}", path.display()))?;
1382    }
1383    Ok(())
1384}
1385
1386fn apply_android_settings_gradle_hardening(root: &Path, project: &FissionProject) -> Result<()> {
1387    let path = root.join("platforms/android/settings.gradle.kts");
1388    if !path.exists() {
1389        return Ok(());
1390    }
1391    let existing =
1392        fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))?;
1393    let missing = android_dependency_repositories(project)
1394        .into_iter()
1395        .filter(|repository| !existing.contains(repository))
1396        .collect::<Vec<_>>();
1397    if missing.is_empty() {
1398        return Ok(());
1399    }
1400    let marker = "    repositories {\n";
1401    let Some(index) = existing.find(marker) else {
1402        return Ok(());
1403    };
1404    let mut insertion = String::new();
1405    for repository in missing {
1406        insertion.push_str("        ");
1407        insertion.push_str(&repository);
1408        insertion.push('\n');
1409    }
1410    let mut updated = existing;
1411    updated.insert_str(index + marker.len(), &insertion);
1412    fs::write(&path, updated).with_context(|| format!("failed to write {}", path.display()))
1413}
1414
1415fn android_wait_for_boot_function() -> &'static str {
1416    r#"wait_for_android_boot() {
1417  "$ADB" wait-for-device
1418  until "$ADB" shell getprop sys.boot_completed 2>/dev/null | tr -d '\r' | grep -q '^1$'; do
1419    sleep 1
1420  done
1421  local deadline=$((SECONDS + 180))
1422  until "$ADB" shell cmd package list packages >/dev/null 2>&1; do
1423    if (( SECONDS > deadline )); then
1424      printf 'Android package manager did not become available. Restart the emulator with ANDROID_EMULATOR_RESTART=1 and try again.\n' >&2
1425      exit 1
1426    fi
1427    sleep 1
1428  done
1429}"#
1430}
1431
1432fn replace_android_boot_wait_after(mut text: String, marker: &str, replacement: &str) -> String {
1433    let Some(start) = text.find(marker) else {
1434        return text;
1435    };
1436    let wait_start = start + marker.len();
1437    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";
1438    if text[wait_start..].starts_with(old_wait) {
1439        text.replace_range(wait_start..wait_start + old_wait.len(), replacement);
1440    }
1441    text
1442}
1443
1444const IOS_INFO_PLIST_PLUTIL_PATCH: &str = r#"cp "$SCRIPT_DIR/Info.plist" "$BUNDLE_DIR/Info.plist"
1445PLUTIL=$(xcrun --find plutil 2>/dev/null || command -v plutil || true)
1446if [[ -z "$PLUTIL" ]]; then
1447  printf 'plutil not found. Install Xcode command line tools to package the iOS simulator app.\n' >&2
1448  exit 1
1449fi
1450"$PLUTIL" -replace CFBundleIdentifier -string "$BUNDLE_ID" "$BUNDLE_DIR/Info.plist"
1451"$PLUTIL" -replace CFBundleDisplayName -string "$DISPLAY_NAME" "$BUNDLE_DIR/Info.plist"
1452"$PLUTIL" -replace CFBundleName -string "$DISPLAY_NAME" "$BUNDLE_DIR/Info.plist"
1453"$PLUTIL" -replace CFBundleExecutable -string "$EXECUTABLE_NAME" "$BUNDLE_DIR/Info.plist"
1454"$PLUTIL" -replace CFBundleShortVersionString -string "$IOS_MARKETING_VERSION" "$BUNDLE_DIR/Info.plist"
1455"$PLUTIL" -replace CFBundleVersion -string "$IOS_BUILD_NUMBER" "$BUNDLE_DIR/Info.plist"
1456"#;
1457
1458fn apply_platform_capability_config(root: &Path, project: &FissionProject) -> Result<()> {
1459    if project.capabilities.is_empty() {
1460        return Ok(());
1461    }
1462    if project.targets.contains(&Target::Android) {
1463        ensure_android_capability_helper(root)?;
1464        apply_android_capability_config(root, project)?;
1465    }
1466    if project.targets.contains(&Target::Ios) {
1467        apply_ios_capability_config(root, project)?;
1468    }
1469    Ok(())
1470}
1471
1472fn ensure_android_capability_helper(root: &Path) -> Result<()> {
1473    write_file_with_policy(
1474        &root.join("platforms/android/java/rs/fission/runtime/FissionAndroidCapabilities.java"),
1475        render_android_capabilities_java(),
1476        WritePolicy::PreserveExisting,
1477    )
1478}
1479
1480fn apply_android_capability_config(root: &Path, project: &FissionProject) -> Result<()> {
1481    let path = root.join("platforms/android/AndroidManifest.xml");
1482    if !path.exists() {
1483        return Ok(());
1484    }
1485    let existing =
1486        fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))?;
1487    let mut capabilities = String::new();
1488    if project.capabilities.contains(&PlatformCapability::Nfc)
1489        && !existing.contains("android.permission.NFC")
1490    {
1491        capabilities.push_str(&render_android_nfc_manifest_entries());
1492    }
1493    if project
1494        .capabilities
1495        .contains(&PlatformCapability::Notifications)
1496        && !existing.contains("android.permission.POST_NOTIFICATIONS")
1497    {
1498        capabilities.push_str(&render_android_notifications_manifest_entries());
1499    }
1500    if project
1501        .capabilities
1502        .contains(&PlatformCapability::Biometric)
1503        && !existing.contains("android.permission.USE_BIOMETRIC")
1504    {
1505        capabilities.push_str(&render_android_biometric_manifest_entries());
1506    }
1507    if project
1508        .capabilities
1509        .contains(&PlatformCapability::Bluetooth)
1510    {
1511        capabilities.push_str(&render_missing_android_bluetooth_manifest_entries(
1512            &existing,
1513        ));
1514    }
1515    if project
1516        .capabilities
1517        .contains(&PlatformCapability::BarcodeScanner)
1518        && !project.capabilities.contains(&PlatformCapability::Camera)
1519        && !existing.contains("android.permission.CAMERA")
1520    {
1521        capabilities.push_str(&render_android_barcode_camera_manifest_entries());
1522    }
1523    if project.capabilities.contains(&PlatformCapability::Camera) {
1524        capabilities.push_str(&render_missing_android_camera_manifest_entries(&existing));
1525    }
1526    if project
1527        .capabilities
1528        .contains(&PlatformCapability::Geolocation)
1529        && !existing.contains("android.permission.ACCESS_FINE_LOCATION")
1530    {
1531        capabilities.push_str(&render_android_geolocation_manifest_entries());
1532    }
1533    if project.capabilities.contains(&PlatformCapability::Haptics)
1534        && !existing.contains("android.permission.VIBRATE")
1535    {
1536        capabilities.push_str(&render_android_haptics_manifest_entries());
1537    }
1538    if project
1539        .capabilities
1540        .contains(&PlatformCapability::Microphone)
1541        && !existing.contains("android.permission.RECORD_AUDIO")
1542    {
1543        capabilities.push_str(&render_android_microphone_manifest_entries());
1544    }
1545    if project.capabilities.contains(&PlatformCapability::Wifi) {
1546        capabilities.push_str(&render_missing_android_wifi_manifest_entries(&existing));
1547    }
1548    if project
1549        .capabilities
1550        .contains(&PlatformCapability::VolumeControl)
1551        && !existing.contains("android.permission.MODIFY_AUDIO_SETTINGS")
1552    {
1553        capabilities.push_str(&render_android_volume_manifest_entries());
1554    }
1555    if capabilities.is_empty() {
1556        return Ok(());
1557    }
1558    let marker = r#"    <uses-permission android:name="android.permission.INTERNET" />"#;
1559    let updated = if existing.contains(marker) {
1560        existing.replacen(marker, &format!("{marker}\n{capabilities}"), 1)
1561    } else {
1562        existing.replacen("<uses-sdk", &format!("{capabilities}\n    <uses-sdk"), 1)
1563    };
1564    fs::write(&path, updated).with_context(|| format!("failed to write {}", path.display()))
1565}
1566
1567fn apply_ios_capability_config(root: &Path, project: &FissionProject) -> Result<()> {
1568    let info_path = root.join("platforms/ios/Info.plist");
1569    if info_path.exists() {
1570        let existing = fs::read_to_string(&info_path)
1571            .with_context(|| format!("failed to read {}", info_path.display()))?;
1572        if project.capabilities.contains(&PlatformCapability::Nfc)
1573            && !existing.contains("NFCReaderUsageDescription")
1574        {
1575            let entry = "  <key>NFCReaderUsageDescription</key>\n  <string>This app uses NFC to scan nearby tags when you request it.</string>\n";
1576            let updated = existing.replacen("</dict>", &format!("{entry}</dict>"), 1);
1577            fs::write(&info_path, updated)
1578                .with_context(|| format!("failed to write {}", info_path.display()))?;
1579        }
1580    }
1581
1582    if project.capabilities.contains(&PlatformCapability::Nfc) {
1583        let entitlements_path = root.join("platforms/ios/Entitlements.plist");
1584        if entitlements_path.exists() {
1585            let existing = fs::read_to_string(&entitlements_path)
1586                .with_context(|| format!("failed to read {}", entitlements_path.display()))?;
1587            if !existing.contains("com.apple.developer.nfc.readersession.formats") {
1588                let entry = "  <key>com.apple.developer.nfc.readersession.formats</key>\n  <array>\n    <string>NDEF</string>\n  </array>\n";
1589                let updated = existing.replacen("</dict>", &format!("{entry}</dict>"), 1);
1590                fs::write(&entitlements_path, updated)
1591                    .with_context(|| format!("failed to write {}", entitlements_path.display()))?;
1592            }
1593        } else {
1594            write_file_with_policy(
1595                &entitlements_path,
1596                IOS_NFC_ENTITLEMENTS_PLIST,
1597                WritePolicy::PreserveExisting,
1598            )?;
1599        }
1600    }
1601    if project
1602        .capabilities
1603        .contains(&PlatformCapability::Biometric)
1604        && info_path.exists()
1605    {
1606        let existing = fs::read_to_string(&info_path)
1607            .with_context(|| format!("failed to read {}", info_path.display()))?;
1608        if !existing.contains("NSFaceIDUsageDescription") {
1609            let entry = "  <key>NSFaceIDUsageDescription</key>\n  <string>This app uses biometrics to authenticate you when you request it.</string>\n";
1610            let updated = existing.replacen("</dict>", &format!("{entry}</dict>"), 1);
1611            fs::write(&info_path, updated)
1612                .with_context(|| format!("failed to write {}", info_path.display()))?;
1613        }
1614    }
1615    if project
1616        .capabilities
1617        .contains(&PlatformCapability::Bluetooth)
1618        && info_path.exists()
1619    {
1620        let existing = fs::read_to_string(&info_path)
1621            .with_context(|| format!("failed to read {}", info_path.display()))?;
1622        if !existing.contains("NSBluetoothAlwaysUsageDescription") {
1623            let entry = "  <key>NSBluetoothAlwaysUsageDescription</key>\n  <string>This app uses Bluetooth when you request nearby-device features.</string>\n";
1624            let updated = existing.replacen("</dict>", &format!("{entry}</dict>"), 1);
1625            fs::write(&info_path, updated)
1626                .with_context(|| format!("failed to write {}", info_path.display()))?;
1627        }
1628    }
1629    if project
1630        .capabilities
1631        .contains(&PlatformCapability::BarcodeScanner)
1632        && info_path.exists()
1633    {
1634        let existing = fs::read_to_string(&info_path)
1635            .with_context(|| format!("failed to read {}", info_path.display()))?;
1636        if !existing.contains("NSCameraUsageDescription") {
1637            let entry = "  <key>NSCameraUsageDescription</key>\n  <string>This app uses the camera to scan barcodes when you request it.</string>\n";
1638            let updated = existing.replacen("</dict>", &format!("{entry}</dict>"), 1);
1639            fs::write(&info_path, updated)
1640                .with_context(|| format!("failed to write {}", info_path.display()))?;
1641        }
1642    }
1643    if project.capabilities.contains(&PlatformCapability::Camera) && info_path.exists() {
1644        let existing = fs::read_to_string(&info_path)
1645            .with_context(|| format!("failed to read {}", info_path.display()))?;
1646        if !existing.contains("NSCameraUsageDescription") {
1647            let entry = "  <key>NSCameraUsageDescription</key>\n  <string>This app uses the camera when you request camera features.</string>\n";
1648            let updated = existing.replacen("</dict>", &format!("{entry}</dict>"), 1);
1649            fs::write(&info_path, updated)
1650                .with_context(|| format!("failed to write {}", info_path.display()))?;
1651        }
1652    }
1653    if project
1654        .capabilities
1655        .contains(&PlatformCapability::Geolocation)
1656        && info_path.exists()
1657    {
1658        let existing = fs::read_to_string(&info_path)
1659            .with_context(|| format!("failed to read {}", info_path.display()))?;
1660        if !existing.contains("NSLocationWhenInUseUsageDescription") {
1661            let entry = "  <key>NSLocationWhenInUseUsageDescription</key>\n  <string>This app uses your location when you request location-aware features.</string>\n";
1662            let updated = existing.replacen("</dict>", &format!("{entry}</dict>"), 1);
1663            fs::write(&info_path, updated)
1664                .with_context(|| format!("failed to write {}", info_path.display()))?;
1665        }
1666    }
1667    if project
1668        .capabilities
1669        .contains(&PlatformCapability::Microphone)
1670        && info_path.exists()
1671    {
1672        let existing = fs::read_to_string(&info_path)
1673            .with_context(|| format!("failed to read {}", info_path.display()))?;
1674        if !existing.contains("NSMicrophoneUsageDescription") {
1675            let entry = "  <key>NSMicrophoneUsageDescription</key>\n  <string>This app uses the microphone when you request audio capture.</string>\n";
1676            let updated = existing.replacen("</dict>", &format!("{entry}</dict>"), 1);
1677            fs::write(&info_path, updated)
1678                .with_context(|| format!("failed to write {}", info_path.display()))?;
1679        }
1680    }
1681    if project.capabilities.contains(&PlatformCapability::Wifi) && info_path.exists() {
1682        let existing = fs::read_to_string(&info_path)
1683            .with_context(|| format!("failed to read {}", info_path.display()))?;
1684        if !existing.contains("NSLocationWhenInUseUsageDescription") {
1685            let entry = "  <key>NSLocationWhenInUseUsageDescription</key>\n  <string>This app uses location permission where the platform requires it for Wi-Fi information.</string>\n";
1686            let updated = existing.replacen("</dict>", &format!("{entry}</dict>"), 1);
1687            fs::write(&info_path, updated)
1688                .with_context(|| format!("failed to write {}", info_path.display()))?;
1689        }
1690    }
1691    if project.capabilities.contains(&PlatformCapability::Wifi) {
1692        let entitlements_path = root.join("platforms/ios/Entitlements.plist");
1693        apply_ios_wifi_entitlements(&entitlements_path)?;
1694    }
1695    Ok(())
1696}
1697
1698fn apply_ios_wifi_entitlements(path: &Path) -> Result<()> {
1699    if path.exists() {
1700        let existing = fs::read_to_string(path)
1701            .with_context(|| format!("failed to read {}", path.display()))?;
1702        let mut entry = String::new();
1703        if !existing.contains("com.apple.developer.networking.wifi-info") {
1704            entry.push_str("  <key>com.apple.developer.networking.wifi-info</key>\n  <true/>\n");
1705        }
1706        if !existing.contains("com.apple.developer.networking.HotspotConfiguration") {
1707            entry.push_str(
1708                "  <key>com.apple.developer.networking.HotspotConfiguration</key>\n  <true/>\n",
1709            );
1710        }
1711        if entry.is_empty() {
1712            return Ok(());
1713        }
1714        let updated = existing.replacen("</dict>", &format!("{entry}</dict>"), 1);
1715        fs::write(path, updated).with_context(|| format!("failed to write {}", path.display()))?;
1716        return Ok(());
1717    }
1718    write_file_with_policy(
1719        path,
1720        IOS_WIFI_ENTITLEMENTS_PLIST,
1721        WritePolicy::PreserveExisting,
1722    )
1723}
1724
1725fn target_scaffold_dir_exists(project_dir: &Path, target: Target) -> bool {
1726    if target == Target::Site && project_dir.join("content").exists() {
1727        return true;
1728    }
1729    if target == Target::Site && project_dir.join("platforms/site").exists() {
1730        return true;
1731    }
1732    if target == Target::Server && project_dir.join("platforms/server").exists() {
1733        return true;
1734    }
1735    Path::new(target.scaffold_relative_path())
1736        .parent()
1737        .is_some_and(|relative| project_dir.join(relative).exists())
1738}
1739
1740fn write_project_config(root: &Path, project: &FissionProject) -> Result<()> {
1741    let path = root.join("fission.toml");
1742    let mut doc = if path.exists() {
1743        let existing = fs::read_to_string(&path)
1744            .with_context(|| format!("failed to read {}", path.display()))?;
1745        existing
1746            .parse::<DocumentMut>()
1747            .with_context(|| format!("failed to parse {}", path.display()))?
1748    } else {
1749        toml::to_string_pretty(project)?
1750            .parse::<DocumentMut>()
1751            .context("failed to render initial fission.toml")?
1752    };
1753    update_project_config_document(root, &mut doc, project);
1754    write_file(&path, &doc.to_string())
1755}
1756
1757fn update_project_config_document(root: &Path, doc: &mut DocumentMut, project: &FissionProject) {
1758    doc["targets"] = value(string_array(
1759        project.targets.iter().map(|target| target.as_str()),
1760    ));
1761    if project.capabilities.is_empty() {
1762        doc.as_table_mut().remove("capabilities");
1763    } else {
1764        doc["capabilities"] = value(string_array(
1765            project
1766                .capabilities
1767                .iter()
1768                .map(|capability| capability.as_str()),
1769        ));
1770    }
1771
1772    if !doc["app"].is_table() {
1773        doc["app"] = Item::Table(Table::new());
1774    }
1775    doc["app"]["name"] = value(project.app.name.clone());
1776    doc["app"]["app_id"] = value(project.app.app_id.clone());
1777    if item_field_is_missing(&doc["app"], "version") {
1778        doc["app"]["version"] =
1779            value(cargo_package_version(root).unwrap_or_else(|| "0.1.0".to_string()));
1780    }
1781    if item_field_is_missing(&doc["app"], "build") {
1782        doc["app"]["build"] = value(1);
1783    }
1784    if let Some(splash) = &project.app.splash {
1785        if !doc["app"]["splash"].is_table() {
1786            doc["app"]["splash"] = Item::Table(Table::new());
1787        }
1788        let splash_item = &mut doc["app"]["splash"];
1789        if let Some(background_color) = &splash.background_color {
1790            splash_item["background_color"] = value(background_color.clone());
1791        }
1792        if let Some(image) = &splash.image {
1793            splash_item["image"] = value(image.clone());
1794        }
1795        if let Some(resize_mode) = splash.resize_mode {
1796            splash_item["resize_mode"] = value(match resize_mode {
1797                SplashResizeMode::Center => "center",
1798                SplashResizeMode::Contain => "contain",
1799                SplashResizeMode::Cover => "cover",
1800            });
1801        }
1802        if let Some(animated_icon) = &splash.android_animated_icon {
1803            splash_item["android_animated_icon"] = value(animated_icon.clone());
1804        }
1805        if let Some(duration) = splash.android_animation_duration_ms {
1806            splash_item["android_animation_duration_ms"] = value(i64::from(duration));
1807        }
1808    } else if let Some(app) = doc["app"].as_table_like_mut() {
1809        app.remove("splash");
1810    }
1811    ensure_package_defaults(doc, project);
1812    ensure_distribution_defaults(doc, project);
1813}
1814
1815fn ensure_package_defaults(doc: &mut DocumentMut, project: &FissionProject) {
1816    if project.targets.contains(&Target::Android) {
1817        let version_name = item_field_string(&doc["app"], "version")
1818            .unwrap_or("0.1.0")
1819            .to_string();
1820        let version_code = item_field_integer(&doc["app"], "build").unwrap_or(1);
1821        let android = ensure_package_target_table(doc, "android");
1822        set_default_string(android, "package_name", &project.app.app_id);
1823        set_default_integer(android, "version_code", version_code);
1824        set_default_string(android, "version_name", &version_name);
1825        set_default_integer(android, "min_sdk", 24);
1826        set_default_integer(android, "target_sdk", 35);
1827        set_default_string(android, "keystore_alias", "upload");
1828        set_default_string(android, "keystore_env", "ANDROID_KEYSTORE");
1829        set_default_string(android, "keystore_base64_env", "ANDROID_KEYSTORE_BASE64");
1830        set_default_string(
1831            android,
1832            "keystore_password_env",
1833            "ANDROID_KEYSTORE_PASSWORD",
1834        );
1835        set_default_string(android, "key_password_env", "ANDROID_KEY_PASSWORD");
1836    }
1837
1838    if project.targets.contains(&Target::Ios) {
1839        let marketing_version = item_field_string(&doc["app"], "version")
1840            .unwrap_or("0.1.0")
1841            .to_string();
1842        let build_number = item_field_integer(&doc["app"], "build")
1843            .unwrap_or(1)
1844            .to_string();
1845        let ios = ensure_package_target_table(doc, "ios");
1846        set_default_string(ios, "bundle_id", &project.app.app_id);
1847        set_default_string(ios, "marketing_version", &marketing_version);
1848        set_default_string(ios, "build_number", &build_number);
1849    }
1850
1851    if project.targets.contains(&Target::Macos) {
1852        let marketing_version = item_field_string(&doc["app"], "version")
1853            .unwrap_or("0.1.0")
1854            .to_string();
1855        let build_number = item_field_integer(&doc["app"], "build")
1856            .unwrap_or(1)
1857            .to_string();
1858        let macos = ensure_package_target_table(doc, "macos");
1859        set_default_string(macos, "bundle_id", &project.app.app_id);
1860        set_default_string(macos, "marketing_version", &marketing_version);
1861        set_default_string(macos, "build_number", &build_number);
1862        set_default_string(macos, "minimum_os", "13.0");
1863    }
1864
1865    if project.targets.contains(&Target::Windows) {
1866        let package_version = item_field_string(&doc["app"], "version")
1867            .unwrap_or("0.1.0")
1868            .to_string();
1869        let windows = ensure_package_target_table(doc, "windows");
1870        set_default_string(windows, "identity_name", &windows_identity_name(project));
1871        set_default_string(windows, "publisher", windows_publisher_name());
1872        set_default_string(windows, "version", &package_version);
1873        set_default_string(windows, "installer", "msix");
1874        set_default_string(
1875            windows,
1876            "certificate_thumbprint_env",
1877            "WINDOWS_CERTIFICATE_THUMBPRINT",
1878        );
1879        set_default_string(
1880            windows,
1881            "certificate_base64_env",
1882            "WINDOWS_CERTIFICATE_BASE64",
1883        );
1884        set_default_string(
1885            windows,
1886            "certificate_password_env",
1887            "WINDOWS_CERTIFICATE_PASSWORD",
1888        );
1889    }
1890
1891    if let Some(package) = doc
1892        .as_table_mut()
1893        .get_mut("package")
1894        .and_then(Item::as_table_mut)
1895    {
1896        if package
1897            .iter()
1898            .all(|(_, item)| item.as_table().is_some() || item.as_array_of_tables().is_some())
1899        {
1900            package.set_implicit(true);
1901        }
1902    }
1903}
1904
1905fn ensure_distribution_defaults(doc: &mut DocumentMut, project: &FissionProject) {
1906    if project.targets.contains(&Target::Android) {
1907        let play_store = ensure_distribution_target_table(doc, "play_store");
1908        set_default_string(play_store, "package_name", &project.app.app_id);
1909        set_default_string(play_store, "default_track", "internal");
1910        set_default_string(play_store, "release_status", "completed");
1911        set_default_string(play_store, "access_token_env", "PLAY_STORE_ACCESS_TOKEN");
1912        set_default_string(
1913            play_store,
1914            "service_account_json_env",
1915            "PLAY_STORE_SERVICE_ACCOUNT_JSON",
1916        );
1917        set_default_string(
1918            play_store,
1919            "service_account_json_base64_env",
1920            "PLAY_STORE_SERVICE_ACCOUNT_JSON_BASE64",
1921        );
1922        set_default_string(
1923            play_store,
1924            "google_application_credentials_env",
1925            "GOOGLE_APPLICATION_CREDENTIALS",
1926        );
1927    }
1928
1929    if project.targets.contains(&Target::Ios) {
1930        let app_store = ensure_distribution_target_table(doc, "app_store");
1931        set_default_string(app_store, "bundle_id", &project.app.app_id);
1932        set_default_string(
1933            app_store,
1934            "access_token_env",
1935            "APP_STORE_CONNECT_ACCESS_TOKEN",
1936        );
1937        set_default_string(app_store, "issuer_id_env", "APP_STORE_CONNECT_ISSUER_ID");
1938        set_default_string(app_store, "key_id_env", "APP_STORE_CONNECT_KEY_ID");
1939        set_default_string(app_store, "api_key_env", "APP_STORE_CONNECT_API_KEY");
1940        set_default_string(
1941            app_store,
1942            "api_key_base64_env",
1943            "APP_STORE_CONNECT_API_KEY_BASE64",
1944        );
1945        set_default_string(
1946            app_store,
1947            "api_key_path_env",
1948            "APP_STORE_CONNECT_API_KEY_PATH",
1949        );
1950        set_default_string(app_store, "default_track", "testflight");
1951    }
1952
1953    if project.targets.contains(&Target::Windows) {
1954        let microsoft_store = ensure_distribution_target_table(doc, "microsoft_store");
1955        set_default_string(
1956            microsoft_store,
1957            "package_identity_name",
1958            &windows_identity_name(project),
1959        );
1960        set_default_string(microsoft_store, "package_type", "msix");
1961        set_default_string(microsoft_store, "token_env", "MICROSOFT_STORE_TOKEN");
1962        set_default_string(microsoft_store, "tenant_id_env", "AZURE_TENANT_ID");
1963        set_default_string(microsoft_store, "client_id_env", "AZURE_CLIENT_ID");
1964        set_default_string(
1965            microsoft_store,
1966            "client_secret_env",
1967            "MICROSOFT_STORE_CLIENT_SECRET",
1968        );
1969        set_default_string(
1970            microsoft_store,
1971            "seller_id_env",
1972            "MICROSOFT_STORE_SELLER_ID",
1973        );
1974    }
1975
1976    if let Some(distribution) = doc
1977        .as_table_mut()
1978        .get_mut("distribution")
1979        .and_then(Item::as_table_mut)
1980    {
1981        if distribution
1982            .iter()
1983            .all(|(_, item)| item.as_table().is_some() || item.as_array_of_tables().is_some())
1984        {
1985            distribution.set_implicit(true);
1986        }
1987    }
1988}
1989
1990fn ensure_package_target_table<'a>(doc: &'a mut DocumentMut, target: &str) -> &'a mut Item {
1991    let missing_or_not_table = match doc.as_table().get("package") {
1992        Some(item) => !item.is_table(),
1993        None => true,
1994    };
1995    if missing_or_not_table {
1996        let mut table = Table::new();
1997        table.set_implicit(true);
1998        doc["package"] = Item::Table(table);
1999    }
2000    let target_missing_or_not_table = match doc["package"].as_table_like() {
2001        Some(package) => package.get(target).is_none_or(|item| !item.is_table()),
2002        None => true,
2003    };
2004    if target_missing_or_not_table {
2005        doc["package"][target] = Item::Table(Table::new());
2006    }
2007    &mut doc["package"][target]
2008}
2009
2010fn ensure_distribution_target_table<'a>(doc: &'a mut DocumentMut, provider: &str) -> &'a mut Item {
2011    let missing_or_not_table = match doc.as_table().get("distribution") {
2012        Some(item) => !item.is_table(),
2013        None => true,
2014    };
2015    if missing_or_not_table {
2016        let mut table = Table::new();
2017        table.set_implicit(true);
2018        doc["distribution"] = Item::Table(table);
2019    }
2020    let provider_missing_or_not_table = match doc["distribution"].as_table_like() {
2021        Some(distribution) => distribution
2022            .get(provider)
2023            .is_none_or(|item| !item.is_table()),
2024        None => true,
2025    };
2026    if provider_missing_or_not_table {
2027        doc["distribution"][provider] = Item::Table(Table::new());
2028    }
2029    &mut doc["distribution"][provider]
2030}
2031
2032fn set_default_string(item: &mut Item, key: &str, value_: &str) {
2033    if item_field_is_missing(item, key) {
2034        item[key] = value(value_.to_string());
2035    }
2036}
2037
2038fn set_default_integer(item: &mut Item, key: &str, value_: i64) {
2039    if item_field_is_missing(item, key) {
2040        item[key] = value(value_);
2041    }
2042}
2043
2044fn item_field_is_missing(item: &Item, key: &str) -> bool {
2045    item.as_table_like()
2046        .and_then(|table| table.get(key))
2047        .is_none()
2048}
2049
2050fn item_field_string<'a>(item: &'a Item, key: &str) -> Option<&'a str> {
2051    item.as_table_like()
2052        .and_then(|table| table.get(key))
2053        .and_then(Item::as_value)
2054        .and_then(Value::as_str)
2055}
2056
2057fn item_field_integer(item: &Item, key: &str) -> Option<i64> {
2058    item.as_table_like()
2059        .and_then(|table| table.get(key))
2060        .and_then(Item::as_value)
2061        .and_then(Value::as_integer)
2062}
2063
2064fn string_array<'a>(values: impl Iterator<Item = &'a str>) -> Array {
2065    let mut array = Array::new();
2066    for value in values {
2067        let mut value = Value::from(value);
2068        value.decor_mut().set_prefix("\n    ");
2069        array.push_formatted(value);
2070    }
2071    array.set_trailing("\n");
2072    array.set_trailing_comma(true);
2073    array
2074}
2075
2076pub fn read_project_config(root: &Path) -> Result<FissionProject> {
2077    let path = root.join("fission.toml");
2078    let data = fs::read_to_string(&path).with_context(|| {
2079        format!(
2080            "failed to read {}; run `fission init {}` to register this project without overwriting existing files",
2081            path.display(),
2082            root.display()
2083        )
2084    })?;
2085    toml::from_str(&data).with_context(|| format!("failed to parse {}", path.display()))
2086}
2087
2088fn update_cargo_fission_features(root: &Path, project: &FissionProject) -> Result<()> {
2089    sync_cargo_fission_dependency(root, project, None)
2090}
2091
2092fn sync_cargo_fission_dependency(
2093    root: &Path,
2094    project: &FissionProject,
2095    local_path: Option<&Path>,
2096) -> Result<()> {
2097    let path = root.join("Cargo.toml");
2098    let Ok(text) = fs::read_to_string(&path) else {
2099        return Ok(());
2100    };
2101
2102    let mut doc = text
2103        .parse::<DocumentMut>()
2104        .with_context(|| format!("failed to parse {}", path.display()))?;
2105    let features = fission_features_for_targets(&project.targets);
2106    let mut changed = false;
2107
2108    if !doc.get("dependencies").is_some_and(Item::is_table_like) {
2109        doc["dependencies"] = Item::Table(Table::new());
2110        changed = true;
2111    }
2112
2113    let use_workspace_fission = local_path.is_none()
2114        && workspace_has_fission_dependency(&doc)
2115        && doc
2116            .get("dependencies")
2117            .and_then(Item::as_table_like)
2118            .is_none_or(|dependencies| !dependencies.contains_key("fission"));
2119    let deps = doc["dependencies"]
2120        .as_table_like_mut()
2121        .expect("dependencies table was just created");
2122    let dep = deps.entry("fission").or_insert(Item::None);
2123    changed |= sync_fission_dependency_item(dep, &features, local_path, use_workspace_fission)?;
2124
2125    if changed {
2126        fs::write(&path, doc.to_string())
2127            .with_context(|| format!("failed to update {}", path.display()))?;
2128    }
2129    Ok(())
2130}
2131
2132fn workspace_has_fission_dependency(doc: &DocumentMut) -> bool {
2133    doc.get("workspace")
2134        .and_then(Item::as_table_like)
2135        .and_then(|workspace| workspace.get("dependencies"))
2136        .and_then(Item::as_table_like)
2137        .is_some_and(|dependencies| dependencies.contains_key("fission"))
2138}
2139
2140fn sync_fission_dependency_item(
2141    item: &mut Item,
2142    features: &[&'static str],
2143    local_path: Option<&Path>,
2144    use_workspace_fission: bool,
2145) -> Result<bool> {
2146    match item {
2147        Item::None => {
2148            *item = Item::Value(Value::InlineTable(new_fission_dependency_table(
2149                features,
2150                local_path,
2151                use_workspace_fission,
2152            )));
2153            Ok(true)
2154        }
2155        Item::Value(Value::String(version)) => {
2156            let mut table = InlineTable::new();
2157            table.insert("version", Value::String(version.clone()));
2158            sync_fission_inline_table(&mut table, features, local_path, use_workspace_fission);
2159            *item = Item::Value(Value::InlineTable(table));
2160            Ok(true)
2161        }
2162        Item::Value(Value::InlineTable(table)) => Ok(sync_fission_inline_table(
2163            table,
2164            features,
2165            local_path,
2166            use_workspace_fission,
2167        )),
2168        Item::Table(table) => Ok(sync_fission_table(
2169            table,
2170            features,
2171            local_path,
2172            use_workspace_fission,
2173        )),
2174        _ => bail!("unsupported fission dependency format in Cargo.toml"),
2175    }
2176}
2177
2178fn new_fission_dependency_table(
2179    features: &[&'static str],
2180    local_path: Option<&Path>,
2181    use_workspace_fission: bool,
2182) -> InlineTable {
2183    let mut table = InlineTable::new();
2184    if let Some(root) = local_path {
2185        table.insert(
2186            "path",
2187            Value::from(
2188                root.join("crates/authoring/fission")
2189                    .to_string_lossy()
2190                    .to_string(),
2191            ),
2192        );
2193    } else if use_workspace_fission {
2194        table.insert("workspace", Value::from(true));
2195    } else {
2196        table.insert("version", Value::from(CURRENT_VERSION));
2197    }
2198    table.insert("default-features", Value::from(false));
2199    table.insert("features", cargo_feature_array_value(features));
2200    table
2201}
2202
2203fn sync_fission_inline_table(
2204    table: &mut InlineTable,
2205    features: &[&'static str],
2206    local_path: Option<&Path>,
2207    use_workspace_fission: bool,
2208) -> bool {
2209    let before = table.to_string();
2210    if let Some(root) = local_path {
2211        table.insert(
2212            "path",
2213            Value::from(
2214                root.join("crates/authoring/fission")
2215                    .to_string_lossy()
2216                    .to_string(),
2217            ),
2218        );
2219        table.remove("version");
2220        table.remove("workspace");
2221    } else if use_workspace_fission
2222        && !table.contains_key("path")
2223        && !table.contains_key("version")
2224        && !table.contains_key("git")
2225    {
2226        table.insert("workspace", Value::from(true));
2227    } else if !table.contains_key("path")
2228        && !table.contains_key("version")
2229        && !table.contains_key("workspace")
2230        && !table.contains_key("git")
2231    {
2232        table.insert("version", Value::from(CURRENT_VERSION));
2233    }
2234    table.insert("default-features", Value::from(false));
2235    table.insert("features", cargo_feature_array_value(features));
2236    table.to_string() != before
2237}
2238
2239fn sync_fission_table(
2240    table: &mut Table,
2241    features: &[&'static str],
2242    local_path: Option<&Path>,
2243    use_workspace_fission: bool,
2244) -> bool {
2245    let before = table.to_string();
2246    if let Some(root) = local_path {
2247        table["path"] = value(
2248            root.join("crates/authoring/fission")
2249                .to_string_lossy()
2250                .to_string(),
2251        );
2252        table.remove("version");
2253        table.remove("workspace");
2254    } else if use_workspace_fission
2255        && !table.contains_key("path")
2256        && !table.contains_key("version")
2257        && !table.contains_key("git")
2258    {
2259        table["workspace"] = value(true);
2260    } else if !table.contains_key("path")
2261        && !table.contains_key("version")
2262        && !table.contains_key("workspace")
2263        && !table.contains_key("git")
2264    {
2265        table["version"] = value(CURRENT_VERSION);
2266    }
2267    table["default-features"] = value(false);
2268    table["features"] = Item::Value(cargo_feature_array_value(features));
2269    table.to_string() != before
2270}
2271
2272fn cargo_feature_array_value(features: &[&'static str]) -> Value {
2273    let mut array = Array::new();
2274    for feature in features {
2275        array.push(*feature);
2276    }
2277    Value::Array(array)
2278}
2279
2280fn scaffold_target_with_policy(
2281    root: &Path,
2282    project: &FissionProject,
2283    target: Target,
2284    write_policy: WritePolicy,
2285) -> Result<()> {
2286    let relative = Path::new(target.scaffold_relative_path());
2287    let text = match target {
2288        Target::Android => {
2289            scaffold_android_bundle(root, project, write_policy)?;
2290            platform_readme(
2291                "Android",
2292                "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.",
2293                &[
2294                    "Install the Rust target: `rustup target add aarch64-linux-android`.",
2295                    "Run `fission doctor android --project-dir .` to check SDK, NDK, emulator, and Rust target setup.",
2296                    "Run `fission devices --project-dir .` to list connected Android devices and configured emulators.",
2297                    "Run `fission run --target android --project-dir .` to build, install, launch, and attach to logs.",
2298                    "Run `fission run --target android --device <adb-serial> --project-dir .` to launch on a specific device.",
2299                    "Run `fission test --target android --project-dir .` for an emulator launch plus test-control health check.",
2300                    "Run `./platforms/android/run-emulator.sh` from the project root to build, package, install, and launch the app on the configured emulator.",
2301                    "Run `fission package --target android --format aab --release --project-dir .` or `./platforms/android/package-aab.sh` to create the signed Play Store app bundle.",
2302                    "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.",
2303                    "Set `ANDROID_EMULATOR_HEADLESS=1` for background/CI runs, or `ANDROID_EMULATOR_RESTART=1` to relaunch a hidden emulator visibly.",
2304                    "The generated package uses `assets/app-icon.png` as its default launcher icon.",
2305                    "Configure `[app.splash]` in `fission.toml` to generate the native Android launch theme, splash background, static image, and optional Android animated drawable.",
2306                    "Run `fission add-capability nfc --project-dir .` to add NFC manifest permission and feature declarations.",
2307                    "Run `fission add-capability notifications --project-dir .` to add Android notification permission for API 33 and newer.",
2308                    "Run `fission add-capability biometric --project-dir .` to add biometric manifest permissions.",
2309                    "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.",
2310                    "Run `fission add-capability bluetooth --project-dir .` to add Bluetooth permissions and optional hardware feature declarations.",
2311                    "Run `fission add-capability barcode-scanner --project-dir .` to add camera permission for barcode scanning.",
2312                    "Run `fission add-capability camera --project-dir .` to add camera permission and optional camera/flash hardware feature declarations.",
2313                    "Run `fission add-capability geolocation --project-dir .` to add location permissions.",
2314                    "Run `fission add-capability haptics --project-dir .` to add the vibration permission.",
2315                    "Run `fission add-capability microphone --project-dir .` to add audio recording permission.",
2316                    "Run `fission add-capability volume-control --project-dir .` to add Android audio settings permission.",
2317                    "Run `fission add-capability wifi --project-dir .` to add Wi-Fi permissions and optional hardware feature declarations.",
2318                    "Set `FISSION_TEST_CONTROL_PORT=<host-port>` before `run-emulator.sh`; the script forwards it to the fixed in-app device port.",
2319                ],
2320            )
2321        }
2322        Target::Ios => {
2323            scaffold_ios_bundle(root, project, write_policy)?;
2324            platform_readme(
2325                "iOS",
2326                "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`.",
2327                &[
2328                    "Install the Rust targets: `rustup target add aarch64-apple-ios aarch64-apple-ios-sim`.",
2329                    "Run `fission doctor ios --project-dir .` to check Xcode, simulator, and Rust target setup.",
2330                    "Confirm the simulator SDK path with `xcrun --sdk iphonesimulator --show-sdk-path`.",
2331                    "Run `fission devices --project-dir .` to list available iOS simulators.",
2332                    "Run `fission run --target ios --project-dir .` to build, install, launch, and attach to simulator logs.",
2333                    "Run `fission run --target ios --device <simulator-udid> --project-dir .` to launch on a specific simulator.",
2334                    "Run `fission test --target ios --project-dir .` for a simulator launch plus test-control health check.",
2335                    "Run `./platforms/ios/run-sim.sh` from the project root to build, install, and launch the app on the first available iPhone simulator.",
2336                    "Run `fission package --target ios --format ipa --release --project-dir .` or `./platforms/ios/package-ipa.sh` to create a signed IPA when IOS_SIGNING_IDENTITY is configured.",
2337                    "The generated bundle uses `assets/app-icon.png` as its default app icon.",
2338                    "Configure `[app.splash]` in `fission.toml` to generate the native iOS launch storyboard and splash image copied into the simulator bundle.",
2339                    "Run `fission add-capability nfc --project-dir .` to add the NFC usage description and entitlements file.",
2340                    "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.",
2341                    "Run `fission add-capability biometric --project-dir .` to add the Face ID usage description.",
2342                    "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.",
2343                    "Run `fission add-capability bluetooth --project-dir .` to add the Bluetooth usage description.",
2344                    "Run `fission add-capability barcode-scanner --project-dir .` to add the camera usage description for barcode scanning.",
2345                    "Run `fission add-capability camera --project-dir .` to add the camera usage description.",
2346                    "Run `fission add-capability geolocation --project-dir .` to add the location usage description.",
2347                    "Run `fission add-capability microphone --project-dir .` to add the microphone usage description.",
2348                    "Run `fission add-capability wifi --project-dir .` to add Wi-Fi entitlements and the location usage description required by current-network information APIs.",
2349                    "Volume control does not require an iOS Info.plist key in the generated scaffold.",
2350                    "Haptics do not require an iOS Info.plist key in the generated scaffold.",
2351                    "Set `FISSION_TEST_CONTROL_PORT=<port>` before `run-sim.sh` to expose the in-app test control server on the host.",
2352                    "Set `IOS_SIM_DEVICE_ID=<udid>` if you want a specific simulator device.",
2353                    "Set `IOS_SIM_HEADLESS=1` for CI or background-only simulator runs; otherwise the script opens Simulator visibly.",
2354                ],
2355            )
2356        }
2357        Target::Web => {
2358            scaffold_web_bundle(root, project, write_policy)?;
2359            platform_readme(
2360                "Web",
2361                "Runnable browser target. The CLI generates a WASM host page plus helper scripts that build the app with `wasm-pack` and serve it locally.",
2362                &[
2363                    "Install the Rust target: `rustup target add wasm32-unknown-unknown`.",
2364                    "Install `wasm-pack` once: `cargo install wasm-pack`.",
2365                    "Install Node.js 22+ so the smoke test can inspect Chrome/Chromium CDP runtime and console output.",
2366                    "Run `fission doctor web --project-dir .` to check wasm-pack, generated JavaScript glue, Chrome/Chromium, and Rust target setup.",
2367                    "Run `fission devices --project-dir .` to confirm Chrome/Chromium detection.",
2368                    "Run `fission run --target web --project-dir .` to build, serve, open, and attach to the local server.",
2369                    "Run `fission run --target web --detach --project-dir .` to keep the local server running in the background.",
2370                    "Run `fission test --target web --project-dir .` for a headless Chrome/Chromium CDP smoke test.",
2371                    "Run `./platforms/web/run-browser.sh` from the project root to build the wasm package and serve the app locally.",
2372                    "Set `FISSION_WEB_PORT=<port>` or `FISSION_WEB_HOST=<host>` if the default `127.0.0.1:8123` does not suit your machine.",
2373                    "Set `FISSION_WEB_OPEN=1` if you want the helper script to open a browser tab automatically.",
2374                    "The generated page uses `assets/app-icon.png` as its default favicon/app icon seed.",
2375                ],
2376            )
2377        }
2378        Target::Server => platform_readme(
2379            "SSR",
2380            "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.",
2381            &[
2382                "Configure `[server].entry` in `fission.toml` so the CLI can invoke the server app.",
2383                "Run `fission server check --project-dir .` to render all declared server routes.",
2384                "Run `fission server serve --project-dir .` to serve the app locally.",
2385                "Run `fission server artifacts --project-dir .` to generate browser worker and island WASM shims.",
2386                "Run `fission package --target ssr --format docker-image --release --project-dir .` to package the server app as an OCI/Docker image.",
2387            ],
2388        ),
2389        Target::Site => {
2390            write_file_with_policy(
2391                &root.join("content/getting-started.md"),
2392                "---\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",
2393                write_policy,
2394            )?;
2395            platform_readme(
2396                "Static site",
2397                "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.",
2398                &[
2399                    "Add Markdown or MDX content under `content/`.",
2400                    "Run `fission site routes --project-dir .` to list generated routes.",
2401                    "Run `fission site build --project-dir .` to render HTML into `target/fission/site`.",
2402                    "Run `fission site serve --project-dir .` to build and serve the generated site locally.",
2403                    "Run `fission package --target static-site --format static --release --project-dir .` to package the generated site.",
2404                    "Unsupported interactive widgets fail during the static render instead of silently falling back to JavaScript.",
2405                ],
2406            )
2407        }
2408        Target::Terminal => platform_readme(
2409            "Terminal",
2410            "Terminal target. The CLI treats this as a terminal-shell app using the project's normal Rust entrypoint and terminal-shell feature.",
2411            &[
2412                "Use `fission::terminal::TerminalApp` or a target-aware app entrypoint for terminal rendering.",
2413                "Run `fission run --target terminal --project-dir .` to execute the app in the current terminal.",
2414                "Run `fission test --target terminal --project-dir .` for Rust tests until terminal-shell package formats are defined by the terminal-shell RFC.",
2415                "This target enables the `terminal-shell` Fission feature but does not imply native desktop, web, or mobile shells.",
2416            ],
2417        ),
2418        Target::Windows => {
2419            scaffold_windows_bundle(root, project, write_policy)?;
2420            platform_readme(
2421                "Windows",
2422                "Runnable desktop target with release packaging scaffolds for EXE, MSI, and MSIX distribution.",
2423                &[
2424                    "Run `fission run --project-dir .` from the project root to launch the desktop app and attach output.",
2425                    "Run `fission build --project-dir . --release` for a release desktop build.",
2426                    "Run `fission package --target windows --format exe --release --project-dir .` to copy the signed release executable into a package artifact.",
2427                    "Run `fission package --target windows --format msix --release --project-dir .` or `./platforms/windows/package-msix.ps1` to create an MSIX package with `makeappx`.",
2428                    "Run `fission package --target windows --format msi --release --project-dir .` or `./platforms/windows/package-msi.ps1` to create an MSI package with WiX.",
2429                    "Set `WINDOWS_CERTIFICATE`, `WINDOWS_CERTIFICATE_BASE64`, or `WINDOWS_CERTIFICATE_THUMBPRINT` plus `WINDOWS_CERTIFICATE_PASSWORD` where needed; never commit certificate files or passwords.",
2430                    "Edit `[package.windows]` in `fission.toml` for Store package identity, publisher identity, package version, and installer preference.",
2431                    "For an unpackaged NSIS app, build the architecture-matched shortcut helper with `./platforms/windows/build-shortcut-aumid-helper.ps1 -Architecture x64` (or `arm64`) and include `platforms/windows/fission-shortcut-aumid.nsh`. Embed the helper once, then apply one stable AppUserModelID to every Start Menu shortcut after `CreateShortCut`.",
2432                    "Pass that exact AppUserModelID to `DesktopApp::with_windows_app_user_model_id`; package identity remains authoritative for MSIX, so the explicit value is only the unpackaged fallback.",
2433                    "Sign the compiled shortcut helper before embedding it in a signed installer. The helper deliberately fails installation if it cannot persist the shortcut identity.",
2434                    "The generated MSIX manifest stages the desktop executable as a full-trust Windows app and copies `assets/app-icon.png` into the package asset set by default.",
2435                ],
2436            )
2437        }
2438        Target::Linux | Target::Macos => platform_readme(
2439            match target {
2440                Target::Linux => "Linux",
2441                Target::Macos => "macOS",
2442                _ => unreachable!(),
2443            },
2444            "Runnable target. Desktop platforms share the default `src/main.rs` entrypoint through `DesktopApp`.",
2445            &[
2446                "Run `fission run --project-dir .` from the project root to launch the desktop app and attach output.",
2447                "Run `fission build --project-dir . --release` for a release desktop build.",
2448                "Run `fission test --project-dir .` for the app crate's Rust tests.",
2449                "This target uses the default Vello desktop shell path.",
2450            ],
2451        ),
2452    };
2453    write_file_with_policy(&root.join(relative), &text, write_policy)
2454}
2455
2456fn scaffold_ios_bundle(
2457    root: &Path,
2458    project: &FissionProject,
2459    write_policy: WritePolicy,
2460) -> Result<()> {
2461    let executable = ios_executable_name(project);
2462    let bundle_name = ios_bundle_name(project);
2463    let plist = render_ios_plist(project, &executable);
2464    let package_script = render_ios_package_script(project, &bundle_name, &executable);
2465    let ipa_script = render_ios_ipa_package_script(project);
2466    let run_script = render_ios_run_script(project);
2467    let test_script = render_ios_test_script();
2468
2469    write_file_with_policy(&root.join("platforms/ios/Info.plist"), &plist, write_policy)?;
2470    write_file_with_policy(
2471        &root.join("platforms/ios/Package.swift"),
2472        &render_ios_host_package(project),
2473        write_policy,
2474    )?;
2475    write_file_with_policy(
2476        &root.join("platforms/ios/Sources/FissionHost/FissionNativeCapabilities.swift"),
2477        render_ios_host_native_capabilities_swift(),
2478        write_policy,
2479    )?;
2480    write_file_with_policy(
2481        &root.join("platforms/ios/NativeModules/README.md"),
2482        IOS_NATIVE_MODULES_README,
2483        write_policy,
2484    )?;
2485    write_file_with_policy(
2486        &root.join("platforms/ios/NativeModules/Package.swift"),
2487        &render_ios_native_modules_package(project),
2488        write_policy,
2489    )?;
2490    write_file_with_policy(
2491        &root.join(
2492            "platforms/ios/NativeModules/Sources/FissionNativeModules/FissionNativeCapabilities.swift",
2493        ),
2494        render_ios_native_capabilities_swift(),
2495        write_policy,
2496    )?;
2497    sync_ios_native_module_sources(root, project)?;
2498    if project.capabilities.contains(&PlatformCapability::Nfc)
2499        || project.capabilities.contains(&PlatformCapability::Wifi)
2500    {
2501        write_file_with_policy(
2502            &root.join("platforms/ios/Entitlements.plist"),
2503            &render_ios_entitlements_plist(project),
2504            write_policy,
2505        )?;
2506    }
2507    write_file_with_policy(
2508        &root.join("platforms/ios/package-sim.sh"),
2509        &package_script,
2510        write_policy,
2511    )?;
2512    write_file_with_policy(
2513        &root.join("platforms/ios/package-ipa.sh"),
2514        &ipa_script,
2515        write_policy,
2516    )?;
2517    write_file_with_policy(
2518        &root.join("platforms/ios/run-sim.sh"),
2519        &run_script,
2520        write_policy,
2521    )?;
2522    write_file_with_policy(
2523        &root.join("platforms/ios/test-sim.sh"),
2524        &test_script,
2525        write_policy,
2526    )?;
2527    #[cfg(unix)]
2528    {
2529        use std::os::unix::fs::PermissionsExt;
2530        for relative in [
2531            "platforms/ios/package-sim.sh",
2532            "platforms/ios/package-ipa.sh",
2533            "platforms/ios/run-sim.sh",
2534            "platforms/ios/test-sim.sh",
2535        ] {
2536            let path = root.join(relative);
2537            if path.exists() {
2538                fs::set_permissions(path, fs::Permissions::from_mode(0o755))?;
2539            }
2540        }
2541    }
2542    Ok(())
2543}
2544
2545fn scaffold_android_bundle(
2546    root: &Path,
2547    project: &FissionProject,
2548    write_policy: WritePolicy,
2549) -> Result<()> {
2550    let manifest = render_android_manifest(project);
2551    let package_script = render_android_package_script(project);
2552    let package_aab_script = render_android_aab_package_script(project);
2553    let run_script = render_android_run_script(project);
2554    let test_script = render_android_test_script();
2555
2556    write_file_with_policy(
2557        &root.join("platforms/android/settings.gradle.kts"),
2558        &render_android_settings_gradle(project),
2559        write_policy,
2560    )?;
2561    write_file_with_policy(
2562        &root.join("platforms/android/build.gradle.kts"),
2563        &render_android_root_build_gradle(),
2564        write_policy,
2565    )?;
2566    write_file_with_policy(
2567        &root.join("platforms/android/gradle.properties"),
2568        render_android_gradle_properties(),
2569        write_policy,
2570    )?;
2571    write_file_with_policy(
2572        &root.join("platforms/android/app/build.gradle.kts"),
2573        &render_android_app_build_gradle(project),
2574        write_policy,
2575    )?;
2576    write_file_with_policy(
2577        &root.join("platforms/android/native-modules.gradle"),
2578        &render_android_native_modules_gradle(project),
2579        write_policy,
2580    )?;
2581    write_file_with_policy(
2582        &root.join("platforms/android/AndroidManifest.xml"),
2583        &manifest,
2584        write_policy,
2585    )?;
2586    write_file_with_policy(
2587        &root.join("platforms/android/package-apk.sh"),
2588        &package_script,
2589        write_policy,
2590    )?;
2591    write_file_with_policy(
2592        &root.join("platforms/android/package-aab.sh"),
2593        &package_aab_script,
2594        write_policy,
2595    )?;
2596    write_file_with_policy(
2597        &root.join("platforms/android/run-emulator.sh"),
2598        &run_script,
2599        write_policy,
2600    )?;
2601    write_file_with_policy(
2602        &root.join("platforms/android/test-emulator.sh"),
2603        &test_script,
2604        write_policy,
2605    )?;
2606    write_file_with_policy(
2607        &root.join("platforms/android/java/rs/fission/runtime/FissionActivity.java"),
2608        render_android_activity_java(),
2609        write_policy,
2610    )?;
2611    write_file_with_policy(
2612        &root.join("platforms/android/native-modules/README.md"),
2613        ANDROID_NATIVE_MODULES_README,
2614        write_policy,
2615    )?;
2616    #[cfg(unix)]
2617    {
2618        use std::os::unix::fs::PermissionsExt;
2619        for relative in [
2620            "platforms/android/package-apk.sh",
2621            "platforms/android/package-aab.sh",
2622            "platforms/android/run-emulator.sh",
2623            "platforms/android/test-emulator.sh",
2624        ] {
2625            let path = root.join(relative);
2626            if path.exists() {
2627                fs::set_permissions(path, fs::Permissions::from_mode(0o755))?;
2628            }
2629        }
2630    }
2631    Ok(())
2632}
2633
2634fn scaffold_windows_bundle(
2635    root: &Path,
2636    project: &FissionProject,
2637    write_policy: WritePolicy,
2638) -> Result<()> {
2639    let executable = windows_executable_name(root, project);
2640    write_file_with_policy(
2641        &root.join("platforms/windows/Package.appxmanifest"),
2642        &render_windows_appx_manifest(project, &executable),
2643        write_policy,
2644    )?;
2645    write_file_with_policy(
2646        &root.join("platforms/windows/package-msix.ps1"),
2647        &render_windows_msix_package_script(project, &executable),
2648        write_policy,
2649    )?;
2650    write_file_with_policy(
2651        &root.join("platforms/windows/package-msi.ps1"),
2652        &render_windows_msi_package_script(project, &executable),
2653        write_policy,
2654    )?;
2655    write_file_with_policy(
2656        &root.join("platforms/windows/shortcut-aumid-helper.cpp"),
2657        render_windows_shortcut_aumid_helper_source(),
2658        write_policy,
2659    )?;
2660    write_file_with_policy(
2661        &root.join("platforms/windows/build-shortcut-aumid-helper.ps1"),
2662        render_windows_shortcut_aumid_helper_build_script(),
2663        write_policy,
2664    )?;
2665    write_file_with_policy(
2666        &root.join("platforms/windows/fission-shortcut-aumid.nsh"),
2667        render_windows_shortcut_aumid_nsis_include(),
2668        write_policy,
2669    )?;
2670    Ok(())
2671}
2672
2673fn windows_executable_name(root: &Path, project: &FissionProject) -> String {
2674    let stem = cargo_package_name(root).unwrap_or_else(|| sanitize_file_stem(&project.app.name));
2675    format!("{stem}.exe")
2676}
2677
2678fn windows_identity_name(project: &FissionProject) -> String {
2679    let mut out = project
2680        .app
2681        .app_id
2682        .chars()
2683        .map(|ch| match ch {
2684            'A'..='Z' | 'a'..='z' | '0'..='9' | '.' | '-' => ch,
2685            '_' => '.',
2686            _ => '.',
2687        })
2688        .collect::<String>();
2689    while out.contains("..") {
2690        out = out.replace("..", ".");
2691    }
2692    out = out.trim_matches(['.', '-']).to_string();
2693    if out.is_empty() {
2694        "Fission.App".to_string()
2695    } else {
2696        out
2697    }
2698}
2699
2700fn windows_publisher_name() -> &'static str {
2701    "CN=Fission Developer"
2702}
2703
2704fn render_windows_appx_manifest(project: &FissionProject, executable: &str) -> String {
2705    let display_name = escape_xml_attribute(&project.app.name);
2706    let identity_name = escape_xml_attribute(&windows_identity_name(project));
2707    let publisher = escape_xml_attribute(windows_publisher_name());
2708    let install_dir = escape_xml_attribute(&sanitize_file_stem(&project.app.name));
2709    let executable = escape_xml_attribute(executable);
2710    format!(
2711        r#"<?xml version="1.0" encoding="utf-8"?>
2712<Package
2713  xmlns="http://schemas.microsoft.com/appx/manifest/foundation/windows10"
2714  xmlns:uap="http://schemas.microsoft.com/appx/manifest/uap/windows10"
2715  xmlns:rescap="http://schemas.microsoft.com/appx/manifest/foundation/windows10/restrictedcapabilities"
2716  IgnorableNamespaces="uap rescap">
2717  <Identity Name="{identity_name}" Publisher="{publisher}" Version="0.1.0.1" ProcessorArchitecture="x64" />
2718  <Properties>
2719    <DisplayName>{display_name}</DisplayName>
2720    <PublisherDisplayName>Fission Developer</PublisherDisplayName>
2721    <Logo>Assets\StoreLogo.png</Logo>
2722  </Properties>
2723  <Dependencies>
2724    <TargetDeviceFamily Name="Windows.Desktop" MinVersion="10.0.17763.0" MaxVersionTested="10.0.22621.0" />
2725  </Dependencies>
2726  <Resources>
2727    <Resource Language="en-us" />
2728  </Resources>
2729  <Applications>
2730    <Application Id="App" Executable="VFS\ProgramFilesX64\{install_dir}\{executable}" EntryPoint="Windows.FullTrustApplication">
2731      <uap:VisualElements DisplayName="{display_name}" Description="{display_name}" BackgroundColor="transparent" Square150x150Logo="Assets\Square150x150Logo.png" Square44x44Logo="Assets\Square44x44Logo.png" />
2732    </Application>
2733  </Applications>
2734  <Capabilities>
2735    <rescap:Capability Name="runFullTrust" />
2736  </Capabilities>
2737</Package>
2738"#
2739    )
2740}
2741
2742fn render_windows_msix_package_script(project: &FissionProject, executable: &str) -> String {
2743    let app_name = sanitize_file_stem(&project.app.name);
2744    let package_name = windows_identity_name(project);
2745    let template = r#"$ErrorActionPreference = "Stop"
2746Set-StrictMode -Version Latest
2747
2748$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
2749$ProjectDir = Resolve-Path (Join-Path $ScriptDir "..\..")
2750$Profile = if ($env:WINDOWS_PROFILE) { $env:WINDOWS_PROFILE } else { "debug" }
2751$CargoProfileArg = if ($Profile -eq "release") { @("--release") } else { @() }
2752$ExecutableName = if ($env:WINDOWS_EXECUTABLE_NAME) { $env:WINDOWS_EXECUTABLE_NAME } else { "__EXECUTABLE__" }
2753$BinaryPath = if ($env:WINDOWS_BINARY) { $env:WINDOWS_BINARY } else { Join-Path $ProjectDir "target\$Profile\$ExecutableName" }
2754$OutRoot = Join-Path $ProjectDir "target\fission\windows\msix"
2755$LayoutDir = Join-Path $OutRoot "layout"
2756$AppDir = Join-Path $LayoutDir "VFS\ProgramFilesX64\__APP_NAME__"
2757$AssetsDir = Join-Path $LayoutDir "Assets"
2758$MsixPath = Join-Path $OutRoot "__PACKAGE_NAME__-$Profile.msix"
2759
2760if (-not $env:WINDOWS_BINARY) {
2761  cargo build @CargoProfileArg --manifest-path (Join-Path $ProjectDir "Cargo.toml")
2762}
2763if (-not (Test-Path $BinaryPath)) {
2764  throw "Windows executable was not found at $BinaryPath. Set WINDOWS_BINARY or WINDOWS_EXECUTABLE_NAME if the crate name changed."
2765}
2766$MakeAppx = Get-Command makeappx -ErrorAction SilentlyContinue
2767if (-not $MakeAppx) {
2768  throw "makeappx was not found. Install Windows SDK MSIX packaging tools and ensure makeappx is on PATH."
2769}
2770
2771Remove-Item -Recurse -Force $LayoutDir -ErrorAction SilentlyContinue
2772New-Item -ItemType Directory -Force $AppDir, $AssetsDir | Out-Null
2773Copy-Item $BinaryPath (Join-Path $AppDir $ExecutableName) -Force
2774if ($env:FISSION_WINDOWS_NATIVE_PRODUCTS_MANIFEST) {
2775  $NativeManifest = Get-Content -Raw $env:FISSION_WINDOWS_NATIVE_PRODUCTS_MANIFEST | ConvertFrom-Json
2776  foreach ($Product in $NativeManifest.products) {
2777    if ($Product.kind -eq "driver-package") {
2778      throw "MSIX native product manifest must not contain driver package $($Product.name)."
2779    }
2780    $NativeDestination = Join-Path $AppDir $Product.destination
2781    $NativeParent = Split-Path -Parent $NativeDestination
2782    New-Item -ItemType Directory -Force $NativeParent | Out-Null
2783    if (Test-Path $Product.source -PathType Container) {
2784      Copy-Item $Product.source $NativeDestination -Recurse -Force
2785    } else {
2786      Copy-Item $Product.source $NativeDestination -Force
2787    }
2788  }
2789}
2790Copy-Item (Join-Path $ScriptDir "Package.appxmanifest") (Join-Path $LayoutDir "AppxManifest.xml") -Force
2791
2792$IconSource = if ($env:WINDOWS_APP_ICON) { $env:WINDOWS_APP_ICON } else { Join-Path $ProjectDir "assets\app-icon.png" }
2793if (Test-Path $IconSource) {
2794  Copy-Item $IconSource (Join-Path $AssetsDir "StoreLogo.png") -Force
2795  Copy-Item $IconSource (Join-Path $AssetsDir "Square44x44Logo.png") -Force
2796  Copy-Item $IconSource (Join-Path $AssetsDir "Square150x150Logo.png") -Force
2797}
2798
2799& $MakeAppx.Source pack /d $LayoutDir /p $MsixPath /overwrite | Out-Host
2800
2801$Certificate = $env:WINDOWS_CERTIFICATE
2802$TempCertificate = $null
2803try {
2804  if (-not $Certificate -and $env:WINDOWS_CERTIFICATE_BASE64) {
2805    $TempCertificate = Join-Path ([System.IO.Path]::GetTempPath()) ("fission-windows-cert-" + [System.Guid]::NewGuid().ToString() + ".pfx")
2806    [System.IO.File]::WriteAllBytes($TempCertificate, [System.Convert]::FromBase64String($env:WINDOWS_CERTIFICATE_BASE64))
2807    $Certificate = $TempCertificate
2808  }
2809  $Thumbprint = $env:WINDOWS_CERTIFICATE_THUMBPRINT
2810  if ($Certificate -or $Thumbprint) {
2811    $SignTool = Get-Command signtool -ErrorAction SilentlyContinue
2812    if (-not $SignTool) {
2813      throw "signtool was not found. Install Windows SDK signing tools or set WINDOWS_SKIP_SIGNING=1 for unsigned local packages."
2814    }
2815    $SignArgs = @("sign", "/fd", "SHA256")
2816    if ($Certificate) {
2817      $SignArgs += @("/f", $Certificate)
2818      if ($env:WINDOWS_CERTIFICATE_PASSWORD) { $SignArgs += @("/p", $env:WINDOWS_CERTIFICATE_PASSWORD) }
2819    } else {
2820      $SignArgs += @("/sha1", $Thumbprint)
2821    }
2822    $SignArgs += $MsixPath
2823    & $SignTool.Source @SignArgs | Out-Host
2824  } elseif ($Profile -eq "release" -and $env:WINDOWS_SKIP_SIGNING -ne "1") {
2825    throw "Release MSIX packaging requires WINDOWS_CERTIFICATE, WINDOWS_CERTIFICATE_BASE64, or WINDOWS_CERTIFICATE_THUMBPRINT from a secure secret source. Set WINDOWS_SKIP_SIGNING=1 only for local unsigned validation."
2826  }
2827} finally {
2828  if ($TempCertificate) { Remove-Item -Force $TempCertificate -ErrorAction SilentlyContinue }
2829}
2830
2831Write-Output $MsixPath
2832"#;
2833    template
2834        .replace("__APP_NAME__", &app_name)
2835        .replace("__PACKAGE_NAME__", &package_name)
2836        .replace("__EXECUTABLE__", executable)
2837}
2838
2839fn render_windows_msi_package_script(project: &FissionProject, executable: &str) -> String {
2840    let app_name = sanitize_file_stem(&project.app.name);
2841    let display_name = project.app.name.clone();
2842    let upgrade_code = deterministic_guid(&project.app.app_id);
2843    let manufacturer = "Fission Developer";
2844    let template = r#"$ErrorActionPreference = "Stop"
2845Set-StrictMode -Version Latest
2846
2847$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
2848$ProjectDir = Resolve-Path (Join-Path $ScriptDir "..\..")
2849$Profile = if ($env:WINDOWS_PROFILE) { $env:WINDOWS_PROFILE } else { "debug" }
2850$CargoProfileArg = if ($Profile -eq "release") { @("--release") } else { @() }
2851$ExecutableName = if ($env:WINDOWS_EXECUTABLE_NAME) { $env:WINDOWS_EXECUTABLE_NAME } else { "__EXECUTABLE__" }
2852$BinaryPath = if ($env:WINDOWS_BINARY) { $env:WINDOWS_BINARY } else { Join-Path $ProjectDir "target\$Profile\$ExecutableName" }
2853$OutRoot = Join-Path $ProjectDir "target\fission\windows\msi"
2854$MsiPath = Join-Path $OutRoot "__APP_NAME__-$Profile.msi"
2855$Version = if ($env:WINDOWS_MSI_VERSION) { $env:WINDOWS_MSI_VERSION } else { "0.1.0" }
2856$UpgradeCode = if ($env:WINDOWS_MSI_UPGRADE_CODE) { $env:WINDOWS_MSI_UPGRADE_CODE } else { "__UPGRADE_CODE__" }
2857
2858if (-not $env:WINDOWS_BINARY) {
2859  cargo build @CargoProfileArg --manifest-path (Join-Path $ProjectDir "Cargo.toml")
2860}
2861if (-not (Test-Path $BinaryPath)) {
2862  throw "Windows executable was not found at $BinaryPath. Set WINDOWS_BINARY or WINDOWS_EXECUTABLE_NAME if the crate name changed."
2863}
2864New-Item -ItemType Directory -Force $OutRoot | Out-Null
2865
2866$Wix = Get-Command wix -ErrorAction SilentlyContinue
2867$Candle = Get-Command candle -ErrorAction SilentlyContinue
2868$Light = Get-Command light -ErrorAction SilentlyContinue
2869if ($Wix) {
2870  $WxsPath = Join-Path $OutRoot "package.wxs"
2871  @"
2872<Wix xmlns="http://wixtoolset.org/schemas/v4/wxs">
2873  <Package Name="__DISPLAY_NAME__" Manufacturer="__MANUFACTURER__" Version="$Version" UpgradeCode="$UpgradeCode" Scope="perMachine">
2874    <MajorUpgrade DowngradeErrorMessage="A newer version of __DISPLAY_NAME__ is already installed." />
2875    <MediaTemplate EmbedCab="yes" />
2876    <StandardDirectory Id="ProgramFiles6432Folder">
2877      <Directory Id="INSTALLFOLDER" Name="__APP_NAME__">
2878        <Component Id="MainExecutable" Guid="*">
2879          <File Id="AppExe" Source="$BinaryPath" KeyPath="yes" />
2880        </Component>
2881      </Directory>
2882    </StandardDirectory>
2883    <Feature Id="MainFeature" Title="__DISPLAY_NAME__" Level="1">
2884      <ComponentRef Id="MainExecutable" />
2885    </Feature>
2886  </Package>
2887</Wix>
2888"@ | Set-Content -Encoding UTF8 $WxsPath
2889  & $Wix.Source build $WxsPath -o $MsiPath | Out-Host
2890} elseif ($Candle -and $Light) {
2891  $WxsPath = Join-Path $OutRoot "package-wix3.wxs"
2892  $WixObj = Join-Path $OutRoot "package.wixobj"
2893  @"
2894<Wix xmlns="http://schemas.microsoft.com/wix/2006/wi">
2895  <Product Id="*" Name="__DISPLAY_NAME__" Language="1033" Version="$Version" Manufacturer="__MANUFACTURER__" UpgradeCode="$UpgradeCode">
2896    <Package InstallerVersion="500" Compressed="yes" InstallScope="perMachine" />
2897    <MajorUpgrade DowngradeErrorMessage="A newer version of __DISPLAY_NAME__ is already installed." />
2898    <MediaTemplate EmbedCab="yes" />
2899    <Directory Id="TARGETDIR" Name="SourceDir">
2900      <Directory Id="ProgramFiles64Folder">
2901        <Directory Id="INSTALLFOLDER" Name="__APP_NAME__">
2902          <Component Id="MainExecutable" Guid="*">
2903            <File Id="AppExe" Source="$BinaryPath" KeyPath="yes" />
2904          </Component>
2905        </Directory>
2906      </Directory>
2907    </Directory>
2908    <Feature Id="MainFeature" Title="__DISPLAY_NAME__" Level="1">
2909      <ComponentRef Id="MainExecutable" />
2910    </Feature>
2911  </Product>
2912</Wix>
2913"@ | Set-Content -Encoding UTF8 $WxsPath
2914  & $Candle.Source -nologo -arch x64 -out $WixObj $WxsPath | Out-Host
2915  & $Light.Source -nologo -out $MsiPath $WixObj | Out-Host
2916} else {
2917  throw "WiX was not found. Install WiX Toolset (`wix`) or WiX 3 (`candle` and `light`) to package an MSI."
2918}
2919
2920$Certificate = $env:WINDOWS_CERTIFICATE
2921$TempCertificate = $null
2922try {
2923  if (-not $Certificate -and $env:WINDOWS_CERTIFICATE_BASE64) {
2924    $TempCertificate = Join-Path ([System.IO.Path]::GetTempPath()) ("fission-windows-cert-" + [System.Guid]::NewGuid().ToString() + ".pfx")
2925    [System.IO.File]::WriteAllBytes($TempCertificate, [System.Convert]::FromBase64String($env:WINDOWS_CERTIFICATE_BASE64))
2926    $Certificate = $TempCertificate
2927  }
2928  $Thumbprint = $env:WINDOWS_CERTIFICATE_THUMBPRINT
2929  if ($Certificate -or $Thumbprint) {
2930    $SignTool = Get-Command signtool -ErrorAction SilentlyContinue
2931    if (-not $SignTool) {
2932      throw "signtool was not found. Install Windows SDK signing tools or set WINDOWS_SKIP_SIGNING=1 for unsigned local packages."
2933    }
2934    $SignArgs = @("sign", "/fd", "SHA256")
2935    if ($Certificate) {
2936      $SignArgs += @("/f", $Certificate)
2937      if ($env:WINDOWS_CERTIFICATE_PASSWORD) { $SignArgs += @("/p", $env:WINDOWS_CERTIFICATE_PASSWORD) }
2938    } else {
2939      $SignArgs += @("/sha1", $Thumbprint)
2940    }
2941    $SignArgs += $MsiPath
2942    & $SignTool.Source @SignArgs | Out-Host
2943  } elseif ($Profile -eq "release" -and $env:WINDOWS_SKIP_SIGNING -ne "1") {
2944    throw "Release MSI packaging requires WINDOWS_CERTIFICATE, WINDOWS_CERTIFICATE_BASE64, or WINDOWS_CERTIFICATE_THUMBPRINT from a secure secret source. Set WINDOWS_SKIP_SIGNING=1 only for local unsigned validation."
2945  }
2946} finally {
2947  if ($TempCertificate) { Remove-Item -Force $TempCertificate -ErrorAction SilentlyContinue }
2948}
2949
2950Write-Output $MsiPath
2951"#;
2952    template
2953        .replace("__APP_NAME__", &app_name)
2954        .replace("__DISPLAY_NAME__", &display_name)
2955        .replace("__MANUFACTURER__", manufacturer)
2956        .replace("__UPGRADE_CODE__", &upgrade_code)
2957        .replace("__EXECUTABLE__", executable)
2958}
2959
2960fn render_windows_shortcut_aumid_helper_source() -> &'static str {
2961    r#"#include <windows.h>
2962
2963#include <cwchar>
2964#include <cwctype>
2965#include <cstdio>
2966
2967#include <propkey.h>
2968#include <propvarutil.h>
2969#include <shobjidl.h>
2970#include <wrl/client.h>
2971
2972namespace {
2973
2974using Microsoft::WRL::ComPtr;
2975
2976bool IsValidAppUserModelId(const wchar_t* app_user_model_id) {
2977  if (app_user_model_id == nullptr) {
2978    return false;
2979  }
2980
2981  const size_t length = std::wcslen(app_user_model_id);
2982  if (length == 0 || length > 128) {
2983    return false;
2984  }
2985
2986  for (size_t index = 0; index < length; ++index) {
2987    if (std::iswspace(app_user_model_id[index]) != 0) {
2988      return false;
2989    }
2990  }
2991
2992  return true;
2993}
2994
2995int ReportFailure(const wchar_t* operation, HRESULT result) {
2996  std::fwprintf(
2997      stderr,
2998      L"%ls failed (HRESULT 0x%08lX).\n",
2999      operation,
3000      static_cast<unsigned long>(result));
3001  return 1;
3002}
3003
3004}  // namespace
3005
3006int wmain(int argc, wchar_t** argv) {
3007  if (argc != 3) {
3008    std::fwprintf(
3009        stderr,
3010        L"Usage: fission-shortcut-aumid.exe <shortcut.lnk> <app-user-model-id>\n");
3011    return 2;
3012  }
3013
3014  const wchar_t* shortcut_path = argv[1];
3015  const wchar_t* app_user_model_id = argv[2];
3016  if (!IsValidAppUserModelId(app_user_model_id)) {
3017    std::fwprintf(
3018        stderr,
3019        L"The AppUserModelID must contain 1-128 UTF-16 code units and no whitespace.\n");
3020    return 3;
3021  }
3022
3023  const HRESULT initialize_result =
3024      CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED);
3025  const bool should_uninitialize = SUCCEEDED(initialize_result);
3026  if (FAILED(initialize_result) && initialize_result != RPC_E_CHANGED_MODE) {
3027    return ReportFailure(L"CoInitializeEx", initialize_result);
3028  }
3029
3030  int exit_code = 0;
3031  ComPtr<IShellLinkW> shell_link;
3032  HRESULT result = CoCreateInstance(
3033      CLSID_ShellLink,
3034      nullptr,
3035      CLSCTX_INPROC_SERVER,
3036      IID_PPV_ARGS(&shell_link));
3037  if (FAILED(result)) {
3038    exit_code = ReportFailure(L"CoCreateInstance(CLSID_ShellLink)", result);
3039    goto finish;
3040  }
3041
3042  {
3043    ComPtr<IPersistFile> persist_file;
3044    result = shell_link.As(&persist_file);
3045    if (FAILED(result)) {
3046      exit_code = ReportFailure(L"QueryInterface(IPersistFile)", result);
3047      goto finish;
3048    }
3049
3050    result = persist_file->Load(shortcut_path, STGM_READWRITE);
3051    if (FAILED(result)) {
3052      exit_code = ReportFailure(L"IPersistFile::Load", result);
3053      goto finish;
3054    }
3055
3056    ComPtr<IPropertyStore> property_store;
3057    result = shell_link.As(&property_store);
3058    if (FAILED(result)) {
3059      exit_code = ReportFailure(L"QueryInterface(IPropertyStore)", result);
3060      goto finish;
3061    }
3062
3063    PROPVARIANT app_id_value;
3064    PropVariantInit(&app_id_value);
3065    result = InitPropVariantFromString(app_user_model_id, &app_id_value);
3066    if (SUCCEEDED(result)) {
3067      result = property_store->SetValue(PKEY_AppUserModel_ID, app_id_value);
3068    }
3069    if (SUCCEEDED(result)) {
3070      result = property_store->Commit();
3071    }
3072    PropVariantClear(&app_id_value);
3073    if (FAILED(result)) {
3074      exit_code = ReportFailure(L"IPropertyStore::SetValue/Commit", result);
3075      goto finish;
3076    }
3077
3078    result = persist_file->Save(shortcut_path, TRUE);
3079    if (FAILED(result)) {
3080      exit_code = ReportFailure(L"IPersistFile::Save", result);
3081      goto finish;
3082    }
3083  }
3084
3085finish:
3086  if (should_uninitialize) {
3087    CoUninitialize();
3088  }
3089  return exit_code;
3090}
3091"#
3092}
3093
3094fn render_windows_shortcut_aumid_helper_build_script() -> &'static str {
3095    r#"[CmdletBinding()]
3096param(
3097  [ValidateSet("x64", "arm64")]
3098  [string] $Architecture = $(if ($env:PROCESSOR_ARCHITECTURE -eq "ARM64") { "arm64" } else { "x64" })
3099)
3100
3101$ErrorActionPreference = "Stop"
3102Set-StrictMode -Version Latest
3103
3104$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
3105$ProjectDir = Resolve-Path (Join-Path $ScriptDir "..\..")
3106$SourcePath = Join-Path $ScriptDir "shortcut-aumid-helper.cpp"
3107$OutputDirectory = Join-Path $ProjectDir "target\fission\windows\shortcut-aumid\$Architecture"
3108$OutputPath = Join-Path $OutputDirectory "fission-shortcut-aumid.exe"
3109$ObjectPath = Join-Path $OutputDirectory "fission-shortcut-aumid.obj"
3110
3111if (-not (Test-Path $SourcePath -PathType Leaf)) {
3112  throw "The shortcut AUMID helper source was not found at $SourcePath."
3113}
3114
3115$VsWhere = Get-Command vswhere.exe -ErrorAction SilentlyContinue
3116if (-not $VsWhere -and ${env:ProgramFiles(x86)}) {
3117  $BundledVsWhere = Join-Path ${env:ProgramFiles(x86)} "Microsoft Visual Studio\Installer\vswhere.exe"
3118  if (Test-Path $BundledVsWhere -PathType Leaf) {
3119    $VsWhere = Get-Item $BundledVsWhere
3120  }
3121}
3122if (-not $VsWhere) {
3123  throw "vswhere.exe was not found. Install Visual Studio Build Tools with the target C++ toolchain."
3124}
3125$VsWherePath = if ($VsWhere -is [System.IO.FileInfo]) {
3126  $VsWhere.FullName
3127} else {
3128  $VsWhere.Source
3129}
3130
3131$RequiredComponent = if ($Architecture -eq "arm64") {
3132  "Microsoft.VisualStudio.Component.VC.Tools.ARM64"
3133} else {
3134  "Microsoft.VisualStudio.Component.VC.Tools.x86.x64"
3135}
3136$Installation = & $VsWherePath -latest -products * -requires $RequiredComponent -property installationPath
3137if (-not $Installation) {
3138  throw "Visual Studio Build Tools with component $RequiredComponent were not found for $Architecture."
3139}
3140$VsDevCmd = Join-Path $Installation "Common7\Tools\VsDevCmd.bat"
3141if (-not (Test-Path $VsDevCmd -PathType Leaf)) {
3142  throw "VsDevCmd.bat was not found at $VsDevCmd."
3143}
3144
3145$DeveloperCommand = "call `"$VsDevCmd`" -no_logo -arch=$Architecture -host_arch=amd64 && set"
3146$EnvironmentLines = & $env:ComSpec /d /c $DeveloperCommand
3147if ($LASTEXITCODE -ne 0) {
3148  throw "Visual Studio failed to initialize the $Architecture C++ build environment."
3149}
3150foreach ($Line in $EnvironmentLines) {
3151  $Separator = $Line.IndexOf("=")
3152  if ($Separator -gt 0) {
3153    $Name = $Line.Substring(0, $Separator)
3154    $Value = $Line.Substring($Separator + 1)
3155    [Environment]::SetEnvironmentVariable($Name, $Value, "Process")
3156  }
3157}
3158
3159$Compiler = Get-Command cl.exe -ErrorAction SilentlyContinue
3160if (-not $Compiler) {
3161  throw "cl.exe was not available after initializing the $Architecture C++ build environment."
3162}
3163
3164New-Item -ItemType Directory -Force $OutputDirectory | Out-Null
3165$CompileArguments = @(
3166  "/nologo",
3167  "/EHsc",
3168  "/MT",
3169  "/DUNICODE",
3170  "/D_UNICODE",
3171  "/Fo$ObjectPath",
3172  "/Fe$OutputPath",
3173  $SourcePath,
3174  "/link",
3175  "ole32.lib",
3176  "shell32.lib",
3177  "propsys.lib"
3178)
3179& $Compiler.Source @CompileArguments | Out-Host
3180if ($LASTEXITCODE -ne 0 -or -not (Test-Path $OutputPath -PathType Leaf)) {
3181  throw "The $Architecture shortcut AUMID helper build failed."
3182}
3183
3184Write-Output $OutputPath
3185"#
3186}
3187
3188fn render_windows_shortcut_aumid_nsis_include() -> &'static str {
3189    r#"!ifndef FISSION_SHORTCUT_AUMID_NSH
3190!define FISSION_SHORTCUT_AUMID_NSH
3191
3192!include "LogicLib.nsh"
3193
3194; Embed the architecture-matched helper once in an installer section.
3195!macro FissionEmbedShortcutAppUserModelIdHelper HELPER_PATH
3196  InitPluginsDir
3197  File "/oname=$PLUGINSDIR\fission-shortcut-aumid.exe" "${HELPER_PATH}"
3198!macroend
3199
3200; Apply the same stable AppUserModelID passed to
3201; WinitApp::with_windows_app_user_model_id or
3202; DesktopApp::with_windows_app_user_model_id. Call this after CreateShortCut.
3203!macro FissionSetShortcutAppUserModelId SHORTCUT_PATH APP_USER_MODEL_ID
3204  Push $0
3205  Push $1
3206  nsExec::ExecToStack /TIMEOUT=30000 '"$PLUGINSDIR\fission-shortcut-aumid.exe" "${SHORTCUT_PATH}" "${APP_USER_MODEL_ID}"'
3207  Pop $0
3208  Pop $1
3209  ${If} $0 != 0
3210    DetailPrint "Failed to apply AppUserModelID to ${SHORTCUT_PATH}: exit=$0 output=$1"
3211    MessageBox MB_ICONSTOP|MB_OK "Windows notification identity setup failed. The installation cannot continue."
3212    Pop $1
3213    Pop $0
3214    SetErrors
3215    Abort
3216  ${EndIf}
3217  Pop $1
3218  Pop $0
3219!macroend
3220
3221!endif
3222"#
3223}
3224
3225fn sanitize_file_stem(value: &str) -> String {
3226    let stem = value
3227        .chars()
3228        .map(|ch| match ch {
3229            'A'..='Z' | 'a'..='z' | '0'..='9' | '-' | '_' | '.' => ch,
3230            _ => '-',
3231        })
3232        .collect::<String>()
3233        .trim_matches(['-', '.', '_'])
3234        .to_string();
3235    if stem.is_empty() {
3236        "app".to_string()
3237    } else {
3238        stem
3239    }
3240}
3241
3242fn deterministic_guid(value: &str) -> String {
3243    fn fnv64(seed: u64, value: &str) -> u64 {
3244        let mut hash = seed;
3245        for byte in value.as_bytes() {
3246            hash ^= u64::from(*byte);
3247            hash = hash.wrapping_mul(0x100000001b3);
3248        }
3249        hash
3250    }
3251    let left = fnv64(0xcbf29ce484222325, value);
3252    let right = fnv64(0x84222325cbf29ce4, value);
3253    let mut bytes = [0u8; 16];
3254    bytes[..8].copy_from_slice(&left.to_be_bytes());
3255    bytes[8..].copy_from_slice(&right.to_be_bytes());
3256    bytes[6] = (bytes[6] & 0x0f) | 0x40;
3257    bytes[8] = (bytes[8] & 0x3f) | 0x80;
3258    format!(
3259        "{:02x}{:02x}{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}",
3260        bytes[0],
3261        bytes[1],
3262        bytes[2],
3263        bytes[3],
3264        bytes[4],
3265        bytes[5],
3266        bytes[6],
3267        bytes[7],
3268        bytes[8],
3269        bytes[9],
3270        bytes[10],
3271        bytes[11],
3272        bytes[12],
3273        bytes[13],
3274        bytes[14],
3275        bytes[15]
3276    )
3277}
3278
3279fn scaffold_web_bundle(
3280    root: &Path,
3281    project: &FissionProject,
3282    write_policy: WritePolicy,
3283) -> Result<()> {
3284    let index_html = render_web_index(project);
3285    let bootstrap = render_web_bootstrap(project);
3286    let build_script = render_web_build_script();
3287    let run_script = render_web_run_script(project);
3288    let test_script = render_web_test_script(project);
3289
3290    write_file_with_policy(
3291        &root.join("platforms/web/index.html"),
3292        &index_html,
3293        write_policy,
3294    )?;
3295    write_file_with_policy(
3296        &root.join("platforms/web/bootstrap.mjs"),
3297        &bootstrap,
3298        write_policy,
3299    )?;
3300    write_file_with_policy(
3301        &root.join("platforms/web/build-wasm.sh"),
3302        &build_script,
3303        write_policy,
3304    )?;
3305    write_file_with_policy(
3306        &root.join("platforms/web/run-browser.sh"),
3307        &run_script,
3308        write_policy,
3309    )?;
3310    write_file_with_policy(
3311        &root.join("platforms/web/test-browser.sh"),
3312        &test_script,
3313        write_policy,
3314    )?;
3315
3316    #[cfg(unix)]
3317    {
3318        use std::os::unix::fs::PermissionsExt;
3319        for relative in [
3320            "platforms/web/build-wasm.sh",
3321            "platforms/web/run-browser.sh",
3322            "platforms/web/test-browser.sh",
3323        ] {
3324            let path = root.join(relative);
3325            if path.exists() {
3326                let mut perms = fs::metadata(&path)?.permissions();
3327                perms.set_mode(0o755);
3328                fs::set_permissions(path, perms)?;
3329            }
3330        }
3331    }
3332
3333    Ok(())
3334}
3335
3336fn write_generated_app_agents(project_root: &Path) -> Result<()> {
3337    let repo_root = find_git_root(project_root).unwrap_or_else(|| project_root.to_path_buf());
3338    let root_agents = repo_root.join("AGENTS.md");
3339    if let Some(existing) = read_optional_string(&root_agents)? {
3340        if is_generated_app_agents(&existing) {
3341            return write_file_with_policy(
3342                &root_agents,
3343                GENERATED_APP_AGENTS_MD,
3344                WritePolicy::Overwrite,
3345            );
3346        }
3347
3348        let fission_agents = repo_root.join("AGENTS.fission.md");
3349        let write_policy = read_optional_string(&fission_agents)?
3350            .filter(|existing| is_generated_app_agents(existing))
3351            .map(|_| WritePolicy::Overwrite)
3352            .unwrap_or(WritePolicy::PreserveExisting);
3353
3354        return write_file_with_policy(&fission_agents, GENERATED_APP_AGENTS_MD, write_policy);
3355    }
3356
3357    write_file_with_policy(
3358        &root_agents,
3359        GENERATED_APP_AGENTS_MD,
3360        WritePolicy::Overwrite,
3361    )
3362}
3363
3364fn read_optional_string(path: &Path) -> Result<Option<String>> {
3365    match fs::read_to_string(path) {
3366        Ok(contents) => Ok(Some(contents)),
3367        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
3368        Err(error) => Err(error).with_context(|| format!("failed to read {}", path.display())),
3369    }
3370}
3371
3372fn is_generated_app_agents(contents: &str) -> bool {
3373    contents.contains(GENERATED_APP_AGENTS_MARKER)
3374        || contents == GENERATED_APP_AGENTS_MD
3375        || (contents.contains("# Fission App Guidelines")
3376            && contents.contains(
3377                "These instructions apply when building or reviewing a Fission-based app",
3378            )
3379            && contents.contains("## Source-Grounded Work")
3380            && contents.contains("## Validation"))
3381}
3382
3383fn find_git_root(start: &Path) -> Option<PathBuf> {
3384    let mut current = fs::canonicalize(start).ok()?;
3385    loop {
3386        if current.join(".git").exists() {
3387            return Some(current);
3388        }
3389        if !current.pop() {
3390            return None;
3391        }
3392    }
3393}
3394
3395pub(crate) fn write_file(path: &Path, contents: &str) -> Result<()> {
3396    write_file_with_policy(path, contents, WritePolicy::Overwrite)
3397}
3398
3399fn write_file_with_policy(path: &Path, contents: &str, write_policy: WritePolicy) -> Result<()> {
3400    if write_policy == WritePolicy::PreserveExisting && path.exists() {
3401        return Ok(());
3402    }
3403    if let Some(parent) = path.parent() {
3404        fs::create_dir_all(parent)?;
3405    }
3406    fs::write(path, contents).with_context(|| format!("failed to write {}", path.display()))
3407}
3408
3409fn write_binary_file_with_policy(
3410    path: &Path,
3411    contents: &[u8],
3412    write_policy: WritePolicy,
3413) -> Result<()> {
3414    if write_policy == WritePolicy::PreserveExisting && path.exists() {
3415        return Ok(());
3416    }
3417    if let Some(parent) = path.parent() {
3418        fs::create_dir_all(parent)?;
3419    }
3420    fs::write(path, contents).with_context(|| format!("failed to write {}", path.display()))
3421}
3422
3423fn render_cargo_toml(project: &FissionProject, local_path: Option<&Path>) -> String {
3424    let feature_list = render_fission_feature_list(&project.targets);
3425    let deps = if let Some(root) = local_path {
3426        let fission_path = root.join("crates/authoring/fission");
3427        format!(
3428            "fission = {{ path = {:?}, default-features = false, features = [{}] }}\n",
3429            fission_path.to_string_lossy().to_string(),
3430            feature_list
3431        )
3432    } else {
3433        format!(
3434            "fission = {{ version = \"{}\", default-features = false, features = [{}] }}\n",
3435            CURRENT_VERSION, feature_list
3436        )
3437    };
3438    let lib_name = project.app.name.replace('-', "_");
3439
3440    format!(
3441        "[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",
3442        project.app.name, lib_name, deps
3443    )
3444}
3445
3446fn render_fission_feature_list(targets: &BTreeSet<Target>) -> String {
3447    fission_features_for_targets(targets)
3448        .into_iter()
3449        .map(|feature| format!("\"{feature}\""))
3450        .collect::<Vec<_>>()
3451        .join(", ")
3452}
3453
3454fn fission_features_for_targets(targets: &BTreeSet<Target>) -> Vec<&'static str> {
3455    let mut features = Vec::new();
3456    if targets
3457        .iter()
3458        .any(|target| matches!(target, Target::Linux | Target::Macos | Target::Windows))
3459    {
3460        features.push("desktop");
3461    }
3462    if targets.contains(&Target::Web) {
3463        features.push("web");
3464    }
3465    if targets.contains(&Target::Android) {
3466        features.push("android");
3467    }
3468    if targets.contains(&Target::Ios) {
3469        features.push("ios");
3470    }
3471    if targets.contains(&Target::Site) {
3472        features.push("site");
3473    }
3474    if targets.contains(&Target::Server) {
3475        features.push("server");
3476    }
3477    if targets.contains(&Target::Terminal) {
3478        features.push("terminal-shell");
3479    }
3480    features
3481}
3482
3483fn render_project_readme(project: &FissionProject) -> String {
3484    let mut targets = String::new();
3485    for target in &project.targets {
3486        targets.push_str(&format!("- `{}`\n", target.as_str()));
3487    }
3488    format!(
3489        "# {}\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",
3490        project.app.name, targets
3491    )
3492}
3493
3494fn platform_readme(title: &str, summary: &str, bullets: &[&str]) -> String {
3495    let mut out = format!("# {} target\n\n{}\n", title, summary);
3496    for bullet in bullets {
3497        out.push_str(&format!("\n- {}", bullet));
3498    }
3499    out.push('\n');
3500    out
3501}
3502
3503fn normalize_crate_name(name: &str) -> String {
3504    name.chars()
3505        .map(|ch| match ch {
3506            'A'..='Z' => ch.to_ascii_lowercase(),
3507            'a'..='z' | '0'..='9' => ch,
3508            _ => '-',
3509        })
3510        .collect::<String>()
3511        .trim_matches('-')
3512        .to_string()
3513}
3514
3515pub fn ios_executable_name(project: &FissionProject) -> String {
3516    project.app.name.replace('-', "_")
3517}
3518
3519fn ios_bundle_name(project: &FissionProject) -> String {
3520    let mut out = String::new();
3521    let mut uppercase_next = true;
3522    for ch in project.app.name.chars() {
3523        match ch {
3524            '-' | '_' | ' ' => uppercase_next = true,
3525            _ if uppercase_next => {
3526                out.extend(ch.to_uppercase());
3527                uppercase_next = false;
3528            }
3529            _ => out.push(ch),
3530        }
3531    }
3532    if out.is_empty() {
3533        "FissionApp".to_string()
3534    } else {
3535        out
3536    }
3537}
3538
3539fn android_library_name(project: &FissionProject) -> String {
3540    project.app.name.replace('-', "_")
3541}
3542
3543fn android_root_project_name(project: &FissionProject) -> String {
3544    project.app.name.replace('-', "_")
3545}
3546
3547fn render_android_settings_gradle(project: &FissionProject) -> String {
3548    let repositories = android_dependency_repositories(project)
3549        .into_iter()
3550        .map(|repository| format!("        {repository}\n"))
3551        .collect::<String>();
3552    format!(
3553        r#"pluginManagement {{
3554    repositories {{
3555        google()
3556        mavenCentral()
3557        gradlePluginPortal()
3558    }}
3559}}
3560
3561dependencyResolutionManagement {{
3562    repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
3563    repositories {{
3564{repositories}
3565    }}
3566}}
3567
3568rootProject.name = "{name}-android"
3569include(":app")
3570"#,
3571        name = android_root_project_name(project),
3572    )
3573}
3574
3575fn render_android_root_build_gradle() -> String {
3576    format!(
3577        r#"plugins {{
3578    id("com.android.application") version "{ANDROID_GRADLE_PLUGIN_VERSION}" apply false
3579}}
3580"#
3581    )
3582}
3583
3584fn render_android_gradle_properties() -> &'static str {
3585    "android.useAndroidX=true\norg.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8\nandroid.javaCompile.suppressSourceTargetDeprecationWarning=true\n"
3586}
3587
3588fn render_android_app_build_gradle(project: &FissionProject) -> String {
3589    format!(
3590        r#"plugins {{
3591    id("com.android.application")
3592}}
3593
3594val releaseKeystore = System.getenv("ANDROID_KEYSTORE")
3595val releaseStorePassword = System.getenv("ANDROID_KEYSTORE_PASSWORD")
3596val releaseKeyAlias = System.getenv("ANDROID_KEYSTORE_ALIAS") ?: "upload"
3597val releaseKeyPassword = System.getenv("ANDROID_KEY_PASSWORD") ?: releaseStorePassword
3598val hasReleaseSigning = !releaseKeystore.isNullOrBlank() &&
3599    !releaseStorePassword.isNullOrBlank() &&
3600    !releaseKeyAlias.isNullOrBlank() &&
3601    !releaseKeyPassword.isNullOrBlank()
3602
3603android {{
3604    namespace = "{app_id}"
3605    compileSdk = (System.getenv("ANDROID_TARGET_API_LEVEL") ?: "35").toInt()
3606
3607    defaultConfig {{
3608        applicationId = "{app_id}"
3609        minSdk = (System.getenv("ANDROID_MIN_API_LEVEL") ?: "24").toInt()
3610        targetSdk = (System.getenv("ANDROID_TARGET_API_LEVEL") ?: "35").toInt()
3611        versionCode = (System.getenv("ANDROID_VERSION_CODE") ?: "1").toInt()
3612        versionName = System.getenv("ANDROID_VERSION_NAME") ?: "0.1.0"
3613    }}
3614
3615    sourceSets {{
3616        getByName("main") {{
3617            manifest.srcFile("../AndroidManifest.xml")
3618            java.srcDirs("../java")
3619            res.srcDirs("../res", "src/main/res")
3620            jniLibs.srcDirs("src/main/jniLibs")
3621        }}
3622    }}
3623
3624    signingConfigs {{
3625        create("release") {{
3626            if (hasReleaseSigning) {{
3627                storeFile = file(releaseKeystore!!)
3628                storePassword = releaseStorePassword
3629                keyAlias = releaseKeyAlias
3630                keyPassword = releaseKeyPassword
3631            }}
3632        }}
3633    }}
3634
3635    buildTypes {{
3636        getByName("debug") {{
3637            isDebuggable = true
3638        }}
3639        getByName("release") {{
3640            isDebuggable = false
3641            if (hasReleaseSigning) {{
3642                signingConfig = signingConfigs.getByName("release")
3643            }}
3644        }}
3645    }}
3646}}
3647
3648apply(from = "../native-modules.gradle")
3649"#,
3650        app_id = project.app.app_id,
3651    )
3652}
3653
3654fn render_android_native_modules_gradle(project: &FissionProject) -> String {
3655    let mut dependencies = Vec::new();
3656    let mut source_dirs = Vec::new();
3657    for module in &project.native.modules {
3658        for dependency in &module.android.gradle_dependencies {
3659            if let Some(dependency) = normalize_gradle_dependency(dependency) {
3660                dependencies.push((module.name.as_str(), dependency));
3661            }
3662        }
3663        for source_dir in &module.android.source_dirs {
3664            let source_dir = source_dir.trim();
3665            if !source_dir.is_empty() {
3666                source_dirs.push((module.name.as_str(), source_dir.to_string()));
3667            }
3668        }
3669    }
3670
3671    let mut out = String::from(
3672        "// Generated by Fission. Native capability modules append Android SDK wiring here.\n",
3673    );
3674    if dependencies.is_empty() && source_dirs.is_empty() {
3675        out.push_str("// No Android native modules are configured in fission.toml.\n");
3676        return out;
3677    }
3678    if !source_dirs.is_empty() {
3679        out.push_str("\ndef fissionProjectDir = rootProject.projectDir.toPath().resolve('../..').normalize().toFile()\n");
3680        out.push_str("android {\n");
3681        out.push_str("    sourceSets {\n");
3682        out.push_str("        main {\n");
3683        for (module, source_dir) in &source_dirs {
3684            out.push_str("            // ");
3685            out.push_str(module);
3686            out.push('\n');
3687            out.push_str("            java.srcDir(new File(fissionProjectDir, ");
3688            out.push_str(&groovy_string_literal(source_dir));
3689            out.push_str("))\n");
3690        }
3691        out.push_str("        }\n");
3692        out.push_str("    }\n");
3693        out.push_str("}\n");
3694    }
3695    if !dependencies.is_empty() {
3696        out.push_str("\ndependencies {\n");
3697        for (module, dependency) in dependencies {
3698            out.push_str("    // ");
3699            out.push_str(module);
3700            out.push('\n');
3701            out.push_str("    ");
3702            out.push_str(&dependency);
3703            out.push('\n');
3704        }
3705        out.push_str("}\n");
3706    }
3707    out
3708}
3709
3710fn android_dependency_repositories(project: &FissionProject) -> BTreeSet<String> {
3711    let mut repositories = BTreeSet::new();
3712    repositories.insert("google()".to_string());
3713    repositories.insert("mavenCentral()".to_string());
3714    for module in &project.native.modules {
3715        for repository in &module.android.repositories {
3716            if let Some(repository) = normalize_gradle_repository(repository) {
3717                repositories.insert(repository);
3718            }
3719        }
3720    }
3721    repositories
3722}
3723
3724fn normalize_gradle_repository(value: &str) -> Option<String> {
3725    let value = value.trim();
3726    if value.is_empty() {
3727        return None;
3728    }
3729    match value {
3730        "google" | "google()" => Some("google()".to_string()),
3731        "mavenCentral" | "mavenCentral()" => Some("mavenCentral()".to_string()),
3732        "gradlePluginPortal" | "gradlePluginPortal()" => Some("gradlePluginPortal()".to_string()),
3733        _ if value.contains('(') => Some(value.to_string()),
3734        _ => Some(format!("maven(\"{value}\")")),
3735    }
3736}
3737
3738fn normalize_gradle_dependency(value: &str) -> Option<String> {
3739    let value = value.trim();
3740    if value.is_empty() {
3741        return None;
3742    }
3743    if let Some((configuration, dependency)) = split_gradle_dependency_invocation(value) {
3744        Some(format!("{configuration} {}", dependency.trim()))
3745    } else if value.contains('(') {
3746        Some(format!("implementation {value}"))
3747    } else {
3748        Some(format!("implementation {}", groovy_string_literal(value)))
3749    }
3750}
3751
3752fn split_gradle_dependency_invocation(value: &str) -> Option<(&str, &str)> {
3753    let open = value.find('(')?;
3754    if !value.ends_with(')') {
3755        return None;
3756    }
3757    let configuration = value[..open].trim();
3758    if !is_gradle_dependency_configuration(configuration) {
3759        return None;
3760    }
3761    let dependency = value[open + 1..value.len() - 1].trim();
3762    if dependency.is_empty() {
3763        return None;
3764    }
3765    Some((configuration, dependency))
3766}
3767
3768fn is_gradle_dependency_configuration(value: &str) -> bool {
3769    matches!(
3770        value,
3771        "implementation"
3772            | "api"
3773            | "compileOnly"
3774            | "runtimeOnly"
3775            | "testImplementation"
3776            | "testCompileOnly"
3777            | "testRuntimeOnly"
3778            | "androidTestImplementation"
3779            | "androidTestCompileOnly"
3780            | "androidTestRuntimeOnly"
3781            | "debugImplementation"
3782            | "debugCompileOnly"
3783            | "debugRuntimeOnly"
3784            | "releaseImplementation"
3785            | "releaseCompileOnly"
3786            | "releaseRuntimeOnly"
3787            | "kapt"
3788            | "ksp"
3789    )
3790}
3791
3792fn groovy_string_literal(value: &str) -> String {
3793    format!("'{}'", value.replace('\\', "\\\\").replace('\'', "\\'"))
3794}
3795
3796fn render_android_activity_java() -> &'static str {
3797    r#"package rs.fission.runtime;
3798
3799import android.app.NativeActivity;
3800import android.media.MediaPlayer;
3801import android.media.PlaybackParams;
3802import android.os.Bundle;
3803import android.view.View;
3804import android.view.ViewGroup;
3805import android.widget.FrameLayout;
3806import android.widget.VideoView;
3807
3808import java.util.HashMap;
3809import java.util.Map;
3810
3811public final class FissionActivity extends NativeActivity {
3812    private static volatile FissionActivity INSTANCE;
3813    private static final Map<Long, FissionVideoSlot> VIDEOS = new HashMap<>();
3814
3815    @Override
3816    protected void onCreate(Bundle savedInstanceState) {
3817        super.onCreate(savedInstanceState);
3818        INSTANCE = this;
3819    }
3820
3821    @Override
3822    protected void onDestroy() {
3823        runOnUiThread(() -> {
3824            synchronized (VIDEOS) {
3825                for (FissionVideoSlot slot : VIDEOS.values()) {
3826                    slot.destroy();
3827                }
3828                VIDEOS.clear();
3829            }
3830        });
3831        INSTANCE = null;
3832        super.onDestroy();
3833    }
3834
3835    public static void fissionCreateVideo(long id, String source) {
3836        runOnUiThreadOrRecordError(id, () -> {
3837            synchronized (VIDEOS) {
3838                FissionVideoSlot previous = VIDEOS.remove(id);
3839                if (previous != null) {
3840                    previous.destroy();
3841                }
3842                FissionVideoSlot slot = new FissionVideoSlot(INSTANCE, source);
3843                VIDEOS.put(id, slot);
3844            }
3845        });
3846    }
3847
3848    public static void fissionUpdateVideoSurface(
3849            long id,
3850            int left,
3851            int top,
3852            int width,
3853            int height,
3854            boolean visible
3855    ) {
3856        runOnUiThreadOrRecordError(id, () -> {
3857            FissionVideoSlot slot = slot(id);
3858            if (slot != null) {
3859                slot.update(left, top, width, height, visible);
3860            }
3861        });
3862    }
3863
3864    public static void fissionSetVideoVisible(long id, boolean visible) {
3865        runOnUiThreadOrRecordError(id, () -> {
3866            FissionVideoSlot slot = slot(id);
3867            if (slot != null && slot.view != null) {
3868                slot.view.setVisibility(visible ? View.VISIBLE : View.GONE);
3869            }
3870        });
3871    }
3872
3873    public static void fissionDestroyVideo(long id) {
3874        runOnUiThreadOrRecordError(id, () -> {
3875            synchronized (VIDEOS) {
3876                FissionVideoSlot slot = VIDEOS.remove(id);
3877                if (slot != null) {
3878                    slot.destroy();
3879                }
3880            }
3881        });
3882    }
3883
3884    public static void fissionPlayVideo(long id) {
3885        runOnUiThreadOrRecordError(id, () -> {
3886            FissionVideoSlot slot = slot(id);
3887            if (slot != null) {
3888                slot.ended = false;
3889                slot.view.start();
3890            }
3891        });
3892    }
3893
3894    public static void fissionPauseVideo(long id) {
3895        runOnUiThreadOrRecordError(id, () -> {
3896            FissionVideoSlot slot = slot(id);
3897            if (slot != null) {
3898                slot.view.pause();
3899            }
3900        });
3901    }
3902
3903    public static void fissionStopVideo(long id) {
3904        runOnUiThreadOrRecordError(id, () -> {
3905            FissionVideoSlot slot = slot(id);
3906            if (slot != null) {
3907                slot.view.pause();
3908                slot.view.seekTo(0);
3909                slot.ended = false;
3910            }
3911        });
3912    }
3913
3914    public static void fissionSeekVideo(long id, long positionMs) {
3915        runOnUiThreadOrRecordError(id, () -> {
3916            FissionVideoSlot slot = slot(id);
3917            if (slot != null) {
3918                slot.view.seekTo((int)Math.max(0L, Math.min(positionMs, Integer.MAX_VALUE)));
3919            }
3920        });
3921    }
3922
3923    public static void fissionSetVideoRate(long id, float rate) {
3924        runOnUiThreadOrRecordError(id, () -> {
3925            FissionVideoSlot slot = slot(id);
3926            if (slot != null) {
3927                slot.rate = Math.max(0.1f, rate);
3928                slot.applyPlaybackParams();
3929            }
3930        });
3931    }
3932
3933    public static void fissionSetVideoVolume(long id, float volume) {
3934        runOnUiThreadOrRecordError(id, () -> {
3935            FissionVideoSlot slot = slot(id);
3936            if (slot != null) {
3937                slot.volume = Math.max(0.0f, Math.min(volume, 1.0f));
3938                slot.applyVolume();
3939            }
3940        });
3941    }
3942
3943    public static void fissionSetVideoMuted(long id, boolean muted) {
3944        runOnUiThreadOrRecordError(id, () -> {
3945            FissionVideoSlot slot = slot(id);
3946            if (slot != null) {
3947                slot.muted = muted;
3948                slot.applyVolume();
3949            }
3950        });
3951    }
3952
3953    public static long fissionVideoPosition(long id) {
3954        FissionVideoSlot slot = slot(id);
3955        return slot == null || slot.view == null ? 0L : Math.max(0, slot.view.getCurrentPosition());
3956    }
3957
3958    public static long fissionVideoDuration(long id) {
3959        FissionVideoSlot slot = slot(id);
3960        return slot == null || !slot.ready ? -1L : Math.max(0, slot.durationMs);
3961    }
3962
3963    public static boolean fissionVideoReady(long id) {
3964        FissionVideoSlot slot = slot(id);
3965        return slot != null && slot.ready;
3966    }
3967
3968    public static boolean fissionVideoEnded(long id) {
3969        FissionVideoSlot slot = slot(id);
3970        return slot != null && slot.ended;
3971    }
3972
3973    public static String fissionVideoError(long id) {
3974        FissionVideoSlot slot = slot(id);
3975        return slot == null ? null : slot.error;
3976    }
3977
3978    private static FissionVideoSlot slot(long id) {
3979        synchronized (VIDEOS) {
3980            return VIDEOS.get(id);
3981        }
3982    }
3983
3984    private static void runOnUiThreadOrRecordError(long id, Runnable action) {
3985        FissionActivity activity = INSTANCE;
3986        if (activity == null) {
3987            recordError(id, "Fission Android video host is not attached to FissionActivity");
3988            return;
3989        }
3990        activity.runOnUiThread(() -> {
3991            try {
3992                action.run();
3993            } catch (Throwable error) {
3994                recordError(id, "Android video host error: " + error);
3995            }
3996        });
3997    }
3998
3999    private static void recordError(long id, String error) {
4000        synchronized (VIDEOS) {
4001            FissionVideoSlot slot = VIDEOS.get(id);
4002            if (slot == null) {
4003                slot = new FissionVideoSlot(error);
4004                VIDEOS.put(id, slot);
4005            } else {
4006                slot.error = error;
4007            }
4008        }
4009    }
4010
4011    private static final class FissionVideoSlot {
4012        final VideoView view;
4013        MediaPlayer mediaPlayer;
4014        volatile boolean ready;
4015        volatile boolean ended;
4016        volatile int durationMs = -1;
4017        volatile String error;
4018        volatile float rate = 1.0f;
4019        volatile float volume = 1.0f;
4020        volatile boolean muted;
4021
4022        FissionVideoSlot(String error) {
4023            this.view = null;
4024            this.error = error;
4025        }
4026
4027        FissionVideoSlot(FissionActivity activity, String source) {
4028            this.view = new VideoView(activity);
4029            this.view.setVisibility(View.GONE);
4030            this.view.setZOrderOnTop(true);
4031            this.view.setOnPreparedListener(player -> {
4032                mediaPlayer = player;
4033                ready = true;
4034                ended = false;
4035                durationMs = Math.max(0, view.getDuration());
4036                applyVolume();
4037                applyPlaybackParams();
4038            });
4039            this.view.setOnCompletionListener(player -> ended = true);
4040            this.view.setOnErrorListener((player, what, extra) -> {
4041                error = "Android MediaCodec playback error: what=" + what + ", extra=" + extra;
4042                return true;
4043            });
4044            this.view.setVideoPath(source);
4045            FrameLayout.LayoutParams params = new FrameLayout.LayoutParams(1, 1);
4046            activity.addContentView(this.view, params);
4047        }
4048
4049        void update(int left, int top, int width, int height, boolean visible) {
4050            if (view == null) {
4051                return;
4052            }
4053            FrameLayout.LayoutParams params = new FrameLayout.LayoutParams(
4054                    Math.max(1, width),
4055                    Math.max(1, height)
4056            );
4057            view.setLayoutParams(params);
4058            view.setX(left);
4059            view.setY(top);
4060            view.setVisibility(visible ? View.VISIBLE : View.GONE);
4061        }
4062
4063        void applyPlaybackParams() {
4064            if (mediaPlayer == null) {
4065                return;
4066            }
4067            PlaybackParams params = mediaPlayer.getPlaybackParams();
4068            params.setSpeed(rate);
4069            mediaPlayer.setPlaybackParams(params);
4070        }
4071
4072        void applyVolume() {
4073            if (mediaPlayer == null) {
4074                return;
4075            }
4076            float effective = muted ? 0.0f : volume;
4077            mediaPlayer.setVolume(effective, effective);
4078        }
4079
4080        void destroy() {
4081            if (view == null) {
4082                return;
4083            }
4084            view.stopPlayback();
4085            ViewGroup parent = (ViewGroup)view.getParent();
4086            if (parent != null) {
4087                parent.removeView(view);
4088            }
4089        }
4090    }
4091}
4092"#
4093}
4094
4095const ANDROID_NATIVE_MODULES_README: &str = r#"# Android native modules
4096
4097This directory is reserved for native capability module sources copied or owned by the app shell.
4098
4099Generic dependency and repository wiring is generated into `../native-modules.gradle` from
4100`fission.toml` `[native]` module declarations. Fission does not ship payment, camera-addon,
4101scanner-addon, or other app-specific modules in core; those crates provide their native adapters.
4102"#;
4103
4104fn render_ios_host_package(project: &FissionProject) -> String {
4105    format!(
4106        r#"// swift-tools-version: 5.9
4107import PackageDescription
4108
4109let package = Package(
4110    name: "{name}FissionHost",
4111    platforms: [
4112        .iOS(.v16),
4113    ],
4114    products: [
4115        .library(name: "FissionHost", targets: ["FissionHost"]),
4116    ],
4117    dependencies: [
4118        .package(path: "NativeModules"),
4119    ],
4120    targets: [
4121        .target(
4122            name: "FissionHost",
4123            dependencies: [
4124                .product(name: "FissionNativeModules", package: "NativeModules"),
4125            ],
4126            path: "Sources/FissionHost"
4127        ),
4128    ]
4129)
4130"#,
4131        name = ios_bundle_name(project),
4132    )
4133}
4134
4135fn render_ios_native_modules_package(project: &FissionProject) -> String {
4136    let package_dependencies = project
4137        .native
4138        .modules
4139        .iter()
4140        .flat_map(|module| module.ios.swift_packages.iter())
4141        .map(render_ios_swift_package_dependency)
4142        .collect::<Vec<_>>();
4143    let target_dependencies = project
4144        .native
4145        .modules
4146        .iter()
4147        .flat_map(|module| module.ios.swift_packages.iter())
4148        .map(render_ios_swift_product_dependency)
4149        .collect::<Vec<_>>();
4150
4151    let dependencies = if package_dependencies.is_empty() {
4152        String::new()
4153    } else {
4154        format!(
4155            "\n        {}\n    ",
4156            package_dependencies.join(",\n        ")
4157        )
4158    };
4159    let target_dependencies = if target_dependencies.is_empty() {
4160        String::new()
4161    } else {
4162        format!(
4163            "\n                {}\n            ",
4164            target_dependencies.join(",\n                ")
4165        )
4166    };
4167
4168    format!(
4169        r#"// swift-tools-version: 5.9
4170import PackageDescription
4171
4172let package = Package(
4173    name: "NativeModules",
4174    platforms: [
4175        .iOS(.v16),
4176    ],
4177    products: [
4178        .library(name: "FissionNativeModules", targets: ["FissionNativeModules"]),
4179    ],
4180    dependencies: [{dependencies}],
4181    targets: [
4182        .target(
4183            name: "FissionNativeModules",
4184            dependencies: [{target_dependencies}],
4185            path: "Sources/FissionNativeModules"
4186        ),
4187    ]
4188)
4189"#
4190    )
4191}
4192
4193fn render_ios_swift_package_dependency(package: &NativeIosSwiftPackageConfig) -> String {
4194    let version = package
4195        .from
4196        .as_deref()
4197        .filter(|value| !value.trim().is_empty())
4198        .unwrap_or("0.0.0");
4199    format!(".package(url: {:?}, from: {:?})", package.url, version)
4200}
4201
4202fn render_ios_swift_product_dependency(package: &NativeIosSwiftPackageConfig) -> String {
4203    let package_name = package
4204        .url
4205        .trim_end_matches('/')
4206        .rsplit('/')
4207        .next()
4208        .unwrap_or(package.product.as_str())
4209        .trim_end_matches(".git");
4210    format!(
4211        ".product(name: {:?}, package: {:?})",
4212        package.product, package_name
4213    )
4214}
4215
4216fn render_ios_host_native_capabilities_swift() -> &'static str {
4217    r#"import Foundation
4218import FissionNativeModules
4219
4220public enum FissionHostNativeCapabilities {
4221    public static func present(name: String, requestID: UInt64, payload: Data, completion: @escaping (Result<Data, Error>) -> Void) -> Bool {
4222        FissionNativeCapabilityRegistry.shared.present(name: name, requestID: requestID, payload: payload, completion: completion)
4223    }
4224}
4225"#
4226}
4227
4228fn render_ios_native_capabilities_swift() -> &'static str {
4229    r#"import Foundation
4230
4231public protocol FissionNativeCapability {
4232    var name: String { get }
4233    func present(requestID: UInt64, payload: Data, completion: @escaping (Result<Data, Error>) -> Void)
4234}
4235
4236public final class FissionNativeCapabilityRegistry {
4237    public static let shared = FissionNativeCapabilityRegistry()
4238    private var capabilities: [String: FissionNativeCapability] = [:]
4239
4240    private init() {}
4241
4242    public func register(_ capability: FissionNativeCapability) {
4243        capabilities[capability.name] = capability
4244    }
4245
4246    public func present(name: String, requestID: UInt64, payload: Data, completion: @escaping (Result<Data, Error>) -> Void) -> Bool {
4247        guard let capability = capabilities[name] else {
4248            return false
4249        }
4250        capability.present(requestID: requestID, payload: payload, completion: completion)
4251        return true
4252    }
4253}
4254"#
4255}
4256
4257const IOS_NATIVE_MODULES_README: &str = r#"# iOS native modules
4258
4259This Swift package is the app-owned integration point for native capability modules.
4260
4261Fission generates `Package.swift` from `fission.toml` `[native]` module declarations. Capability
4262crates can provide Swift sources or package dependencies here without adding product-specific
4263logic to Fission itself.
4264"#;
4265
4266fn render_ios_plist(project: &FissionProject, executable: &str) -> String {
4267    let capability_entries = render_ios_info_plist_capability_entries(project);
4268    format!(
4269        r#"<?xml version="1.0" encoding="UTF-8"?>
4270<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
4271<plist version="1.0">
4272<dict>
4273  <key>CFBundleDevelopmentRegion</key>
4274  <string>en</string>
4275  <key>CFBundleDisplayName</key>
4276  <string>{display_name}</string>
4277  <key>CFBundleExecutable</key>
4278  <string>{executable}</string>
4279  <key>CFBundleIdentifier</key>
4280  <string>{bundle_id}</string>
4281  <key>CFBundleInfoDictionaryVersion</key>
4282  <string>6.0</string>
4283  <key>CFBundleName</key>
4284  <string>{display_name}</string>
4285  <key>CFBundlePackageType</key>
4286  <string>APPL</string>
4287  <key>CFBundleShortVersionString</key>
4288  <string>0.1.0</string>
4289  <key>CFBundleVersion</key>
4290  <string>1</string>
4291  <key>CFBundleIconFile</key>
4292  <string>AppIcon</string>
4293  <key>UILaunchStoryboardName</key>
4294  <string>LaunchScreen</string>
4295  <key>LSRequiresIPhoneOS</key>
4296  <true/>
4297  <key>MinimumOSVersion</key>
4298  <string>18.0</string>
4299{capability_entries}
4300  <key>UIDeviceFamily</key>
4301  <array>
4302    <integer>1</integer>
4303    <integer>2</integer>
4304  </array>
4305</dict>
4306</plist>
4307"#,
4308        display_name = ios_bundle_name(project),
4309        executable = executable,
4310        bundle_id = project.app.app_id,
4311        capability_entries = capability_entries,
4312    )
4313}
4314
4315fn render_ios_info_plist_capability_entries(project: &FissionProject) -> String {
4316    let mut out = String::new();
4317    if project.capabilities.contains(&PlatformCapability::Nfc) {
4318        out.push_str("  <key>NFCReaderUsageDescription</key>\n  <string>This app uses NFC to scan nearby tags when you request it.</string>\n");
4319    }
4320    if project
4321        .capabilities
4322        .contains(&PlatformCapability::Biometric)
4323    {
4324        out.push_str("  <key>NSFaceIDUsageDescription</key>\n  <string>This app uses biometrics to authenticate you when you request it.</string>\n");
4325    }
4326    if project
4327        .capabilities
4328        .contains(&PlatformCapability::Bluetooth)
4329    {
4330        out.push_str("  <key>NSBluetoothAlwaysUsageDescription</key>\n  <string>This app uses Bluetooth when you request nearby-device features.</string>\n");
4331    }
4332    if project
4333        .capabilities
4334        .contains(&PlatformCapability::BarcodeScanner)
4335    {
4336        out.push_str("  <key>NSCameraUsageDescription</key>\n  <string>This app uses the camera to scan barcodes when you request it.</string>\n");
4337    }
4338    if project.capabilities.contains(&PlatformCapability::Camera)
4339        && !project
4340            .capabilities
4341            .contains(&PlatformCapability::BarcodeScanner)
4342    {
4343        out.push_str("  <key>NSCameraUsageDescription</key>\n  <string>This app uses the camera when you request camera features.</string>\n");
4344    }
4345    if project
4346        .capabilities
4347        .contains(&PlatformCapability::Geolocation)
4348    {
4349        out.push_str("  <key>NSLocationWhenInUseUsageDescription</key>\n  <string>This app uses your location when you request location-aware features.</string>\n");
4350    }
4351    if project
4352        .capabilities
4353        .contains(&PlatformCapability::Microphone)
4354    {
4355        out.push_str("  <key>NSMicrophoneUsageDescription</key>\n  <string>This app uses the microphone when you request audio capture.</string>\n");
4356    }
4357    if project.capabilities.contains(&PlatformCapability::Wifi)
4358        && !project
4359            .capabilities
4360            .contains(&PlatformCapability::Geolocation)
4361    {
4362        out.push_str("  <key>NSLocationWhenInUseUsageDescription</key>\n  <string>This app uses location permission where the platform requires it for Wi-Fi information.</string>\n");
4363    }
4364    out
4365}
4366
4367fn render_ios_package_script(
4368    project: &FissionProject,
4369    bundle_name: &str,
4370    executable: &str,
4371) -> String {
4372    format!(
4373        r#"#!/usr/bin/env bash
4374set -euo pipefail
4375
4376SCRIPT_DIR=$(cd -- "$(dirname "${{BASH_SOURCE[0]}}")" && pwd)
4377PROJECT_DIR=$(cd -- "$SCRIPT_DIR/../.." && pwd)
4378TARGET="${{IOS_SIM_TARGET:-aarch64-apple-ios-sim}}"
4379PROFILE="${{IOS_SIM_PROFILE:-debug}}"
4380PACKAGE_NAME="{package_name}"
4381BUNDLE_ID="${{IOS_BUNDLE_ID:-{bundle_id}}}"
4382DISPLAY_NAME="${{IOS_DISPLAY_NAME:-{bundle_name}}}"
4383EXECUTABLE_NAME="${{IOS_EXECUTABLE_NAME:-{executable}}}"
4384BUNDLE_NAME="${{IOS_BUNDLE_NAME:-$DISPLAY_NAME.app}}"
4385IOS_MARKETING_VERSION="${{IOS_MARKETING_VERSION:-0.1.0}}"
4386IOS_BUILD_NUMBER="${{IOS_BUILD_NUMBER:-1}}"
4387BUILD_DIR="$SCRIPT_DIR/build/$PROFILE"
4388BUNDLE_DIR="$BUILD_DIR/$BUNDLE_NAME"
4389
4390BUILD_ARGS=(build --manifest-path "$PROJECT_DIR/Cargo.toml" --target "$TARGET" --package "$PACKAGE_NAME")
4391ARTIFACT_DIR=debug
4392if [[ "$PROFILE" == "release" ]]; then
4393  BUILD_ARGS+=(--release)
4394  ARTIFACT_DIR=release
4395fi
4396
4397cargo "${{BUILD_ARGS[@]}}"
4398TARGET_DIR=$(python3 - <<'PY' "$PROJECT_DIR/Cargo.toml"
4399import json
4400import subprocess
4401import sys
4402
4403manifest = sys.argv[1]
4404metadata = json.loads(
4405    subprocess.check_output(
4406        ["cargo", "metadata", "--manifest-path", manifest, "--format-version", "1", "--no-deps"]
4407    )
4408)
4409print(metadata["target_directory"])
4410PY
4411)
4412
4413rm -rf "$BUNDLE_DIR"
4414mkdir -p "$BUNDLE_DIR"
4415cp "$TARGET_DIR/$TARGET/$ARTIFACT_DIR/$PACKAGE_NAME" "$BUNDLE_DIR/$EXECUTABLE_NAME"
4416chmod +x "$BUNDLE_DIR/$EXECUTABLE_NAME"
4417{plist_patch}
4418shopt -s nullglob
4419PLATFORM_APP_ICONS=("$SCRIPT_DIR"/AppIcon.*)
4420if (( ${{#PLATFORM_APP_ICONS[@]}} == 0 )); then
4421  cp "$PROJECT_DIR/assets/app-icon.png" "$BUNDLE_DIR/AppIcon.png"
4422else
4423  app_icon="${{PLATFORM_APP_ICONS[0]}}"
4424  cp "$app_icon" "$BUNDLE_DIR/$(basename "$app_icon")"
4425fi
4426shopt -u nullglob
4427shopt -s nullglob
4428SPLASH_IMAGES=("$SCRIPT_DIR"/SplashImage.*)
4429if (( ${{#SPLASH_IMAGES[@]}} == 0 )); then
4430  cp "$PROJECT_DIR/assets/app-icon.png" "$BUNDLE_DIR/SplashImage.png"
4431else
4432  for splash_image in "${{SPLASH_IMAGES[@]}}"; do
4433    cp "$splash_image" "$BUNDLE_DIR/"
4434  done
4435fi
4436shopt -u nullglob
4437if [[ -f "$SCRIPT_DIR/LaunchScreen.storyboard" ]]; then
4438  IBTOOL=$(xcrun --find ibtool 2>/dev/null || true)
4439  if [[ -z "$IBTOOL" ]]; then
4440    printf 'ibtool not found. Install Xcode command line tools to compile the iOS launch screen storyboard.\n' >&2
4441    exit 1
4442  fi
4443  "$IBTOOL" \
4444    --errors \
4445    --warnings \
4446    --notices \
4447    --target-device iphone \
4448    --target-device ipad \
4449    --minimum-deployment-target 18.0 \
4450    --output-format human-readable-text \
4451    --compile "$BUNDLE_DIR/LaunchScreen.storyboardc" \
4452    "$SCRIPT_DIR/LaunchScreen.storyboard"
4453fi
4454printf 'APPL????' > "$BUNDLE_DIR/PkgInfo"
4455printf '%s\n' "$BUNDLE_DIR"
4456"#,
4457        package_name = project.app.name,
4458        bundle_id = project.app.app_id,
4459        bundle_name = bundle_name,
4460        executable = executable,
4461        plist_patch = IOS_INFO_PLIST_PLUTIL_PATCH,
4462    )
4463}
4464
4465fn render_ios_ipa_package_script(project: &FissionProject) -> String {
4466    format!(
4467        r#"#!/usr/bin/env bash
4468set -euo pipefail
4469
4470SCRIPT_DIR=$(cd -- "$(dirname "${{BASH_SOURCE[0]}}")" && pwd)
4471PROJECT_DIR=$(cd -- "$SCRIPT_DIR/../.." && pwd)
4472IOS_TARGET="${{IOS_TARGET:-aarch64-apple-ios}}"
4473IOS_PROFILE="${{IOS_PROFILE:-release}}"
4474IOS_SIGNING_IDENTITY="${{IOS_SIGNING_IDENTITY:-}}"
4475IOS_PROVISIONING_PROFILE="${{IOS_PROVISIONING_PROFILE:-}}"
4476IOS_REQUIRE_PROVISIONING_PROFILE="${{IOS_REQUIRE_PROVISIONING_PROFILE:-1}}"
4477IPA_DIR="$SCRIPT_DIR/build/ipa"
4478PAYLOAD_DIR="$IPA_DIR/Payload"
4479IPA_PATH="$IPA_DIR/{package_name}.ipa"
4480
4481if [[ "$IOS_PROFILE" == "release" && -z "$IOS_SIGNING_IDENTITY" ]]; then
4482  printf 'Release IPA packaging requires IOS_SIGNING_IDENTITY from a secure local or CI secret source.\n' >&2
4483  exit 1
4484fi
4485
4486BUNDLE_DIR=$(IOS_SIM_TARGET="$IOS_TARGET" IOS_SIM_PROFILE="$IOS_PROFILE" "$SCRIPT_DIR/package-sim.sh")
4487
4488if [[ -n "$IOS_PROVISIONING_PROFILE" ]]; then
4489  cp "$IOS_PROVISIONING_PROFILE" "$BUNDLE_DIR/embedded.mobileprovision"
4490elif [[ "$IOS_PROFILE" == "release" && "$IOS_REQUIRE_PROVISIONING_PROFILE" == "1" ]]; then
4491  printf 'Release IPA packaging requires IOS_PROVISIONING_PROFILE, or set IOS_REQUIRE_PROVISIONING_PROFILE=0 for an explicitly unsigned-profile test package.\n' >&2
4492  exit 1
4493fi
4494
4495if [[ -n "$IOS_SIGNING_IDENTITY" ]]; then
4496  CODESIGN_ARGS=(--force --sign "$IOS_SIGNING_IDENTITY")
4497  if [[ -n "${{IOS_ENTITLEMENTS:-}}" ]]; then
4498    CODESIGN_ARGS+=(--entitlements "$IOS_ENTITLEMENTS")
4499  elif [[ -f "$SCRIPT_DIR/Entitlements.plist" ]]; then
4500    CODESIGN_ARGS+=(--entitlements "$SCRIPT_DIR/Entitlements.plist")
4501  fi
4502  codesign "${{CODESIGN_ARGS[@]}}" "$BUNDLE_DIR"
4503  codesign --verify --deep --strict "$BUNDLE_DIR"
4504fi
4505
4506rm -rf "$PAYLOAD_DIR"
4507mkdir -p "$PAYLOAD_DIR"
4508cp -R "$BUNDLE_DIR" "$PAYLOAD_DIR/"
4509rm -f "$IPA_PATH"
4510(cd "$IPA_DIR" && zip -qry "$IPA_PATH" Payload)
4511printf '%s\n' "$IPA_PATH"
4512"#,
4513        package_name = project.app.name,
4514    )
4515}
4516
4517fn render_ios_run_script(project: &FissionProject) -> String {
4518    format!(
4519        r#"#!/usr/bin/env bash
4520set -euo pipefail
4521
4522SCRIPT_DIR=$(cd -- "$(dirname "${{BASH_SOURCE[0]}}")" && pwd)
4523BUNDLE_DIR=$("$SCRIPT_DIR/package-sim.sh")
4524BUNDLE_ID="${{IOS_BUNDLE_ID:-{bundle_id}}}"
4525DEVICE_ID="${{IOS_SIM_DEVICE_ID:-}}"
4526
4527if [[ -z "$DEVICE_ID" ]]; then
4528  DEVICE_ID=$(python3 - <<'PY'
4529import json
4530import subprocess
4531payload = json.loads(subprocess.check_output(["xcrun", "simctl", "list", "devices", "available", "-j"]))
4532for runtime, devices in payload["devices"].items():
4533    if not runtime.startswith("com.apple.CoreSimulator.SimRuntime.iOS-"):
4534        continue
4535    for device in devices:
4536        if device.get("isAvailable") and "iPhone" in device["name"]:
4537            print(device["udid"])
4538            raise SystemExit(0)
4539raise SystemExit("no available iPhone simulator found")
4540PY
4541)
4542fi
4543
4544if [[ "${{IOS_SIM_HEADLESS:-0}}" != "1" ]] && command -v open >/dev/null 2>&1; then
4545  open -a Simulator --args -CurrentDeviceUDID "$DEVICE_ID" >/dev/null 2>&1 \
4546    || open -a Simulator >/dev/null 2>&1 \
4547    || true
4548fi
4549
4550xcrun simctl boot "$DEVICE_ID" >/dev/null 2>&1 || true
4551xcrun simctl bootstatus "$DEVICE_ID" -b
4552if [[ "${{IOS_SIM_UNINSTALL_BEFORE_INSTALL:-1}}" == "1" ]]; then
4553  xcrun simctl uninstall "$DEVICE_ID" "$BUNDLE_ID" >/dev/null 2>&1 || true
4554fi
4555xcrun simctl install "$DEVICE_ID" "$BUNDLE_DIR"
4556
4557if [[ -n "${{FISSION_TEST_CONTROL_PORT:-}}" ]]; then
4558  SIMCTL_CHILD_FISSION_TEST_CONTROL_PORT="${{FISSION_TEST_CONTROL_PORT}}" \
4559    xcrun simctl launch --terminate-running-process "$DEVICE_ID" "$BUNDLE_ID"
4560else
4561  xcrun simctl launch --terminate-running-process "$DEVICE_ID" "$BUNDLE_ID"
4562fi
4563"#,
4564        bundle_id = project.app.app_id,
4565    )
4566}
4567
4568fn render_ios_test_script() -> String {
4569    r#"#!/usr/bin/env bash
4570set -euo pipefail
4571
4572SCRIPT_DIR=$(cd -- "$(dirname "${BASH_SOURCE[0]}")" && pwd)
4573export FISSION_TEST_CONTROL_PORT="${FISSION_TEST_CONTROL_PORT:-48711}"
4574
4575"$SCRIPT_DIR/run-sim.sh"
4576
4577python3 - <<'PY' "$FISSION_TEST_CONTROL_PORT"
4578import sys
4579import time
4580import urllib.request
4581
4582port = sys.argv[1]
4583url = f"http://127.0.0.1:{port}/health"
4584deadline = time.time() + 90
4585last_error = None
4586while time.time() < deadline:
4587    try:
4588        with urllib.request.urlopen(url, timeout=1) as response:
4589            body = response.read().decode("utf-8", "replace")
4590        if response.status == 200 and '"status":"ok"' in body:
4591            print(f"iOS simulator test control is healthy on {url}")
4592            raise SystemExit(0)
4593    except Exception as error:
4594        last_error = error
4595    time.sleep(1)
4596raise SystemExit(f"iOS simulator test control did not become healthy on {url}: {last_error}")
4597PY
4598"#
4599    .to_string()
4600}
4601
4602fn render_android_manifest(project: &FissionProject) -> String {
4603    let capability_entries = render_android_capability_manifest_entries(project);
4604    let native_application_entries = render_android_native_application_entries(project);
4605    format!(
4606        r#"<?xml version="1.0" encoding="utf-8"?>
4607<manifest xmlns:android="http://schemas.android.com/apk/res/android"
4608    package="{app_id}">
4609
4610    <uses-permission android:name="android.permission.INTERNET" />
4611{capability_entries}
4612
4613    <uses-sdk
4614        android:minSdkVersion="24"
4615        android:targetSdkVersion="35" />
4616
4617    <application
4618        android:extractNativeLibs="true"
4619        android:hasCode="true"
4620        android:icon="@drawable/app_icon"
4621        android:label="{label}">
4622{native_application_entries}
4623        <activity
4624            android:name="rs.fission.runtime.FissionActivity"
4625            android:configChanges="orientation|keyboardHidden|screenSize|screenLayout|smallestScreenSize|uiMode|density"
4626            android:exported="true"
4627            android:launchMode="singleTask"
4628            android:theme="@style/FissionLaunchTheme">
4629            <meta-data
4630                android:name="android.app.lib_name"
4631                android:value="{lib_name}" />
4632            <intent-filter>
4633                <action android:name="android.intent.action.MAIN" />
4634                <category android:name="android.intent.category.LAUNCHER" />
4635            </intent-filter>
4636        </activity>
4637    </application>
4638
4639</manifest>
4640"#,
4641        app_id = project.app.app_id,
4642        label = ios_bundle_name(project),
4643        lib_name = android_library_name(project),
4644        capability_entries = capability_entries,
4645        native_application_entries = native_application_entries,
4646    )
4647}
4648
4649fn render_android_native_application_entries(project: &FissionProject) -> String {
4650    let mut out = String::new();
4651    for module in &project.native.modules {
4652        for entry in &module.android.manifest_application_entries {
4653            let entry = entry.trim();
4654            if entry.is_empty() {
4655                continue;
4656            }
4657            out.push_str("        ");
4658            out.push_str(entry);
4659            if !entry.ends_with('\n') {
4660                out.push('\n');
4661            }
4662        }
4663    }
4664    out
4665}
4666
4667fn render_android_capability_manifest_entries(project: &FissionProject) -> String {
4668    let mut out = String::new();
4669    if project.capabilities.contains(&PlatformCapability::Nfc) {
4670        out.push_str(&render_android_nfc_manifest_entries());
4671    }
4672    if project
4673        .capabilities
4674        .contains(&PlatformCapability::Notifications)
4675    {
4676        out.push_str(&render_android_notifications_manifest_entries());
4677    }
4678    if project
4679        .capabilities
4680        .contains(&PlatformCapability::Biometric)
4681    {
4682        out.push_str(&render_android_biometric_manifest_entries());
4683    }
4684    if project
4685        .capabilities
4686        .contains(&PlatformCapability::Bluetooth)
4687    {
4688        out.push_str(&render_android_bluetooth_manifest_entries());
4689    }
4690    if project.capabilities.contains(&PlatformCapability::Camera) {
4691        out.push_str(&render_android_camera_manifest_entries());
4692    } else if project
4693        .capabilities
4694        .contains(&PlatformCapability::BarcodeScanner)
4695    {
4696        out.push_str(&render_android_barcode_camera_manifest_entries());
4697    }
4698    if project
4699        .capabilities
4700        .contains(&PlatformCapability::Geolocation)
4701    {
4702        out.push_str(&render_android_geolocation_manifest_entries());
4703    }
4704    if project.capabilities.contains(&PlatformCapability::Haptics) {
4705        out.push_str(&render_android_haptics_manifest_entries());
4706    }
4707    if project
4708        .capabilities
4709        .contains(&PlatformCapability::Microphone)
4710    {
4711        out.push_str(&render_android_microphone_manifest_entries());
4712    }
4713    if project
4714        .capabilities
4715        .contains(&PlatformCapability::VolumeControl)
4716    {
4717        out.push_str(&render_android_volume_manifest_entries());
4718    }
4719    if project.capabilities.contains(&PlatformCapability::Wifi) {
4720        out.push_str(&render_android_wifi_manifest_entries());
4721    }
4722    for permission in android_native_module_permissions(project) {
4723        out.push_str(&format!(
4724            "    <uses-permission android:name=\"{}\" />\n",
4725            permission
4726        ));
4727    }
4728    out
4729}
4730
4731fn android_native_module_permissions(project: &FissionProject) -> BTreeSet<String> {
4732    project
4733        .native
4734        .modules
4735        .iter()
4736        .flat_map(|module| module.android.permissions.iter())
4737        .map(|permission| permission.trim().to_string())
4738        .filter(|permission| !permission.is_empty())
4739        .collect()
4740}
4741
4742fn render_android_nfc_manifest_entries() -> String {
4743    let mut out = String::new();
4744    out.push_str("    <uses-permission android:name=\"android.permission.NFC\" />\n");
4745    out.push_str(
4746        "    <uses-feature android:name=\"android.hardware.nfc\" android:required=\"false\" />\n",
4747    );
4748    out
4749}
4750
4751fn render_android_notifications_manifest_entries() -> String {
4752    "    <uses-permission android:name=\"android.permission.POST_NOTIFICATIONS\" />\n".to_string()
4753}
4754
4755fn render_android_biometric_manifest_entries() -> String {
4756    let mut out = String::new();
4757    out.push_str("    <uses-permission android:name=\"android.permission.USE_BIOMETRIC\" />\n");
4758    out.push_str("    <uses-permission android:name=\"android.permission.USE_FINGERPRINT\" android:maxSdkVersion=\"28\" />\n");
4759    out
4760}
4761
4762fn render_android_bluetooth_manifest_entries() -> String {
4763    let mut out = String::new();
4764    out.push_str("    <uses-permission android:name=\"android.permission.BLUETOOTH\" android:maxSdkVersion=\"30\" />\n");
4765    out.push_str("    <uses-permission android:name=\"android.permission.BLUETOOTH_ADMIN\" android:maxSdkVersion=\"30\" />\n");
4766    out.push_str("    <uses-permission android:name=\"android.permission.BLUETOOTH_SCAN\" android:usesPermissionFlags=\"neverForLocation\" />\n");
4767    out.push_str("    <uses-permission android:name=\"android.permission.BLUETOOTH_CONNECT\" />\n");
4768    out.push_str(
4769        "    <uses-permission android:name=\"android.permission.BLUETOOTH_ADVERTISE\" />\n",
4770    );
4771    out.push_str(
4772        "    <uses-feature android:name=\"android.hardware.bluetooth\" android:required=\"false\" />\n",
4773    );
4774    out.push_str(
4775        "    <uses-feature android:name=\"android.hardware.bluetooth_le\" android:required=\"false\" />\n",
4776    );
4777    out
4778}
4779
4780fn render_missing_android_bluetooth_manifest_entries(existing: &str) -> String {
4781    let mut out = String::new();
4782    if !existing.contains("android.permission.BLUETOOTH\"") {
4783        out.push_str("    <uses-permission android:name=\"android.permission.BLUETOOTH\" android:maxSdkVersion=\"30\" />\n");
4784    }
4785    if !existing.contains("android.permission.BLUETOOTH_ADMIN") {
4786        out.push_str("    <uses-permission android:name=\"android.permission.BLUETOOTH_ADMIN\" android:maxSdkVersion=\"30\" />\n");
4787    }
4788    if !existing.contains("android.permission.BLUETOOTH_SCAN") {
4789        out.push_str("    <uses-permission android:name=\"android.permission.BLUETOOTH_SCAN\" android:usesPermissionFlags=\"neverForLocation\" />\n");
4790    }
4791    if !existing.contains("android.permission.BLUETOOTH_CONNECT") {
4792        out.push_str(
4793            "    <uses-permission android:name=\"android.permission.BLUETOOTH_CONNECT\" />\n",
4794        );
4795    }
4796    if !existing.contains("android.permission.BLUETOOTH_ADVERTISE") {
4797        out.push_str(
4798            "    <uses-permission android:name=\"android.permission.BLUETOOTH_ADVERTISE\" />\n",
4799        );
4800    }
4801    if !existing.contains("android.hardware.bluetooth\"") {
4802        out.push_str(
4803            "    <uses-feature android:name=\"android.hardware.bluetooth\" android:required=\"false\" />\n",
4804        );
4805    }
4806    if !existing.contains("android.hardware.bluetooth_le") {
4807        out.push_str(
4808            "    <uses-feature android:name=\"android.hardware.bluetooth_le\" android:required=\"false\" />\n",
4809        );
4810    }
4811    out
4812}
4813
4814fn render_android_barcode_camera_manifest_entries() -> String {
4815    let mut out = String::new();
4816    out.push_str("    <uses-permission android:name=\"android.permission.CAMERA\" />\n");
4817    out.push_str(
4818        "    <uses-feature android:name=\"android.hardware.camera.any\" android:required=\"false\" />\n",
4819    );
4820    out
4821}
4822
4823fn render_android_camera_manifest_entries() -> String {
4824    let mut out = String::new();
4825    out.push_str("    <uses-permission android:name=\"android.permission.CAMERA\" />\n");
4826    out.push_str(
4827        "    <uses-feature android:name=\"android.hardware.camera.any\" android:required=\"false\" />\n",
4828    );
4829    out.push_str(
4830        "    <uses-feature android:name=\"android.hardware.camera\" android:required=\"false\" />\n",
4831    );
4832    out.push_str(
4833        "    <uses-feature android:name=\"android.hardware.camera.front\" android:required=\"false\" />\n",
4834    );
4835    out.push_str(
4836        "    <uses-feature android:name=\"android.hardware.camera.flash\" android:required=\"false\" />\n",
4837    );
4838    out
4839}
4840
4841fn render_missing_android_camera_manifest_entries(existing: &str) -> String {
4842    let mut out = String::new();
4843    if !existing.contains("android.permission.CAMERA") {
4844        out.push_str("    <uses-permission android:name=\"android.permission.CAMERA\" />\n");
4845    }
4846    if !existing.contains("android.hardware.camera.any") {
4847        out.push_str(
4848            "    <uses-feature android:name=\"android.hardware.camera.any\" android:required=\"false\" />\n",
4849        );
4850    }
4851    if !existing.contains("android.hardware.camera\"") {
4852        out.push_str(
4853            "    <uses-feature android:name=\"android.hardware.camera\" android:required=\"false\" />\n",
4854        );
4855    }
4856    if !existing.contains("android.hardware.camera.front") {
4857        out.push_str(
4858            "    <uses-feature android:name=\"android.hardware.camera.front\" android:required=\"false\" />\n",
4859        );
4860    }
4861    if !existing.contains("android.hardware.camera.flash") {
4862        out.push_str(
4863            "    <uses-feature android:name=\"android.hardware.camera.flash\" android:required=\"false\" />\n",
4864        );
4865    }
4866    out
4867}
4868
4869fn render_android_geolocation_manifest_entries() -> String {
4870    let mut out = String::new();
4871    out.push_str(
4872        "    <uses-permission android:name=\"android.permission.ACCESS_COARSE_LOCATION\" />\n",
4873    );
4874    out.push_str(
4875        "    <uses-permission android:name=\"android.permission.ACCESS_FINE_LOCATION\" />\n",
4876    );
4877    out
4878}
4879
4880fn render_android_haptics_manifest_entries() -> String {
4881    "    <uses-permission android:name=\"android.permission.VIBRATE\" />\n".to_string()
4882}
4883
4884fn render_android_microphone_manifest_entries() -> String {
4885    "    <uses-permission android:name=\"android.permission.RECORD_AUDIO\" />\n".to_string()
4886}
4887
4888fn render_android_volume_manifest_entries() -> String {
4889    "    <uses-permission android:name=\"android.permission.MODIFY_AUDIO_SETTINGS\" />\n"
4890        .to_string()
4891}
4892
4893fn render_android_wifi_manifest_entries() -> String {
4894    let mut out = String::new();
4895    out.push_str("    <uses-permission android:name=\"android.permission.ACCESS_WIFI_STATE\" />\n");
4896    out.push_str("    <uses-permission android:name=\"android.permission.CHANGE_WIFI_STATE\" />\n");
4897    out.push_str(
4898        "    <uses-permission android:name=\"android.permission.ACCESS_NETWORK_STATE\" />\n",
4899    );
4900    out.push_str(
4901        "    <uses-permission android:name=\"android.permission.CHANGE_NETWORK_STATE\" />\n",
4902    );
4903    out.push_str("    <uses-permission android:name=\"android.permission.NEARBY_WIFI_DEVICES\" android:usesPermissionFlags=\"neverForLocation\" />\n");
4904    out.push_str("    <uses-permission android:name=\"android.permission.ACCESS_FINE_LOCATION\" android:maxSdkVersion=\"32\" />\n");
4905    out.push_str(
4906        "    <uses-feature android:name=\"android.hardware.wifi\" android:required=\"false\" />\n",
4907    );
4908    out.push_str(
4909        "    <uses-feature android:name=\"android.hardware.wifi.direct\" android:required=\"false\" />\n",
4910    );
4911    out
4912}
4913
4914fn render_missing_android_wifi_manifest_entries(existing: &str) -> String {
4915    let mut out = String::new();
4916    if !existing.contains("android.permission.ACCESS_WIFI_STATE") {
4917        out.push_str(
4918            "    <uses-permission android:name=\"android.permission.ACCESS_WIFI_STATE\" />\n",
4919        );
4920    }
4921    if !existing.contains("android.permission.CHANGE_WIFI_STATE") {
4922        out.push_str(
4923            "    <uses-permission android:name=\"android.permission.CHANGE_WIFI_STATE\" />\n",
4924        );
4925    }
4926    if !existing.contains("android.permission.ACCESS_NETWORK_STATE") {
4927        out.push_str(
4928            "    <uses-permission android:name=\"android.permission.ACCESS_NETWORK_STATE\" />\n",
4929        );
4930    }
4931    if !existing.contains("android.permission.CHANGE_NETWORK_STATE") {
4932        out.push_str(
4933            "    <uses-permission android:name=\"android.permission.CHANGE_NETWORK_STATE\" />\n",
4934        );
4935    }
4936    if !existing.contains("android.permission.NEARBY_WIFI_DEVICES") {
4937        out.push_str("    <uses-permission android:name=\"android.permission.NEARBY_WIFI_DEVICES\" android:usesPermissionFlags=\"neverForLocation\" />\n");
4938    }
4939    if !existing.contains("android.permission.ACCESS_FINE_LOCATION") {
4940        out.push_str("    <uses-permission android:name=\"android.permission.ACCESS_FINE_LOCATION\" android:maxSdkVersion=\"32\" />\n");
4941    }
4942    if !existing.contains("android.hardware.wifi\"") {
4943        out.push_str(
4944            "    <uses-feature android:name=\"android.hardware.wifi\" android:required=\"false\" />\n",
4945        );
4946    }
4947    if !existing.contains("android.hardware.wifi.direct") {
4948        out.push_str(
4949            "    <uses-feature android:name=\"android.hardware.wifi.direct\" android:required=\"false\" />\n",
4950        );
4951    }
4952    out
4953}
4954
4955fn render_ios_entitlements_plist(project: &FissionProject) -> String {
4956    let mut entries = String::new();
4957    if project.capabilities.contains(&PlatformCapability::Nfc) {
4958        entries.push_str("  <key>com.apple.developer.nfc.readersession.formats</key>\n  <array>\n    <string>NDEF</string>\n  </array>\n");
4959    }
4960    if project.capabilities.contains(&PlatformCapability::Wifi) {
4961        entries.push_str("  <key>com.apple.developer.networking.wifi-info</key>\n  <true/>\n");
4962        entries.push_str(
4963            "  <key>com.apple.developer.networking.HotspotConfiguration</key>\n  <true/>\n",
4964        );
4965    }
4966    format!(
4967        "<?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"
4968    )
4969}
4970
4971const IOS_NFC_ENTITLEMENTS_PLIST: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
4972<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
4973<plist version="1.0">
4974<dict>
4975  <key>com.apple.developer.nfc.readersession.formats</key>
4976  <array>
4977    <string>NDEF</string>
4978  </array>
4979</dict>
4980</plist>
4981"#;
4982
4983const IOS_WIFI_ENTITLEMENTS_PLIST: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
4984<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
4985<plist version="1.0">
4986<dict>
4987  <key>com.apple.developer.networking.wifi-info</key>
4988  <true/>
4989  <key>com.apple.developer.networking.HotspotConfiguration</key>
4990  <true/>
4991</dict>
4992</plist>
4993"#;
4994
4995fn render_android_capabilities_java() -> &'static str {
4996    include_str!("../assets/android/rs/fission/runtime/FissionAndroidCapabilities.java")
4997}
4998
4999fn render_android_package_script(project: &FissionProject) -> String {
5000    render_android_gradle_package_script(
5001        project,
5002        AndroidGradlePackageKind {
5003            task_prefix: "assemble",
5004            output_subdir: "apk",
5005            extension: "apk",
5006            label: "APK",
5007        },
5008    )
5009}
5010
5011fn render_android_aab_package_script(project: &FissionProject) -> String {
5012    render_android_gradle_package_script(
5013        project,
5014        AndroidGradlePackageKind {
5015            task_prefix: "bundle",
5016            output_subdir: "bundle",
5017            extension: "aab",
5018            label: "AAB",
5019        },
5020    )
5021}
5022
5023struct AndroidGradlePackageKind {
5024    task_prefix: &'static str,
5025    output_subdir: &'static str,
5026    extension: &'static str,
5027    label: &'static str,
5028}
5029
5030fn render_android_gradle_package_script(
5031    project: &FissionProject,
5032    kind: AndroidGradlePackageKind,
5033) -> String {
5034    let lib_name = android_library_name(project);
5035    format!(
5036        r#"#!/usr/bin/env bash
5037set -euo pipefail
5038
5039SCRIPT_DIR=$(cd -- "$(dirname "${{BASH_SOURCE[0]}}")" && pwd)
5040PROJECT_DIR=$(cd -- "$SCRIPT_DIR/../.." && pwd)
5041TARGET="${{ANDROID_TARGET_TRIPLE:-aarch64-linux-android}}"
5042PACKAGE_NAME="{package_name}"
5043LIB_NAME="{lib_name}"
5044PROFILE="${{ANDROID_PROFILE:-debug}}"
5045ANDROID_HOME="${{ANDROID_HOME:-${{ANDROID_SDK_ROOT:-$HOME/Library/Android/sdk}}}}"
5046ANDROID_MIN_API_LEVEL="${{ANDROID_MIN_API_LEVEL:-${{ANDROID_API_LEVEL:-24}}}}"
5047
5048find_android_ndk() {{
5049  if [[ -n "${{ANDROID_NDK:-}}" ]]; then
5050    printf '%s\n' "$ANDROID_NDK"
5051    return
5052  fi
5053  local ndk_root="$ANDROID_HOME/ndk"
5054  if [[ ! -d "$ndk_root" ]]; then
5055    printf 'Android NDK not found. Set ANDROID_NDK or install one under %s.\n' "$ndk_root" >&2
5056    return 1
5057  fi
5058  local ndk
5059  ndk=$(find "$ndk_root" -maxdepth 1 -mindepth 1 -type d | sort -V | tail -1)
5060  if [[ -z "$ndk" ]]; then
5061    printf 'Android NDK not found. Set ANDROID_NDK or install one under %s.\n' "$ndk_root" >&2
5062    return 1
5063  fi
5064  printf '%s\n' "$ndk"
5065}}
5066
5067detect_android_toolchain() {{
5068  local prebuilt_root="$ANDROID_NDK/toolchains/llvm/prebuilt"
5069  local host
5070  for host in darwin-aarch64 darwin-x86_64 linux-x86_64 windows-x86_64; do
5071    if [[ -d "$prebuilt_root/$host/bin" ]]; then
5072      printf '%s\n' "$prebuilt_root/$host/bin"
5073      return
5074    fi
5075  done
5076  local fallback
5077  fallback=$(find "$prebuilt_root" -maxdepth 1 -mindepth 1 -type d 2>/dev/null | sort | head -1 || true)
5078  if [[ -n "$fallback" && -d "$fallback/bin" ]]; then
5079    printf '%s\n' "$fallback/bin"
5080    return
5081  fi
5082  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
5083  return 1
5084}}
5085
5086detect_latest_android_api() {{
5087  find "$ANDROID_HOME/platforms" -maxdepth 1 -type d -name 'android-*' 2>/dev/null \
5088    | sed 's#.*android-##' \
5089    | sort -n \
5090    | tail -1
5091}}
5092
5093ANDROID_TARGET_API_LEVEL="${{ANDROID_TARGET_API_LEVEL:-$(detect_latest_android_api)}}"
5094if [[ -z "$ANDROID_TARGET_API_LEVEL" ]]; then
5095  printf 'No Android platform found under %s/platforms. Install one with sdkmanager "platforms;android-35" or newer.\n' "$ANDROID_HOME" >&2
5096  exit 1
5097fi
5098
5099ANDROID_NDK=$(find_android_ndk)
5100ANDROID_TOOLCHAIN="${{ANDROID_TOOLCHAIN:-$(detect_android_toolchain)}}"
5101CC_aarch64_linux_android="${{CC_aarch64_linux_android:-$ANDROID_TOOLCHAIN/aarch64-linux-android${{ANDROID_MIN_API_LEVEL}}-clang}}"
5102AR_aarch64_linux_android="${{AR_aarch64_linux_android:-$ANDROID_TOOLCHAIN/llvm-ar}}"
5103CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER="${{CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER:-$CC_aarch64_linux_android}}"
5104CARGO_TARGET_AARCH64_LINUX_ANDROID_AR="${{CARGO_TARGET_AARCH64_LINUX_ANDROID_AR:-$AR_aarch64_linux_android}}"
5105export ANDROID_HOME ANDROID_NDK ANDROID_MIN_API_LEVEL ANDROID_TARGET_API_LEVEL ANDROID_TOOLCHAIN CC_aarch64_linux_android AR_aarch64_linux_android
5106export CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER CARGO_TARGET_AARCH64_LINUX_ANDROID_AR
5107
5108if [[ -n "${{FISSION_GRADLE:-}}" ]]; then
5109  read -r -a GRADLE_CMD <<< "$FISSION_GRADLE"
5110elif [[ -x "$SCRIPT_DIR/gradlew" ]]; then
5111  GRADLE_CMD=("$SCRIPT_DIR/gradlew")
5112else
5113  if ! command -v gradle >/dev/null 2>&1; then
5114    printf 'Gradle is required for the generated Android project shell. Install Gradle or add a wrapper under %s.\n' "$SCRIPT_DIR" >&2
5115    exit 1
5116  fi
5117  GRADLE_CMD=(gradle)
5118fi
5119
5120BUILD_ARGS=(build --manifest-path "$PROJECT_DIR/Cargo.toml" --lib --target "$TARGET" --package "$PACKAGE_NAME")
5121ARTIFACT_DIR=debug
5122GRADLE_VARIANT=Debug
5123GRADLE_OUTPUT_DIR=debug
5124if [[ "$PROFILE" == "release" ]]; then
5125  BUILD_ARGS+=(--release)
5126  ARTIFACT_DIR=release
5127  GRADLE_VARIANT=Release
5128  GRADLE_OUTPUT_DIR=release
5129fi
5130
5131SIGNING_TEMP_DIR=""
5132cleanup_android_signing_temp() {{
5133  if [[ -n "$SIGNING_TEMP_DIR" ]]; then
5134    rm -rf "$SIGNING_TEMP_DIR"
5135  fi
5136}}
5137trap cleanup_android_signing_temp EXIT
5138
5139if [[ "$PROFILE" == "release" ]]; then
5140  if [[ -z "${{ANDROID_KEYSTORE:-}}" && -n "${{ANDROID_KEYSTORE_BASE64:-}}" ]]; then
5141    SIGNING_TEMP_DIR=$(mktemp -d)
5142    ANDROID_KEYSTORE="$SIGNING_TEMP_DIR/upload.jks"
5143    export ANDROID_KEYSTORE
5144    python3 - "$ANDROID_KEYSTORE" <<'PY'
5145import base64
5146import os
5147import sys
5148
5149out_path = sys.argv[1]
5150raw = os.environ["ANDROID_KEYSTORE_BASE64"]
5151with open(out_path, "wb") as handle:
5152    handle.write(base64.b64decode(raw))
5153PY
5154  fi
5155  if [[ -z "${{ANDROID_KEYSTORE:-}}" ]]; then
5156    printf 'Release Android builds require ANDROID_KEYSTORE or ANDROID_KEYSTORE_BASE64 from a secret source.\n' >&2
5157    exit 1
5158  fi
5159  if [[ -z "${{ANDROID_KEYSTORE_PASSWORD:-}}" ]]; then
5160    printf 'Release Android builds require ANDROID_KEYSTORE_PASSWORD from a secret source.\n' >&2
5161    exit 1
5162  fi
5163  if [[ -z "${{ANDROID_KEYSTORE_ALIAS:-}}" ]]; then
5164    ANDROID_KEYSTORE_ALIAS=upload
5165    export ANDROID_KEYSTORE_ALIAS
5166  fi
5167  if [[ -z "${{ANDROID_KEY_PASSWORD:-}}" ]]; then
5168    ANDROID_KEY_PASSWORD="$ANDROID_KEYSTORE_PASSWORD"
5169    export ANDROID_KEY_PASSWORD
5170  fi
5171fi
5172
5173cargo "${{BUILD_ARGS[@]}}"
5174TARGET_DIR=$(python3 - <<'PY' "$PROJECT_DIR/Cargo.toml"
5175import json
5176import subprocess
5177import sys
5178
5179manifest = sys.argv[1]
5180metadata = json.loads(
5181    subprocess.check_output(
5182        ["cargo", "metadata", "--manifest-path", manifest, "--format-version", "1", "--no-deps"]
5183    )
5184)
5185print(metadata["target_directory"])
5186PY
5187)
5188
5189SO_PATH="$TARGET_DIR/$TARGET/$ARTIFACT_DIR/lib$LIB_NAME.so"
5190JNI_DIR="$SCRIPT_DIR/app/src/main/jniLibs/arm64-v8a"
5191GENERATED_RES_DIR="$SCRIPT_DIR/app/src/main/res/drawable-nodpi"
5192mkdir -p "$JNI_DIR" "$GENERATED_RES_DIR"
5193cp "$SO_PATH" "$JNI_DIR/lib$LIB_NAME.so"
5194shopt -s nullglob
5195APP_ICONS=("$SCRIPT_DIR"/res/drawable-nodpi/app_icon.* "$SCRIPT_DIR"/res/drawable/app_icon.*)
5196if (( ${{#APP_ICONS[@]}} == 0 )); then
5197  cp "$PROJECT_DIR/assets/app-icon.png" "$GENERATED_RES_DIR/app_icon.png"
5198fi
5199shopt -u nullglob
5200shopt -s nullglob
5201SPLASH_IMAGES=("$SCRIPT_DIR"/res/drawable-nodpi/fission_splash_image.*)
5202if (( ${{#SPLASH_IMAGES[@]}} == 0 )); then
5203  cp "$PROJECT_DIR/assets/app-icon.png" "$GENERATED_RES_DIR/fission_splash_image.png"
5204fi
5205shopt -u nullglob
5206
5207"${{GRADLE_CMD[@]}}" -p "$SCRIPT_DIR" ":app:{task_prefix}$GRADLE_VARIANT"
5208
5209ARTIFACT="$SCRIPT_DIR/app/build/outputs/{output_subdir}/$GRADLE_OUTPUT_DIR/app-$GRADLE_OUTPUT_DIR.{extension}"
5210if [[ ! -f "$ARTIFACT" ]]; then
5211  printf 'Gradle did not produce the expected {label}: %s\n' "$ARTIFACT" >&2
5212  exit 1
5213fi
5214printf '%s\n' "$ARTIFACT"
5215"#,
5216        package_name = project.app.name,
5217        lib_name = lib_name,
5218        task_prefix = kind.task_prefix,
5219        output_subdir = kind.output_subdir,
5220        extension = kind.extension,
5221        label = kind.label,
5222    )
5223}
5224
5225fn render_android_run_script(project: &FissionProject) -> String {
5226    format!(
5227        r#"#!/usr/bin/env bash
5228set -euo pipefail
5229
5230SCRIPT_DIR=$(cd -- "$(dirname "${{BASH_SOURCE[0]}}")" && pwd)
5231ANDROID_HOME="${{ANDROID_HOME:-${{ANDROID_SDK_ROOT:-$HOME/Library/Android/sdk}}}}"
5232ADB="$ANDROID_HOME/platform-tools/adb"
5233EMULATOR_BIN="$ANDROID_HOME/emulator/emulator"
5234AVDMANAGER="${{ANDROID_AVDMANAGER:-$ANDROID_HOME/cmdline-tools/latest/bin/avdmanager}}"
5235
5236detect_latest_emulator_api() {{
5237  find "$ANDROID_HOME/system-images" -path '*/google_apis/arm64-v8a' -type d 2>/dev/null \
5238    | sed -n 's#.*system-images/android-\([0-9][0-9]*\)/google_apis/arm64-v8a#\1#p' \
5239    | sort -n \
5240    | tail -1
5241}}
5242
5243android_system_image_path() {{
5244  local image="$1"
5245  image="${{image#system-images;}}"
5246  printf '%s/system-images/%s\n' "$ANDROID_HOME" "${{image//;/\/}}"
5247}}
5248
5249wait_for_android_boot() {{
5250  "$ADB" wait-for-device
5251  until "$ADB" shell getprop sys.boot_completed 2>/dev/null | tr -d '\r' | grep -q '^1$'; do
5252    sleep 1
5253  done
5254  local deadline=$((SECONDS + 180))
5255  until "$ADB" shell cmd package list packages >/dev/null 2>&1; do
5256    if (( SECONDS > deadline )); then
5257      printf 'Android package manager did not become available. Restart the emulator with ANDROID_EMULATOR_RESTART=1 and try again.\n' >&2
5258      exit 1
5259    fi
5260    sleep 1
5261  done
5262}}
5263
5264ANDROID_EMULATOR_API_LEVEL="${{ANDROID_EMULATOR_API_LEVEL:-$(detect_latest_emulator_api)}}"
5265if [[ -z "$ANDROID_EMULATOR_API_LEVEL" ]]; then
5266  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
5267  exit 1
5268fi
5269AVD_NAME="${{ANDROID_AVD_NAME:-FissionApi${{ANDROID_EMULATOR_API_LEVEL}}Arm64}}"
5270SYSTEM_IMAGE="${{ANDROID_SYSTEM_IMAGE:-system-images;android-${{ANDROID_EMULATOR_API_LEVEL}};google_apis;arm64-v8a}}"
5271DEVICE_PORT="${{ANDROID_TEST_CONTROL_DEVICE_PORT:-48761}}"
5272HOST_PORT="${{FISSION_TEST_CONTROL_PORT:-48761}}"
5273HEADLESS="${{ANDROID_EMULATOR_HEADLESS:-0}}"
5274RESTART_EMULATOR="${{ANDROID_EMULATOR_RESTART:-0}}"
5275
5276for tool in "$ADB" "$EMULATOR_BIN" "$AVDMANAGER"; do
5277  if [[ ! -x "$tool" ]]; then
5278    printf 'Required Android tool is missing or not executable: %s\nRun `fission doctor android --project-dir .` for setup help.\n' "$tool" >&2
5279    exit 1
5280  fi
5281done
5282
5283if ! "$AVDMANAGER" list avd | grep -q "Name: $AVD_NAME"; then
5284  if [[ ! -d "$(android_system_image_path "$SYSTEM_IMAGE")" ]]; then
5285    printf 'Android system image is not installed: %s\nInstall it with sdkmanager "%s" or set ANDROID_SYSTEM_IMAGE.\n' "$SYSTEM_IMAGE" "$SYSTEM_IMAGE" >&2
5286    exit 1
5287  fi
5288  echo "no" | "$AVDMANAGER" create avd -n "$AVD_NAME" -k "$SYSTEM_IMAGE" --abi "google_apis/arm64-v8a" --device "pixel_5"
5289fi
5290
5291RUNNING_EMULATOR=$("$ADB" devices | awk '/^emulator-.*device$/ {{ print $1; exit }}')
5292if [[ -n "$RUNNING_EMULATOR" && "$RESTART_EMULATOR" == "1" ]]; then
5293  "$ADB" -s "$RUNNING_EMULATOR" emu kill >/dev/null || true
5294  until ! "$ADB" devices | grep -q '^emulator-'; do
5295    sleep 1
5296  done
5297  RUNNING_EMULATOR=""
5298fi
5299
5300if [[ -z "$RUNNING_EMULATOR" ]]; then
5301  EMULATOR_ARGS=(-avd "$AVD_NAME" -gpu "${{ANDROID_EMULATOR_GPU:-swiftshader_indirect}}" -no-audio)
5302  if [[ "$HEADLESS" == "1" ]]; then
5303    EMULATOR_ARGS+=(-no-window)
5304  fi
5305  printf 'Launching emulator %s (%s)\n' "$AVD_NAME" "$([[ "$HEADLESS" == "1" ]] && echo headless || echo visible)"
5306  nohup "$EMULATOR_BIN" "${{EMULATOR_ARGS[@]}}" >/tmp/fission-android-emulator.log 2>&1 &
5307  disown || true
5308  wait_for_android_boot
5309else
5310  printf 'Using existing emulator %s\n' "$RUNNING_EMULATOR"
5311  wait_for_android_boot
5312  if [[ "$HEADLESS" != "1" ]]; then
5313    printf 'If the window is not visible, restart with ANDROID_EMULATOR_RESTART=1 to relaunch a visible emulator.\n'
5314  fi
5315fi
5316
5317APK=$("$SCRIPT_DIR/package-apk.sh")
5318read -r -a ADB_INSTALL_FLAGS <<< "${{ADB_INSTALL_FLAGS:---no-streaming -r}}"
5319"$ADB" install "${{ADB_INSTALL_FLAGS[@]}}" "$APK"
5320"$ADB" forward "tcp:$HOST_PORT" "tcp:$DEVICE_PORT"
5321"$ADB" shell am start -n {app_id}/rs.fission.runtime.FissionActivity >/dev/null
5322printf 'APK=%s\n' "$APK"
5323"#,
5324        app_id = project.app.app_id,
5325    )
5326}
5327
5328fn render_android_test_script() -> String {
5329    r#"#!/usr/bin/env bash
5330set -euo pipefail
5331
5332SCRIPT_DIR=$(cd -- "$(dirname "${BASH_SOURCE[0]}")" && pwd)
5333export FISSION_TEST_CONTROL_PORT="${FISSION_TEST_CONTROL_PORT:-48761}"
5334
5335"$SCRIPT_DIR/run-emulator.sh"
5336
5337python3 - <<'PY' "$FISSION_TEST_CONTROL_PORT"
5338import sys
5339import time
5340import urllib.request
5341
5342port = sys.argv[1]
5343url = f"http://127.0.0.1:{port}/health"
5344deadline = time.time() + 90
5345last_error = None
5346while time.time() < deadline:
5347    try:
5348        with urllib.request.urlopen(url, timeout=1) as response:
5349            body = response.read().decode("utf-8", "replace")
5350        if response.status == 200 and '"status":"ok"' in body:
5351            print(f"Android emulator test control is healthy on {url}")
5352            raise SystemExit(0)
5353    except Exception as error:
5354        last_error = error
5355    time.sleep(1)
5356raise SystemExit(f"Android emulator test control did not become healthy on {url}: {last_error}")
5357PY
5358"#
5359    .to_string()
5360}
5361
5362fn render_web_index(project: &FissionProject) -> String {
5363    let title = ios_bundle_name(project);
5364    format!(
5365        r#"<!doctype html>
5366<html lang="en">
5367  <head>
5368    <meta charset="utf-8" />
5369    <meta name="viewport" content="width=device-width, initial-scale=1" />
5370    <title>{title}</title>
5371    <link rel="icon" type="image/png" href="../../assets/app-icon.png" />
5372    <style>
5373      :root {{
5374        color-scheme: dark;
5375        background: #14171f;
5376      }}
5377      html, body {{
5378        margin: 0;
5379        width: 100%;
5380        height: 100%;
5381        overflow: hidden;
5382        overscroll-behavior: none;
5383        background: #14171f;
5384      }}
5385      body, #fission-web-mount {{
5386        width: 100vw;
5387        height: 100vh;
5388      }}
5389      canvas {{
5390        display: block;
5391        width: 100vw;
5392        height: 100vh;
5393        border: 0;
5394        outline: none;
5395        user-select: none;
5396        -webkit-user-drag: none;
5397        touch-action: none;
5398        -webkit-tap-highlight-color: transparent;
5399      }}
5400      canvas:focus, canvas:focus-visible {{
5401        outline: none;
5402      }}
5403    </style>
5404  </head>
5405  <body>
5406    <main id="fission-web-mount" aria-label="{title}"></main>
5407    <script type="module" src="./bootstrap.mjs"></script>
5408  </body>
5409</html>
5410"#,
5411        title = title,
5412    )
5413}
5414
5415fn render_web_bootstrap(project: &FissionProject) -> String {
5416    let module_name = project.app.name.replace('-', "_");
5417    format!(
5418        "import init from \"./pkg/{}.js\";\n\nawait init();\n",
5419        module_name
5420    )
5421}
5422
5423fn render_web_build_script() -> String {
5424    r#"#!/usr/bin/env bash
5425set -euo pipefail
5426
5427SCRIPT_DIR=$(cd -- "$(dirname "${BASH_SOURCE[0]}")" && pwd)
5428PROJECT_DIR=$(cd -- "$SCRIPT_DIR/../.." && pwd)
5429PROFILE="${FISSION_WEB_PROFILE:-dev}"
5430BUILD_ARGS=(build "$PROJECT_DIR" --target web --out-dir "$SCRIPT_DIR/pkg")
5431
5432if [[ "$PROFILE" == "release" ]]; then
5433  BUILD_ARGS+=(--release)
5434else
5435  BUILD_ARGS+=(--dev)
5436fi
5437
5438wasm-pack "${BUILD_ARGS[@]}"
5439"#
5440    .to_string()
5441}
5442
5443fn render_web_run_script(_project: &FissionProject) -> String {
5444    format!(
5445        r#"#!/usr/bin/env bash
5446set -euo pipefail
5447
5448SCRIPT_DIR=$(cd -- "$(dirname "${{BASH_SOURCE[0]}}")" && pwd)
5449PROJECT_DIR=$(cd -- "$SCRIPT_DIR/../.." && pwd)
5450HOST="${{FISSION_WEB_HOST:-127.0.0.1}}"
5451REQUESTED_PORT="${{FISSION_WEB_PORT:-8123}}"
5452PORT="$REQUESTED_PORT"
5453if [[ -z "${{FISSION_WEB_PORT:-}}" ]]; then
5454  PORT=$(python3 - "$HOST" "$REQUESTED_PORT" <<'PY'
5455import socket
5456import sys
5457
5458host = sys.argv[1]
5459start = int(sys.argv[2])
5460for port in range(start, start + 51):
5461    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe:
5462        probe.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
5463        try:
5464            probe.bind((host, port))
5465        except OSError:
5466            continue
5467        print(port)
5468        raise SystemExit(0)
5469raise SystemExit(f"no free web port found from {{host}}:{{start}}")
5470PY
5471)
5472  if [[ "$PORT" != "$REQUESTED_PORT" ]]; then
5473    printf 'Port %s:%s is already in use; using %s:%s.\n' "$HOST" "$REQUESTED_PORT" "$HOST" "$PORT"
5474  fi
5475fi
5476URL="http://${{HOST}}:${{PORT}}/platforms/web/"
5477
5478"$SCRIPT_DIR/build-wasm.sh"
5479
5480printf 'Serving %s\n' "$URL"
5481printf 'Press Ctrl+C to stop the local server.\n'
5482if [[ "${{FISSION_WEB_OPEN:-0}}" == "1" ]]; then
5483  if command -v open >/dev/null 2>&1; then
5484    open "$URL"
5485  elif command -v xdg-open >/dev/null 2>&1; then
5486    xdg-open "$URL"
5487  elif command -v cmd.exe >/dev/null 2>&1; then
5488    cmd.exe /C start "$URL"
5489  else
5490    printf 'No browser opener found. Open %s manually.\n' "$URL"
5491  fi
5492fi
5493
5494cd "$PROJECT_DIR"
5495python3 -m http.server "$PORT" --bind "$HOST"
5496"#
5497    )
5498}
5499
5500fn render_web_test_script(_project: &FissionProject) -> String {
5501    r#"#!/usr/bin/env bash
5502set -euo pipefail
5503
5504SCRIPT_DIR=$(cd -- "$(dirname "${BASH_SOURCE[0]}")" && pwd)
5505PROJECT_DIR=$(cd -- "$SCRIPT_DIR/../.." && pwd)
5506HOST="${FISSION_WEB_HOST:-127.0.0.1}"
5507REQUESTED_PORT="${FISSION_WEB_PORT:-8123}"
5508PORT="$REQUESTED_PORT"
5509if [[ -z "${FISSION_WEB_PORT:-}" ]]; then
5510  PORT=$(python3 - "$HOST" "$REQUESTED_PORT" <<'PY'
5511import socket
5512import sys
5513
5514host = sys.argv[1]
5515start = int(sys.argv[2])
5516for port in range(start, start + 51):
5517    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe:
5518        probe.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
5519        try:
5520            probe.bind((host, port))
5521        except OSError:
5522            continue
5523        print(port)
5524        raise SystemExit(0)
5525raise SystemExit(f"no free web port found from {host}:{start}")
5526PY
5527)
5528  if [[ "$PORT" != "$REQUESTED_PORT" ]]; then
5529    printf 'Port %s:%s is already in use; using %s:%s.\n' "$HOST" "$REQUESTED_PORT" "$HOST" "$PORT"
5530  fi
5531fi
5532REQUESTED_CDP_PORT="${FISSION_WEB_CDP_PORT:-9222}"
5533CDP_PORT="$REQUESTED_CDP_PORT"
5534if [[ -z "${FISSION_WEB_CDP_PORT:-}" ]]; then
5535  CDP_PORT=$(python3 - "127.0.0.1" "$REQUESTED_CDP_PORT" <<'PY'
5536import socket
5537import sys
5538
5539host = sys.argv[1]
5540start = int(sys.argv[2])
5541for port in range(start, start + 51):
5542    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe:
5543        probe.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
5544        try:
5545            probe.bind((host, port))
5546        except OSError:
5547            continue
5548        print(port)
5549        raise SystemExit(0)
5550raise SystemExit(f"no free CDP port found from {host}:{start}")
5551PY
5552)
5553  if [[ "$CDP_PORT" != "$REQUESTED_CDP_PORT" ]]; then
5554    printf 'CDP port 127.0.0.1:%s is already in use; using 127.0.0.1:%s.\n' "$REQUESTED_CDP_PORT" "$CDP_PORT"
5555  fi
5556fi
5557URL="http://${HOST}:${PORT}/platforms/web/"
5558PROFILE_DIR="$SCRIPT_DIR/build/chrome-profile"
5559
5560require_node_websocket() {
5561  if ! command -v node >/dev/null 2>&1; then
5562    printf 'Node.js was not found. Install Node 22+ so the generated browser smoke test can inspect Chrome CDP console/runtime errors.\n' >&2
5563    exit 1
5564  fi
5565  if ! node -e 'process.exit(typeof WebSocket === "function" ? 0 : 1)' >/dev/null 2>&1; then
5566    printf 'Node.js is available but does not expose the built-in WebSocket client. Install Node 22+ for Chrome CDP smoke tests.\n' >&2
5567    exit 1
5568  fi
5569}
5570
5571detect_chrome() {
5572  if [[ -n "${FISSION_CHROME:-}" && -x "$FISSION_CHROME" ]]; then
5573    printf '%s\n' "$FISSION_CHROME"
5574    return
5575  fi
5576  local candidate
5577  for candidate in \
5578    "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" \
5579    "/Applications/Chromium.app/Contents/MacOS/Chromium" \
5580    "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge"; do
5581    if [[ -x "$candidate" ]]; then
5582      printf '%s\n' "$candidate"
5583      return
5584    fi
5585  done
5586  for candidate in google-chrome chromium chromium-browser chrome; do
5587    if command -v "$candidate" >/dev/null 2>&1; then
5588      command -v "$candidate"
5589      return
5590    fi
5591  done
5592  return 1
5593}
5594
5595require_node_websocket
5596"$SCRIPT_DIR/build-wasm.sh"
5597
5598mkdir -p "$SCRIPT_DIR/build"
5599cd "$PROJECT_DIR"
5600python3 -m http.server "$PORT" --bind "$HOST" >"$SCRIPT_DIR/build/web-server.log" 2>&1 &
5601SERVER_PID=$!
5602
5603cleanup() {
5604  if [[ -n "${CHROME_PID:-}" ]]; then
5605    kill "$CHROME_PID" >/dev/null 2>&1 || true
5606  fi
5607  kill "$SERVER_PID" >/dev/null 2>&1 || true
5608}
5609trap cleanup EXIT
5610
5611printf 'Running transient web smoke test at %s\n' "$URL"
5612printf 'The local server is stopped automatically when this script exits.\n'
5613
5614python3 - <<'PY' "$URL"
5615import sys
5616import time
5617import urllib.request
5618
5619url = sys.argv[1]
5620deadline = time.time() + 30
5621last_error = None
5622while time.time() < deadline:
5623    try:
5624        with urllib.request.urlopen(url, timeout=1) as response:
5625            if response.status == 200:
5626                raise SystemExit(0)
5627    except Exception as error:
5628        last_error = error
5629    time.sleep(0.5)
5630raise SystemExit(f"web server did not serve {url}: {last_error}")
5631PY
5632
5633CHROME=$(detect_chrome) || {
5634  printf 'Chrome/Chromium was not found. Set FISSION_CHROME=/path/to/chrome or run `fission doctor web --project-dir .`.\n' >&2
5635  exit 1
5636}
5637
5638rm -rf "$PROFILE_DIR"
5639"$CHROME" \
5640  --headless=new \
5641  --enable-unsafe-webgpu \
5642  --no-first-run \
5643  --no-default-browser-check \
5644  --remote-debugging-port="$CDP_PORT" \
5645  --user-data-dir="$PROFILE_DIR" \
5646  "$URL" >"$SCRIPT_DIR/build/chrome.log" 2>&1 &
5647CHROME_PID=$!
5648
5649CDP_PORT="$CDP_PORT" FISSION_WEB_URL="$URL" node <<'NODE'
5650const cdpPort = process.env.CDP_PORT;
5651const expectedUrl = process.env.FISSION_WEB_URL;
5652const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
5653
5654async function waitForTarget() {
5655  const deadline = Date.now() + 60_000;
5656  let lastError = null;
5657  while (Date.now() < deadline) {
5658    try {
5659      const response = await fetch(`http://127.0.0.1:${cdpPort}/json/list`);
5660      const targets = await response.json();
5661      const target = targets.find((entry) => entry.type === 'page' && entry.url.startsWith(expectedUrl));
5662      if (target?.webSocketDebuggerUrl) {
5663        return target.webSocketDebuggerUrl;
5664      }
5665    } catch (error) {
5666      lastError = error;
5667    }
5668    await sleep(250);
5669  }
5670  throw new Error(`Chrome CDP target did not become ready for ${expectedUrl}: ${lastError?.message ?? lastError}`);
5671}
5672
5673class CdpClient {
5674  constructor(url) {
5675    this.url = url;
5676    this.ws = null;
5677    this.nextId = 1;
5678    this.pending = new Map();
5679    this.errors = [];
5680  }
5681
5682  async open() {
5683    await new Promise((resolve, reject) => {
5684      const ws = new WebSocket(this.url);
5685      this.ws = ws;
5686      ws.addEventListener('open', resolve, { once: true });
5687      ws.addEventListener('error', (event) => reject(new Error(`CDP websocket error: ${event.message ?? 'unknown error'}`)), { once: true });
5688      ws.addEventListener('message', (event) => this.onMessage(event.data));
5689      ws.addEventListener('close', () => {
5690        for (const { reject: rejectPending } of this.pending.values()) {
5691          rejectPending(new Error('CDP websocket closed'));
5692        }
5693        this.pending.clear();
5694      });
5695    });
5696  }
5697
5698  send(method, params = {}) {
5699    const id = this.nextId++;
5700    const message = { id, method, params };
5701    return new Promise((resolve, reject) => {
5702      const timeout = setTimeout(() => {
5703        this.pending.delete(id);
5704        reject(new Error(`CDP command timed out: ${method}`));
5705      }, 10_000);
5706      this.pending.set(id, { resolve, reject, timeout, method });
5707      this.ws.send(JSON.stringify(message));
5708    });
5709  }
5710
5711  onMessage(raw) {
5712    const message = JSON.parse(raw);
5713    if (message.id) {
5714      const pending = this.pending.get(message.id);
5715      if (!pending) return;
5716      clearTimeout(pending.timeout);
5717      this.pending.delete(message.id);
5718      if (message.error) {
5719        pending.reject(new Error(`${pending.method}: ${message.error.message}`));
5720      } else {
5721        pending.resolve(message.result ?? {});
5722      }
5723      return;
5724    }
5725
5726    if (message.method === 'Runtime.exceptionThrown') {
5727      this.errors.push(formatException(message.params?.exceptionDetails));
5728    } else if (message.method === 'Runtime.consoleAPICalled') {
5729      const type = message.params?.type;
5730      if (type === 'error' || type === 'assert') {
5731        this.errors.push(`console.${type}: ${(message.params?.args ?? []).map(formatRemoteObject).join(' ')}`);
5732      }
5733    } else if (message.method === 'Log.entryAdded') {
5734      const entry = message.params?.entry;
5735      if (entry?.level === 'error') {
5736        if ((entry.url ?? '').endsWith('/__fission/renderer')) {
5737          return;
5738        }
5739        this.errors.push(`browser log error: ${entry.text}${entry.url ? ` (${entry.url}:${entry.lineNumber ?? 0})` : ''}`);
5740      }
5741    }
5742  }
5743
5744  close() {
5745    this.ws?.close();
5746  }
5747}
5748
5749function formatRemoteObject(value) {
5750  if (!value) return '<missing>';
5751  if (Object.prototype.hasOwnProperty.call(value, 'value')) return JSON.stringify(value.value);
5752  return value.description ?? value.unserializableValue ?? value.type ?? '<unknown>';
5753}
5754
5755function formatException(details) {
5756  if (!details) return 'runtime exception: <missing details>';
5757  const exception = details.exception?.description ?? details.exception?.value ?? details.text ?? 'unknown exception';
5758  const location = details.url ? ` at ${details.url}:${details.lineNumber ?? 0}:${details.columnNumber ?? 0}` : '';
5759  return `runtime exception: ${exception}${location}`;
5760}
5761
5762function errorBlock(errors) {
5763  return errors.slice(0, 10).map((error, index) => `${index + 1}. ${error}`).join('\n');
5764}
5765
5766async function readRuntimeStatus(client) {
5767  const expression = `(() => {
5768    const canvas = document.querySelector('canvas');
5769    if (!canvas) return { ready: false, reason: 'no canvas element' };
5770    const rect = canvas.getBoundingClientRect();
5771    const perf = globalThis.__FISSION_PERF ?? { frames: [], inputLatencies: [] };
5772    return {
5773      ready: rect.width > 0 && rect.height > 0,
5774      width: Math.round(rect.width),
5775      height: Math.round(rect.height),
5776      gpu: typeof navigator.gpu !== 'undefined',
5777      renderer: globalThis.__FISSION_RENDERER_INFO ?? null,
5778      frames: Array.isArray(perf.frames) ? perf.frames.slice(-120) : [],
5779      inputLatencies: Array.isArray(perf.inputLatencies) ? perf.inputLatencies.slice(-30) : [],
5780      title: document.title,
5781    };
5782  })()`;
5783  const result = await client.send('Runtime.evaluate', { expression, returnByValue: true });
5784  if (result.exceptionDetails) {
5785    throw new Error(formatException(result.exceptionDetails));
5786  }
5787  return result.result?.value ?? { ready: false, reason: 'evaluation returned no value' };
5788}
5789
5790function average(values) {
5791  if (!values.length) return 0;
5792  return values.reduce((sum, value) => sum + value, 0) / values.length;
5793}
5794
5795async function clickCanvasCenter(client, status) {
5796  const x = Math.max(1, Math.floor(status.width / 2));
5797  const y = Math.max(1, Math.floor(status.height / 2));
5798  await client.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x, y, button: 'none' });
5799  await client.send('Input.dispatchMouseEvent', { type: 'mousePressed', x, y, button: 'left', clickCount: 1 });
5800  await client.send('Input.dispatchMouseEvent', { type: 'mouseReleased', x, y, button: 'left', clickCount: 1 });
5801}
5802
5803async function main() {
5804  const wsUrl = await waitForTarget();
5805  const client = new CdpClient(wsUrl);
5806  await client.open();
5807  try {
5808    await Promise.all([
5809      client.send('Runtime.enable'),
5810      client.send('Log.enable'),
5811      client.send('Page.enable'),
5812    ]);
5813
5814    const deadline = Date.now() + 60_000;
5815    let readySince = null;
5816    let lastStatus = null;
5817    while (Date.now() < deadline) {
5818      if (client.errors.length > 0) {
5819        throw new Error(`browser reported runtime/console errors:\n${errorBlock(client.errors)}`);
5820      }
5821      lastStatus = await readRuntimeStatus(client);
5822      if (lastStatus.ready && lastStatus.renderer) {
5823        readySince ??= Date.now();
5824        if (Date.now() - readySince >= 1_500) {
5825          const renderer = lastStatus.renderer.active;
5826          if (lastStatus.gpu && renderer === 'canvas2d-software' && !lastStatus.renderer.fallback_reason && process.env.FISSION_ALLOW_WEBGPU_FALLBACK !== '1') {
5827            throw new Error(`WebGPU is exposed but Fission used canvas2d-software without a fallback reason: ${JSON.stringify(lastStatus.renderer)}`);
5828          }
5829          await clickCanvasCenter(client, lastStatus);
5830          const inputDeadline = Date.now() + 10_000;
5831          while (Date.now() < inputDeadline) {
5832            lastStatus = await readRuntimeStatus(client);
5833            if ((lastStatus.inputLatencies ?? []).length > 0) break;
5834            await sleep(100);
5835          }
5836          const frames = lastStatus.frames ?? [];
5837          const latencies = lastStatus.inputLatencies ?? [];
5838          if (frames.length < 2) {
5839            throw new Error(`web perf smoke did not capture enough frame samples: ${JSON.stringify(lastStatus)}`);
5840          }
5841          if (latencies.length < 1) {
5842            throw new Error(`web perf smoke did not capture input latency samples: ${JSON.stringify(lastStatus)}`);
5843          }
5844          const avgFrame = average(frames.slice(-30));
5845          const avgLatency = average(latencies.slice(-10));
5846          if (avgFrame > Number(process.env.FISSION_WEB_MAX_AVG_FRAME_MS ?? 80)) {
5847            throw new Error(`web average frame time ${avgFrame.toFixed(2)}ms exceeded smoke threshold`);
5848          }
5849          if (avgLatency > Number(process.env.FISSION_WEB_MAX_INPUT_LATENCY_MS ?? 180)) {
5850            throw new Error(`web input latency ${avgLatency.toFixed(2)}ms exceeded smoke threshold`);
5851          }
5852          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.`);
5853          return;
5854        }
5855      } else {
5856        readySince = null;
5857      }
5858      await sleep(250);
5859    }
5860    throw new Error(`web app did not render a non-empty canvas with renderer diagnostics. Last state: ${JSON.stringify(lastStatus)}`);
5861  } finally {
5862    client.close();
5863  }
5864}
5865
5866main().catch((error) => {
5867  console.error(error.stack ?? error.message ?? String(error));
5868  process.exit(1);
5869});
5870NODE
5871"#
5872    .to_string()
5873}
5874fn render_app_main(package_name: &str) -> String {
5875    let lib_name = package_name.replace('-', "_");
5876    format!(
5877        r#"#[cfg(target_os = "android")]
5878fn main() {{}}
5879
5880#[cfg(target_arch = "wasm32")]
5881fn main() {{}}
5882
5883#[cfg(target_os = "ios")]
5884fn main() -> anyhow::Result<()> {{
5885    {lib_name}::run_mobile()
5886}}
5887
5888#[cfg(not(any(target_arch = "wasm32", target_os = "ios", target_os = "android")))]
5889fn main() -> anyhow::Result<()> {{
5890    {lib_name}::run_desktop()
5891}}
5892"#
5893    )
5894}
5895
5896const APP_LIB: &str = r#"pub mod app;
5897
5898use crate::app::CounterApp;
5899use fission::prelude::*;
5900
5901#[cfg(target_os = "android")]
5902const ANDROID_TEST_CONTROL_PORT: u16 = 48761;
5903
5904#[cfg(any(target_os = "android", target_os = "ios"))]
5905fn mobile_app() -> MobileApp<crate::app::CounterState, CounterApp> {
5906    let app = MobileApp::<crate::app::CounterState, _>::new(CounterApp).with_title("Fission App");
5907    #[cfg(target_os = "android")]
5908    let app = app.with_test_control_port(ANDROID_TEST_CONTROL_PORT);
5909    app
5910}
5911
5912#[cfg(target_arch = "wasm32")]
5913fn web_app() -> WebApp<crate::app::CounterState, CounterApp> {
5914    WebApp::<crate::app::CounterState, _>::new(CounterApp).with_title("Fission App")
5915}
5916
5917#[cfg(not(any(target_arch = "wasm32", target_os = "android", target_os = "ios")))]
5918pub fn run_desktop() -> anyhow::Result<()> {
5919    DesktopApp::<crate::app::CounterState, _>::new(CounterApp).run()
5920}
5921
5922#[cfg(any(target_os = "android", target_os = "ios"))]
5923pub fn run_mobile() -> anyhow::Result<()> {
5924    mobile_app().run()
5925}
5926
5927#[cfg(target_os = "android")]
5928#[no_mangle]
5929fn android_main(app_handle: AndroidApp) {
5930    let _ = mobile_app().run_with_android_app(app_handle);
5931}
5932
5933#[cfg(target_arch = "wasm32")]
5934#[wasm_bindgen::prelude::wasm_bindgen(start)]
5935pub fn run_web() -> Result<(), wasm_bindgen::JsValue> {
5936    console_error_panic_hook::set_once();
5937    web_app()
5938        .run()
5939        .map_err(|error| wasm_bindgen::JsValue::from_str(&error.to_string()))
5940}
5941"#;
5942
5943const APP_RS: &str = r#"use fission::prelude::*;
5944
5945#[derive(Default, Debug, Clone, PartialEq)]
5946pub struct CounterState {
5947    pub count: i32,
5948}
5949
5950impl GlobalState for CounterState {}
5951
5952#[fission_reducer(Increment)]
5953fn on_increment(state: &mut CounterState) {
5954    state.count += 1;
5955}
5956
5957#[derive(Clone)]
5958pub struct CounterApp;
5959
5960impl From<CounterApp> for Widget {
5961    fn from(component: CounterApp) -> Self {
5962        let (ctx, view) = fission::build::current::<CounterState>();
5963        let increment = with_reducer!(ctx, Increment, on_increment);
5964
5965        Column {
5966            gap: Some(16.0),
5967            children: vec![
5968                Text::new(format!("Count: {}", view.state().count)).size(28.0).into(),
5969                Button {
5970                    on_press: Some(increment),
5971                    child: Some(Text::new("Increment").into()),
5972                    ..Default::default()
5973                }
5974                .into(),
5975            ],
5976            ..Default::default()
5977        }
5978        .into()
5979
5980    }
5981}
5982"#;
5983
5984#[cfg(test)]
5985mod tests {
5986    use super::*;
5987
5988    fn unique_dir(name: &str) -> PathBuf {
5989        let dir = std::env::temp_dir().join(format!(
5990            "fission-command-core-{name}-{}",
5991            std::process::id()
5992        ));
5993        fs::remove_dir_all(&dir).ok();
5994        fs::create_dir_all(&dir).unwrap();
5995        dir
5996    }
5997
5998    #[test]
5999    fn project_assets_stage_nested_resources_and_replace_stale_output() {
6000        let dir = unique_dir("stage-project-assets");
6001        let project = dir.join("project");
6002        let destination = dir.join("destination");
6003        fs::create_dir_all(project.join("assets/intelligence")).unwrap();
6004        fs::create_dir_all(destination.join("assets/stale")).unwrap();
6005        fs::write(
6006            project.join("assets/intelligence/base.pdb.zst"),
6007            b"signed base",
6008        )
6009        .unwrap();
6010        fs::write(destination.join("assets/stale/old"), b"stale").unwrap();
6011
6012        let staged = stage_project_assets(&project, &destination)
6013            .unwrap()
6014            .expect("assets directory should be staged");
6015
6016        assert_eq!(staged, destination.join("assets"));
6017        assert_eq!(
6018            fs::read(staged.join("intelligence/base.pdb.zst")).unwrap(),
6019            b"signed base"
6020        );
6021        assert!(!staged.join("stale/old").exists());
6022        fs::remove_dir_all(dir).unwrap();
6023    }
6024
6025    #[test]
6026    fn project_assets_are_optional_but_must_be_a_directory_when_present() {
6027        let dir = unique_dir("stage-project-assets-validation");
6028        let project = dir.join("project");
6029        let destination = dir.join("destination");
6030        fs::create_dir_all(&project).unwrap();
6031
6032        assert_eq!(stage_project_assets(&project, &destination).unwrap(), None);
6033
6034        fs::write(project.join("assets"), b"not a directory").unwrap();
6035        let error = stage_project_assets(&project, &destination).unwrap_err();
6036        assert!(error.to_string().contains("project assets path"));
6037        fs::remove_dir_all(dir).unwrap();
6038    }
6039
6040    #[test]
6041    fn windows_release_sync_updates_appx_identity() {
6042        let dir = unique_dir("windows-release-sync");
6043        let windows_dir = dir.join("platforms/windows");
6044        fs::create_dir_all(&windows_dir).unwrap();
6045        fs::write(
6046            dir.join("fission.toml"),
6047            r#"[package.windows]
6048identity_name = "Example.App"
6049publisher = "CN=Example & Co"
6050"#,
6051        )
6052        .unwrap();
6053        let manifest = windows_dir.join("Package.appxmanifest");
6054        fs::write(
6055            &manifest,
6056            r#"<Package>
6057  <Identity Name="Old.App" Publisher="CN=Old" Version="0.0.0.0" />
6058</Package>
6059"#,
6060        )
6061        .unwrap();
6062
6063        sync_release_platform_config(
6064            &dir,
6065            Target::Windows,
6066            &ReleaseVersionConfig {
6067                version: Some("1.2.3".to_string()),
6068                build: Some(42),
6069            },
6070        )
6071        .unwrap();
6072
6073        let updated = fs::read_to_string(&manifest).unwrap();
6074        assert!(updated.contains(r#"Name="Example.App""#));
6075        assert!(updated.contains(r#"Publisher="CN=Example &amp; Co""#));
6076        assert!(updated.contains(r#"Version="1.2.3.42""#));
6077    }
6078
6079    #[test]
6080    fn windows_release_sync_rejects_invalid_version() {
6081        let dir = unique_dir("windows-release-invalid-version");
6082        let windows_dir = dir.join("platforms/windows");
6083        fs::create_dir_all(&windows_dir).unwrap();
6084        fs::write(
6085            windows_dir.join("Package.appxmanifest"),
6086            r#"<Package><Identity Version="0.0.0.0" /></Package>"#,
6087        )
6088        .unwrap();
6089
6090        let error = sync_release_platform_config(
6091            &dir,
6092            Target::Windows,
6093            &ReleaseVersionConfig {
6094                version: Some("1.2.beta".to_string()),
6095                build: Some(1),
6096            },
6097        )
6098        .unwrap_err();
6099
6100        assert!(error
6101            .to_string()
6102            .contains("Windows package version `1.2.beta` must be numeric"));
6103    }
6104
6105    #[test]
6106    fn windows_scaffold_includes_opt_in_nsis_shortcut_identity_support() {
6107        let dir = unique_dir("windows-shortcut-aumid-scaffold");
6108        let project = FissionProject {
6109            app: AppConfig {
6110                name: "Example App".to_string(),
6111                app_id: "com.example.app".to_string(),
6112                splash: None,
6113            },
6114            targets: BTreeSet::from([Target::Windows]),
6115            capabilities: BTreeSet::new(),
6116            native: NativeConfig::default(),
6117        };
6118
6119        scaffold_windows_bundle(&dir, &project, WritePolicy::Overwrite).unwrap();
6120
6121        let source =
6122            fs::read_to_string(dir.join("platforms/windows/shortcut-aumid-helper.cpp")).unwrap();
6123        assert!(source.contains("PKEY_AppUserModel_ID"));
6124        assert!(source.contains("length > 128"));
6125        assert!(source.contains("std::iswspace"));
6126
6127        let build =
6128            fs::read_to_string(dir.join("platforms/windows/build-shortcut-aumid-helper.ps1"))
6129                .unwrap();
6130        assert!(build.contains(r#"[ValidateSet("x64", "arm64")]"#));
6131        assert!(build.contains("/MT"));
6132        assert!(build.contains("Microsoft.VisualStudio.Component.VC.Tools.ARM64"));
6133        assert!(build.contains("propsys.lib"));
6134
6135        let nsis =
6136            fs::read_to_string(dir.join("platforms/windows/fission-shortcut-aumid.nsh")).unwrap();
6137        assert!(nsis.contains("nsExec::ExecToStack"));
6138        assert!(nsis.contains("FissionEmbedShortcutAppUserModelIdHelper"));
6139        assert!(nsis.contains("FissionSetShortcutAppUserModelId"));
6140        assert!(nsis.contains("Abort"));
6141        assert!(!nsis.contains("WinShell"));
6142
6143        fs::remove_dir_all(dir).unwrap();
6144    }
6145
6146    #[test]
6147    fn windows_shortcut_identity_support_is_opt_in() {
6148        let source = render_windows_shortcut_aumid_helper_source();
6149        let build = render_windows_shortcut_aumid_helper_build_script();
6150        let nsis = render_windows_shortcut_aumid_nsis_include();
6151
6152        assert!(source.contains("argv[2]"));
6153        assert!(build.contains("$Architecture"));
6154        assert!(nsis.contains("APP_USER_MODEL_ID"));
6155        assert!(!nsis.contains("APP_USER_MODEL_ID ="));
6156        assert!(!nsis.contains("!define FISSION_APP_USER_MODEL_ID"));
6157    }
6158
6159    #[test]
6160    fn macos_release_sync_updates_info_plist_version() {
6161        let dir = unique_dir("macos-release-sync");
6162        let macos_dir = dir.join("platforms/macos");
6163        fs::create_dir_all(&macos_dir).unwrap();
6164        let plist = macos_dir.join("Info.plist");
6165        fs::write(
6166            &plist,
6167            r#"<?xml version="1.0" encoding="UTF-8"?>
6168<plist version="1.0">
6169<dict>
6170  <key>CFBundleShortVersionString</key>
6171  <string>0.0.1</string>
6172  <key>CFBundleVersion</key>
6173  <string>1</string>
6174</dict>
6175</plist>
6176"#,
6177        )
6178        .unwrap();
6179
6180        sync_release_platform_config(
6181            &dir,
6182            Target::Macos,
6183            &ReleaseVersionConfig {
6184                version: Some("1.2.3".to_string()),
6185                build: Some(42),
6186            },
6187        )
6188        .unwrap();
6189
6190        let updated = fs::read_to_string(&plist).unwrap();
6191        assert!(updated.contains("<string>1.2.3</string>"));
6192        assert!(updated.contains("<string>42</string>"));
6193    }
6194
6195    #[test]
6196    fn project_config_includes_release_package_defaults() {
6197        let dir = unique_dir("release-package-defaults");
6198        let project = FissionProject {
6199            app: AppConfig {
6200                name: "release-demo".to_string(),
6201                app_id: "com.example.release_demo".to_string(),
6202                splash: None,
6203            },
6204            targets: BTreeSet::from([Target::Android, Target::Ios, Target::Macos, Target::Windows]),
6205            capabilities: BTreeSet::new(),
6206            native: NativeConfig::default(),
6207        };
6208
6209        write_project_config(&dir, &project).unwrap();
6210
6211        let text = fs::read_to_string(dir.join("fission.toml")).unwrap();
6212        assert!(text.contains("version = \"0.1.0\""));
6213        assert!(text.contains("build = 1"));
6214        assert!(text.contains("[package.android]"));
6215        assert!(text.contains("package_name = \"com.example.release_demo\""));
6216        assert!(text.contains("keystore_env = \"ANDROID_KEYSTORE\""));
6217        assert!(text.contains("[package.ios]"));
6218        assert!(text.contains("bundle_id = \"com.example.release_demo\""));
6219        assert!(text.contains("[package.macos]"));
6220        assert!(text.contains("marketing_version = \"0.1.0\""));
6221        assert!(text.contains("build_number = \"1\""));
6222        assert!(text.contains("[package.windows]"));
6223        assert!(text.contains("identity_name = \"com.example.release.demo\""));
6224        assert!(text.contains("certificate_base64_env = \"WINDOWS_CERTIFICATE_BASE64\""));
6225        assert!(text.contains("[distribution.play_store]"));
6226        assert!(text.contains(
6227            "service_account_json_base64_env = \"PLAY_STORE_SERVICE_ACCOUNT_JSON_BASE64\""
6228        ));
6229        assert!(text.contains("[distribution.app_store]"));
6230        assert!(text.contains("api_key_base64_env = \"APP_STORE_CONNECT_API_KEY_BASE64\""));
6231        assert!(text.contains("[distribution.microsoft_store]"));
6232        assert!(text.contains("client_secret_env = \"MICROSOFT_STORE_CLIENT_SECRET\""));
6233    }
6234
6235    #[test]
6236    fn target_aliases_parse_legacy_names_and_write_canonical_names() {
6237        assert_eq!(
6238            <Target as clap::ValueEnum>::from_str("site", true).unwrap(),
6239            Target::Site
6240        );
6241        assert_eq!(
6242            <Target as clap::ValueEnum>::from_str("server", true).unwrap(),
6243            Target::Server
6244        );
6245
6246        let dir = unique_dir("target-aliases");
6247        fs::write(
6248            dir.join("fission.toml"),
6249            r#"targets = ["site", "server"]
6250
6251[app]
6252name = "Alias Demo"
6253app_id = "com.example.alias"
6254"#,
6255        )
6256        .unwrap();
6257
6258        let project = read_project_config(&dir).unwrap();
6259        assert!(project.targets.contains(&Target::Site));
6260        assert!(project.targets.contains(&Target::Server));
6261
6262        write_project_config(&dir, &project).unwrap();
6263        let updated = fs::read_to_string(dir.join("fission.toml")).unwrap();
6264        assert!(updated.contains("\"static-site\""));
6265        assert!(updated.contains("\"ssr\""));
6266        assert!(!updated.contains("\"site\""));
6267        assert!(!updated.contains("\"server\""));
6268    }
6269
6270    #[test]
6271    fn static_site_uses_the_scaffold_path_created_by_add_target() {
6272        assert_eq!(
6273            Target::Site.scaffold_relative_path(),
6274            "platforms/site/README.md"
6275        );
6276    }
6277
6278    #[test]
6279    fn app_id_accepts_short_id_alias() {
6280        let dir = unique_dir("app-id-alias");
6281        fs::write(
6282            dir.join("fission.toml"),
6283            r#"targets = ["android"]
6284
6285[app]
6286name = "Alias Demo"
6287id = "com.example.alias"
6288"#,
6289        )
6290        .unwrap();
6291
6292        let project = read_project_config(&dir).unwrap();
6293        assert_eq!(project.app.app_id, "com.example.alias");
6294    }
6295}