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