Skip to main content

fission_cli/
lib.rs

1use anyhow::{bail, Result};
2use clap::Parser;
3use std::path::Path;
4
5mod cli;
6
7#[cfg(test)]
8use fission_command_core::{read_project_config, Target};
9
10use cli::{Cli, Command, ServerCommand, SiteCommand};
11
12pub fn run<I, S>(args: I) -> Result<()>
13where
14    I: IntoIterator<Item = S>,
15    S: Into<std::ffi::OsString> + Clone,
16{
17    let mut argv: Vec<std::ffi::OsString> = args.into_iter().map(Into::into).collect();
18    if let Some(bin) = argv.first() {
19        if let Some(name) = Path::new(bin).file_name().and_then(|value| value.to_str()) {
20            if name == "cargo-fission" {
21                argv[0] = std::ffi::OsString::from("fission");
22                if argv.get(1).and_then(|value| value.to_str()) == Some("fission") {
23                    argv.remove(1);
24                }
25            }
26        }
27    }
28    let cli = Cli::parse_from(argv);
29    match cli.command {
30        Command::Init {
31            path,
32            name,
33            app_id,
34            local_path,
35        } => fission_command_core::init_project(&path, name, app_id, local_path),
36        Command::AddTarget {
37            targets,
38            project_dir,
39        } => fission_command_core::add_targets(&project_dir, &targets),
40        Command::AddCapability {
41            capabilities,
42            project_dir,
43        } => fission_command_core::add_capabilities(&project_dir, &capabilities),
44        Command::Doctor {
45            targets,
46            project_dir,
47            strict,
48        } => fission_command_run::doctor::run_doctor(&project_dir, &targets, strict),
49        Command::Devices { project_dir, json } => {
50            fission_command_run::list_devices(&project_dir, json)
51        }
52        Command::Run {
53            target,
54            device,
55            project_dir,
56            detach,
57            release,
58            host,
59            port,
60            no_open,
61            headless,
62        } => fission_command_run::run_app(fission_command_run::RunOptions {
63            project_dir,
64            target,
65            device,
66            detach,
67            release,
68            host,
69            port,
70            no_open,
71            headless,
72        }),
73        Command::Build {
74            target,
75            project_dir,
76            release,
77        } => fission_command_run::build_app(fission_command_run::BuildOptions {
78            project_dir,
79            target,
80            release,
81        }),
82        Command::Test {
83            target,
84            project_dir,
85            headless,
86        } => fission_command_run::test_app(fission_command_run::TestOptions {
87            project_dir,
88            target,
89            headless,
90        }),
91        Command::Site { command } => match command {
92            SiteCommand::Build {
93                project_dir,
94                release,
95            } => fission_command_site::build(&project_dir, release),
96            SiteCommand::Check {
97                project_dir,
98                release,
99            } => fission_command_site::check(&project_dir, release),
100            SiteCommand::Serve {
101                project_dir,
102                host,
103                port,
104                release,
105                no_open,
106            } => fission_command_site::serve(&project_dir, release, host, port, !no_open),
107            SiteCommand::Routes { project_dir } => fission_command_site::routes(&project_dir),
108        },
109        Command::Server { command } => match command {
110            ServerCommand::Build {
111                project_dir,
112                release,
113            } => fission_command_server::build(&project_dir, release),
114            ServerCommand::Check {
115                project_dir,
116                release,
117            } => fission_command_server::check(&project_dir, release),
118            ServerCommand::Serve {
119                project_dir,
120                host,
121                port,
122                release,
123            } => fission_command_server::serve(&project_dir, release, host, port),
124            ServerCommand::Routes { project_dir } => fission_command_server::routes(&project_dir),
125            ServerCommand::Artifacts {
126                project_dir,
127                release,
128                no_compile,
129            } => fission_command_server::artifacts(&project_dir, release, !no_compile),
130        },
131        Command::Package {
132            target,
133            format,
134            project_dir,
135            release,
136            json,
137        } => fission_command_package::package(fission_command_package::PackageOptions {
138            project_dir,
139            target,
140            format,
141            release,
142            json,
143        }),
144        Command::Distribute {
145            action,
146            provider,
147            artifact,
148            site,
149            deploy,
150            track,
151            locales,
152            dry_run,
153            yes,
154            project_dir,
155            json,
156        } => {
157            let action = action.unwrap_or(fission_command_package::DistributeAction::Publish);
158            if action == fission_command_package::DistributeAction::Publish {
159                fission_command_release::publish_workflow(
160                    fission_command_release::PublishWorkflowOptions {
161                        project_dir,
162                        provider,
163                        target: None,
164                        format: None,
165                        artifact,
166                        site,
167                        deploy,
168                        track,
169                        locales,
170                        overwrite_remote: false,
171                        dry_run,
172                        yes,
173                        json,
174                    },
175                )
176            } else {
177                fission_command_package::distribute(fission_command_package::DistributeOptions {
178                    project_dir,
179                    provider,
180                    action,
181                    target: None,
182                    format: None,
183                    artifact,
184                    site,
185                    deploy,
186                    track,
187                    locales,
188                    dry_run,
189                    yes,
190                    json,
191                })
192            }
193        }
194        Command::Publish {
195            provider,
196            target,
197            format,
198            artifact,
199            site,
200            deploy,
201            track,
202            locales,
203            app,
204            guided,
205            dry_run,
206            overwrite_remote,
207            yes,
208            project_dir,
209            json,
210        } => {
211            if app {
212                if guided || dry_run || yes || json || overwrite_remote {
213                    bail!(
214                        "publish --app opens the interactive windowed publish app; remove --app for --guided/--dry-run/--yes/--json/--overwrite-remote automation"
215                    );
216                }
217                fission_command_ui::run_publish_window(fission_command_ui::PublishUiOptions {
218                    project_dir,
219                    provider,
220                    target,
221                    format,
222                    artifact,
223                    site,
224                    deploy,
225                    track,
226                    locales,
227                    screenshot: None,
228                    exit_after_render: false,
229                    width: None,
230                    height: None,
231                    native_file_dialog: true,
232                })
233            } else if artifact.is_some()
234                && target.is_none()
235                && format.is_none()
236                && locales.is_empty()
237                && !guided
238            {
239                fission_command_release::publish_workflow(
240                    fission_command_release::PublishWorkflowOptions {
241                        project_dir,
242                        provider,
243                        target,
244                        format,
245                        artifact,
246                        site,
247                        deploy,
248                        track,
249                        locales,
250                        overwrite_remote,
251                        dry_run,
252                        yes,
253                        json,
254                    },
255                )
256            } else if !guided && (dry_run || yes || json) {
257                fission_command_release::publish_workflow(
258                    fission_command_release::PublishWorkflowOptions {
259                        project_dir,
260                        provider,
261                        target,
262                        format,
263                        artifact,
264                        site,
265                        deploy,
266                        track,
267                        locales,
268                        overwrite_remote,
269                        dry_run,
270                        yes,
271                        json,
272                    },
273                )
274            } else if guided || dry_run || yes || json {
275                fission_command_package::run_publish_shell_with_hooks(
276                    fission_command_package::PublishShellOptions {
277                        project_dir,
278                        provider,
279                        target,
280                        format,
281                        artifact,
282                        site,
283                        deploy,
284                        track,
285                        locales,
286                        overwrite_remote,
287                        dry_run,
288                        yes,
289                        json,
290                        app,
291                    },
292                    publish_shell_hooks(),
293                )
294            } else {
295                fission_command_ui::run_publish_tui(fission_command_ui::PublishUiOptions {
296                    project_dir,
297                    provider,
298                    target,
299                    format,
300                    artifact,
301                    site,
302                    deploy,
303                    track,
304                    locales,
305                    screenshot: None,
306                    exit_after_render: false,
307                    width: None,
308                    height: None,
309                    native_file_dialog: false,
310                })
311            }
312        }
313        Command::Readiness {
314            kind,
315            target,
316            format,
317            release,
318            provider,
319            artifact,
320            site,
321            track,
322            locales,
323            project_dir,
324            json,
325        } => {
326            if kind == fission_command_package::ReadinessKind::Release {
327                let Some(provider) = provider else {
328                    bail!("readiness release requires --provider");
329                };
330                fission_command_release::readiness_release(
331                    fission_command_release::PublishWorkflowOptions {
332                        project_dir,
333                        provider,
334                        target,
335                        format,
336                        artifact,
337                        site,
338                        deploy: None,
339                        track,
340                        locales,
341                        overwrite_remote: false,
342                        dry_run: true,
343                        yes: true,
344                        json,
345                    },
346                )
347            } else {
348                fission_command_package::readiness(fission_command_package::ReadinessOptions {
349                    project_dir,
350                    kind,
351                    target,
352                    format,
353                    provider,
354                    artifact,
355                    site,
356                    track,
357                    release,
358                    json,
359                })
360            }
361        }
362        Command::ReleaseConfig { command } => match command {
363            fission_command_release::ReleaseConfigCommand::Edit {
364                project_dir,
365                tui: true,
366                provider,
367            } => fission_command_ui::run_release_config_tui(
368                fission_command_ui::ReleaseConfigEditorOptions {
369                    project_dir,
370                    provider,
371                    screenshot: None,
372                    exit_after_render: false,
373                    width: None,
374                    height: None,
375                },
376            ),
377            command => fission_command_release::release_config(command),
378        },
379        Command::ReleaseContent { command } => fission_command_release::release_content(command),
380        Command::Beta { command } => fission_command_release::beta(command),
381        Command::Signing { command } => fission_command_release::signing(command),
382        Command::Reviews { command } => fission_command_release::reviews(command),
383        Command::ReleaseWorkflow { command } => fission_command_release::release_workflow(command),
384        Command::Auth { command } => fission_command_release::auth(command),
385        Command::Logs {
386            target,
387            device,
388            project_dir,
389            follow,
390        } => fission_command_run::attach_logs(fission_command_run::LogOptions {
391            project_dir,
392            target,
393            device,
394            follow,
395        }),
396        Command::Ui {
397            provider,
398            target,
399            format,
400            track,
401            locales,
402            project_dir,
403            screenshot,
404            exit_after_render,
405            width,
406            height,
407        } => fission_command_ui::run_publish_tui(fission_command_ui::PublishUiOptions {
408            project_dir,
409            provider,
410            target,
411            format,
412            artifact: None,
413            site: "production".to_string(),
414            deploy: None,
415            track,
416            locales,
417            screenshot,
418            exit_after_render,
419            width,
420            height,
421            native_file_dialog: false,
422        }),
423        Command::ServeWeb {
424            project_dir,
425            host,
426            port,
427            open,
428        } => fission_command_run::serve_web(fission_command_run::ServeWebOptions {
429            project_dir,
430            host,
431            port,
432            open,
433        }),
434    }
435}
436
437fn publish_shell_hooks() -> fission_command_package::PublishShellHooks {
438    fission_command_package::PublishShellHooks {
439        release_plan: publish_shell_release_plan,
440        release_checks: publish_shell_release_checks,
441        publish: publish_shell_publish,
442        skip_requirement: publish_shell_skip_requirement,
443        bump_build: publish_shell_bump_build,
444    }
445}
446
447fn publish_shell_release_plan(
448    request: fission_command_package::PublishShellWorkflowRequest,
449) -> Result<fission_command_package::PublishShellReleasePlan> {
450    let plan = fission_command_release::release_plan_snapshot(publish_workflow_options(request))?;
451    Ok(fission_command_package::PublishShellReleasePlan {
452        status: plan.status,
453        provider: plan.provider,
454        target: plan.target,
455        format: plan.format,
456        track: plan.track,
457        locales: plan.locales,
458        steps: plan
459            .steps
460            .into_iter()
461            .map(|step| fission_command_package::PublishShellReleaseStep {
462                id: step.id,
463                title: step.title,
464                status: step.status,
465            })
466            .collect(),
467        capabilities: plan
468            .capabilities
469            .into_iter()
470            .map(
471                |capability| fission_command_package::PublishShellProviderCapability {
472                    id: capability.id,
473                    status: capability.status,
474                    summary: capability.summary,
475                },
476            )
477            .collect(),
478        requirements: plan
479            .requirements
480            .into_iter()
481            .map(publish_shell_requirement_check)
482            .collect(),
483    })
484}
485
486fn publish_shell_release_checks(
487    request: fission_command_package::PublishShellWorkflowRequest,
488) -> Result<Vec<fission_command_package::ReadinessCheck>> {
489    fission_command_release::release_readiness_checks(publish_workflow_options(request))
490}
491
492fn publish_shell_publish(
493    request: fission_command_package::PublishShellWorkflowRequest,
494) -> Result<String> {
495    let dry_run = request.dry_run;
496    fission_command_release::publish_workflow(publish_workflow_options(request))?;
497    Ok(if dry_run {
498        "publish dry-run completed".to_string()
499    } else {
500        "publish workflow completed".to_string()
501    })
502}
503
504fn publish_shell_skip_requirement(
505    request: fission_command_package::PublishShellWorkflowRequest,
506    id: String,
507) -> Result<String> {
508    fission_command_release::skip_release_requirement(&request.project_dir, &id, true)?;
509    Ok(format!("recorded explicit skip for {id}"))
510}
511
512fn publish_shell_bump_build(
513    request: fission_command_package::PublishShellWorkflowRequest,
514) -> Result<String> {
515    fission_command_release::bump_release_build(&request.project_dir, request.target, 1, true)?;
516    Ok("bumped release build".to_string())
517}
518
519fn publish_workflow_options(
520    request: fission_command_package::PublishShellWorkflowRequest,
521) -> fission_command_release::PublishWorkflowOptions {
522    fission_command_release::PublishWorkflowOptions {
523        project_dir: request.project_dir,
524        provider: request.provider,
525        target: request.target,
526        format: request.format,
527        artifact: request.artifact,
528        site: request.site,
529        deploy: request.deploy,
530        track: request.track,
531        locales: request.locales,
532        overwrite_remote: request.overwrite_remote,
533        dry_run: request.dry_run,
534        yes: request.yes,
535        json: request.json,
536    }
537}
538
539fn publish_shell_requirement_check(
540    requirement: fission_command_release::ReleaseRequirementSnapshot,
541) -> fission_command_package::ReadinessCheck {
542    fission_command_package::ReadinessCheck {
543        id: requirement.id,
544        severity: match requirement.level.as_str() {
545            "provider-required" => fission_command_package::CheckSeverity::Error,
546            "fission-recommended" => fission_command_package::CheckSeverity::Warning,
547            _ => fission_command_package::CheckSeverity::Info,
548        },
549        status: match requirement.status.as_str() {
550            "passed" => fission_command_package::CheckStatus::Passed,
551            "missing" => fission_command_package::CheckStatus::Missing,
552            "failed" => fission_command_package::CheckStatus::Failed,
553            "warning" => fission_command_package::CheckStatus::Warning,
554            "skipped" => fission_command_package::CheckStatus::Skipped,
555            _ => fission_command_package::CheckStatus::Warning,
556        },
557        summary: requirement.summary,
558        details: requirement.details,
559        remediation: requirement.remediation,
560    }
561}
562
563#[cfg(test)]
564mod tests {
565    use super::*;
566    use std::{fs, path::PathBuf, process::Command};
567
568    fn unique_dir(name: &str) -> PathBuf {
569        let dir =
570            std::env::temp_dir().join(format!("cargo-fission-{}-{}", name, std::process::id()));
571        let _ = fs::remove_dir_all(&dir);
572        dir
573    }
574
575    #[cfg(unix)]
576    fn write_executable(path: impl AsRef<std::path::Path>, contents: &str) {
577        use std::os::unix::fs::PermissionsExt;
578
579        let path = path.as_ref();
580        if let Some(parent) = path.parent() {
581            fs::create_dir_all(parent).unwrap();
582        }
583        fs::write(path, contents).unwrap();
584        fs::set_permissions(path, fs::Permissions::from_mode(0o755)).unwrap();
585    }
586
587    #[cfg(unix)]
588    fn path_with_fake_bin(fake_bin: &std::path::Path) -> String {
589        let existing = std::env::var("PATH").unwrap_or_default();
590        format!("{}:{existing}", fake_bin.display())
591    }
592
593    #[cfg(unix)]
594    fn write_fake_cargo(fake_bin: &std::path::Path) {
595        write_executable(
596            fake_bin.join("cargo"),
597            r#"#!/usr/bin/env bash
598set -euo pipefail
599if [[ "${1:-}" == "metadata" ]]; then
600  printf '%s\n' "$FAKE_TARGET_DIR"
601  exit 0
602fi
603if [[ "${1:-}" == "build" ]]; then
604  target=""
605  package=""
606  profile="debug"
607  args=("$@")
608  for ((i = 0; i < ${#args[@]}; i++)); do
609    case "${args[$i]}" in
610      --target) target="${args[$((i + 1))]}" ;;
611      --package) package="${args[$((i + 1))]}" ;;
612      --release) profile="release" ;;
613    esac
614  done
615  if [[ -z "$target" || -z "$package" ]]; then
616    printf 'fake cargo expected --target and --package\n' >&2
617    exit 2
618  fi
619  artifact_dir="$FAKE_TARGET_DIR/$target/$profile"
620  mkdir -p "$artifact_dir"
621  lib_name="${package//-/_}"
622  printf 'fake native library\n' > "$artifact_dir/lib${lib_name}.so"
623  printf '#!/usr/bin/env sh\nexit 0\n' > "$artifact_dir/$package"
624  chmod +x "$artifact_dir/$package"
625  exit 0
626fi
627printf 'unsupported fake cargo invocation: %s\n' "$*" >&2
628exit 2
629"#,
630        );
631    }
632
633    #[cfg(unix)]
634    fn write_fake_python3(fake_bin: &std::path::Path) {
635        write_executable(
636            fake_bin.join("python3"),
637            r#"#!/usr/bin/env bash
638set -euo pipefail
639script=$(cat)
640if [[ "$script" == *"import plistlib"* ]]; then
641  printf 'plistlib must not be used by generated mobile package scripts\n' >&2
642  exit 44
643fi
644if [[ "$script" == *"cargo\", \"metadata\""* || "$script" == *"metadata = json.loads"* ]]; then
645  printf '%s\n' "$FAKE_TARGET_DIR"
646  exit 0
647fi
648if [[ "$script" == *"android:minSdkVersion"* ]]; then
649  if [[ "${1:-}" == "-" ]]; then
650    shift
651  fi
652  source="$1"
653  dest="$2"
654  min_api="$3"
655  target_api="$4"
656  has_code=false
657  if [[ -f "$(dirname "$dest")/apk-root/classes.dex" ]]; then
658    has_code=true
659  fi
660  sed -E \
661    -e "s/android:minSdkVersion=\"[0-9]+\"/android:minSdkVersion=\"$min_api\"/" \
662    -e "s/android:targetSdkVersion=\"[0-9]+\"/android:targetSdkVersion=\"$target_api\"/" \
663    -e "s/android:hasCode=\"(true|false)\"/android:hasCode=\"$has_code\"/" \
664    "$source" > "$dest"
665  exit 0
666fi
667printf 'unsupported fake python3 script\n%s\n' "$script" >&2
668exit 2
669"#,
670        );
671    }
672
673    #[cfg(unix)]
674    fn write_fake_ios_tools(fake_bin: &std::path::Path) {
675        write_executable(
676            fake_bin.join("xcrun"),
677            r#"#!/usr/bin/env bash
678set -euo pipefail
679if [[ "${1:-}" == "--find" && "${2:-}" == "plutil" ]]; then
680  printf '%s/plutil\n' "$FISSION_FAKE_BIN"
681  exit 0
682fi
683if [[ "${1:-}" == "--find" && "${2:-}" == "ibtool" ]]; then
684  printf '%s/ibtool\n' "$FISSION_FAKE_BIN"
685  exit 0
686fi
687printf 'unsupported fake xcrun invocation: %s\n' "$*" >&2
688exit 2
689"#,
690        );
691        write_executable(
692            fake_bin.join("plutil"),
693            r#"#!/usr/bin/env bash
694set -euo pipefail
695if [[ "${1:-}" != "-replace" || "${3:-}" != "-string" ]]; then
696  printf 'unsupported fake plutil invocation: %s\n' "$*" >&2
697  exit 2
698fi
699key="$2"
700value="$4"
701file="$5"
702tmp="$(mktemp)"
703awk -v key="$key" -v value="$value" '
704  replace_next {
705    print "  <string>" value "</string>"
706    replace_next = 0
707    next
708  }
709  {
710    print
711    if ($0 ~ "<key>" key "</key>") {
712      replace_next = 1
713    }
714  }
715' "$file" > "$tmp"
716mv "$tmp" "$file"
717"#,
718        );
719        write_executable(
720            fake_bin.join("ibtool"),
721            r#"#!/usr/bin/env bash
722set -euo pipefail
723args=("$@")
724for ((i = 0; i < ${#args[@]}; i++)); do
725  if [[ "${args[$i]}" == "--compile" ]]; then
726    mkdir -p "${args[$((i + 1))]}"
727    exit 0
728  fi
729done
730printf 'fake ibtool missing --compile\n' >&2
731exit 2
732"#,
733        );
734    }
735
736    #[cfg(unix)]
737    fn write_fake_android_tools(android_home: &std::path::Path, fake_bin: &std::path::Path) {
738        let build_tools = android_home.join("build-tools/35.0.0");
739        let ndk_bin = android_home.join("ndk/27.0.0/toolchains/llvm/prebuilt/linux-x86_64/bin");
740        fs::create_dir_all(android_home.join("platforms/android-35")).unwrap();
741        fs::write(android_home.join("platforms/android-35/android.jar"), "").unwrap();
742        fs::create_dir_all(&build_tools).unwrap();
743        fs::create_dir_all(&ndk_bin).unwrap();
744        fs::write(ndk_bin.join("aarch64-linux-android24-clang"), "").unwrap();
745        fs::write(ndk_bin.join("llvm-ar"), "").unwrap();
746
747        write_executable(
748            build_tools.join("aapt"),
749            r#"#!/usr/bin/env bash
750set -euo pipefail
751out=""
752manifest=""
753args=("$@")
754for ((i = 0; i < ${#args[@]}; i++)); do
755  case "${args[$i]}" in
756    -F) out="${args[$((i + 1))]}" ;;
757    -M) manifest="${args[$((i + 1))]}" ;;
758  esac
759done
760if [[ -z "$out" || -z "$manifest" ]]; then
761  printf 'fake aapt missing -F or -M\n' >&2
762  exit 2
763fi
764mkdir -p "$(dirname "$out")"
765{
766  printf 'AAPT_MANIFEST_BEGIN\n'
767  cat "$manifest"
768  printf '\nAAPT_MANIFEST_END\n'
769} > "$out"
770"#,
771        );
772        write_executable(
773            build_tools.join("zipalign"),
774            r#"#!/usr/bin/env bash
775set -euo pipefail
776args=("$@")
777count=${#args[@]}
778input="${args[$((count - 2))]}"
779output="${args[$((count - 1))]}"
780cp "$input" "$output"
781"#,
782        );
783        write_executable(
784            build_tools.join("apksigner"),
785            r#"#!/usr/bin/env bash
786set -euo pipefail
787out=""
788input=""
789args=("$@")
790for ((i = 0; i < ${#args[@]}; i++)); do
791  if [[ "${args[$i]}" == "--out" ]]; then
792    out="${args[$((i + 1))]}"
793  fi
794done
795input="${args[$((${#args[@]} - 1))]}"
796if [[ -z "$out" ]]; then
797  printf 'fake apksigner missing --out\n' >&2
798  exit 2
799fi
800cp "$input" "$out"
801"#,
802        );
803        write_executable(
804            fake_bin.join("zip"),
805            r#"#!/usr/bin/env bash
806set -euo pipefail
807archive=""
808entries=()
809for arg in "$@"; do
810  if [[ "$arg" == -* ]]; then
811    continue
812  fi
813  if [[ -z "$archive" ]]; then
814    archive="$arg"
815  else
816    entries+=("$arg")
817  fi
818done
819if [[ -z "$archive" ]]; then
820  printf 'fake zip missing archive\n' >&2
821  exit 2
822fi
823for entry in "${entries[@]}"; do
824  if [[ -d "$entry" ]]; then
825    while IFS= read -r file; do
826      printf 'APK_ENTRY=%s\n' "$file" >> "$archive"
827    done < <(find "$entry" -type f | sort)
828  elif [[ -f "$entry" ]]; then
829    printf 'APK_ENTRY=%s\n' "$entry" >> "$archive"
830  fi
831done
832	"#,
833        );
834        write_executable(
835            fake_bin.join("gradle"),
836            r#"#!/usr/bin/env bash
837set -euo pipefail
838project=""
839args=("$@")
840for ((i = 0; i < ${#args[@]}; i++)); do
841  if [[ "${args[$i]}" == "-p" ]]; then
842    project="${args[$((i + 1))]}"
843  fi
844done
845if [[ -z "$project" ]]; then
846  printf 'fake gradle missing -p project\n' >&2
847  exit 2
848fi
849variant="debug"
850kind="apk"
851for arg in "$@"; do
852  if [[ "$arg" == *"Release"* ]]; then
853    variant="release"
854  fi
855  if [[ "$arg" == *"bundle"* ]]; then
856    kind="aab"
857  fi
858done
859if [[ "$kind" == "aab" ]]; then
860  artifact="$project/app/build/outputs/bundle/$variant/app-$variant.aab"
861  prefix="AAB"
862else
863  artifact="$project/app/build/outputs/apk/$variant/app-$variant.apk"
864  prefix="APK"
865fi
866mkdir -p "$(dirname "$artifact")"
867{
868  printf 'GRADLE_MANIFEST_BEGIN\n'
869  cat "$project/AndroidManifest.xml"
870  printf '\nGRADLE_MANIFEST_END\n'
871  if [[ -d "$project/app/src/main/jniLibs" ]]; then
872    (cd "$project/app/src/main/jniLibs" && find . -type f | sort | sed "s#^\./#${prefix}_ENTRY=lib/#")
873  fi
874} > "$artifact"
875"#,
876        );
877    }
878
879    #[test]
880    fn init_creates_project_files() {
881        let dir = unique_dir("init");
882        run([
883            "fission",
884            "init",
885            dir.to_str().unwrap(),
886            "--name",
887            "hello-fission",
888        ])
889        .unwrap();
890
891        assert!(dir.join("Cargo.toml").exists());
892        assert!(dir.join("src/main.rs").exists());
893        assert!(dir.join("src/lib.rs").exists());
894        assert!(dir.join("src/app.rs").exists());
895        assert!(dir.join("assets/app-icon.png").exists());
896        assert!(dir.join("fission.toml").exists());
897        assert!(dir.join("platforms/windows/README.md").exists());
898        assert!(dir.join("platforms/windows/Package.appxmanifest").exists());
899        assert!(dir.join("platforms/windows/package-msix.ps1").exists());
900        assert!(dir.join("platforms/windows/package-msi.ps1").exists());
901        assert!(dir.join("platforms/macos/README.md").exists());
902        assert!(dir.join("platforms/linux/README.md").exists());
903        let windows_manifest =
904            std::fs::read_to_string(dir.join("platforms/windows/Package.appxmanifest")).unwrap();
905        assert!(windows_manifest.contains("Windows.FullTrustApplication"));
906        assert!(windows_manifest.contains("runFullTrust"));
907        let windows_msix =
908            std::fs::read_to_string(dir.join("platforms/windows/package-msix.ps1")).unwrap();
909        assert!(windows_msix.contains("makeappx"));
910        assert!(windows_msix.contains("WINDOWS_CERTIFICATE_BASE64"));
911        assert!(windows_msix.contains("finally"));
912        assert!(windows_msix.contains("Remove-Item -Force $TempCertificate"));
913        let windows_msi =
914            std::fs::read_to_string(dir.join("platforms/windows/package-msi.ps1")).unwrap();
915        assert!(windows_msi.contains("WiX"));
916        assert!(windows_msi.contains("WINDOWS_MSI_UPGRADE_CODE"));
917        assert!(windows_msi.contains("finally"));
918        assert!(windows_msi.contains("Remove-Item -Force $TempCertificate"));
919        let readme = std::fs::read_to_string(dir.join("README.md")).unwrap();
920        let agents = std::fs::read_to_string(dir.join("AGENTS.md")).unwrap();
921        assert!(agents.contains("# Fission App Guidelines"));
922        assert!(agents.contains("#[fission_component]"));
923        assert!(agents.contains("Use Fission's native Router and RouterParams"));
924        assert!(readme.contains("fission devices --project-dir ."));
925        assert!(readme.contains("fission run --project-dir ."));
926        assert!(readme.contains("fission logs --target <target>"));
927        assert!(readme.contains("fission build --target <target>"));
928        assert!(readme.contains("fission test --target <target>"));
929        let manifest = std::fs::read_to_string(dir.join("Cargo.toml")).unwrap();
930        assert!(manifest.contains("default-features = false"));
931        assert!(manifest.contains("features = [\"desktop\"]"));
932    }
933
934    #[test]
935    fn publish_app_rejects_noninteractive_flags() {
936        let err = run([
937            "fission",
938            "publish",
939            "--provider",
940            "play-store",
941            "--app",
942            "--dry-run",
943        ])
944        .unwrap_err()
945        .to_string();
946
947        assert!(err.contains("publish --app opens the interactive windowed publish app"));
948
949        let err = run([
950            "fission",
951            "publish",
952            "--provider",
953            "play-store",
954            "--app",
955            "--overwrite-remote",
956        ])
957        .unwrap_err()
958        .to_string();
959
960        assert!(err.contains("--overwrite-remote"));
961    }
962
963    #[test]
964    fn init_writes_agents_to_git_root() {
965        let repo = unique_dir("init-agents-root");
966        fs::create_dir_all(repo.join(".git")).unwrap();
967        let app = repo.join("apps/todo");
968
969        run(["fission", "init", app.to_str().unwrap(), "--name", "todo"]).unwrap();
970
971        assert!(repo.join("AGENTS.md").exists());
972        assert!(!app.join("AGENTS.md").exists());
973    }
974
975    #[test]
976    fn init_uses_fission_agents_name_when_repo_agents_exists() {
977        let repo = unique_dir("init-agents-existing");
978        fs::create_dir_all(repo.join(".git")).unwrap();
979        fs::write(repo.join("AGENTS.md"), "existing repo instructions").unwrap();
980        let app = repo.join("apps/todo");
981
982        run(["fission", "init", app.to_str().unwrap(), "--name", "todo"]).unwrap();
983
984        assert_eq!(
985            fs::read_to_string(repo.join("AGENTS.md")).unwrap(),
986            "existing repo instructions"
987        );
988        let fission_agents = fs::read_to_string(repo.join("AGENTS.fission.md")).unwrap();
989        assert!(fission_agents.contains("# Fission App Guidelines"));
990        assert!(!app.join("AGENTS.md").exists());
991    }
992
993    #[test]
994    fn add_target_updates_manifest_and_scaffold() {
995        let dir = unique_dir("targets");
996        run(["fission", "init", dir.to_str().unwrap()]).unwrap();
997        run([
998            "fission",
999            "add-target",
1000            "web",
1001            "ios",
1002            "android",
1003            "--project-dir",
1004            dir.to_str().unwrap(),
1005        ])
1006        .unwrap();
1007
1008        let project = read_project_config(&dir).unwrap();
1009        assert!(project.targets.contains(&Target::Web));
1010        assert!(project.targets.contains(&Target::Ios));
1011        assert!(project.targets.contains(&Target::Android));
1012        let manifest = std::fs::read_to_string(dir.join("Cargo.toml")).unwrap();
1013        assert!(manifest.contains("default-features = false"));
1014        assert!(manifest.contains("features = [\"desktop\", \"web\", \"android\", \"ios\"]"));
1015        assert!(dir.join("platforms/web/README.md").exists());
1016        assert!(dir.join("platforms/web/index.html").exists());
1017        assert!(dir.join("platforms/web/bootstrap.mjs").exists());
1018        assert!(dir.join("platforms/web/build-wasm.sh").exists());
1019        assert!(dir.join("platforms/web/run-browser.sh").exists());
1020        assert!(dir.join("platforms/web/test-browser.sh").exists());
1021        assert!(dir.join("platforms/ios/README.md").exists());
1022        assert!(dir.join("platforms/ios/Info.plist").exists());
1023        assert!(dir.join("platforms/ios/LaunchScreen.storyboard").exists());
1024        assert!(dir.join("platforms/ios/package-sim.sh").exists());
1025        assert!(dir.join("platforms/ios/package-ipa.sh").exists());
1026        assert!(dir.join("platforms/ios/run-sim.sh").exists());
1027        assert!(dir.join("platforms/ios/test-sim.sh").exists());
1028        assert!(dir.join("platforms/ios/Package.swift").exists());
1029        assert!(dir
1030            .join("platforms/ios/Sources/FissionHost/FissionNativeCapabilities.swift")
1031            .exists());
1032        assert!(dir
1033            .join(
1034                "platforms/ios/NativeModules/Sources/FissionNativeModules/FissionNativeCapabilities.swift"
1035            )
1036            .exists());
1037        assert!(dir.join("platforms/android/README.md").exists());
1038        assert!(dir.join("platforms/android/AndroidManifest.xml").exists());
1039        assert!(dir.join("platforms/android/settings.gradle.kts").exists());
1040        assert!(dir.join("platforms/android/build.gradle.kts").exists());
1041        assert!(dir.join("platforms/android/gradle.properties").exists());
1042        assert!(dir.join("platforms/android/app/build.gradle.kts").exists());
1043        assert!(dir.join("platforms/android/native-modules.gradle").exists());
1044        assert!(dir
1045            .join("platforms/android/java/rs/fission/runtime/FissionActivity.java")
1046            .exists());
1047        assert!(dir
1048            .join("platforms/android/native-modules/README.md")
1049            .exists());
1050        assert!(dir.join("platforms/android/res/values/colors.xml").exists());
1051        assert!(dir.join("platforms/android/res/values/styles.xml").exists());
1052        assert!(dir
1053            .join("platforms/android/res/drawable/fission_splash_background.xml")
1054            .exists());
1055        assert!(dir.join("platforms/android/package-apk.sh").exists());
1056        assert!(dir.join("platforms/android/package-aab.sh").exists());
1057        assert!(dir.join("platforms/android/run-emulator.sh").exists());
1058        assert!(dir.join("platforms/android/test-emulator.sh").exists());
1059        let android_manifest =
1060            std::fs::read_to_string(dir.join("platforms/android/AndroidManifest.xml")).unwrap();
1061        assert!(android_manifest.contains("android:icon=\"@drawable/app_icon\""));
1062        assert!(android_manifest.contains("android:hasCode=\"true\""));
1063        assert!(android_manifest.contains("android:targetSdkVersion=\"35\""));
1064        assert!(android_manifest.contains("android:theme=\"@style/FissionLaunchTheme\""));
1065        assert!(android_manifest.contains("rs.fission.runtime.FissionActivity"));
1066        let android_styles =
1067            std::fs::read_to_string(dir.join("platforms/android/res/values/styles.xml")).unwrap();
1068        assert!(android_styles.contains("android:windowBackground"));
1069        assert!(android_styles.contains("android:windowSplashScreenAnimatedIcon"));
1070        let android_package_script =
1071            std::fs::read_to_string(dir.join("platforms/android/package-apk.sh")).unwrap();
1072        assert!(android_package_script.contains("detect_android_toolchain"));
1073        assert!(android_package_script
1074            .contains("darwin-aarch64 darwin-x86_64 linux-x86_64 windows-x86_64"));
1075        assert!(android_package_script.contains(
1076            "ANDROID_MIN_API_LEVEL=\"${ANDROID_MIN_API_LEVEL:-${ANDROID_API_LEVEL:-24}}\""
1077        ));
1078        assert!(android_package_script.contains("ANDROID_TARGET_API_LEVEL="));
1079        assert!(
1080            android_package_script.contains("aarch64-linux-android${ANDROID_MIN_API_LEVEL}-clang")
1081        );
1082        assert!(android_package_script.contains("GRADLE_CMD"));
1083        assert!(android_package_script.contains(":app:assemble"));
1084        assert!(android_package_script.contains("app/src/main/jniLibs/arm64-v8a"));
1085        assert!(android_package_script.contains("FISSION_GRADLE"));
1086        let android_aab_script =
1087            std::fs::read_to_string(dir.join("platforms/android/package-aab.sh")).unwrap();
1088        assert!(android_aab_script.contains(":app:bundle"));
1089        assert!(android_aab_script.contains("app/build/outputs/bundle"));
1090        assert!(android_aab_script.contains(".aab"));
1091        assert!(android_package_script.contains("Gradle is required"));
1092        let android_run_script =
1093            std::fs::read_to_string(dir.join("platforms/android/run-emulator.sh")).unwrap();
1094        assert!(android_run_script.contains("ANDROID_EMULATOR_API_LEVEL"));
1095        assert!(android_run_script.contains("fission doctor android"));
1096        assert!(android_run_script.contains("wait_for_android_boot()"));
1097        assert!(android_run_script.contains("cmd package list packages"));
1098        assert!(android_run_script.contains("ADB_INSTALL_FLAGS:---no-streaming -r"));
1099        assert!(android_run_script.contains("rs.fission.runtime.FissionActivity"));
1100        assert!(!android_run_script.contains("wait_for_android_boot() {\n  wait_for_android_boot"));
1101        assert!(!android_run_script.contains("  wait_for_android_boot\n  wait_for_android_boot"));
1102        assert!(
1103            std::fs::read_to_string(dir.join("platforms/android/README.md"))
1104                .unwrap()
1105                .contains("fission run --target android")
1106        );
1107        let android_test_script =
1108            std::fs::read_to_string(dir.join("platforms/android/test-emulator.sh")).unwrap();
1109        assert!(android_test_script.contains("/health"));
1110        let ios_package_script =
1111            std::fs::read_to_string(dir.join("platforms/ios/package-sim.sh")).unwrap();
1112        assert!(ios_package_script.contains("TARGET=\"${IOS_SIM_TARGET:-aarch64-apple-ios-sim}\""));
1113        assert!(ios_package_script.contains("PROFILE=\"${IOS_SIM_PROFILE:-debug}\""));
1114        assert!(ios_package_script.contains("BUNDLE_ID=\"${IOS_BUNDLE_ID:-com.example."));
1115        assert!(ios_package_script.contains("DISPLAY_NAME=\"${IOS_DISPLAY_NAME:-"));
1116        assert!(ios_package_script.contains("EXECUTABLE_NAME=\"${IOS_EXECUTABLE_NAME:-"));
1117        assert!(ios_package_script.contains("xcrun --find plutil"));
1118        assert!(ios_package_script.contains("-replace CFBundleIdentifier -string"));
1119        assert!(!ios_package_script.contains("import plistlib"));
1120        assert!(ios_package_script.contains("PkgInfo"));
1121        assert!(ios_package_script.contains("PLATFORM_APP_ICONS"));
1122        assert!(ios_package_script.contains("AppIcon.png"));
1123        assert!(ios_package_script.contains("LaunchScreen.storyboard"));
1124        assert!(ios_package_script.contains("ibtool"));
1125        assert!(ios_package_script.contains("LaunchScreen.storyboardc"));
1126        assert!(ios_package_script.contains("SplashImage.png"));
1127        let ios_ipa_script =
1128            std::fs::read_to_string(dir.join("platforms/ios/package-ipa.sh")).unwrap();
1129        assert!(ios_ipa_script.contains(r#"IOS_TARGET="${IOS_TARGET:-aarch64-apple-ios}""#));
1130        assert!(ios_ipa_script.contains("IOS_SIGNING_IDENTITY"));
1131        assert!(ios_ipa_script.contains("embedded.mobileprovision"));
1132        assert!(ios_ipa_script.contains("zip -qry"));
1133        let ios_plist = std::fs::read_to_string(dir.join("platforms/ios/Info.plist")).unwrap();
1134        assert!(ios_plist.contains("UILaunchStoryboardName"));
1135        let ios_run_script = std::fs::read_to_string(dir.join("platforms/ios/run-sim.sh")).unwrap();
1136        assert!(ios_run_script.contains("BUNDLE_ID=\"${IOS_BUNDLE_ID:-com.example."));
1137        assert!(ios_run_script.contains("IOS_SIM_UNINSTALL_BEFORE_INSTALL"));
1138        assert!(ios_run_script.contains(
1139            "xcrun simctl launch --terminate-running-process \"$DEVICE_ID\" \"$BUNDLE_ID\""
1140        ));
1141        assert!(std::fs::read_to_string(dir.join("platforms/ios/README.md"))
1142            .unwrap()
1143            .contains("fission run --target ios"));
1144        assert!(
1145            std::fs::read_to_string(dir.join("platforms/ios/test-sim.sh"))
1146                .unwrap()
1147                .contains("/health")
1148        );
1149        assert!(
1150            std::fs::read_to_string(dir.join("platforms/web/index.html"))
1151                .unwrap()
1152                .contains("../../assets/app-icon.png")
1153        );
1154        let web_index = std::fs::read_to_string(dir.join("platforms/web/index.html")).unwrap();
1155        assert!(web_index.contains("id=\"fission-web-mount\""));
1156        assert!(web_index.contains("height: 100vh"));
1157        assert!(web_index.contains("outline: none"));
1158        assert!(web_index.contains("touch-action: none"));
1159        assert!(!web_index.contains("Generated by"));
1160        let web_test_script =
1161            std::fs::read_to_string(dir.join("platforms/web/test-browser.sh")).unwrap();
1162        assert!(web_test_script.contains("--remote-debugging-port=\"$CDP_PORT\""));
1163        assert!(web_test_script.contains("/json/list"));
1164        assert!(std::fs::read_to_string(dir.join("platforms/web/README.md"))
1165            .unwrap()
1166            .contains("fission run --target web"));
1167    }
1168
1169    #[test]
1170    fn init_hardens_existing_android_native_only_scaffold() {
1171        let dir = unique_dir("android-hardening");
1172        run(["fission", "init", dir.to_str().unwrap()]).unwrap();
1173        run([
1174            "fission",
1175            "add-target",
1176            "android",
1177            "--project-dir",
1178            dir.to_str().unwrap(),
1179        ])
1180        .unwrap();
1181
1182        let manifest_path = dir.join("platforms/android/AndroidManifest.xml");
1183        let package_path = dir.join("platforms/android/package-apk.sh");
1184        fs::write(
1185            &manifest_path,
1186            fs::read_to_string(&manifest_path).unwrap().replace(
1187                "rs.fission.runtime.FissionActivity",
1188                "android.app.NativeActivity",
1189            ),
1190        )
1191        .unwrap();
1192        fs::write(
1193            &package_path,
1194            fs::read_to_string(&package_path)
1195                .unwrap()
1196                .replace("import pathlib\n", "")
1197                .replace(
1198                    r#"has_code = "true" if pathlib.Path(dest).with_name("apk-root").joinpath("classes.dex").exists() else "false"
1199manifest = re.sub(r'android:hasCode="(?:true|false)"', f'android:hasCode="{has_code}"', manifest)
1200"#,
1201                    "",
1202                ),
1203        )
1204        .unwrap();
1205
1206        run(["fission", "init", dir.to_str().unwrap()]).unwrap();
1207
1208        let manifest = fs::read_to_string(manifest_path).unwrap();
1209        assert!(manifest.contains("android:hasCode=\"false\""));
1210        assert!(manifest.contains("android.app.NativeActivity"));
1211        let package_script = fs::read_to_string(package_path).unwrap();
1212        assert!(package_script.contains(":app:assemble"));
1213        assert!(package_script.contains("app/src/main/jniLibs/arm64-v8a"));
1214        assert!(!package_script.contains("import pathlib"));
1215    }
1216
1217    #[test]
1218    fn init_hardens_existing_android_raw_package_script_resources() {
1219        let dir = unique_dir("android-raw-resources");
1220        run(["fission", "init", dir.to_str().unwrap()]).unwrap();
1221        run([
1222            "fission",
1223            "add-target",
1224            "android",
1225            "--project-dir",
1226            dir.to_str().unwrap(),
1227        ])
1228        .unwrap();
1229
1230        let package_path = dir.join("platforms/android/package-apk.sh");
1231        fs::write(
1232            &package_path,
1233            r#"#!/usr/bin/env bash
1234set -euo pipefail
1235SCRIPT_DIR=$(cd -- "$(dirname "${BASH_SOURCE[0]}")" && pwd)
1236ICON_SOURCE="${FISSION_APP_ICON:-$SCRIPT_DIR/icon.png}"
1237APK_ROOT="$SCRIPT_DIR/build/debug/apk-root"
1238SO_PATH="$SCRIPT_DIR/libexample.so"
1239LIB_NAME="example"
1240mkdir -p "$APK_ROOT/lib/arm64-v8a" "$APK_ROOT/res/drawable-nodpi"
1241cp "$SO_PATH" "$APK_ROOT/lib/arm64-v8a/lib$LIB_NAME.so"
1242cp "$ICON_SOURCE" "$APK_ROOT/res/drawable-nodpi/app_icon.png"
1243"#,
1244        )
1245        .unwrap();
1246
1247        run(["fission", "init", dir.to_str().unwrap()]).unwrap();
1248
1249        let package_script = fs::read_to_string(package_path).unwrap();
1250        assert!(package_script.contains("SPLASH_IMAGES"));
1251        assert!(package_script.contains(
1252            "cp \"$ICON_SOURCE\" \"$APK_ROOT/res/drawable-nodpi/fission_splash_image.png\""
1253        ));
1254        assert!(package_script.contains("cp -R \"$SCRIPT_DIR/res/.\" \"$APK_ROOT/res/\""));
1255    }
1256
1257    #[test]
1258    fn init_hardens_existing_ios_package_script_without_replacing_user_files() {
1259        let dir = unique_dir("ios-package-hardening");
1260        run(["fission", "init", dir.to_str().unwrap()]).unwrap();
1261        run([
1262            "fission",
1263            "add-target",
1264            "ios",
1265            "--project-dir",
1266            dir.to_str().unwrap(),
1267        ])
1268        .unwrap();
1269        let script_path = dir.join("platforms/ios/package-sim.sh");
1270        let mut script = fs::read_to_string(&script_path).unwrap();
1271        script = script.replace(
1272            r#"cp "$SCRIPT_DIR/Info.plist" "$BUNDLE_DIR/Info.plist"
1273PLUTIL=$(xcrun --find plutil 2>/dev/null || command -v plutil || true)
1274if [[ -z "$PLUTIL" ]]; then
1275  printf 'plutil not found. Install Xcode command line tools to package the iOS simulator app.\n' >&2
1276  exit 1
1277fi
1278"$PLUTIL" -replace CFBundleIdentifier -string "$BUNDLE_ID" "$BUNDLE_DIR/Info.plist"
1279"$PLUTIL" -replace CFBundleDisplayName -string "$DISPLAY_NAME" "$BUNDLE_DIR/Info.plist"
1280"$PLUTIL" -replace CFBundleName -string "$DISPLAY_NAME" "$BUNDLE_DIR/Info.plist"
1281"$PLUTIL" -replace CFBundleExecutable -string "$EXECUTABLE_NAME" "$BUNDLE_DIR/Info.plist"
1282"#,
1283            r#"python3 - <<'PY' "$SCRIPT_DIR/Info.plist" "$BUNDLE_DIR/Info.plist" "$BUNDLE_ID" "$DISPLAY_NAME" "$EXECUTABLE_NAME"
1284import plistlib
1285import sys
1286
1287source, dest, bundle_id, display_name, executable_name = sys.argv[1:]
1288with open(source, "rb") as handle:
1289    plist = plistlib.load(handle)
1290plist["CFBundleIdentifier"] = bundle_id
1291plist["CFBundleDisplayName"] = display_name
1292plist["CFBundleName"] = display_name
1293plist["CFBundleExecutable"] = executable_name
1294with open(dest, "wb") as handle:
1295    plistlib.dump(plist, handle, sort_keys=False)
1296PY
1297"#,
1298        );
1299        fs::write(&script_path, script).unwrap();
1300
1301        run(["fission", "init", dir.to_str().unwrap()]).unwrap();
1302
1303        let hardened = fs::read_to_string(script_path).unwrap();
1304        assert!(hardened.contains("xcrun --find plutil"));
1305        assert!(hardened.contains("-replace CFBundleExecutable -string"));
1306        assert!(!hardened.contains("import plistlib"));
1307    }
1308
1309    #[cfg(unix)]
1310    #[test]
1311    fn ios_package_script_executes_without_python_plistlib() {
1312        let dir = unique_dir("ios-package-e2e");
1313        run([
1314            "fission",
1315            "init",
1316            dir.to_str().unwrap(),
1317            "--name",
1318            "ios-package-e2e",
1319            "--app-id",
1320            "com.example.iospackagee2e",
1321        ])
1322        .unwrap();
1323        run([
1324            "fission",
1325            "add-target",
1326            "ios",
1327            "--project-dir",
1328            dir.to_str().unwrap(),
1329        ])
1330        .unwrap();
1331
1332        let fake_bin = dir.join("fake-bin");
1333        let fake_target = dir.join("fake-target");
1334        fs::create_dir_all(&fake_bin).unwrap();
1335        fs::create_dir_all(&fake_target).unwrap();
1336        write_fake_cargo(&fake_bin);
1337        write_fake_python3(&fake_bin);
1338        write_fake_ios_tools(&fake_bin);
1339
1340        let output = Command::new("bash")
1341            .arg("platforms/ios/package-sim.sh")
1342            .current_dir(&dir)
1343            .env("PATH", path_with_fake_bin(&fake_bin))
1344            .env("FAKE_TARGET_DIR", &fake_target)
1345            .env("FISSION_FAKE_BIN", &fake_bin)
1346            .env("IOS_BUNDLE_ID", "com.example.overridden")
1347            .env("IOS_DISPLAY_NAME", "Overridden iOS")
1348            .env("IOS_EXECUTABLE_NAME", "OverriddenExecutable")
1349            .output()
1350            .unwrap();
1351        assert!(
1352            output.status.success(),
1353            "package-sim.sh failed\nstdout:\n{}\nstderr:\n{}",
1354            String::from_utf8_lossy(&output.stdout),
1355            String::from_utf8_lossy(&output.stderr)
1356        );
1357
1358        let bundle_dir = String::from_utf8(output.stdout).unwrap();
1359        let bundle_dir = PathBuf::from(bundle_dir.trim());
1360        assert!(bundle_dir.join("OverriddenExecutable").exists());
1361        assert!(bundle_dir.join("LaunchScreen.storyboardc").exists());
1362        let plist = fs::read_to_string(bundle_dir.join("Info.plist")).unwrap();
1363        assert!(plist.contains("<string>com.example.overridden</string>"));
1364        assert!(plist.contains("<string>Overridden iOS</string>"));
1365        assert!(plist.contains("<string>OverriddenExecutable</string>"));
1366    }
1367
1368    #[cfg(unix)]
1369    #[test]
1370    fn android_package_script_builds_native_only_apk_metadata() {
1371        let dir = unique_dir("android-package-e2e");
1372        run([
1373            "fission",
1374            "init",
1375            dir.to_str().unwrap(),
1376            "--name",
1377            "android-package-e2e",
1378            "--app-id",
1379            "com.example.androidpackagee2e",
1380        ])
1381        .unwrap();
1382        run([
1383            "fission",
1384            "add-target",
1385            "android",
1386            "--project-dir",
1387            dir.to_str().unwrap(),
1388        ])
1389        .unwrap();
1390
1391        let fake_bin = dir.join("fake-bin");
1392        let fake_target = dir.join("fake-target");
1393        let android_home = dir.join("fake-android-sdk");
1394        fs::create_dir_all(&fake_bin).unwrap();
1395        fs::create_dir_all(&fake_target).unwrap();
1396        write_fake_cargo(&fake_bin);
1397        write_fake_python3(&fake_bin);
1398        write_fake_android_tools(&android_home, &fake_bin);
1399        let keystore = dir.join("debug.keystore");
1400        fs::write(&keystore, "fake debug keystore").unwrap();
1401
1402        let output = Command::new("bash")
1403            .arg("platforms/android/package-apk.sh")
1404            .current_dir(&dir)
1405            .env("PATH", path_with_fake_bin(&fake_bin))
1406            .env("FAKE_TARGET_DIR", &fake_target)
1407            .env("ANDROID_HOME", &android_home)
1408            .env("ANDROID_DEBUG_KEYSTORE", &keystore)
1409            .output()
1410            .unwrap();
1411        assert!(
1412            output.status.success(),
1413            "package-apk.sh failed\nstdout:\n{}\nstderr:\n{}",
1414            String::from_utf8_lossy(&output.stdout),
1415            String::from_utf8_lossy(&output.stderr)
1416        );
1417
1418        let apk = String::from_utf8(output.stdout).unwrap();
1419        let apk = PathBuf::from(apk.trim());
1420        let apk_payload = fs::read_to_string(apk).unwrap();
1421        assert!(apk_payload.contains("android:hasCode=\"true\""));
1422        assert!(apk_payload.contains("android:minSdkVersion=\"24\""));
1423        assert!(apk_payload.contains("android:targetSdkVersion=\"35\""));
1424        assert!(apk_payload.contains("APK_ENTRY=lib/arm64-v8a/libandroid_package_e2e.so"));
1425        assert!(!apk_payload.contains("APK_ENTRY=classes.dex"));
1426
1427        let output = Command::new("bash")
1428            .arg("platforms/android/package-aab.sh")
1429            .current_dir(&dir)
1430            .env("PATH", path_with_fake_bin(&fake_bin))
1431            .env("FAKE_TARGET_DIR", &fake_target)
1432            .env("ANDROID_HOME", &android_home)
1433            .env("ANDROID_PROFILE", "release")
1434            .env("ANDROID_KEYSTORE", &keystore)
1435            .env("ANDROID_KEYSTORE_PASSWORD", "secret")
1436            .env("ANDROID_KEYSTORE_ALIAS", "upload")
1437            .output()
1438            .unwrap();
1439        assert!(
1440            output.status.success(),
1441            "package-aab.sh failed\nstdout:\n{}\nstderr:\n{}",
1442            String::from_utf8_lossy(&output.stdout),
1443            String::from_utf8_lossy(&output.stderr)
1444        );
1445        let aab = String::from_utf8(output.stdout).unwrap();
1446        let aab = PathBuf::from(aab.trim());
1447        assert_eq!(aab.extension().and_then(|ext| ext.to_str()), Some("aab"));
1448        let aab_payload = fs::read_to_string(aab).unwrap();
1449        assert!(aab_payload.contains("android:hasCode=\"true\""));
1450        assert!(aab_payload.contains("AAB_ENTRY=lib/arm64-v8a/libandroid_package_e2e.so"));
1451        assert!(!aab_payload.contains("AAB_ENTRY=classes.dex"));
1452    }
1453
1454    #[test]
1455    fn init_existing_project_enables_features_for_detected_mobile_targets() {
1456        let dir = unique_dir("init-mobile-features");
1457        fs::create_dir_all(dir.join("src")).unwrap();
1458        fs::create_dir_all(dir.join("platforms/android")).unwrap();
1459        fs::create_dir_all(dir.join("platforms/ios")).unwrap();
1460        fs::write(dir.join("src/main.rs"), "fn main() {}\n").unwrap();
1461        fs::write(
1462            dir.join("Cargo.toml"),
1463            r#"[package]
1464name = "existing-mobile"
1465version = "0.1.0"
1466edition = "2021"
1467
1468[dependencies]
1469fission = { version = "0.7.0", default-features = false, features = ["desktop"] }
1470"#,
1471        )
1472        .unwrap();
1473
1474        run(["fission", "init", dir.to_str().unwrap()]).unwrap();
1475
1476        let manifest = fs::read_to_string(dir.join("Cargo.toml")).unwrap();
1477        assert!(manifest.contains("default-features = false"));
1478        assert!(manifest.contains(r#"features = ["desktop", "android", "ios"]"#));
1479    }
1480
1481    #[test]
1482    fn add_target_updates_multiline_fission_dependency_features() {
1483        let dir = unique_dir("multiline-features");
1484        run(["fission", "init", dir.to_str().unwrap()]).unwrap();
1485        fs::write(
1486            dir.join("Cargo.toml"),
1487            r#"[package]
1488name = "multiline-features"
1489version = "0.1.0"
1490edition = "2021"
1491
1492[dependencies]
1493anyhow = "1"
1494
1495[dependencies.fission]
1496version = "0.7.0"
1497default-features = true
1498features = ["desktop"]
1499"#,
1500        )
1501        .unwrap();
1502
1503        run([
1504            "fission",
1505            "add-target",
1506            "android",
1507            "ios",
1508            "--project-dir",
1509            dir.to_str().unwrap(),
1510        ])
1511        .unwrap();
1512
1513        let manifest = fs::read_to_string(dir.join("Cargo.toml")).unwrap();
1514        assert!(manifest.contains("[dependencies.fission]"));
1515        assert!(manifest.contains("default-features = false"));
1516        assert!(manifest.contains(r#"features = ["desktop", "android", "ios"]"#));
1517    }
1518
1519    #[test]
1520    fn custom_icon_config_is_preserved_and_applied_to_mobile_scaffolds() {
1521        let dir = unique_dir("custom-icons");
1522        run(["fission", "init", dir.to_str().unwrap()]).unwrap();
1523        fs::copy(
1524            dir.join("assets/app-icon.png"),
1525            dir.join("assets/shared-icon.png"),
1526        )
1527        .unwrap();
1528        fs::copy(
1529            dir.join("assets/app-icon.png"),
1530            dir.join("assets/android-icon.png"),
1531        )
1532        .unwrap();
1533        fs::copy(
1534            dir.join("assets/app-icon.png"),
1535            dir.join("assets/ios-icon.png"),
1536        )
1537        .unwrap();
1538        let mut manifest = fs::read_to_string(dir.join("fission.toml")).unwrap();
1539        manifest.push_str(
1540            r##"
1541
1542[package.icons]
1543mode = "mixed"
1544source = "assets/shared-icon.png"
1545safe_zone = 0.72
1546allow_upscale = false
1547
1548[package.icons.android]
1549source = "assets/android-icon.png"
1550
1551[package.icons.ios]
1552source = "assets/ios-icon.png"
1553"##,
1554        );
1555        fs::write(dir.join("fission.toml"), manifest).unwrap();
1556
1557        run([
1558            "fission",
1559            "add-target",
1560            "ios",
1561            "android",
1562            "--project-dir",
1563            dir.to_str().unwrap(),
1564        ])
1565        .unwrap();
1566
1567        let manifest = fs::read_to_string(dir.join("fission.toml")).unwrap();
1568        assert!(manifest.contains("[package.icons]"));
1569        assert!(manifest.contains("source = \"assets/shared-icon.png\""));
1570        assert!(manifest.contains("[package.icons.android]"));
1571        assert!(manifest.contains("[package.icons.ios]"));
1572        assert!(dir
1573            .join("platforms/android/res/drawable-nodpi/app_icon.png")
1574            .exists());
1575        assert!(dir.join("platforms/ios/AppIcon.png").exists());
1576        let android_manifest =
1577            fs::read_to_string(dir.join("platforms/android/AndroidManifest.xml")).unwrap();
1578        assert!(android_manifest.contains("android:icon=\"@drawable/app_icon\""));
1579        let android_package =
1580            fs::read_to_string(dir.join("platforms/android/package-apk.sh")).unwrap();
1581        assert!(android_package.contains("APP_ICONS"));
1582        let ios_package = fs::read_to_string(dir.join("platforms/ios/package-sim.sh")).unwrap();
1583        assert!(ios_package.contains("PLATFORM_APP_ICONS"));
1584    }
1585
1586    #[test]
1587    fn custom_splash_config_updates_mobile_platform_files() {
1588        let dir = unique_dir("custom-splash");
1589        run(["fission", "init", dir.to_str().unwrap()]).unwrap();
1590        fs::copy(
1591            dir.join("assets/app-icon.png"),
1592            dir.join("assets/custom-splash.png"),
1593        )
1594        .unwrap();
1595        let mut manifest = fs::read_to_string(dir.join("fission.toml")).unwrap();
1596        manifest.push_str(
1597            r##"
1598
1599[app.splash]
1600background_color = "#123456"
1601image = "assets/custom-splash.png"
1602resize_mode = "cover"
1603android_animation_duration_ms = 650
1604"##,
1605        );
1606        fs::write(dir.join("fission.toml"), manifest).unwrap();
1607
1608        run([
1609            "fission",
1610            "add-target",
1611            "ios",
1612            "android",
1613            "--project-dir",
1614            dir.to_str().unwrap(),
1615        ])
1616        .unwrap();
1617
1618        let android_colors =
1619            fs::read_to_string(dir.join("platforms/android/res/values/colors.xml")).unwrap();
1620        assert!(android_colors.contains("#123456"));
1621        let android_styles =
1622            fs::read_to_string(dir.join("platforms/android/res/values/styles.xml")).unwrap();
1623        assert!(android_styles.contains("650"));
1624        assert!(dir
1625            .join("platforms/android/res/drawable-nodpi/fission_splash_image.png")
1626            .exists());
1627        let storyboard =
1628            fs::read_to_string(dir.join("platforms/ios/LaunchScreen.storyboard")).unwrap();
1629        assert!(storyboard.contains("scaleAspectFill"));
1630        assert!(storyboard.contains("red=\"0.070588\""));
1631        assert!(dir.join("platforms/ios/SplashImage.png").exists());
1632    }
1633
1634    #[test]
1635    fn add_capability_updates_project_and_platform_config() {
1636        let dir = unique_dir("capability");
1637        run(["fission", "init", dir.to_str().unwrap()]).unwrap();
1638        run([
1639            "fission",
1640            "add-target",
1641            "ios",
1642            "android",
1643            "--project-dir",
1644            dir.to_str().unwrap(),
1645        ])
1646        .unwrap();
1647        run([
1648            "fission",
1649            "add-capability",
1650            "nfc",
1651            "biometric",
1652            "bluetooth",
1653            "barcode-scanner",
1654            "camera",
1655            "geolocation",
1656            "haptics",
1657            "microphone",
1658            "volume-control",
1659            "wifi",
1660            "--project-dir",
1661            dir.to_str().unwrap(),
1662        ])
1663        .unwrap();
1664
1665        let project = read_project_config(&dir).unwrap();
1666        assert!(project
1667            .capabilities
1668            .contains(&fission_command_core::PlatformCapability::Nfc));
1669        assert!(project
1670            .capabilities
1671            .contains(&fission_command_core::PlatformCapability::Biometric));
1672        assert!(project
1673            .capabilities
1674            .contains(&fission_command_core::PlatformCapability::Bluetooth));
1675        assert!(project
1676            .capabilities
1677            .contains(&fission_command_core::PlatformCapability::BarcodeScanner));
1678        assert!(project
1679            .capabilities
1680            .contains(&fission_command_core::PlatformCapability::Camera));
1681        assert!(project
1682            .capabilities
1683            .contains(&fission_command_core::PlatformCapability::Geolocation));
1684        assert!(project
1685            .capabilities
1686            .contains(&fission_command_core::PlatformCapability::Haptics));
1687        assert!(project
1688            .capabilities
1689            .contains(&fission_command_core::PlatformCapability::Microphone));
1690        assert!(project
1691            .capabilities
1692            .contains(&fission_command_core::PlatformCapability::VolumeControl));
1693        assert!(project
1694            .capabilities
1695            .contains(&fission_command_core::PlatformCapability::Wifi));
1696
1697        let android_manifest =
1698            std::fs::read_to_string(dir.join("platforms/android/AndroidManifest.xml")).unwrap();
1699        assert!(android_manifest.contains("android.permission.NFC"));
1700        assert!(android_manifest.contains("android.hardware.nfc"));
1701        assert!(android_manifest.contains("android.permission.USE_BIOMETRIC"));
1702        assert!(android_manifest.contains("android.permission.BLUETOOTH_SCAN"));
1703        assert!(android_manifest.contains("android.permission.BLUETOOTH_CONNECT"));
1704        assert!(android_manifest.contains("android.hardware.bluetooth_le"));
1705        assert!(android_manifest.contains("android.permission.CAMERA"));
1706        assert!(android_manifest.contains("android.hardware.camera.flash"));
1707        assert!(android_manifest.contains("android.permission.ACCESS_FINE_LOCATION"));
1708        assert!(android_manifest.contains("android.permission.VIBRATE"));
1709        assert!(android_manifest.contains("android.permission.RECORD_AUDIO"));
1710        assert!(android_manifest.contains("android.permission.MODIFY_AUDIO_SETTINGS"));
1711        assert!(android_manifest.contains("android.permission.NEARBY_WIFI_DEVICES"));
1712        assert!(android_manifest.contains("android.permission.ACCESS_WIFI_STATE"));
1713        assert!(dir
1714            .join("platforms/android/java/rs/fission/runtime/FissionAndroidCapabilities.java")
1715            .exists());
1716
1717        let ios_info = std::fs::read_to_string(dir.join("platforms/ios/Info.plist")).unwrap();
1718        assert!(ios_info.contains("NFCReaderUsageDescription"));
1719        assert!(ios_info.contains("NSFaceIDUsageDescription"));
1720        assert!(ios_info.contains("NSBluetoothAlwaysUsageDescription"));
1721        assert!(ios_info.contains("NSCameraUsageDescription"));
1722        assert!(ios_info.contains("NSLocationWhenInUseUsageDescription"));
1723        assert!(ios_info.contains("NSMicrophoneUsageDescription"));
1724        let ios_entitlements =
1725            std::fs::read_to_string(dir.join("platforms/ios/Entitlements.plist")).unwrap();
1726        assert!(ios_entitlements.contains("com.apple.developer.nfc.readersession.formats"));
1727        assert!(ios_entitlements.contains("com.apple.developer.networking.wifi-info"));
1728    }
1729
1730    #[test]
1731    fn init_existing_project_preserves_user_files_and_detects_targets() {
1732        let dir = unique_dir("existing");
1733        fs::create_dir_all(dir.join("src")).unwrap();
1734        fs::create_dir_all(dir.join("platforms/web")).unwrap();
1735        fs::write(
1736            dir.join("Cargo.toml"),
1737            "[package]\nname = \"existing-web\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
1738        )
1739        .unwrap();
1740        fs::write(dir.join("src/main.rs"), "fn main() {}\n").unwrap();
1741        fs::write(dir.join("src/lib.rs"), "pub fn existing() {}\n").unwrap();
1742        fs::write(dir.join("README.md"), "# keep me\n").unwrap();
1743        fs::write(
1744            dir.join("platforms/web/index.html"),
1745            "<!doctype html><title>keep me</title>\n",
1746        )
1747        .unwrap();
1748
1749        run(["fission", "init", dir.to_str().unwrap()]).unwrap();
1750
1751        assert_eq!(
1752            fs::read_to_string(dir.join("README.md")).unwrap(),
1753            "# keep me\n"
1754        );
1755        assert_eq!(
1756            fs::read_to_string(dir.join("src/main.rs")).unwrap(),
1757            "fn main() {}\n"
1758        );
1759        assert_eq!(
1760            fs::read_to_string(dir.join("src/lib.rs")).unwrap(),
1761            "pub fn existing() {}\n"
1762        );
1763        assert_eq!(
1764            fs::read_to_string(dir.join("platforms/web/index.html")).unwrap(),
1765            "<!doctype html><title>keep me</title>\n"
1766        );
1767
1768        let project = read_project_config(&dir).unwrap();
1769        assert_eq!(project.app.name, "existing-web");
1770        assert!(project.targets.contains(&Target::Web));
1771        assert!(project.targets.contains(&Target::Macos));
1772        assert!(project.targets.contains(&Target::Linux));
1773        assert!(project.targets.contains(&Target::Windows));
1774        assert!(dir.join("platforms/web/README.md").exists());
1775        assert!(dir.join("platforms/web/bootstrap.mjs").exists());
1776        assert!(dir.join("assets/app-icon.png").exists());
1777    }
1778
1779    #[test]
1780    fn init_existing_project_is_idempotent() {
1781        let dir = unique_dir("idempotent");
1782        run(["fission", "init", dir.to_str().unwrap(), "--name", "idem"]).unwrap();
1783        let manifest = fs::read_to_string(dir.join("fission.toml")).unwrap();
1784        let main = fs::read_to_string(dir.join("src/main.rs")).unwrap();
1785        let agents = fs::read_to_string(dir.join("AGENTS.md")).unwrap();
1786
1787        run(["fission", "init", dir.to_str().unwrap()]).unwrap();
1788
1789        assert_eq!(
1790            fs::read_to_string(dir.join("fission.toml")).unwrap(),
1791            manifest
1792        );
1793        assert_eq!(fs::read_to_string(dir.join("src/main.rs")).unwrap(), main);
1794        assert_eq!(fs::read_to_string(dir.join("AGENTS.md")).unwrap(), agents);
1795        assert!(!dir.join("AGENTS.fission.md").exists());
1796    }
1797
1798    #[test]
1799    fn add_target_preserves_existing_target_files() {
1800        let dir = unique_dir("preserve-target");
1801        run([
1802            "fission",
1803            "init",
1804            dir.to_str().unwrap(),
1805            "--name",
1806            "preserve-target",
1807        ])
1808        .unwrap();
1809        fs::create_dir_all(dir.join("platforms/web")).unwrap();
1810        fs::write(
1811            dir.join("platforms/web/index.html"),
1812            "<!doctype html><title>custom</title>\n",
1813        )
1814        .unwrap();
1815        fs::write(dir.join("README.md"), "# custom readme\n").unwrap();
1816
1817        run([
1818            "fission",
1819            "add-target",
1820            "web",
1821            "--project-dir",
1822            dir.to_str().unwrap(),
1823        ])
1824        .unwrap();
1825
1826        assert_eq!(
1827            fs::read_to_string(dir.join("platforms/web/index.html")).unwrap(),
1828            "<!doctype html><title>custom</title>\n"
1829        );
1830        assert_eq!(
1831            fs::read_to_string(dir.join("README.md")).unwrap(),
1832            "# custom readme\n"
1833        );
1834        assert!(dir.join("platforms/web/README.md").exists());
1835        assert!(dir.join("platforms/web/bootstrap.mjs").exists());
1836        let project = read_project_config(&dir).unwrap();
1837        assert!(project.targets.contains(&Target::Web));
1838    }
1839
1840    #[test]
1841    fn cargo_fission_alias_accepts_prefixed_subcommand() {
1842        let dir = unique_dir("cargo-fission");
1843        run([
1844            "cargo-fission",
1845            "fission",
1846            "init",
1847            dir.to_str().unwrap(),
1848            "--name",
1849            "cargo-fission-demo",
1850        ])
1851        .unwrap();
1852
1853        assert!(dir.join("Cargo.toml").exists());
1854        assert!(dir.join("fission.toml").exists());
1855    }
1856
1857    #[test]
1858    fn doctor_command_runs_in_non_strict_mode() {
1859        let dir = unique_dir("doctor");
1860        run(["fission", "init", dir.to_str().unwrap()]).unwrap();
1861        run([
1862            "fission",
1863            "doctor",
1864            "web",
1865            "--project-dir",
1866            dir.to_str().unwrap(),
1867        ])
1868        .unwrap();
1869    }
1870}