Skip to main content

fission_command_core/
lib.rs

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