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};
7
8const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION");
9const DEFAULT_APP_ICON_PNG: &[u8] = include_bytes!("../assets/fission_logo.png");
10
11#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, ValueEnum, Serialize, Deserialize)]
12#[serde(rename_all = "kebab-case")]
13pub enum Target {
14    Android,
15    Ios,
16    Linux,
17    Macos,
18    Site,
19    Web,
20    Windows,
21}
22
23#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, ValueEnum, Serialize, Deserialize)]
24#[serde(rename_all = "kebab-case")]
25pub enum PlatformCapability {
26    BarcodeScanner,
27    Biometric,
28    Bluetooth,
29    Camera,
30    Geolocation,
31    Haptics,
32    Microphone,
33    Nfc,
34    Passkeys,
35    VolumeControl,
36    Wifi,
37}
38
39impl PlatformCapability {
40    pub fn as_str(self) -> &'static str {
41        match self {
42            Self::BarcodeScanner => "barcode-scanner",
43            Self::Biometric => "biometric",
44            Self::Bluetooth => "bluetooth",
45            Self::Camera => "camera",
46            Self::Geolocation => "geolocation",
47            Self::Haptics => "haptics",
48            Self::Microphone => "microphone",
49            Self::Nfc => "nfc",
50            Self::Passkeys => "passkeys",
51            Self::VolumeControl => "volume-control",
52            Self::Wifi => "wifi",
53        }
54    }
55}
56
57impl Target {
58    pub fn as_str(self) -> &'static str {
59        match self {
60            Self::Android => "android",
61            Self::Ios => "ios",
62            Self::Linux => "linux",
63            Self::Macos => "macos",
64            Self::Site => "site",
65            Self::Web => "web",
66            Self::Windows => "windows",
67        }
68    }
69
70    pub fn scaffold_relative_path(self) -> &'static str {
71        match self {
72            Self::Android => "platforms/android/README.md",
73            Self::Ios => "platforms/ios/README.md",
74            Self::Linux => "platforms/linux/README.md",
75            Self::Macos => "platforms/macos/README.md",
76            Self::Site => "platforms/site/README.md",
77            Self::Web => "platforms/web/README.md",
78            Self::Windows => "platforms/windows/README.md",
79        }
80    }
81}
82
83#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
84pub enum DistributionProvider {
85    #[value(name = "app-store")]
86    AppStore,
87    #[value(name = "github-pages")]
88    GithubPages,
89    #[value(name = "github-releases")]
90    GithubReleases,
91    #[value(name = "cloudflare-pages")]
92    CloudflarePages,
93    Dropbox,
94    #[value(name = "google-drive")]
95    GoogleDrive,
96    #[value(name = "microsoft-store")]
97    MicrosoftStore,
98    Netlify,
99    #[value(name = "onedrive")]
100    OneDrive,
101    #[value(name = "play-store")]
102    PlayStore,
103    S3,
104}
105
106impl DistributionProvider {
107    pub fn as_str(self) -> &'static str {
108        match self {
109            Self::AppStore => "app-store",
110            Self::GithubPages => "github-pages",
111            Self::GithubReleases => "github-releases",
112            Self::CloudflarePages => "cloudflare-pages",
113            Self::Dropbox => "dropbox",
114            Self::GoogleDrive => "google-drive",
115            Self::MicrosoftStore => "microsoft-store",
116            Self::Netlify => "netlify",
117            Self::OneDrive => "onedrive",
118            Self::PlayStore => "play-store",
119            Self::S3 => "s3",
120        }
121    }
122}
123
124#[derive(Debug, Serialize, Deserialize)]
125pub struct FissionProject {
126    pub app: AppConfig,
127    pub targets: BTreeSet<Target>,
128    #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
129    pub capabilities: BTreeSet<PlatformCapability>,
130}
131
132#[derive(Debug, Serialize, Deserialize)]
133pub struct AppConfig {
134    pub name: String,
135    pub app_id: String,
136}
137
138#[derive(Debug, Deserialize)]
139struct CargoManifest {
140    package: Option<CargoPackage>,
141}
142
143#[derive(Debug, Deserialize)]
144struct CargoPackage {
145    pub name: String,
146}
147
148#[derive(Clone, Copy, Debug, Eq, PartialEq)]
149enum WritePolicy {
150    Overwrite,
151    PreserveExisting,
152}
153
154pub fn init_project(
155    root: &Path,
156    name: Option<String>,
157    app_id: Option<String>,
158    local_path: Option<PathBuf>,
159) -> Result<()> {
160    let existing_project = root.exists() && root.read_dir()?.next().is_some();
161    fs::create_dir_all(root.join("src"))?;
162
163    let write_policy = if existing_project {
164        WritePolicy::PreserveExisting
165    } else {
166        WritePolicy::Overwrite
167    };
168    let project = initial_project_config(root, name, app_id)?;
169
170    write_file_with_policy(
171        &root.join("Cargo.toml"),
172        &render_cargo_toml(&project, local_path.as_deref()),
173        write_policy,
174    )?;
175    write_file_with_policy(
176        &root.join("src/main.rs"),
177        &render_app_main(project.app.name.as_str()),
178        write_policy,
179    )?;
180    write_file_with_policy(&root.join("src/lib.rs"), APP_LIB, write_policy)?;
181    write_file_with_policy(&root.join("src/app.rs"), APP_RS, write_policy)?;
182    write_binary_file_with_policy(
183        &root.join("assets/app-icon.png"),
184        DEFAULT_APP_ICON_PNG,
185        write_policy,
186    )?;
187    write_file_with_policy(
188        &root.join("README.md"),
189        &render_project_readme(&project),
190        write_policy,
191    )?;
192    write_file_with_policy(
193        &root.join(".gitignore"),
194        "target/\nplatforms/*/build/\n",
195        write_policy,
196    )?;
197    write_project_config(root, &project)?;
198
199    let targets = project.targets.iter().copied().collect::<Vec<_>>();
200    for target in targets {
201        scaffold_target_with_policy(root, &project, target, write_policy)?;
202    }
203    apply_platform_capability_config(root, &project)?;
204
205    Ok(())
206}
207
208fn initial_project_config(
209    root: &Path,
210    name: Option<String>,
211    app_id: Option<String>,
212) -> Result<FissionProject> {
213    let existing = if root.join("fission.toml").exists() {
214        Some(read_project_config(root)?)
215    } else {
216        None
217    };
218    let cargo_name = cargo_package_name(root);
219    if let (Some(requested), Some(cargo_name)) = (&name, &cargo_name) {
220        let requested = normalize_crate_name(requested);
221        let cargo_name = normalize_crate_name(cargo_name);
222        if requested != cargo_name {
223            bail!(
224                "refusing to set app name `{requested}` for existing Cargo package `{cargo_name}`; rename the package in Cargo.toml first or omit --name"
225            );
226        }
227    }
228    let project_name = cargo_name
229        .or(name)
230        .or_else(|| existing.as_ref().map(|project| project.app.name.clone()))
231        .unwrap_or_else(|| {
232            root.file_name()
233                .and_then(|value| value.to_str())
234                .unwrap_or("fission-app")
235                .to_string()
236        });
237    let normalized_name = normalize_crate_name(&project_name);
238
239    let mut targets = existing
240        .as_ref()
241        .map(|project| project.targets.clone())
242        .unwrap_or_default();
243    targets.extend(detect_project_targets(root));
244    if targets.is_empty() {
245        targets.extend([Target::Windows, Target::Macos, Target::Linux]);
246    }
247
248    Ok(FissionProject {
249        app: AppConfig {
250            name: normalized_name.clone(),
251            app_id: app_id
252                .or_else(|| existing.as_ref().map(|project| project.app.app_id.clone()))
253                .unwrap_or_else(|| format!("com.example.{}", normalized_name.replace('-', "_"))),
254        },
255        targets,
256        capabilities: existing
257            .as_ref()
258            .map(|project| project.capabilities.clone())
259            .unwrap_or_default(),
260    })
261}
262
263pub fn cargo_package_name(root: &Path) -> Option<String> {
264    let manifest = fs::read_to_string(root.join("Cargo.toml")).ok()?;
265    let manifest: CargoManifest = toml::from_str(&manifest).ok()?;
266    manifest.package.map(|package| package.name)
267}
268
269fn detect_project_targets(root: &Path) -> BTreeSet<Target> {
270    let mut targets = BTreeSet::new();
271    if root.join("src/main.rs").exists() || root.join("src/lib.rs").exists() {
272        targets.extend([Target::Windows, Target::Macos, Target::Linux]);
273    }
274    for (target, relative) in [
275        (Target::Android, "platforms/android"),
276        (Target::Ios, "platforms/ios"),
277        (Target::Linux, "platforms/linux"),
278        (Target::Macos, "platforms/macos"),
279        (Target::Site, "content"),
280        (Target::Web, "platforms/web"),
281        (Target::Windows, "platforms/windows"),
282    ] {
283        if root.join(relative).exists() {
284            targets.insert(target);
285        }
286    }
287    targets
288}
289
290pub fn add_targets(project_dir: &Path, targets: &[Target]) -> Result<()> {
291    if targets.is_empty() {
292        bail!("no targets provided");
293    }
294    let mut project = read_project_config(project_dir)?;
295    for target in targets {
296        let target_exists =
297            project.targets.contains(target) || target_scaffold_dir_exists(project_dir, *target);
298        project.targets.insert(*target);
299        let write_policy = if target_exists {
300            WritePolicy::PreserveExisting
301        } else {
302            WritePolicy::Overwrite
303        };
304        scaffold_target_with_policy(project_dir, &project, *target, write_policy)?;
305    }
306    apply_platform_capability_config(project_dir, &project)?;
307    write_project_config(project_dir, &project)?;
308    update_cargo_fission_features(project_dir, &project)?;
309    write_file_with_policy(
310        &project_dir.join("README.md"),
311        &render_project_readme(&project),
312        WritePolicy::PreserveExisting,
313    )?;
314    Ok(())
315}
316
317pub fn add_capabilities(project_dir: &Path, capabilities: &[PlatformCapability]) -> Result<()> {
318    if capabilities.is_empty() {
319        bail!("no capabilities provided");
320    }
321    let mut project = read_project_config(project_dir)?;
322    for capability in capabilities {
323        project.capabilities.insert(*capability);
324    }
325    write_project_config(project_dir, &project)?;
326    apply_platform_capability_config(project_dir, &project)?;
327    Ok(())
328}
329
330fn apply_platform_capability_config(root: &Path, project: &FissionProject) -> Result<()> {
331    if project.capabilities.is_empty() {
332        return Ok(());
333    }
334    if project.targets.contains(&Target::Android) {
335        apply_android_capability_config(root, project)?;
336    }
337    if project.targets.contains(&Target::Ios) {
338        apply_ios_capability_config(root, project)?;
339    }
340    Ok(())
341}
342
343fn apply_android_capability_config(root: &Path, project: &FissionProject) -> Result<()> {
344    let path = root.join("platforms/android/AndroidManifest.xml");
345    if !path.exists() {
346        return Ok(());
347    }
348    let existing =
349        fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))?;
350    let mut capabilities = String::new();
351    if project.capabilities.contains(&PlatformCapability::Nfc)
352        && !existing.contains("android.permission.NFC")
353    {
354        capabilities.push_str(&render_android_nfc_manifest_entries());
355    }
356    if project
357        .capabilities
358        .contains(&PlatformCapability::Biometric)
359        && !existing.contains("android.permission.USE_BIOMETRIC")
360    {
361        capabilities.push_str(&render_android_biometric_manifest_entries());
362    }
363    if project
364        .capabilities
365        .contains(&PlatformCapability::Bluetooth)
366    {
367        capabilities.push_str(&render_missing_android_bluetooth_manifest_entries(
368            &existing,
369        ));
370    }
371    if project
372        .capabilities
373        .contains(&PlatformCapability::BarcodeScanner)
374        && !project.capabilities.contains(&PlatformCapability::Camera)
375        && !existing.contains("android.permission.CAMERA")
376    {
377        capabilities.push_str(&render_android_barcode_camera_manifest_entries());
378    }
379    if project.capabilities.contains(&PlatformCapability::Camera) {
380        capabilities.push_str(&render_missing_android_camera_manifest_entries(&existing));
381    }
382    if project
383        .capabilities
384        .contains(&PlatformCapability::Geolocation)
385        && !existing.contains("android.permission.ACCESS_FINE_LOCATION")
386    {
387        capabilities.push_str(&render_android_geolocation_manifest_entries());
388    }
389    if project.capabilities.contains(&PlatformCapability::Haptics)
390        && !existing.contains("android.permission.VIBRATE")
391    {
392        capabilities.push_str(&render_android_haptics_manifest_entries());
393    }
394    if project
395        .capabilities
396        .contains(&PlatformCapability::Microphone)
397        && !existing.contains("android.permission.RECORD_AUDIO")
398    {
399        capabilities.push_str(&render_android_microphone_manifest_entries());
400    }
401    if project.capabilities.contains(&PlatformCapability::Wifi) {
402        capabilities.push_str(&render_missing_android_wifi_manifest_entries(&existing));
403    }
404    if project
405        .capabilities
406        .contains(&PlatformCapability::VolumeControl)
407        && !existing.contains("android.permission.MODIFY_AUDIO_SETTINGS")
408    {
409        capabilities.push_str(&render_android_volume_manifest_entries());
410    }
411    if capabilities.is_empty() {
412        return Ok(());
413    }
414    let marker = r#"    <uses-permission android:name="android.permission.INTERNET" />"#;
415    let updated = if existing.contains(marker) {
416        existing.replacen(marker, &format!("{marker}\n{capabilities}"), 1)
417    } else {
418        existing.replacen("<uses-sdk", &format!("{capabilities}\n    <uses-sdk"), 1)
419    };
420    fs::write(&path, updated).with_context(|| format!("failed to write {}", path.display()))
421}
422
423fn apply_ios_capability_config(root: &Path, project: &FissionProject) -> Result<()> {
424    let info_path = root.join("platforms/ios/Info.plist");
425    if info_path.exists() {
426        let existing = fs::read_to_string(&info_path)
427            .with_context(|| format!("failed to read {}", info_path.display()))?;
428        if project.capabilities.contains(&PlatformCapability::Nfc)
429            && !existing.contains("NFCReaderUsageDescription")
430        {
431            let entry = "  <key>NFCReaderUsageDescription</key>\n  <string>This app uses NFC to scan nearby tags when you request it.</string>\n";
432            let updated = existing.replacen("</dict>", &format!("{entry}</dict>"), 1);
433            fs::write(&info_path, updated)
434                .with_context(|| format!("failed to write {}", info_path.display()))?;
435        }
436    }
437
438    if project.capabilities.contains(&PlatformCapability::Nfc) {
439        let entitlements_path = root.join("platforms/ios/Entitlements.plist");
440        if entitlements_path.exists() {
441            let existing = fs::read_to_string(&entitlements_path)
442                .with_context(|| format!("failed to read {}", entitlements_path.display()))?;
443            if !existing.contains("com.apple.developer.nfc.readersession.formats") {
444                let entry = "  <key>com.apple.developer.nfc.readersession.formats</key>\n  <array>\n    <string>NDEF</string>\n  </array>\n";
445                let updated = existing.replacen("</dict>", &format!("{entry}</dict>"), 1);
446                fs::write(&entitlements_path, updated)
447                    .with_context(|| format!("failed to write {}", entitlements_path.display()))?;
448            }
449        } else {
450            write_file_with_policy(
451                &entitlements_path,
452                IOS_NFC_ENTITLEMENTS_PLIST,
453                WritePolicy::PreserveExisting,
454            )?;
455        }
456    }
457    if project
458        .capabilities
459        .contains(&PlatformCapability::Biometric)
460        && info_path.exists()
461    {
462        let existing = fs::read_to_string(&info_path)
463            .with_context(|| format!("failed to read {}", info_path.display()))?;
464        if !existing.contains("NSFaceIDUsageDescription") {
465            let entry = "  <key>NSFaceIDUsageDescription</key>\n  <string>This app uses biometrics to authenticate you when you request it.</string>\n";
466            let updated = existing.replacen("</dict>", &format!("{entry}</dict>"), 1);
467            fs::write(&info_path, updated)
468                .with_context(|| format!("failed to write {}", info_path.display()))?;
469        }
470    }
471    if project
472        .capabilities
473        .contains(&PlatformCapability::Bluetooth)
474        && info_path.exists()
475    {
476        let existing = fs::read_to_string(&info_path)
477            .with_context(|| format!("failed to read {}", info_path.display()))?;
478        if !existing.contains("NSBluetoothAlwaysUsageDescription") {
479            let entry = "  <key>NSBluetoothAlwaysUsageDescription</key>\n  <string>This app uses Bluetooth when you request nearby-device features.</string>\n";
480            let updated = existing.replacen("</dict>", &format!("{entry}</dict>"), 1);
481            fs::write(&info_path, updated)
482                .with_context(|| format!("failed to write {}", info_path.display()))?;
483        }
484    }
485    if project
486        .capabilities
487        .contains(&PlatformCapability::BarcodeScanner)
488        && info_path.exists()
489    {
490        let existing = fs::read_to_string(&info_path)
491            .with_context(|| format!("failed to read {}", info_path.display()))?;
492        if !existing.contains("NSCameraUsageDescription") {
493            let entry = "  <key>NSCameraUsageDescription</key>\n  <string>This app uses the camera to scan barcodes when you request it.</string>\n";
494            let updated = existing.replacen("</dict>", &format!("{entry}</dict>"), 1);
495            fs::write(&info_path, updated)
496                .with_context(|| format!("failed to write {}", info_path.display()))?;
497        }
498    }
499    if project.capabilities.contains(&PlatformCapability::Camera) && info_path.exists() {
500        let existing = fs::read_to_string(&info_path)
501            .with_context(|| format!("failed to read {}", info_path.display()))?;
502        if !existing.contains("NSCameraUsageDescription") {
503            let entry = "  <key>NSCameraUsageDescription</key>\n  <string>This app uses the camera when you request camera features.</string>\n";
504            let updated = existing.replacen("</dict>", &format!("{entry}</dict>"), 1);
505            fs::write(&info_path, updated)
506                .with_context(|| format!("failed to write {}", info_path.display()))?;
507        }
508    }
509    if project
510        .capabilities
511        .contains(&PlatformCapability::Geolocation)
512        && info_path.exists()
513    {
514        let existing = fs::read_to_string(&info_path)
515            .with_context(|| format!("failed to read {}", info_path.display()))?;
516        if !existing.contains("NSLocationWhenInUseUsageDescription") {
517            let entry = "  <key>NSLocationWhenInUseUsageDescription</key>\n  <string>This app uses your location when you request location-aware features.</string>\n";
518            let updated = existing.replacen("</dict>", &format!("{entry}</dict>"), 1);
519            fs::write(&info_path, updated)
520                .with_context(|| format!("failed to write {}", info_path.display()))?;
521        }
522    }
523    if project
524        .capabilities
525        .contains(&PlatformCapability::Microphone)
526        && info_path.exists()
527    {
528        let existing = fs::read_to_string(&info_path)
529            .with_context(|| format!("failed to read {}", info_path.display()))?;
530        if !existing.contains("NSMicrophoneUsageDescription") {
531            let entry = "  <key>NSMicrophoneUsageDescription</key>\n  <string>This app uses the microphone when you request audio capture.</string>\n";
532            let updated = existing.replacen("</dict>", &format!("{entry}</dict>"), 1);
533            fs::write(&info_path, updated)
534                .with_context(|| format!("failed to write {}", info_path.display()))?;
535        }
536    }
537    if project.capabilities.contains(&PlatformCapability::Wifi) && info_path.exists() {
538        let existing = fs::read_to_string(&info_path)
539            .with_context(|| format!("failed to read {}", info_path.display()))?;
540        if !existing.contains("NSLocationWhenInUseUsageDescription") {
541            let entry = "  <key>NSLocationWhenInUseUsageDescription</key>\n  <string>This app uses location permission where the platform requires it for Wi-Fi information.</string>\n";
542            let updated = existing.replacen("</dict>", &format!("{entry}</dict>"), 1);
543            fs::write(&info_path, updated)
544                .with_context(|| format!("failed to write {}", info_path.display()))?;
545        }
546    }
547    if project.capabilities.contains(&PlatformCapability::Wifi) {
548        let entitlements_path = root.join("platforms/ios/Entitlements.plist");
549        apply_ios_wifi_entitlements(&entitlements_path)?;
550    }
551    Ok(())
552}
553
554fn apply_ios_wifi_entitlements(path: &Path) -> Result<()> {
555    if path.exists() {
556        let existing = fs::read_to_string(path)
557            .with_context(|| format!("failed to read {}", path.display()))?;
558        let mut entry = String::new();
559        if !existing.contains("com.apple.developer.networking.wifi-info") {
560            entry.push_str("  <key>com.apple.developer.networking.wifi-info</key>\n  <true/>\n");
561        }
562        if !existing.contains("com.apple.developer.networking.HotspotConfiguration") {
563            entry.push_str(
564                "  <key>com.apple.developer.networking.HotspotConfiguration</key>\n  <true/>\n",
565            );
566        }
567        if entry.is_empty() {
568            return Ok(());
569        }
570        let updated = existing.replacen("</dict>", &format!("{entry}</dict>"), 1);
571        fs::write(path, updated).with_context(|| format!("failed to write {}", path.display()))?;
572        return Ok(());
573    }
574    write_file_with_policy(
575        path,
576        IOS_WIFI_ENTITLEMENTS_PLIST,
577        WritePolicy::PreserveExisting,
578    )
579}
580
581fn target_scaffold_dir_exists(project_dir: &Path, target: Target) -> bool {
582    Path::new(target.scaffold_relative_path())
583        .parent()
584        .is_some_and(|relative| project_dir.join(relative).exists())
585}
586
587fn write_project_config(root: &Path, project: &FissionProject) -> Result<()> {
588    let data = toml::to_string_pretty(project)?;
589    write_file(&root.join("fission.toml"), &(data + "\n"))
590}
591
592pub fn read_project_config(root: &Path) -> Result<FissionProject> {
593    let path = root.join("fission.toml");
594    let data = fs::read_to_string(&path).with_context(|| {
595        format!(
596            "failed to read {}; run `fission init {}` to register this project without overwriting existing files",
597            path.display(),
598            root.display()
599        )
600    })?;
601    toml::from_str(&data).with_context(|| format!("failed to parse {}", path.display()))
602}
603
604fn update_cargo_fission_features(root: &Path, project: &FissionProject) -> Result<()> {
605    let path = root.join("Cargo.toml");
606    let Ok(text) = fs::read_to_string(&path) else {
607        return Ok(());
608    };
609    let feature_list = render_fission_feature_list(&project.targets);
610    let mut changed = false;
611    let mut out = Vec::new();
612    for line in text.lines() {
613        if let Some(updated) = update_inline_fission_dependency(line, &feature_list) {
614            changed |= updated != line;
615            out.push(updated);
616        } else {
617            out.push(line.to_string());
618        }
619    }
620    if changed {
621        fs::write(&path, out.join("\n") + "\n")
622            .with_context(|| format!("failed to update {}", path.display()))?;
623    }
624    Ok(())
625}
626
627fn update_inline_fission_dependency(line: &str, feature_list: &str) -> Option<String> {
628    let trimmed = line.trim_start();
629    if !trimmed.starts_with("fission =") {
630        return None;
631    }
632    let indent = &line[..line.len() - trimmed.len()];
633    let value = trimmed.strip_prefix("fission =")?.trim();
634    if value.starts_with('"') {
635        return Some(format!(
636            "{indent}fission = {{ version = {value}, default-features = false, features = [{feature_list}] }}"
637        ));
638    }
639    if !(value.starts_with('{') && value.ends_with('}')) {
640        return None;
641    }
642    let inner = value
643        .strip_prefix('{')
644        .and_then(|value| value.strip_suffix('}'))?
645        .trim();
646    let mut fields = split_top_level_fields(inner)
647        .into_iter()
648        .filter(|field| {
649            let key = field
650                .split_once('=')
651                .map(|(key, _)| key.trim())
652                .unwrap_or_default();
653            key != "default-features" && key != "features"
654        })
655        .collect::<Vec<_>>();
656    fields.push("default-features = false".to_string());
657    fields.push(format!("features = [{feature_list}]"));
658    Some(format!("{indent}fission = {{ {} }}", fields.join(", ")))
659}
660
661fn split_top_level_fields(input: &str) -> Vec<String> {
662    let mut fields = Vec::new();
663    let mut start = 0;
664    let mut bracket_depth = 0usize;
665    let mut in_string = false;
666    let mut escaped = false;
667    for (index, ch) in input.char_indices() {
668        if in_string {
669            if escaped {
670                escaped = false;
671            } else if ch == '\\' {
672                escaped = true;
673            } else if ch == '"' {
674                in_string = false;
675            }
676            continue;
677        }
678        match ch {
679            '"' => in_string = true,
680            '[' => bracket_depth += 1,
681            ']' => bracket_depth = bracket_depth.saturating_sub(1),
682            ',' if bracket_depth == 0 => {
683                let field = input[start..index].trim();
684                if !field.is_empty() {
685                    fields.push(field.to_string());
686                }
687                start = index + ch.len_utf8();
688            }
689            _ => {}
690        }
691    }
692    let field = input[start..].trim();
693    if !field.is_empty() {
694        fields.push(field.to_string());
695    }
696    fields
697}
698
699fn scaffold_target_with_policy(
700    root: &Path,
701    project: &FissionProject,
702    target: Target,
703    write_policy: WritePolicy,
704) -> Result<()> {
705    let relative = Path::new(target.scaffold_relative_path());
706    let text = match target {
707        Target::Android => {
708            scaffold_android_bundle(root, project, write_policy)?;
709            platform_readme(
710                "Android",
711                "Runnable emulator target. The CLI generates a NativeActivity manifest plus shell scripts that build, install, and launch the Fission app on an Android emulator.",
712                &[
713                    "Install the Rust target: `rustup target add aarch64-linux-android`.",
714                    "Run `fission doctor android --project-dir .` to check SDK, NDK, emulator, and Rust target setup.",
715                    "Run `fission devices --project-dir .` to list connected Android devices and configured emulators.",
716                    "Run `fission run --target android --project-dir .` to build, install, launch, and attach to logs.",
717                    "Run `fission run --target android --device <adb-serial> --project-dir .` to launch on a specific device.",
718                    "Run `fission test --target android --project-dir .` for an emulator launch plus test-control health check.",
719                    "Run `./platforms/android/run-emulator.sh` from the project root to build, package, install, and launch the app on the configured emulator.",
720                    "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.",
721                    "Set `ANDROID_EMULATOR_HEADLESS=1` for background/CI runs, or `ANDROID_EMULATOR_RESTART=1` to relaunch a hidden emulator visibly.",
722                    "The generated package uses `assets/app-icon.png` as its default launcher icon.",
723                    "Run `fission add-capability nfc --project-dir .` to add NFC manifest permission and feature declarations.",
724                    "Run `fission add-capability biometric --project-dir .` to add biometric manifest permissions.",
725                    "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.",
726                    "Run `fission add-capability bluetooth --project-dir .` to add Bluetooth permissions and optional hardware feature declarations.",
727                    "Run `fission add-capability barcode-scanner --project-dir .` to add camera permission for barcode scanning.",
728                    "Run `fission add-capability camera --project-dir .` to add camera permission and optional camera/flash hardware feature declarations.",
729                    "Run `fission add-capability geolocation --project-dir .` to add location permissions.",
730                    "Run `fission add-capability haptics --project-dir .` to add the vibration permission.",
731                    "Run `fission add-capability microphone --project-dir .` to add audio recording permission.",
732                    "Run `fission add-capability volume-control --project-dir .` to add Android audio settings permission.",
733                    "Run `fission add-capability wifi --project-dir .` to add Wi-Fi permissions and optional hardware feature declarations.",
734                    "Set `FISSION_TEST_CONTROL_PORT=<host-port>` before `run-emulator.sh`; the script forwards it to the fixed in-app device port.",
735                ],
736            )
737        }
738        Target::Ios => {
739            scaffold_ios_bundle(root, project, write_policy)?;
740            platform_readme(
741                "iOS",
742                "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`.",
743                &[
744                    "Install the Rust targets: `rustup target add aarch64-apple-ios aarch64-apple-ios-sim`.",
745                    "Run `fission doctor ios --project-dir .` to check Xcode, simulator, and Rust target setup.",
746                    "Confirm the simulator SDK path with `xcrun --sdk iphonesimulator --show-sdk-path`.",
747                    "Run `fission devices --project-dir .` to list available iOS simulators.",
748                    "Run `fission run --target ios --project-dir .` to build, install, launch, and attach to simulator logs.",
749                    "Run `fission run --target ios --device <simulator-udid> --project-dir .` to launch on a specific simulator.",
750                    "Run `fission test --target ios --project-dir .` for a simulator launch plus test-control health check.",
751                    "Run `./platforms/ios/run-sim.sh` from the project root to build, install, and launch the app on the first available iPhone simulator.",
752                    "The generated bundle uses `assets/app-icon.png` as its default app icon.",
753                    "Run `fission add-capability nfc --project-dir .` to add the NFC usage description and entitlements file.",
754                    "Run `fission add-capability biometric --project-dir .` to add the Face ID usage description.",
755                    "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.",
756                    "Run `fission add-capability bluetooth --project-dir .` to add the Bluetooth usage description.",
757                    "Run `fission add-capability barcode-scanner --project-dir .` to add the camera usage description for barcode scanning.",
758                    "Run `fission add-capability camera --project-dir .` to add the camera usage description.",
759                    "Run `fission add-capability geolocation --project-dir .` to add the location usage description.",
760                    "Run `fission add-capability microphone --project-dir .` to add the microphone usage description.",
761                    "Run `fission add-capability wifi --project-dir .` to add Wi-Fi entitlements and the location usage description required by current-network information APIs.",
762                    "Volume control does not require an iOS Info.plist key in the generated scaffold.",
763                    "Haptics do not require an iOS Info.plist key in the generated scaffold.",
764                    "Set `FISSION_TEST_CONTROL_PORT=<port>` before `run-sim.sh` to expose the in-app test control server on the host.",
765                    "Set `IOS_SIM_DEVICE_ID=<udid>` if you want a specific simulator device.",
766                    "Set `IOS_SIM_HEADLESS=1` for CI or background-only simulator runs; otherwise the script opens Simulator visibly.",
767                ],
768            )
769        }
770        Target::Web => {
771            scaffold_web_bundle(root, project, write_policy)?;
772            platform_readme(
773                "Web",
774                "Runnable browser target. The CLI generates a WASM host page plus helper scripts that build the app with `wasm-pack` and serve it locally.",
775                &[
776                    "Install the Rust target: `rustup target add wasm32-unknown-unknown`.",
777                    "Install `wasm-pack` once: `cargo install wasm-pack`.",
778                    "Install Node.js 22+ so the smoke test can inspect Chrome/Chromium CDP runtime and console output.",
779                    "Run `fission doctor web --project-dir .` to check wasm-pack, Node.js, Chrome/Chromium, and Rust target setup.",
780                    "Run `fission devices --project-dir .` to confirm Chrome/Chromium detection.",
781                    "Run `fission run --target web --project-dir .` to build, serve, open, and attach to the local server.",
782                    "Run `fission run --target web --detach --project-dir .` to keep the local server running in the background.",
783                    "Run `fission test --target web --project-dir .` for a headless Chrome/Chromium CDP smoke test.",
784                    "Run `./platforms/web/run-browser.sh` from the project root to build the wasm package and serve the app locally.",
785                    "Set `FISSION_WEB_PORT=<port>` or `FISSION_WEB_HOST=<host>` if the default `127.0.0.1:8123` does not suit your machine.",
786                    "Set `FISSION_WEB_OPEN=1` if you want the helper script to open a browser tab automatically.",
787                    "The generated page uses `assets/app-icon.png` as its default favicon/app icon seed.",
788                ],
789            )
790        }
791        Target::Site => {
792            write_file_with_policy(
793                &root.join("content/getting-started.md"),
794                "---\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",
795                write_policy,
796            )?;
797            platform_readme(
798                "Static site",
799                "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.",
800                &[
801                    "Add Markdown or MDX content under `content/`.",
802                    "Run `fission site routes --project-dir .` to list generated routes.",
803                    "Run `fission site build --project-dir .` to render HTML into `target/fission/site`.",
804                    "Run `fission site serve --project-dir .` to build and serve the generated site locally.",
805                    "Unsupported interactive widgets fail during the static render instead of silently falling back to JavaScript.",
806                ],
807            )
808        }
809        Target::Linux | Target::Macos | Target::Windows => platform_readme(
810            match target {
811                Target::Linux => "Linux",
812                Target::Macos => "macOS",
813                Target::Windows => "Windows",
814                _ => unreachable!(),
815            },
816            "Runnable target. Desktop platforms share the default `src/main.rs` entrypoint through `DesktopApp`.",
817            &[
818                "Run `fission run --project-dir .` from the project root to launch the desktop app and attach output.",
819                "Run `fission build --project-dir . --release` for a release desktop build.",
820                "Run `fission test --project-dir .` for the app crate's Rust tests.",
821                "This target uses the default Vello desktop shell path.",
822            ],
823        ),
824    };
825    write_file_with_policy(&root.join(relative), &text, write_policy)
826}
827
828fn scaffold_ios_bundle(
829    root: &Path,
830    project: &FissionProject,
831    write_policy: WritePolicy,
832) -> Result<()> {
833    let executable = ios_executable_name(project);
834    let bundle_name = ios_bundle_name(project);
835    let plist = render_ios_plist(project, &executable);
836    let package_script = render_ios_package_script(project, &bundle_name, &executable);
837    let run_script = render_ios_run_script(project);
838    let test_script = render_ios_test_script();
839
840    write_file_with_policy(&root.join("platforms/ios/Info.plist"), &plist, write_policy)?;
841    if project.capabilities.contains(&PlatformCapability::Nfc)
842        || project.capabilities.contains(&PlatformCapability::Wifi)
843    {
844        write_file_with_policy(
845            &root.join("platforms/ios/Entitlements.plist"),
846            &render_ios_entitlements_plist(project),
847            write_policy,
848        )?;
849    }
850    write_file_with_policy(
851        &root.join("platforms/ios/package-sim.sh"),
852        &package_script,
853        write_policy,
854    )?;
855    write_file_with_policy(
856        &root.join("platforms/ios/run-sim.sh"),
857        &run_script,
858        write_policy,
859    )?;
860    write_file_with_policy(
861        &root.join("platforms/ios/test-sim.sh"),
862        &test_script,
863        write_policy,
864    )?;
865    #[cfg(unix)]
866    {
867        use std::os::unix::fs::PermissionsExt;
868        for relative in [
869            "platforms/ios/package-sim.sh",
870            "platforms/ios/run-sim.sh",
871            "platforms/ios/test-sim.sh",
872        ] {
873            let path = root.join(relative);
874            if path.exists() {
875                fs::set_permissions(path, fs::Permissions::from_mode(0o755))?;
876            }
877        }
878    }
879    Ok(())
880}
881
882fn scaffold_android_bundle(
883    root: &Path,
884    project: &FissionProject,
885    write_policy: WritePolicy,
886) -> Result<()> {
887    let manifest = render_android_manifest(project);
888    let package_script = render_android_package_script(project);
889    let run_script = render_android_run_script(project);
890    let test_script = render_android_test_script();
891
892    write_file_with_policy(
893        &root.join("platforms/android/AndroidManifest.xml"),
894        &manifest,
895        write_policy,
896    )?;
897    write_file_with_policy(
898        &root.join("platforms/android/package-apk.sh"),
899        &package_script,
900        write_policy,
901    )?;
902    write_file_with_policy(
903        &root.join("platforms/android/run-emulator.sh"),
904        &run_script,
905        write_policy,
906    )?;
907    write_file_with_policy(
908        &root.join("platforms/android/test-emulator.sh"),
909        &test_script,
910        write_policy,
911    )?;
912    #[cfg(unix)]
913    {
914        use std::os::unix::fs::PermissionsExt;
915        for relative in [
916            "platforms/android/package-apk.sh",
917            "platforms/android/run-emulator.sh",
918            "platforms/android/test-emulator.sh",
919        ] {
920            let path = root.join(relative);
921            if path.exists() {
922                fs::set_permissions(path, fs::Permissions::from_mode(0o755))?;
923            }
924        }
925    }
926    Ok(())
927}
928
929fn scaffold_web_bundle(
930    root: &Path,
931    project: &FissionProject,
932    write_policy: WritePolicy,
933) -> Result<()> {
934    let index_html = render_web_index(project);
935    let bootstrap = render_web_bootstrap(project);
936    let build_script = render_web_build_script();
937    let run_script = render_web_run_script(project);
938    let test_script = render_web_test_script(project);
939
940    write_file_with_policy(
941        &root.join("platforms/web/index.html"),
942        &index_html,
943        write_policy,
944    )?;
945    write_file_with_policy(
946        &root.join("platforms/web/bootstrap.mjs"),
947        &bootstrap,
948        write_policy,
949    )?;
950    write_file_with_policy(
951        &root.join("platforms/web/build-wasm.sh"),
952        &build_script,
953        write_policy,
954    )?;
955    write_file_with_policy(
956        &root.join("platforms/web/run-browser.sh"),
957        &run_script,
958        write_policy,
959    )?;
960    write_file_with_policy(
961        &root.join("platforms/web/test-browser.sh"),
962        &test_script,
963        write_policy,
964    )?;
965
966    #[cfg(unix)]
967    {
968        use std::os::unix::fs::PermissionsExt;
969        for relative in [
970            "platforms/web/build-wasm.sh",
971            "platforms/web/run-browser.sh",
972            "platforms/web/test-browser.sh",
973        ] {
974            let path = root.join(relative);
975            if path.exists() {
976                let mut perms = fs::metadata(&path)?.permissions();
977                perms.set_mode(0o755);
978                fs::set_permissions(path, perms)?;
979            }
980        }
981    }
982
983    Ok(())
984}
985
986fn write_file(path: &Path, contents: &str) -> Result<()> {
987    write_file_with_policy(path, contents, WritePolicy::Overwrite)
988}
989
990fn write_file_with_policy(path: &Path, contents: &str, write_policy: WritePolicy) -> Result<()> {
991    if write_policy == WritePolicy::PreserveExisting && path.exists() {
992        return Ok(());
993    }
994    if let Some(parent) = path.parent() {
995        fs::create_dir_all(parent)?;
996    }
997    fs::write(path, contents).with_context(|| format!("failed to write {}", path.display()))
998}
999
1000fn write_binary_file_with_policy(
1001    path: &Path,
1002    contents: &[u8],
1003    write_policy: WritePolicy,
1004) -> Result<()> {
1005    if write_policy == WritePolicy::PreserveExisting && path.exists() {
1006        return Ok(());
1007    }
1008    if let Some(parent) = path.parent() {
1009        fs::create_dir_all(parent)?;
1010    }
1011    fs::write(path, contents).with_context(|| format!("failed to write {}", path.display()))
1012}
1013
1014fn render_cargo_toml(project: &FissionProject, local_path: Option<&Path>) -> String {
1015    let feature_list = render_fission_feature_list(&project.targets);
1016    let deps = if let Some(root) = local_path {
1017        let fission_path = root.join("crates/authoring/fission");
1018        format!(
1019            "fission = {{ path = {:?}, default-features = false, features = [{}] }}\n",
1020            fission_path.to_string_lossy().to_string(),
1021            feature_list
1022        )
1023    } else {
1024        format!(
1025            "fission = {{ version = \"{}\", default-features = false, features = [{}] }}\n",
1026            CURRENT_VERSION, feature_list
1027        )
1028    };
1029    let lib_name = project.app.name.replace('-', "_");
1030
1031    format!(
1032        "[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",
1033        project.app.name, lib_name, deps
1034    )
1035}
1036
1037fn render_fission_feature_list(targets: &BTreeSet<Target>) -> String {
1038    fission_features_for_targets(targets)
1039        .into_iter()
1040        .map(|feature| format!("\"{feature}\""))
1041        .collect::<Vec<_>>()
1042        .join(", ")
1043}
1044
1045fn fission_features_for_targets(targets: &BTreeSet<Target>) -> Vec<&'static str> {
1046    let mut features = Vec::new();
1047    if targets
1048        .iter()
1049        .any(|target| matches!(target, Target::Linux | Target::Macos | Target::Windows))
1050    {
1051        features.push("desktop");
1052    }
1053    if targets.contains(&Target::Web) {
1054        features.push("web");
1055    }
1056    if targets.contains(&Target::Android) {
1057        features.push("android");
1058    }
1059    if targets.contains(&Target::Ios) {
1060        features.push("ios");
1061    }
1062    if targets.contains(&Target::Site) {
1063        features.push("site");
1064    }
1065    features
1066}
1067
1068fn render_project_readme(project: &FissionProject) -> String {
1069    let mut targets = String::new();
1070    for target in &project.targets {
1071        targets.push_str(&format!("- `{}`\n", target.as_str()));
1072    }
1073    format!(
1074        "# {}\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 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",
1075        project.app.name, targets
1076    )
1077}
1078
1079fn platform_readme(title: &str, summary: &str, bullets: &[&str]) -> String {
1080    let mut out = format!("# {} target\n\n{}\n", title, summary);
1081    for bullet in bullets {
1082        out.push_str(&format!("\n- {}", bullet));
1083    }
1084    out.push('\n');
1085    out
1086}
1087
1088fn normalize_crate_name(name: &str) -> String {
1089    name.chars()
1090        .map(|ch| match ch {
1091            'A'..='Z' => ch.to_ascii_lowercase(),
1092            'a'..='z' | '0'..='9' => ch,
1093            _ => '-',
1094        })
1095        .collect::<String>()
1096        .trim_matches('-')
1097        .to_string()
1098}
1099
1100pub fn ios_executable_name(project: &FissionProject) -> String {
1101    project.app.name.replace('-', "_")
1102}
1103
1104fn ios_bundle_name(project: &FissionProject) -> String {
1105    let mut out = String::new();
1106    let mut uppercase_next = true;
1107    for ch in project.app.name.chars() {
1108        match ch {
1109            '-' | '_' | ' ' => uppercase_next = true,
1110            _ if uppercase_next => {
1111                out.extend(ch.to_uppercase());
1112                uppercase_next = false;
1113            }
1114            _ => out.push(ch),
1115        }
1116    }
1117    if out.is_empty() {
1118        "FissionApp".to_string()
1119    } else {
1120        out
1121    }
1122}
1123
1124fn android_library_name(project: &FissionProject) -> String {
1125    project.app.name.replace('-', "_")
1126}
1127
1128fn render_ios_plist(project: &FissionProject, executable: &str) -> String {
1129    let capability_entries = render_ios_info_plist_capability_entries(project);
1130    format!(
1131        r#"<?xml version="1.0" encoding="UTF-8"?>
1132<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
1133<plist version="1.0">
1134<dict>
1135  <key>CFBundleDevelopmentRegion</key>
1136  <string>en</string>
1137  <key>CFBundleDisplayName</key>
1138  <string>{display_name}</string>
1139  <key>CFBundleExecutable</key>
1140  <string>{executable}</string>
1141  <key>CFBundleIdentifier</key>
1142  <string>{bundle_id}</string>
1143  <key>CFBundleInfoDictionaryVersion</key>
1144  <string>6.0</string>
1145  <key>CFBundleName</key>
1146  <string>{display_name}</string>
1147  <key>CFBundlePackageType</key>
1148  <string>APPL</string>
1149  <key>CFBundleShortVersionString</key>
1150  <string>0.1.0</string>
1151  <key>CFBundleVersion</key>
1152  <string>1</string>
1153  <key>CFBundleIconFile</key>
1154  <string>AppIcon</string>
1155  <key>LSRequiresIPhoneOS</key>
1156  <true/>
1157  <key>MinimumOSVersion</key>
1158  <string>18.0</string>
1159{capability_entries}
1160  <key>UIDeviceFamily</key>
1161  <array>
1162    <integer>1</integer>
1163    <integer>2</integer>
1164  </array>
1165</dict>
1166</plist>
1167"#,
1168        display_name = ios_bundle_name(project),
1169        executable = executable,
1170        bundle_id = project.app.app_id,
1171        capability_entries = capability_entries,
1172    )
1173}
1174
1175fn render_ios_info_plist_capability_entries(project: &FissionProject) -> String {
1176    let mut out = String::new();
1177    if project.capabilities.contains(&PlatformCapability::Nfc) {
1178        out.push_str("  <key>NFCReaderUsageDescription</key>\n  <string>This app uses NFC to scan nearby tags when you request it.</string>\n");
1179    }
1180    if project
1181        .capabilities
1182        .contains(&PlatformCapability::Biometric)
1183    {
1184        out.push_str("  <key>NSFaceIDUsageDescription</key>\n  <string>This app uses biometrics to authenticate you when you request it.</string>\n");
1185    }
1186    if project
1187        .capabilities
1188        .contains(&PlatformCapability::Bluetooth)
1189    {
1190        out.push_str("  <key>NSBluetoothAlwaysUsageDescription</key>\n  <string>This app uses Bluetooth when you request nearby-device features.</string>\n");
1191    }
1192    if project
1193        .capabilities
1194        .contains(&PlatformCapability::BarcodeScanner)
1195    {
1196        out.push_str("  <key>NSCameraUsageDescription</key>\n  <string>This app uses the camera to scan barcodes when you request it.</string>\n");
1197    }
1198    if project.capabilities.contains(&PlatformCapability::Camera)
1199        && !project
1200            .capabilities
1201            .contains(&PlatformCapability::BarcodeScanner)
1202    {
1203        out.push_str("  <key>NSCameraUsageDescription</key>\n  <string>This app uses the camera when you request camera features.</string>\n");
1204    }
1205    if project
1206        .capabilities
1207        .contains(&PlatformCapability::Geolocation)
1208    {
1209        out.push_str("  <key>NSLocationWhenInUseUsageDescription</key>\n  <string>This app uses your location when you request location-aware features.</string>\n");
1210    }
1211    if project
1212        .capabilities
1213        .contains(&PlatformCapability::Microphone)
1214    {
1215        out.push_str("  <key>NSMicrophoneUsageDescription</key>\n  <string>This app uses the microphone when you request audio capture.</string>\n");
1216    }
1217    if project.capabilities.contains(&PlatformCapability::Wifi)
1218        && !project
1219            .capabilities
1220            .contains(&PlatformCapability::Geolocation)
1221    {
1222        out.push_str("  <key>NSLocationWhenInUseUsageDescription</key>\n  <string>This app uses location permission where the platform requires it for Wi-Fi information.</string>\n");
1223    }
1224    out
1225}
1226
1227fn render_ios_package_script(
1228    project: &FissionProject,
1229    bundle_name: &str,
1230    executable: &str,
1231) -> String {
1232    format!(
1233        r#"#!/usr/bin/env bash
1234set -euo pipefail
1235
1236SCRIPT_DIR=$(cd -- "$(dirname "${{BASH_SOURCE[0]}}")" && pwd)
1237PROJECT_DIR=$(cd -- "$SCRIPT_DIR/../.." && pwd)
1238TARGET="${{IOS_SIM_TARGET:-aarch64-apple-ios-sim}}"
1239PROFILE="${{IOS_SIM_PROFILE:-debug}}"
1240PACKAGE_NAME="{package_name}"
1241BUNDLE_ID="${{IOS_BUNDLE_ID:-{bundle_id}}}"
1242DISPLAY_NAME="${{IOS_DISPLAY_NAME:-{bundle_name}}}"
1243EXECUTABLE_NAME="${{IOS_EXECUTABLE_NAME:-{executable}}}"
1244BUNDLE_NAME="${{IOS_BUNDLE_NAME:-$DISPLAY_NAME.app}}"
1245BUILD_DIR="$SCRIPT_DIR/build/$PROFILE"
1246BUNDLE_DIR="$BUILD_DIR/$BUNDLE_NAME"
1247
1248BUILD_ARGS=(build --manifest-path "$PROJECT_DIR/Cargo.toml" --target "$TARGET" --package "$PACKAGE_NAME")
1249ARTIFACT_DIR=debug
1250if [[ "$PROFILE" == "release" ]]; then
1251  BUILD_ARGS+=(--release)
1252  ARTIFACT_DIR=release
1253fi
1254
1255cargo "${{BUILD_ARGS[@]}}"
1256TARGET_DIR=$(python3 - <<'PY' "$PROJECT_DIR/Cargo.toml"
1257import json
1258import subprocess
1259import sys
1260
1261manifest = sys.argv[1]
1262metadata = json.loads(
1263    subprocess.check_output(
1264        ["cargo", "metadata", "--manifest-path", manifest, "--format-version", "1", "--no-deps"]
1265    )
1266)
1267print(metadata["target_directory"])
1268PY
1269)
1270
1271rm -rf "$BUNDLE_DIR"
1272mkdir -p "$BUNDLE_DIR"
1273cp "$TARGET_DIR/$TARGET/$ARTIFACT_DIR/$PACKAGE_NAME" "$BUNDLE_DIR/$EXECUTABLE_NAME"
1274chmod +x "$BUNDLE_DIR/$EXECUTABLE_NAME"
1275python3 - <<'PY' "$SCRIPT_DIR/Info.plist" "$BUNDLE_DIR/Info.plist" "$BUNDLE_ID" "$DISPLAY_NAME" "$EXECUTABLE_NAME"
1276import plistlib
1277import sys
1278
1279source, dest, bundle_id, display_name, executable_name = sys.argv[1:]
1280with open(source, "rb") as handle:
1281    plist = plistlib.load(handle)
1282plist["CFBundleIdentifier"] = bundle_id
1283plist["CFBundleDisplayName"] = display_name
1284plist["CFBundleName"] = display_name
1285plist["CFBundleExecutable"] = executable_name
1286with open(dest, "wb") as handle:
1287    plistlib.dump(plist, handle, sort_keys=False)
1288PY
1289cp "$PROJECT_DIR/assets/app-icon.png" "$BUNDLE_DIR/AppIcon.png"
1290printf 'APPL????' > "$BUNDLE_DIR/PkgInfo"
1291printf '%s\n' "$BUNDLE_DIR"
1292"#,
1293        package_name = project.app.name,
1294        bundle_id = project.app.app_id,
1295        bundle_name = bundle_name,
1296        executable = executable,
1297    )
1298}
1299
1300fn render_ios_run_script(project: &FissionProject) -> String {
1301    format!(
1302        r#"#!/usr/bin/env bash
1303set -euo pipefail
1304
1305SCRIPT_DIR=$(cd -- "$(dirname "${{BASH_SOURCE[0]}}")" && pwd)
1306BUNDLE_DIR=$("$SCRIPT_DIR/package-sim.sh")
1307BUNDLE_ID="${{IOS_BUNDLE_ID:-{bundle_id}}}"
1308DEVICE_ID="${{IOS_SIM_DEVICE_ID:-}}"
1309
1310if [[ -z "$DEVICE_ID" ]]; then
1311  DEVICE_ID=$(python3 - <<'PY'
1312import json
1313import subprocess
1314payload = json.loads(subprocess.check_output(["xcrun", "simctl", "list", "devices", "available", "-j"]))
1315for runtime, devices in payload["devices"].items():
1316    if not runtime.startswith("com.apple.CoreSimulator.SimRuntime.iOS-"):
1317        continue
1318    for device in devices:
1319        if device.get("isAvailable") and "iPhone" in device["name"]:
1320            print(device["udid"])
1321            raise SystemExit(0)
1322raise SystemExit("no available iPhone simulator found")
1323PY
1324)
1325fi
1326
1327if [[ "${{IOS_SIM_HEADLESS:-0}}" != "1" ]] && command -v open >/dev/null 2>&1; then
1328  open -a Simulator --args -CurrentDeviceUDID "$DEVICE_ID" >/dev/null 2>&1 \
1329    || open -a Simulator >/dev/null 2>&1 \
1330    || true
1331fi
1332
1333xcrun simctl boot "$DEVICE_ID" >/dev/null 2>&1 || true
1334xcrun simctl bootstatus "$DEVICE_ID" -b
1335xcrun simctl install "$DEVICE_ID" "$BUNDLE_DIR"
1336
1337if [[ -n "${{FISSION_TEST_CONTROL_PORT:-}}" ]]; then
1338  SIMCTL_CHILD_FISSION_TEST_CONTROL_PORT="${{FISSION_TEST_CONTROL_PORT}}" \
1339    xcrun simctl launch --terminate-running-process "$DEVICE_ID" "$BUNDLE_ID"
1340else
1341  xcrun simctl launch --terminate-running-process "$DEVICE_ID" "$BUNDLE_ID"
1342fi
1343"#,
1344        bundle_id = project.app.app_id,
1345    )
1346}
1347
1348fn render_ios_test_script() -> String {
1349    r#"#!/usr/bin/env bash
1350set -euo pipefail
1351
1352SCRIPT_DIR=$(cd -- "$(dirname "${BASH_SOURCE[0]}")" && pwd)
1353export FISSION_TEST_CONTROL_PORT="${FISSION_TEST_CONTROL_PORT:-48711}"
1354
1355"$SCRIPT_DIR/run-sim.sh"
1356
1357python3 - <<'PY' "$FISSION_TEST_CONTROL_PORT"
1358import sys
1359import time
1360import urllib.request
1361
1362port = sys.argv[1]
1363url = f"http://127.0.0.1:{port}/health"
1364deadline = time.time() + 90
1365last_error = None
1366while time.time() < deadline:
1367    try:
1368        with urllib.request.urlopen(url, timeout=1) as response:
1369            body = response.read().decode("utf-8", "replace")
1370        if response.status == 200 and '"status":"ok"' in body:
1371            print(f"iOS simulator test control is healthy on {url}")
1372            raise SystemExit(0)
1373    except Exception as error:
1374        last_error = error
1375    time.sleep(1)
1376raise SystemExit(f"iOS simulator test control did not become healthy on {url}: {last_error}")
1377PY
1378"#
1379    .to_string()
1380}
1381
1382fn render_android_manifest(project: &FissionProject) -> String {
1383    let capability_entries = render_android_capability_manifest_entries(project);
1384    format!(
1385        r#"<?xml version="1.0" encoding="utf-8"?>
1386<manifest xmlns:android="http://schemas.android.com/apk/res/android"
1387    package="{app_id}">
1388
1389    <uses-permission android:name="android.permission.INTERNET" />
1390{capability_entries}
1391
1392    <uses-sdk
1393        android:minSdkVersion="24"
1394        android:targetSdkVersion="35" />
1395
1396    <application
1397        android:debuggable="true"
1398        android:extractNativeLibs="true"
1399        android:hasCode="false"
1400        android:icon="@drawable/app_icon"
1401        android:label="{label}">
1402        <activity
1403            android:name="android.app.NativeActivity"
1404            android:configChanges="orientation|keyboardHidden|screenSize|screenLayout|smallestScreenSize|uiMode|density"
1405            android:exported="true"
1406            android:launchMode="singleTask">
1407            <meta-data
1408                android:name="android.app.lib_name"
1409                android:value="{lib_name}" />
1410            <intent-filter>
1411                <action android:name="android.intent.action.MAIN" />
1412                <category android:name="android.intent.category.LAUNCHER" />
1413            </intent-filter>
1414        </activity>
1415    </application>
1416
1417</manifest>
1418"#,
1419        app_id = project.app.app_id,
1420        label = ios_bundle_name(project),
1421        lib_name = android_library_name(project),
1422        capability_entries = capability_entries,
1423    )
1424}
1425
1426fn render_android_capability_manifest_entries(project: &FissionProject) -> String {
1427    let mut out = String::new();
1428    if project.capabilities.contains(&PlatformCapability::Nfc) {
1429        out.push_str(&render_android_nfc_manifest_entries());
1430    }
1431    if project
1432        .capabilities
1433        .contains(&PlatformCapability::Biometric)
1434    {
1435        out.push_str(&render_android_biometric_manifest_entries());
1436    }
1437    if project
1438        .capabilities
1439        .contains(&PlatformCapability::Bluetooth)
1440    {
1441        out.push_str(&render_android_bluetooth_manifest_entries());
1442    }
1443    if project.capabilities.contains(&PlatformCapability::Camera) {
1444        out.push_str(&render_android_camera_manifest_entries());
1445    } else if project
1446        .capabilities
1447        .contains(&PlatformCapability::BarcodeScanner)
1448    {
1449        out.push_str(&render_android_barcode_camera_manifest_entries());
1450    }
1451    if project
1452        .capabilities
1453        .contains(&PlatformCapability::Geolocation)
1454    {
1455        out.push_str(&render_android_geolocation_manifest_entries());
1456    }
1457    if project.capabilities.contains(&PlatformCapability::Haptics) {
1458        out.push_str(&render_android_haptics_manifest_entries());
1459    }
1460    if project
1461        .capabilities
1462        .contains(&PlatformCapability::Microphone)
1463    {
1464        out.push_str(&render_android_microphone_manifest_entries());
1465    }
1466    if project
1467        .capabilities
1468        .contains(&PlatformCapability::VolumeControl)
1469    {
1470        out.push_str(&render_android_volume_manifest_entries());
1471    }
1472    if project.capabilities.contains(&PlatformCapability::Wifi) {
1473        out.push_str(&render_android_wifi_manifest_entries());
1474    }
1475    out
1476}
1477
1478fn render_android_nfc_manifest_entries() -> String {
1479    let mut out = String::new();
1480    out.push_str("    <uses-permission android:name=\"android.permission.NFC\" />\n");
1481    out.push_str(
1482        "    <uses-feature android:name=\"android.hardware.nfc\" android:required=\"false\" />\n",
1483    );
1484    out
1485}
1486
1487fn render_android_biometric_manifest_entries() -> String {
1488    let mut out = String::new();
1489    out.push_str("    <uses-permission android:name=\"android.permission.USE_BIOMETRIC\" />\n");
1490    out.push_str("    <uses-permission android:name=\"android.permission.USE_FINGERPRINT\" android:maxSdkVersion=\"28\" />\n");
1491    out
1492}
1493
1494fn render_android_bluetooth_manifest_entries() -> String {
1495    let mut out = String::new();
1496    out.push_str("    <uses-permission android:name=\"android.permission.BLUETOOTH\" android:maxSdkVersion=\"30\" />\n");
1497    out.push_str("    <uses-permission android:name=\"android.permission.BLUETOOTH_ADMIN\" android:maxSdkVersion=\"30\" />\n");
1498    out.push_str("    <uses-permission android:name=\"android.permission.BLUETOOTH_SCAN\" android:usesPermissionFlags=\"neverForLocation\" />\n");
1499    out.push_str("    <uses-permission android:name=\"android.permission.BLUETOOTH_CONNECT\" />\n");
1500    out.push_str(
1501        "    <uses-permission android:name=\"android.permission.BLUETOOTH_ADVERTISE\" />\n",
1502    );
1503    out.push_str(
1504        "    <uses-feature android:name=\"android.hardware.bluetooth\" android:required=\"false\" />\n",
1505    );
1506    out.push_str(
1507        "    <uses-feature android:name=\"android.hardware.bluetooth_le\" android:required=\"false\" />\n",
1508    );
1509    out
1510}
1511
1512fn render_missing_android_bluetooth_manifest_entries(existing: &str) -> String {
1513    let mut out = String::new();
1514    if !existing.contains("android.permission.BLUETOOTH\"") {
1515        out.push_str("    <uses-permission android:name=\"android.permission.BLUETOOTH\" android:maxSdkVersion=\"30\" />\n");
1516    }
1517    if !existing.contains("android.permission.BLUETOOTH_ADMIN") {
1518        out.push_str("    <uses-permission android:name=\"android.permission.BLUETOOTH_ADMIN\" android:maxSdkVersion=\"30\" />\n");
1519    }
1520    if !existing.contains("android.permission.BLUETOOTH_SCAN") {
1521        out.push_str("    <uses-permission android:name=\"android.permission.BLUETOOTH_SCAN\" android:usesPermissionFlags=\"neverForLocation\" />\n");
1522    }
1523    if !existing.contains("android.permission.BLUETOOTH_CONNECT") {
1524        out.push_str(
1525            "    <uses-permission android:name=\"android.permission.BLUETOOTH_CONNECT\" />\n",
1526        );
1527    }
1528    if !existing.contains("android.permission.BLUETOOTH_ADVERTISE") {
1529        out.push_str(
1530            "    <uses-permission android:name=\"android.permission.BLUETOOTH_ADVERTISE\" />\n",
1531        );
1532    }
1533    if !existing.contains("android.hardware.bluetooth\"") {
1534        out.push_str(
1535            "    <uses-feature android:name=\"android.hardware.bluetooth\" android:required=\"false\" />\n",
1536        );
1537    }
1538    if !existing.contains("android.hardware.bluetooth_le") {
1539        out.push_str(
1540            "    <uses-feature android:name=\"android.hardware.bluetooth_le\" android:required=\"false\" />\n",
1541        );
1542    }
1543    out
1544}
1545
1546fn render_android_barcode_camera_manifest_entries() -> String {
1547    let mut out = String::new();
1548    out.push_str("    <uses-permission android:name=\"android.permission.CAMERA\" />\n");
1549    out.push_str(
1550        "    <uses-feature android:name=\"android.hardware.camera.any\" android:required=\"false\" />\n",
1551    );
1552    out
1553}
1554
1555fn render_android_camera_manifest_entries() -> String {
1556    let mut out = String::new();
1557    out.push_str("    <uses-permission android:name=\"android.permission.CAMERA\" />\n");
1558    out.push_str(
1559        "    <uses-feature android:name=\"android.hardware.camera.any\" android:required=\"false\" />\n",
1560    );
1561    out.push_str(
1562        "    <uses-feature android:name=\"android.hardware.camera\" android:required=\"false\" />\n",
1563    );
1564    out.push_str(
1565        "    <uses-feature android:name=\"android.hardware.camera.front\" android:required=\"false\" />\n",
1566    );
1567    out.push_str(
1568        "    <uses-feature android:name=\"android.hardware.camera.flash\" android:required=\"false\" />\n",
1569    );
1570    out
1571}
1572
1573fn render_missing_android_camera_manifest_entries(existing: &str) -> String {
1574    let mut out = String::new();
1575    if !existing.contains("android.permission.CAMERA") {
1576        out.push_str("    <uses-permission android:name=\"android.permission.CAMERA\" />\n");
1577    }
1578    if !existing.contains("android.hardware.camera.any") {
1579        out.push_str(
1580            "    <uses-feature android:name=\"android.hardware.camera.any\" android:required=\"false\" />\n",
1581        );
1582    }
1583    if !existing.contains("android.hardware.camera\"") {
1584        out.push_str(
1585            "    <uses-feature android:name=\"android.hardware.camera\" android:required=\"false\" />\n",
1586        );
1587    }
1588    if !existing.contains("android.hardware.camera.front") {
1589        out.push_str(
1590            "    <uses-feature android:name=\"android.hardware.camera.front\" android:required=\"false\" />\n",
1591        );
1592    }
1593    if !existing.contains("android.hardware.camera.flash") {
1594        out.push_str(
1595            "    <uses-feature android:name=\"android.hardware.camera.flash\" android:required=\"false\" />\n",
1596        );
1597    }
1598    out
1599}
1600
1601fn render_android_geolocation_manifest_entries() -> String {
1602    let mut out = String::new();
1603    out.push_str(
1604        "    <uses-permission android:name=\"android.permission.ACCESS_COARSE_LOCATION\" />\n",
1605    );
1606    out.push_str(
1607        "    <uses-permission android:name=\"android.permission.ACCESS_FINE_LOCATION\" />\n",
1608    );
1609    out
1610}
1611
1612fn render_android_haptics_manifest_entries() -> String {
1613    "    <uses-permission android:name=\"android.permission.VIBRATE\" />\n".to_string()
1614}
1615
1616fn render_android_microphone_manifest_entries() -> String {
1617    "    <uses-permission android:name=\"android.permission.RECORD_AUDIO\" />\n".to_string()
1618}
1619
1620fn render_android_volume_manifest_entries() -> String {
1621    "    <uses-permission android:name=\"android.permission.MODIFY_AUDIO_SETTINGS\" />\n"
1622        .to_string()
1623}
1624
1625fn render_android_wifi_manifest_entries() -> String {
1626    let mut out = String::new();
1627    out.push_str("    <uses-permission android:name=\"android.permission.ACCESS_WIFI_STATE\" />\n");
1628    out.push_str("    <uses-permission android:name=\"android.permission.CHANGE_WIFI_STATE\" />\n");
1629    out.push_str(
1630        "    <uses-permission android:name=\"android.permission.ACCESS_NETWORK_STATE\" />\n",
1631    );
1632    out.push_str(
1633        "    <uses-permission android:name=\"android.permission.CHANGE_NETWORK_STATE\" />\n",
1634    );
1635    out.push_str("    <uses-permission android:name=\"android.permission.NEARBY_WIFI_DEVICES\" android:usesPermissionFlags=\"neverForLocation\" />\n");
1636    out.push_str("    <uses-permission android:name=\"android.permission.ACCESS_FINE_LOCATION\" android:maxSdkVersion=\"32\" />\n");
1637    out.push_str(
1638        "    <uses-feature android:name=\"android.hardware.wifi\" android:required=\"false\" />\n",
1639    );
1640    out.push_str(
1641        "    <uses-feature android:name=\"android.hardware.wifi.direct\" android:required=\"false\" />\n",
1642    );
1643    out
1644}
1645
1646fn render_missing_android_wifi_manifest_entries(existing: &str) -> String {
1647    let mut out = String::new();
1648    if !existing.contains("android.permission.ACCESS_WIFI_STATE") {
1649        out.push_str(
1650            "    <uses-permission android:name=\"android.permission.ACCESS_WIFI_STATE\" />\n",
1651        );
1652    }
1653    if !existing.contains("android.permission.CHANGE_WIFI_STATE") {
1654        out.push_str(
1655            "    <uses-permission android:name=\"android.permission.CHANGE_WIFI_STATE\" />\n",
1656        );
1657    }
1658    if !existing.contains("android.permission.ACCESS_NETWORK_STATE") {
1659        out.push_str(
1660            "    <uses-permission android:name=\"android.permission.ACCESS_NETWORK_STATE\" />\n",
1661        );
1662    }
1663    if !existing.contains("android.permission.CHANGE_NETWORK_STATE") {
1664        out.push_str(
1665            "    <uses-permission android:name=\"android.permission.CHANGE_NETWORK_STATE\" />\n",
1666        );
1667    }
1668    if !existing.contains("android.permission.NEARBY_WIFI_DEVICES") {
1669        out.push_str("    <uses-permission android:name=\"android.permission.NEARBY_WIFI_DEVICES\" android:usesPermissionFlags=\"neverForLocation\" />\n");
1670    }
1671    if !existing.contains("android.permission.ACCESS_FINE_LOCATION") {
1672        out.push_str("    <uses-permission android:name=\"android.permission.ACCESS_FINE_LOCATION\" android:maxSdkVersion=\"32\" />\n");
1673    }
1674    if !existing.contains("android.hardware.wifi\"") {
1675        out.push_str(
1676            "    <uses-feature android:name=\"android.hardware.wifi\" android:required=\"false\" />\n",
1677        );
1678    }
1679    if !existing.contains("android.hardware.wifi.direct") {
1680        out.push_str(
1681            "    <uses-feature android:name=\"android.hardware.wifi.direct\" android:required=\"false\" />\n",
1682        );
1683    }
1684    out
1685}
1686
1687fn render_ios_entitlements_plist(project: &FissionProject) -> String {
1688    let mut entries = String::new();
1689    if project.capabilities.contains(&PlatformCapability::Nfc) {
1690        entries.push_str("  <key>com.apple.developer.nfc.readersession.formats</key>\n  <array>\n    <string>NDEF</string>\n  </array>\n");
1691    }
1692    if project.capabilities.contains(&PlatformCapability::Wifi) {
1693        entries.push_str("  <key>com.apple.developer.networking.wifi-info</key>\n  <true/>\n");
1694        entries.push_str(
1695            "  <key>com.apple.developer.networking.HotspotConfiguration</key>\n  <true/>\n",
1696        );
1697    }
1698    format!(
1699        "<?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"
1700    )
1701}
1702
1703const IOS_NFC_ENTITLEMENTS_PLIST: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
1704<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
1705<plist version="1.0">
1706<dict>
1707  <key>com.apple.developer.nfc.readersession.formats</key>
1708  <array>
1709    <string>NDEF</string>
1710  </array>
1711</dict>
1712</plist>
1713"#;
1714
1715const IOS_WIFI_ENTITLEMENTS_PLIST: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
1716<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
1717<plist version="1.0">
1718<dict>
1719  <key>com.apple.developer.networking.wifi-info</key>
1720  <true/>
1721  <key>com.apple.developer.networking.HotspotConfiguration</key>
1722  <true/>
1723</dict>
1724</plist>
1725"#;
1726
1727fn render_android_package_script(project: &FissionProject) -> String {
1728    let lib_name = android_library_name(project);
1729    format!(
1730        r#"#!/usr/bin/env bash
1731set -euo pipefail
1732
1733SCRIPT_DIR=$(cd -- "$(dirname "${{BASH_SOURCE[0]}}")" && pwd)
1734PROJECT_DIR=$(cd -- "$SCRIPT_DIR/../.." && pwd)
1735TARGET="${{ANDROID_TARGET_TRIPLE:-aarch64-linux-android}}"
1736PACKAGE_NAME="{package_name}"
1737LIB_NAME="{lib_name}"
1738PROFILE="${{ANDROID_PROFILE:-debug}}"
1739ANDROID_HOME="${{ANDROID_HOME:-${{ANDROID_SDK_ROOT:-$HOME/Library/Android/sdk}}}}"
1740ANDROID_MIN_API_LEVEL="${{ANDROID_MIN_API_LEVEL:-${{ANDROID_API_LEVEL:-24}}}}"
1741
1742find_android_ndk() {{
1743  if [[ -n "${{ANDROID_NDK:-}}" ]]; then
1744    printf '%s\n' "$ANDROID_NDK"
1745    return
1746  fi
1747  local ndk_root="$ANDROID_HOME/ndk"
1748  if [[ ! -d "$ndk_root" ]]; then
1749    printf 'Android NDK not found. Set ANDROID_NDK or install one under %s.\n' "$ndk_root" >&2
1750    return 1
1751  fi
1752  local ndk
1753  ndk=$(find "$ndk_root" -maxdepth 1 -mindepth 1 -type d | sort -V | tail -1)
1754  if [[ -z "$ndk" ]]; then
1755    printf 'Android NDK not found. Set ANDROID_NDK or install one under %s.\n' "$ndk_root" >&2
1756    return 1
1757  fi
1758  printf '%s\n' "$ndk"
1759}}
1760
1761detect_android_toolchain() {{
1762  local prebuilt_root="$ANDROID_NDK/toolchains/llvm/prebuilt"
1763  local host
1764  for host in darwin-aarch64 darwin-x86_64 linux-x86_64 windows-x86_64; do
1765    if [[ -d "$prebuilt_root/$host/bin" ]]; then
1766      printf '%s\n' "$prebuilt_root/$host/bin"
1767      return
1768    fi
1769  done
1770  local fallback
1771  fallback=$(find "$prebuilt_root" -maxdepth 1 -mindepth 1 -type d 2>/dev/null | sort | head -1 || true)
1772  if [[ -n "$fallback" && -d "$fallback/bin" ]]; then
1773    printf '%s\n' "$fallback/bin"
1774    return
1775  fi
1776  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
1777  return 1
1778}}
1779
1780detect_latest_android_api() {{
1781  find "$ANDROID_HOME/platforms" -maxdepth 1 -type d -name 'android-*' 2>/dev/null \
1782    | sed 's#.*android-##' \
1783    | sort -n \
1784    | tail -1
1785}}
1786
1787detect_build_tools_dir() {{
1788  if [[ -n "${{ANDROID_BUILD_TOOLS:-}}" ]]; then
1789    if [[ -d "$ANDROID_BUILD_TOOLS" ]]; then
1790      printf '%s\n' "$ANDROID_BUILD_TOOLS"
1791      return
1792    fi
1793    if [[ -d "$ANDROID_HOME/build-tools/$ANDROID_BUILD_TOOLS" ]]; then
1794      printf '%s\n' "$ANDROID_HOME/build-tools/$ANDROID_BUILD_TOOLS"
1795      return
1796    fi
1797  fi
1798  find "$ANDROID_HOME/build-tools" -maxdepth 1 -mindepth 1 -type d 2>/dev/null | sort -V | tail -1
1799}}
1800
1801ANDROID_TARGET_API_LEVEL="${{ANDROID_TARGET_API_LEVEL:-$(detect_latest_android_api)}}"
1802if [[ -z "$ANDROID_TARGET_API_LEVEL" ]]; then
1803  printf 'No Android platform found under %s/platforms. Install one with sdkmanager "platforms;android-35" or newer.\n' "$ANDROID_HOME" >&2
1804  exit 1
1805fi
1806
1807ANDROID_NDK=$(find_android_ndk)
1808ANDROID_TOOLCHAIN="${{ANDROID_TOOLCHAIN:-$(detect_android_toolchain)}}"
1809CC_aarch64_linux_android="${{CC_aarch64_linux_android:-$ANDROID_TOOLCHAIN/aarch64-linux-android${{ANDROID_MIN_API_LEVEL}}-clang}}"
1810AR_aarch64_linux_android="${{AR_aarch64_linux_android:-$ANDROID_TOOLCHAIN/llvm-ar}}"
1811CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER="${{CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER:-$CC_aarch64_linux_android}}"
1812CARGO_TARGET_AARCH64_LINUX_ANDROID_AR="${{CARGO_TARGET_AARCH64_LINUX_ANDROID_AR:-$AR_aarch64_linux_android}}"
1813export ANDROID_HOME ANDROID_NDK ANDROID_MIN_API_LEVEL ANDROID_TARGET_API_LEVEL ANDROID_TOOLCHAIN CC_aarch64_linux_android AR_aarch64_linux_android
1814export CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER CARGO_TARGET_AARCH64_LINUX_ANDROID_AR
1815
1816BUILD_TOOLS=$(detect_build_tools_dir)
1817if [[ -z "$BUILD_TOOLS" || ! -d "$BUILD_TOOLS" ]]; then
1818  printf 'Android build-tools not found. Install them with sdkmanager "build-tools;35.0.0" or set ANDROID_BUILD_TOOLS.\n' >&2
1819  exit 1
1820fi
1821ANDROID_JAR="$ANDROID_HOME/platforms/android-$ANDROID_TARGET_API_LEVEL/android.jar"
1822if [[ ! -f "$ANDROID_JAR" ]]; then
1823  printf 'Android platform android-%s not found. Install it with sdkmanager "platforms;android-%s" or set ANDROID_TARGET_API_LEVEL.\n' "$ANDROID_TARGET_API_LEVEL" "$ANDROID_TARGET_API_LEVEL" >&2
1824  exit 1
1825fi
1826AAPT="$BUILD_TOOLS/aapt"
1827ZIPALIGN="$BUILD_TOOLS/zipalign"
1828APKSIGNER="$BUILD_TOOLS/apksigner"
1829for tool in "$AAPT" "$ZIPALIGN" "$APKSIGNER"; do
1830  if [[ ! -x "$tool" ]]; then
1831    printf 'Required Android build tool is missing or not executable: %s\n' "$tool" >&2
1832    exit 1
1833  fi
1834done
1835
1836BUILD_ARGS=(build --manifest-path "$PROJECT_DIR/Cargo.toml" --lib --target "$TARGET" --package "$PACKAGE_NAME")
1837ARTIFACT_DIR=debug
1838if [[ "$PROFILE" == "release" ]]; then
1839  BUILD_ARGS+=(--release)
1840  ARTIFACT_DIR=release
1841fi
1842
1843cargo "${{BUILD_ARGS[@]}}"
1844TARGET_DIR=$(python3 - <<'PY' "$PROJECT_DIR/Cargo.toml"
1845import json
1846import subprocess
1847import sys
1848
1849manifest = sys.argv[1]
1850metadata = json.loads(
1851    subprocess.check_output(
1852        ["cargo", "metadata", "--manifest-path", manifest, "--format-version", "1", "--no-deps"]
1853    )
1854)
1855print(metadata["target_directory"])
1856PY
1857)
1858
1859SO_PATH="$TARGET_DIR/$TARGET/$ARTIFACT_DIR/lib$LIB_NAME.so"
1860BUILD_DIR="$SCRIPT_DIR/build/$PROFILE"
1861APK_ROOT="$BUILD_DIR/apk-root"
1862UNALIGNED_APK="$BUILD_DIR/$PACKAGE_NAME-unaligned.apk"
1863ALIGNED_APK="$BUILD_DIR/$PACKAGE_NAME-aligned.apk"
1864SIGNED_APK="$BUILD_DIR/$PACKAGE_NAME.apk"
1865KEYSTORE="${{ANDROID_DEBUG_KEYSTORE:-$HOME/.android/debug.keystore}}"
1866
1867rm -rf "$APK_ROOT"
1868mkdir -p "$APK_ROOT/lib/arm64-v8a" "$APK_ROOT/res/drawable-nodpi" "$BUILD_DIR"
1869cp "$SO_PATH" "$APK_ROOT/lib/arm64-v8a/lib$LIB_NAME.so"
1870cp "$PROJECT_DIR/assets/app-icon.png" "$APK_ROOT/res/drawable-nodpi/app_icon.png"
1871
1872BUILD_MANIFEST="$BUILD_DIR/AndroidManifest.xml"
1873python3 - <<'PY' "$SCRIPT_DIR/AndroidManifest.xml" "$BUILD_MANIFEST" "$ANDROID_MIN_API_LEVEL" "$ANDROID_TARGET_API_LEVEL"
1874import re
1875import sys
1876
1877source, dest, min_api, target_api = sys.argv[1:]
1878manifest = open(source, encoding="utf-8").read()
1879manifest = re.sub(r'android:minSdkVersion="\d+"', f'android:minSdkVersion="{{min_api}}"', manifest)
1880manifest = re.sub(r'android:targetSdkVersion="\d+"', f'android:targetSdkVersion="{{target_api}}"', manifest)
1881open(dest, "w", encoding="utf-8").write(manifest)
1882PY
1883
1884"$AAPT" package -f -F "$UNALIGNED_APK" -M "$BUILD_MANIFEST" -S "$APK_ROOT/res" -I "$ANDROID_JAR"
1885(cd "$APK_ROOT" && zip -qr "$UNALIGNED_APK" lib)
1886"$ZIPALIGN" -f 4 "$UNALIGNED_APK" "$ALIGNED_APK"
1887
1888if [[ ! -f "$KEYSTORE" ]]; then
1889  mkdir -p "$(dirname "$KEYSTORE")"
1890  keytool -genkeypair -v \
1891    -keystore "$KEYSTORE" \
1892    -storepass android \
1893    -alias androiddebugkey \
1894    -keypass android \
1895    -dname "CN=Android Debug,O=Android,C=US" \
1896    -keyalg RSA \
1897    -keysize 2048 \
1898    -validity 10000 >/dev/null 2>&1
1899fi
1900
1901"$APKSIGNER" sign \
1902  --ks "$KEYSTORE" \
1903  --ks-pass pass:android \
1904  --key-pass pass:android \
1905  --out "$SIGNED_APK" \
1906  "$ALIGNED_APK"
1907
1908printf '%s\n' "$SIGNED_APK"
1909"#,
1910        package_name = project.app.name,
1911        lib_name = lib_name,
1912    )
1913}
1914
1915fn render_android_run_script(project: &FissionProject) -> String {
1916    format!(
1917        r#"#!/usr/bin/env bash
1918set -euo pipefail
1919
1920SCRIPT_DIR=$(cd -- "$(dirname "${{BASH_SOURCE[0]}}")" && pwd)
1921ANDROID_HOME="${{ANDROID_HOME:-${{ANDROID_SDK_ROOT:-$HOME/Library/Android/sdk}}}}"
1922ADB="$ANDROID_HOME/platform-tools/adb"
1923EMULATOR_BIN="$ANDROID_HOME/emulator/emulator"
1924AVDMANAGER="${{ANDROID_AVDMANAGER:-$ANDROID_HOME/cmdline-tools/latest/bin/avdmanager}}"
1925
1926detect_latest_emulator_api() {{
1927  find "$ANDROID_HOME/system-images" -path '*/google_apis/arm64-v8a' -type d 2>/dev/null \
1928    | sed -n 's#.*system-images/android-\([0-9][0-9]*\)/google_apis/arm64-v8a#\1#p' \
1929    | sort -n \
1930    | tail -1
1931}}
1932
1933android_system_image_path() {{
1934  local image="$1"
1935  image="${{image#system-images;}}"
1936  printf '%s/system-images/%s\n' "$ANDROID_HOME" "${{image//;/\/}}"
1937}}
1938
1939ANDROID_EMULATOR_API_LEVEL="${{ANDROID_EMULATOR_API_LEVEL:-$(detect_latest_emulator_api)}}"
1940if [[ -z "$ANDROID_EMULATOR_API_LEVEL" ]]; then
1941  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
1942  exit 1
1943fi
1944AVD_NAME="${{ANDROID_AVD_NAME:-FissionApi${{ANDROID_EMULATOR_API_LEVEL}}Arm64}}"
1945SYSTEM_IMAGE="${{ANDROID_SYSTEM_IMAGE:-system-images;android-${{ANDROID_EMULATOR_API_LEVEL}};google_apis;arm64-v8a}}"
1946DEVICE_PORT="${{ANDROID_TEST_CONTROL_DEVICE_PORT:-48761}}"
1947HOST_PORT="${{FISSION_TEST_CONTROL_PORT:-48761}}"
1948HEADLESS="${{ANDROID_EMULATOR_HEADLESS:-0}}"
1949RESTART_EMULATOR="${{ANDROID_EMULATOR_RESTART:-0}}"
1950
1951for tool in "$ADB" "$EMULATOR_BIN" "$AVDMANAGER"; do
1952  if [[ ! -x "$tool" ]]; then
1953    printf 'Required Android tool is missing or not executable: %s\nRun `fission doctor android --project-dir .` for setup help.\n' "$tool" >&2
1954    exit 1
1955  fi
1956done
1957
1958if ! "$AVDMANAGER" list avd | grep -q "Name: $AVD_NAME"; then
1959  if [[ ! -d "$(android_system_image_path "$SYSTEM_IMAGE")" ]]; then
1960    printf 'Android system image is not installed: %s\nInstall it with sdkmanager "%s" or set ANDROID_SYSTEM_IMAGE.\n' "$SYSTEM_IMAGE" "$SYSTEM_IMAGE" >&2
1961    exit 1
1962  fi
1963  echo "no" | "$AVDMANAGER" create avd -n "$AVD_NAME" -k "$SYSTEM_IMAGE" --abi "google_apis/arm64-v8a" --device "pixel_5"
1964fi
1965
1966RUNNING_EMULATOR=$("$ADB" devices | awk '/^emulator-.*device$/ {{ print $1; exit }}')
1967if [[ -n "$RUNNING_EMULATOR" && "$RESTART_EMULATOR" == "1" ]]; then
1968  "$ADB" -s "$RUNNING_EMULATOR" emu kill >/dev/null || true
1969  until ! "$ADB" devices | grep -q '^emulator-'; do
1970    sleep 1
1971  done
1972  RUNNING_EMULATOR=""
1973fi
1974
1975if [[ -z "$RUNNING_EMULATOR" ]]; then
1976  EMULATOR_ARGS=(-avd "$AVD_NAME" -gpu "${{ANDROID_EMULATOR_GPU:-swiftshader_indirect}}" -no-audio)
1977  if [[ "$HEADLESS" == "1" ]]; then
1978    EMULATOR_ARGS+=(-no-window)
1979  fi
1980  printf 'Launching emulator %s (%s)\n' "$AVD_NAME" "$([[ "$HEADLESS" == "1" ]] && echo headless || echo visible)"
1981  "$EMULATOR_BIN" "${{EMULATOR_ARGS[@]}}" >/tmp/fission-android-emulator.log 2>&1 &
1982  "$ADB" wait-for-device
1983  until "$ADB" shell getprop sys.boot_completed 2>/dev/null | tr -d '\r' | grep -q '^1$'; do
1984    sleep 1
1985  done
1986else
1987  printf 'Using existing emulator %s\n' "$RUNNING_EMULATOR"
1988  if [[ "$HEADLESS" != "1" ]]; then
1989    printf 'If the window is not visible, restart with ANDROID_EMULATOR_RESTART=1 to relaunch a visible emulator.\n'
1990  fi
1991fi
1992
1993APK=$("$SCRIPT_DIR/package-apk.sh")
1994"$ADB" install -r "$APK"
1995"$ADB" forward "tcp:$HOST_PORT" "tcp:$DEVICE_PORT"
1996"$ADB" shell am start -n {app_id}/android.app.NativeActivity >/dev/null
1997printf 'APK=%s\n' "$APK"
1998"#,
1999        app_id = project.app.app_id,
2000    )
2001}
2002
2003fn render_android_test_script() -> String {
2004    r#"#!/usr/bin/env bash
2005set -euo pipefail
2006
2007SCRIPT_DIR=$(cd -- "$(dirname "${BASH_SOURCE[0]}")" && pwd)
2008export FISSION_TEST_CONTROL_PORT="${FISSION_TEST_CONTROL_PORT:-48761}"
2009
2010"$SCRIPT_DIR/run-emulator.sh"
2011
2012python3 - <<'PY' "$FISSION_TEST_CONTROL_PORT"
2013import sys
2014import time
2015import urllib.request
2016
2017port = sys.argv[1]
2018url = f"http://127.0.0.1:{port}/health"
2019deadline = time.time() + 90
2020last_error = None
2021while time.time() < deadline:
2022    try:
2023        with urllib.request.urlopen(url, timeout=1) as response:
2024            body = response.read().decode("utf-8", "replace")
2025        if response.status == 200 and '"status":"ok"' in body:
2026            print(f"Android emulator test control is healthy on {url}")
2027            raise SystemExit(0)
2028    except Exception as error:
2029        last_error = error
2030    time.sleep(1)
2031raise SystemExit(f"Android emulator test control did not become healthy on {url}: {last_error}")
2032PY
2033"#
2034    .to_string()
2035}
2036
2037fn render_web_index(project: &FissionProject) -> String {
2038    let title = ios_bundle_name(project);
2039    format!(
2040        r#"<!doctype html>
2041<html lang="en">
2042  <head>
2043    <meta charset="utf-8" />
2044    <meta name="viewport" content="width=device-width, initial-scale=1" />
2045    <title>{title}</title>
2046    <link rel="icon" type="image/png" href="../../assets/app-icon.png" />
2047    <style>
2048      :root {{
2049        color-scheme: dark;
2050        background: #14171f;
2051      }}
2052      html, body {{
2053        margin: 0;
2054        width: 100%;
2055        height: 100%;
2056        overflow: hidden;
2057        overscroll-behavior: none;
2058        background: #14171f;
2059      }}
2060      body, #fission-web-mount {{
2061        width: 100vw;
2062        height: 100vh;
2063      }}
2064      canvas {{
2065        display: block;
2066        width: 100vw;
2067        height: 100vh;
2068        border: 0;
2069        outline: none;
2070        user-select: none;
2071        -webkit-user-drag: none;
2072        touch-action: none;
2073        -webkit-tap-highlight-color: transparent;
2074      }}
2075      canvas:focus, canvas:focus-visible {{
2076        outline: none;
2077      }}
2078    </style>
2079  </head>
2080  <body>
2081    <main id="fission-web-mount" aria-label="{title}"></main>
2082    <script type="module" src="./bootstrap.mjs"></script>
2083  </body>
2084</html>
2085"#,
2086        title = title,
2087    )
2088}
2089
2090fn render_web_bootstrap(project: &FissionProject) -> String {
2091    let module_name = project.app.name.replace('-', "_");
2092    format!(
2093        "import init from \"./pkg/{}.js\";\n\nawait init();\n",
2094        module_name
2095    )
2096}
2097
2098fn render_web_build_script() -> String {
2099    r#"#!/usr/bin/env bash
2100set -euo pipefail
2101
2102SCRIPT_DIR=$(cd -- "$(dirname "${BASH_SOURCE[0]}")" && pwd)
2103PROJECT_DIR=$(cd -- "$SCRIPT_DIR/../.." && pwd)
2104PROFILE="${FISSION_WEB_PROFILE:-dev}"
2105BUILD_ARGS=(build "$PROJECT_DIR" --target web --out-dir "$SCRIPT_DIR/pkg")
2106
2107if [[ "$PROFILE" == "release" ]]; then
2108  BUILD_ARGS+=(--release)
2109else
2110  BUILD_ARGS+=(--dev)
2111fi
2112
2113wasm-pack "${BUILD_ARGS[@]}"
2114"#
2115    .to_string()
2116}
2117
2118fn render_web_run_script(_project: &FissionProject) -> String {
2119    format!(
2120        r#"#!/usr/bin/env bash
2121set -euo pipefail
2122
2123SCRIPT_DIR=$(cd -- "$(dirname "${{BASH_SOURCE[0]}}")" && pwd)
2124PROJECT_DIR=$(cd -- "$SCRIPT_DIR/../.." && pwd)
2125HOST="${{FISSION_WEB_HOST:-127.0.0.1}}"
2126PORT="${{FISSION_WEB_PORT:-8123}}"
2127URL="http://${{HOST}}:${{PORT}}/platforms/web/"
2128
2129"$SCRIPT_DIR/build-wasm.sh"
2130
2131printf 'Serving %s\n' "$URL"
2132printf 'Press Ctrl+C to stop the local server.\n'
2133if [[ "${{FISSION_WEB_OPEN:-0}}" == "1" ]]; then
2134  if command -v open >/dev/null 2>&1; then
2135    open "$URL"
2136  elif command -v xdg-open >/dev/null 2>&1; then
2137    xdg-open "$URL"
2138  elif command -v cmd.exe >/dev/null 2>&1; then
2139    cmd.exe /C start "$URL"
2140  else
2141    printf 'No browser opener found. Open %s manually.\n' "$URL"
2142  fi
2143fi
2144
2145cd "$PROJECT_DIR"
2146python3 -m http.server "$PORT" --bind "$HOST"
2147"#
2148    )
2149}
2150
2151fn render_web_test_script(_project: &FissionProject) -> String {
2152    r#"#!/usr/bin/env bash
2153set -euo pipefail
2154
2155SCRIPT_DIR=$(cd -- "$(dirname "${BASH_SOURCE[0]}")" && pwd)
2156PROJECT_DIR=$(cd -- "$SCRIPT_DIR/../.." && pwd)
2157HOST="${FISSION_WEB_HOST:-127.0.0.1}"
2158PORT="${FISSION_WEB_PORT:-8123}"
2159CDP_PORT="${FISSION_WEB_CDP_PORT:-9222}"
2160URL="http://${HOST}:${PORT}/platforms/web/"
2161PROFILE_DIR="$SCRIPT_DIR/build/chrome-profile"
2162
2163require_node_websocket() {
2164  if ! command -v node >/dev/null 2>&1; then
2165    printf 'Node.js was not found. Install Node 22+ so the generated browser smoke test can inspect Chrome CDP console/runtime errors.\n' >&2
2166    exit 1
2167  fi
2168  if ! node -e 'process.exit(typeof WebSocket === "function" ? 0 : 1)' >/dev/null 2>&1; then
2169    printf 'Node.js is available but does not expose the built-in WebSocket client. Install Node 22+ for Chrome CDP smoke tests.\n' >&2
2170    exit 1
2171  fi
2172}
2173
2174detect_chrome() {
2175  if [[ -n "${FISSION_CHROME:-}" && -x "$FISSION_CHROME" ]]; then
2176    printf '%s\n' "$FISSION_CHROME"
2177    return
2178  fi
2179  local candidate
2180  for candidate in \
2181    "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" \
2182    "/Applications/Chromium.app/Contents/MacOS/Chromium" \
2183    "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge"; do
2184    if [[ -x "$candidate" ]]; then
2185      printf '%s\n' "$candidate"
2186      return
2187    fi
2188  done
2189  for candidate in google-chrome chromium chromium-browser chrome; do
2190    if command -v "$candidate" >/dev/null 2>&1; then
2191      command -v "$candidate"
2192      return
2193    fi
2194  done
2195  return 1
2196}
2197
2198require_node_websocket
2199"$SCRIPT_DIR/build-wasm.sh"
2200
2201mkdir -p "$SCRIPT_DIR/build"
2202cd "$PROJECT_DIR"
2203python3 -m http.server "$PORT" --bind "$HOST" >"$SCRIPT_DIR/build/web-server.log" 2>&1 &
2204SERVER_PID=$!
2205
2206cleanup() {
2207  if [[ -n "${CHROME_PID:-}" ]]; then
2208    kill "$CHROME_PID" >/dev/null 2>&1 || true
2209  fi
2210  kill "$SERVER_PID" >/dev/null 2>&1 || true
2211}
2212trap cleanup EXIT
2213
2214printf 'Running transient web smoke test at %s\n' "$URL"
2215printf 'The local server is stopped automatically when this script exits.\n'
2216
2217python3 - <<'PY' "$URL"
2218import sys
2219import time
2220import urllib.request
2221
2222url = sys.argv[1]
2223deadline = time.time() + 30
2224last_error = None
2225while time.time() < deadline:
2226    try:
2227        with urllib.request.urlopen(url, timeout=1) as response:
2228            if response.status == 200:
2229                raise SystemExit(0)
2230    except Exception as error:
2231        last_error = error
2232    time.sleep(0.5)
2233raise SystemExit(f"web server did not serve {url}: {last_error}")
2234PY
2235
2236CHROME=$(detect_chrome) || {
2237  printf 'Chrome/Chromium was not found. Set FISSION_CHROME=/path/to/chrome or run `fission doctor web --project-dir .`.\n' >&2
2238  exit 1
2239}
2240
2241rm -rf "$PROFILE_DIR"
2242"$CHROME" \
2243  --headless=new \
2244  --no-first-run \
2245  --no-default-browser-check \
2246  --remote-debugging-port="$CDP_PORT" \
2247  --user-data-dir="$PROFILE_DIR" \
2248  "$URL" >"$SCRIPT_DIR/build/chrome.log" 2>&1 &
2249CHROME_PID=$!
2250
2251CDP_PORT="$CDP_PORT" FISSION_WEB_URL="$URL" node <<'NODE'
2252const cdpPort = process.env.CDP_PORT;
2253const expectedUrl = process.env.FISSION_WEB_URL;
2254const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
2255
2256async function waitForTarget() {
2257  const deadline = Date.now() + 60_000;
2258  let lastError = null;
2259  while (Date.now() < deadline) {
2260    try {
2261      const response = await fetch(`http://127.0.0.1:${cdpPort}/json/list`);
2262      const targets = await response.json();
2263      const target = targets.find((entry) => entry.type === 'page' && entry.url.startsWith(expectedUrl));
2264      if (target?.webSocketDebuggerUrl) {
2265        return target.webSocketDebuggerUrl;
2266      }
2267    } catch (error) {
2268      lastError = error;
2269    }
2270    await sleep(250);
2271  }
2272  throw new Error(`Chrome CDP target did not become ready for ${expectedUrl}: ${lastError?.message ?? lastError}`);
2273}
2274
2275class CdpClient {
2276  constructor(url) {
2277    this.url = url;
2278    this.ws = null;
2279    this.nextId = 1;
2280    this.pending = new Map();
2281    this.errors = [];
2282  }
2283
2284  async open() {
2285    await new Promise((resolve, reject) => {
2286      const ws = new WebSocket(this.url);
2287      this.ws = ws;
2288      ws.addEventListener('open', resolve, { once: true });
2289      ws.addEventListener('error', (event) => reject(new Error(`CDP websocket error: ${event.message ?? 'unknown error'}`)), { once: true });
2290      ws.addEventListener('message', (event) => this.onMessage(event.data));
2291      ws.addEventListener('close', () => {
2292        for (const { reject: rejectPending } of this.pending.values()) {
2293          rejectPending(new Error('CDP websocket closed'));
2294        }
2295        this.pending.clear();
2296      });
2297    });
2298  }
2299
2300  send(method, params = {}) {
2301    const id = this.nextId++;
2302    const message = { id, method, params };
2303    return new Promise((resolve, reject) => {
2304      const timeout = setTimeout(() => {
2305        this.pending.delete(id);
2306        reject(new Error(`CDP command timed out: ${method}`));
2307      }, 10_000);
2308      this.pending.set(id, { resolve, reject, timeout, method });
2309      this.ws.send(JSON.stringify(message));
2310    });
2311  }
2312
2313  onMessage(raw) {
2314    const message = JSON.parse(raw);
2315    if (message.id) {
2316      const pending = this.pending.get(message.id);
2317      if (!pending) return;
2318      clearTimeout(pending.timeout);
2319      this.pending.delete(message.id);
2320      if (message.error) {
2321        pending.reject(new Error(`${pending.method}: ${message.error.message}`));
2322      } else {
2323        pending.resolve(message.result ?? {});
2324      }
2325      return;
2326    }
2327
2328    if (message.method === 'Runtime.exceptionThrown') {
2329      this.errors.push(formatException(message.params?.exceptionDetails));
2330    } else if (message.method === 'Runtime.consoleAPICalled') {
2331      const type = message.params?.type;
2332      if (type === 'error' || type === 'assert') {
2333        this.errors.push(`console.${type}: ${(message.params?.args ?? []).map(formatRemoteObject).join(' ')}`);
2334      }
2335    } else if (message.method === 'Log.entryAdded') {
2336      const entry = message.params?.entry;
2337      if (entry?.level === 'error') {
2338        this.errors.push(`browser log error: ${entry.text}${entry.url ? ` (${entry.url}:${entry.lineNumber ?? 0})` : ''}`);
2339      }
2340    }
2341  }
2342
2343  close() {
2344    this.ws?.close();
2345  }
2346}
2347
2348function formatRemoteObject(value) {
2349  if (!value) return '<missing>';
2350  if (Object.prototype.hasOwnProperty.call(value, 'value')) return JSON.stringify(value.value);
2351  return value.description ?? value.unserializableValue ?? value.type ?? '<unknown>';
2352}
2353
2354function formatException(details) {
2355  if (!details) return 'runtime exception: <missing details>';
2356  const exception = details.exception?.description ?? details.exception?.value ?? details.text ?? 'unknown exception';
2357  const location = details.url ? ` at ${details.url}:${details.lineNumber ?? 0}:${details.columnNumber ?? 0}` : '';
2358  return `runtime exception: ${exception}${location}`;
2359}
2360
2361function errorBlock(errors) {
2362  return errors.slice(0, 10).map((error, index) => `${index + 1}. ${error}`).join('\n');
2363}
2364
2365async function readCanvas(client) {
2366  const expression = `(() => {
2367    const canvas = document.querySelector('canvas');
2368    if (!canvas) return { ready: false, reason: 'no canvas element' };
2369    const rect = canvas.getBoundingClientRect();
2370    return {
2371      ready: rect.width > 0 && rect.height > 0,
2372      width: Math.round(rect.width),
2373      height: Math.round(rect.height),
2374      gpu: typeof navigator.gpu !== 'undefined',
2375      title: document.title,
2376    };
2377  })()`;
2378  const result = await client.send('Runtime.evaluate', { expression, returnByValue: true });
2379  if (result.exceptionDetails) {
2380    throw new Error(formatException(result.exceptionDetails));
2381  }
2382  return result.result?.value ?? { ready: false, reason: 'evaluation returned no value' };
2383}
2384
2385async function main() {
2386  const wsUrl = await waitForTarget();
2387  const client = new CdpClient(wsUrl);
2388  await client.open();
2389  try {
2390    await Promise.all([
2391      client.send('Runtime.enable'),
2392      client.send('Log.enable'),
2393      client.send('Page.enable'),
2394    ]);
2395
2396    const deadline = Date.now() + 60_000;
2397    let readySince = null;
2398    let lastCanvas = null;
2399    while (Date.now() < deadline) {
2400      if (client.errors.length > 0) {
2401        throw new Error(`browser reported runtime/console errors:\n${errorBlock(client.errors)}`);
2402      }
2403      lastCanvas = await readCanvas(client);
2404      if (lastCanvas.ready) {
2405        readySince ??= Date.now();
2406        if (Date.now() - readySince >= 1_500) {
2407          console.log(`Web app rendered canvas ${lastCanvas.width}x${lastCanvas.height}; no runtime console errors observed.`);
2408          return;
2409        }
2410      } else {
2411        readySince = null;
2412      }
2413      await sleep(250);
2414    }
2415    throw new Error(`web app did not render a non-empty canvas. Last canvas state: ${JSON.stringify(lastCanvas)}`);
2416  } finally {
2417    client.close();
2418  }
2419}
2420
2421main().catch((error) => {
2422  console.error(error.stack ?? error.message ?? String(error));
2423  process.exit(1);
2424});
2425NODE
2426"#
2427    .to_string()
2428}
2429fn render_app_main(package_name: &str) -> String {
2430    let lib_name = package_name.replace('-', "_");
2431    format!(
2432        r#"#[cfg(target_os = "android")]
2433fn main() {{}}
2434
2435#[cfg(target_arch = "wasm32")]
2436fn main() {{}}
2437
2438#[cfg(target_os = "ios")]
2439fn main() -> anyhow::Result<()> {{
2440    {lib_name}::run_mobile()
2441}}
2442
2443#[cfg(not(any(target_arch = "wasm32", target_os = "ios", target_os = "android")))]
2444fn main() -> anyhow::Result<()> {{
2445    {lib_name}::run_desktop()
2446}}
2447"#
2448    )
2449}
2450
2451const APP_LIB: &str = r#"pub mod app;
2452
2453use crate::app::CounterApp;
2454use fission::prelude::*;
2455
2456#[cfg(target_os = "android")]
2457const ANDROID_TEST_CONTROL_PORT: u16 = 48761;
2458
2459#[cfg(any(target_os = "android", target_os = "ios"))]
2460fn mobile_app() -> MobileApp<crate::app::CounterState, CounterApp> {
2461    let app = MobileApp::new(CounterApp).with_title("Fission App");
2462    #[cfg(target_os = "android")]
2463    let app = app.with_test_control_port(ANDROID_TEST_CONTROL_PORT);
2464    app
2465}
2466
2467#[cfg(target_arch = "wasm32")]
2468fn web_app() -> WebApp<crate::app::CounterState, CounterApp> {
2469    WebApp::new(CounterApp).with_title("Fission App")
2470}
2471
2472#[cfg(not(any(target_arch = "wasm32", target_os = "android", target_os = "ios")))]
2473pub fn run_desktop() -> anyhow::Result<()> {
2474    DesktopApp::new(CounterApp).run()
2475}
2476
2477#[cfg(any(target_os = "android", target_os = "ios"))]
2478pub fn run_mobile() -> anyhow::Result<()> {
2479    mobile_app().run()
2480}
2481
2482#[cfg(target_os = "android")]
2483#[no_mangle]
2484fn android_main(app_handle: AndroidApp) {
2485    let _ = mobile_app().run_with_android_app(app_handle);
2486}
2487
2488#[cfg(target_arch = "wasm32")]
2489#[wasm_bindgen::prelude::wasm_bindgen(start)]
2490pub fn run_web() -> Result<(), wasm_bindgen::JsValue> {
2491    console_error_panic_hook::set_once();
2492    web_app()
2493        .run()
2494        .map_err(|error| wasm_bindgen::JsValue::from_str(&error.to_string()))
2495}
2496"#;
2497
2498const APP_RS: &str = r#"use fission::prelude::*;
2499
2500#[derive(Default, Debug, Clone, PartialEq)]
2501pub struct CounterState {
2502    pub count: i32,
2503}
2504
2505impl AppState for CounterState {}
2506
2507#[fission_reducer(Increment)]
2508fn on_increment(state: &mut CounterState) {
2509    state.count += 1;
2510}
2511
2512pub struct CounterApp;
2513
2514impl Widget<CounterState> for CounterApp {
2515    fn build(&self, ctx: &mut BuildCtx<CounterState>, view: &View<CounterState>) -> Node {
2516        let increment = with_reducer!(ctx, Increment, on_increment);
2517
2518        Column {
2519            gap: Some(16.0),
2520            children: vec![
2521                Text::new(format!("Count: {}", view.state.count)).size(28.0).into_node(),
2522                Button {
2523                    on_press: Some(increment),
2524                    child: Some(Box::new(Text::new("Increment").into_node())),
2525                    ..Default::default()
2526                }
2527                .into_node(),
2528            ],
2529            ..Default::default()
2530        }
2531        .into_node()
2532    }
2533}
2534"#;