Skip to main content

fission_command_core/
lib.rs

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