Skip to main content

fission_cli/
lib.rs

1use anyhow::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            dry_run,
152            yes,
153            project_dir,
154            json,
155        } => fission_command_package::distribute(fission_command_package::DistributeOptions {
156            project_dir,
157            provider,
158            action: action.unwrap_or(fission_command_package::DistributeAction::Publish),
159            artifact,
160            site,
161            deploy,
162            track,
163            dry_run,
164            yes,
165            json,
166        }),
167        Command::Publish {
168            provider,
169            artifact,
170            site,
171            deploy,
172            track,
173            dry_run,
174            yes,
175            project_dir,
176            json,
177        } => fission_command_package::distribute(fission_command_package::DistributeOptions {
178            project_dir,
179            provider,
180            action: fission_command_package::DistributeAction::Publish,
181            artifact,
182            site,
183            deploy,
184            track,
185            dry_run,
186            yes,
187            json,
188        }),
189        Command::Readiness {
190            kind,
191            target,
192            format,
193            provider,
194            artifact,
195            site,
196            track,
197            project_dir,
198            json,
199        } => fission_command_package::readiness(fission_command_package::ReadinessOptions {
200            project_dir,
201            kind,
202            target,
203            format,
204            provider,
205            artifact,
206            site,
207            track,
208            json,
209        }),
210        Command::ReleaseConfig { command } => fission_command_release::release_config(command),
211        Command::ReleaseContent { command } => fission_command_release::release_content(command),
212        Command::Beta { command } => fission_command_release::beta(command),
213        Command::Signing { command } => fission_command_release::signing(command),
214        Command::Reviews { command } => fission_command_release::reviews(command),
215        Command::ReleaseWorkflow { command } => fission_command_release::release_workflow(command),
216        Command::Auth { command } => fission_command_release::auth(command),
217        Command::Logs {
218            target,
219            device,
220            project_dir,
221            follow,
222        } => fission_command_run::attach_logs(fission_command_run::LogOptions {
223            project_dir,
224            target,
225            device,
226            follow,
227        }),
228        Command::Ui {
229            project_dir,
230            screenshot,
231            exit_after_render,
232            width,
233            height,
234        } => fission_command_ui::run_ui(fission_command_ui::UiOptions {
235            project_dir,
236            screenshot,
237            exit_after_render,
238            width,
239            height,
240        }),
241        Command::ServeWeb {
242            project_dir,
243            host,
244            port,
245            open,
246        } => fission_command_run::serve_web(fission_command_run::ServeWebOptions {
247            project_dir,
248            host,
249            port,
250            open,
251        }),
252    }
253}
254
255#[cfg(test)]
256mod tests {
257    use super::*;
258    use std::{fs, path::PathBuf, process::Command};
259
260    fn unique_dir(name: &str) -> PathBuf {
261        let dir =
262            std::env::temp_dir().join(format!("cargo-fission-{}-{}", name, std::process::id()));
263        let _ = fs::remove_dir_all(&dir);
264        dir
265    }
266
267    #[cfg(unix)]
268    fn write_executable(path: impl AsRef<std::path::Path>, contents: &str) {
269        use std::os::unix::fs::PermissionsExt;
270
271        let path = path.as_ref();
272        if let Some(parent) = path.parent() {
273            fs::create_dir_all(parent).unwrap();
274        }
275        fs::write(path, contents).unwrap();
276        fs::set_permissions(path, fs::Permissions::from_mode(0o755)).unwrap();
277    }
278
279    #[cfg(unix)]
280    fn path_with_fake_bin(fake_bin: &std::path::Path) -> String {
281        let existing = std::env::var("PATH").unwrap_or_default();
282        format!("{}:{existing}", fake_bin.display())
283    }
284
285    #[cfg(unix)]
286    fn write_fake_cargo(fake_bin: &std::path::Path) {
287        write_executable(
288            fake_bin.join("cargo"),
289            r#"#!/usr/bin/env bash
290set -euo pipefail
291if [[ "${1:-}" == "metadata" ]]; then
292  printf '%s\n' "$FAKE_TARGET_DIR"
293  exit 0
294fi
295if [[ "${1:-}" == "build" ]]; then
296  target=""
297  package=""
298  profile="debug"
299  args=("$@")
300  for ((i = 0; i < ${#args[@]}; i++)); do
301    case "${args[$i]}" in
302      --target) target="${args[$((i + 1))]}" ;;
303      --package) package="${args[$((i + 1))]}" ;;
304      --release) profile="release" ;;
305    esac
306  done
307  if [[ -z "$target" || -z "$package" ]]; then
308    printf 'fake cargo expected --target and --package\n' >&2
309    exit 2
310  fi
311  artifact_dir="$FAKE_TARGET_DIR/$target/$profile"
312  mkdir -p "$artifact_dir"
313  lib_name="${package//-/_}"
314  printf 'fake native library\n' > "$artifact_dir/lib${lib_name}.so"
315  printf '#!/usr/bin/env sh\nexit 0\n' > "$artifact_dir/$package"
316  chmod +x "$artifact_dir/$package"
317  exit 0
318fi
319printf 'unsupported fake cargo invocation: %s\n' "$*" >&2
320exit 2
321"#,
322        );
323    }
324
325    #[cfg(unix)]
326    fn write_fake_python3(fake_bin: &std::path::Path) {
327        write_executable(
328            fake_bin.join("python3"),
329            r#"#!/usr/bin/env bash
330set -euo pipefail
331script=$(cat)
332if [[ "$script" == *"import plistlib"* ]]; then
333  printf 'plistlib must not be used by generated mobile package scripts\n' >&2
334  exit 44
335fi
336if [[ "$script" == *"cargo\", \"metadata\""* || "$script" == *"metadata = json.loads"* ]]; then
337  printf '%s\n' "$FAKE_TARGET_DIR"
338  exit 0
339fi
340if [[ "$script" == *"android:minSdkVersion"* ]]; then
341  if [[ "${1:-}" == "-" ]]; then
342    shift
343  fi
344  source="$1"
345  dest="$2"
346  min_api="$3"
347  target_api="$4"
348  has_code=false
349  if [[ -f "$(dirname "$dest")/apk-root/classes.dex" ]]; then
350    has_code=true
351  fi
352  sed -E \
353    -e "s/android:minSdkVersion=\"[0-9]+\"/android:minSdkVersion=\"$min_api\"/" \
354    -e "s/android:targetSdkVersion=\"[0-9]+\"/android:targetSdkVersion=\"$target_api\"/" \
355    -e "s/android:hasCode=\"(true|false)\"/android:hasCode=\"$has_code\"/" \
356    "$source" > "$dest"
357  exit 0
358fi
359printf 'unsupported fake python3 script\n%s\n' "$script" >&2
360exit 2
361"#,
362        );
363    }
364
365    #[cfg(unix)]
366    fn write_fake_ios_tools(fake_bin: &std::path::Path) {
367        write_executable(
368            fake_bin.join("xcrun"),
369            r#"#!/usr/bin/env bash
370set -euo pipefail
371if [[ "${1:-}" == "--find" && "${2:-}" == "plutil" ]]; then
372  printf '%s/plutil\n' "$FISSION_FAKE_BIN"
373  exit 0
374fi
375if [[ "${1:-}" == "--find" && "${2:-}" == "ibtool" ]]; then
376  printf '%s/ibtool\n' "$FISSION_FAKE_BIN"
377  exit 0
378fi
379printf 'unsupported fake xcrun invocation: %s\n' "$*" >&2
380exit 2
381"#,
382        );
383        write_executable(
384            fake_bin.join("plutil"),
385            r#"#!/usr/bin/env bash
386set -euo pipefail
387if [[ "${1:-}" != "-replace" || "${3:-}" != "-string" ]]; then
388  printf 'unsupported fake plutil invocation: %s\n' "$*" >&2
389  exit 2
390fi
391key="$2"
392value="$4"
393file="$5"
394tmp="$(mktemp)"
395awk -v key="$key" -v value="$value" '
396  replace_next {
397    print "  <string>" value "</string>"
398    replace_next = 0
399    next
400  }
401  {
402    print
403    if ($0 ~ "<key>" key "</key>") {
404      replace_next = 1
405    }
406  }
407' "$file" > "$tmp"
408mv "$tmp" "$file"
409"#,
410        );
411        write_executable(
412            fake_bin.join("ibtool"),
413            r#"#!/usr/bin/env bash
414set -euo pipefail
415args=("$@")
416for ((i = 0; i < ${#args[@]}; i++)); do
417  if [[ "${args[$i]}" == "--compile" ]]; then
418    mkdir -p "${args[$((i + 1))]}"
419    exit 0
420  fi
421done
422printf 'fake ibtool missing --compile\n' >&2
423exit 2
424"#,
425        );
426    }
427
428    #[cfg(unix)]
429    fn write_fake_android_tools(android_home: &std::path::Path, fake_bin: &std::path::Path) {
430        let build_tools = android_home.join("build-tools/35.0.0");
431        let ndk_bin = android_home.join("ndk/27.0.0/toolchains/llvm/prebuilt/linux-x86_64/bin");
432        fs::create_dir_all(android_home.join("platforms/android-35")).unwrap();
433        fs::write(android_home.join("platforms/android-35/android.jar"), "").unwrap();
434        fs::create_dir_all(&build_tools).unwrap();
435        fs::create_dir_all(&ndk_bin).unwrap();
436        fs::write(ndk_bin.join("aarch64-linux-android24-clang"), "").unwrap();
437        fs::write(ndk_bin.join("llvm-ar"), "").unwrap();
438
439        write_executable(
440            build_tools.join("aapt"),
441            r#"#!/usr/bin/env bash
442set -euo pipefail
443out=""
444manifest=""
445args=("$@")
446for ((i = 0; i < ${#args[@]}; i++)); do
447  case "${args[$i]}" in
448    -F) out="${args[$((i + 1))]}" ;;
449    -M) manifest="${args[$((i + 1))]}" ;;
450  esac
451done
452if [[ -z "$out" || -z "$manifest" ]]; then
453  printf 'fake aapt missing -F or -M\n' >&2
454  exit 2
455fi
456mkdir -p "$(dirname "$out")"
457{
458  printf 'AAPT_MANIFEST_BEGIN\n'
459  cat "$manifest"
460  printf '\nAAPT_MANIFEST_END\n'
461} > "$out"
462"#,
463        );
464        write_executable(
465            build_tools.join("zipalign"),
466            r#"#!/usr/bin/env bash
467set -euo pipefail
468args=("$@")
469count=${#args[@]}
470input="${args[$((count - 2))]}"
471output="${args[$((count - 1))]}"
472cp "$input" "$output"
473"#,
474        );
475        write_executable(
476            build_tools.join("apksigner"),
477            r#"#!/usr/bin/env bash
478set -euo pipefail
479out=""
480input=""
481args=("$@")
482for ((i = 0; i < ${#args[@]}; i++)); do
483  if [[ "${args[$i]}" == "--out" ]]; then
484    out="${args[$((i + 1))]}"
485  fi
486done
487input="${args[$((${#args[@]} - 1))]}"
488if [[ -z "$out" ]]; then
489  printf 'fake apksigner missing --out\n' >&2
490  exit 2
491fi
492cp "$input" "$out"
493"#,
494        );
495        write_executable(
496            fake_bin.join("zip"),
497            r#"#!/usr/bin/env bash
498set -euo pipefail
499archive=""
500entries=()
501for arg in "$@"; do
502  if [[ "$arg" == -* ]]; then
503    continue
504  fi
505  if [[ -z "$archive" ]]; then
506    archive="$arg"
507  else
508    entries+=("$arg")
509  fi
510done
511if [[ -z "$archive" ]]; then
512  printf 'fake zip missing archive\n' >&2
513  exit 2
514fi
515for entry in "${entries[@]}"; do
516  if [[ -d "$entry" ]]; then
517    while IFS= read -r file; do
518      printf 'APK_ENTRY=%s\n' "$file" >> "$archive"
519    done < <(find "$entry" -type f | sort)
520  elif [[ -f "$entry" ]]; then
521    printf 'APK_ENTRY=%s\n' "$entry" >> "$archive"
522  fi
523done
524	"#,
525        );
526        write_executable(
527            fake_bin.join("gradle"),
528            r#"#!/usr/bin/env bash
529set -euo pipefail
530project=""
531args=("$@")
532for ((i = 0; i < ${#args[@]}; i++)); do
533  if [[ "${args[$i]}" == "-p" ]]; then
534    project="${args[$((i + 1))]}"
535  fi
536done
537if [[ -z "$project" ]]; then
538  printf 'fake gradle missing -p project\n' >&2
539  exit 2
540fi
541variant="debug"
542for arg in "$@"; do
543  if [[ "$arg" == *"assembleRelease"* ]]; then
544    variant="release"
545  fi
546done
547apk="$project/app/build/outputs/apk/$variant/app-$variant.apk"
548mkdir -p "$(dirname "$apk")"
549{
550  printf 'GRADLE_MANIFEST_BEGIN\n'
551  cat "$project/AndroidManifest.xml"
552  printf '\nGRADLE_MANIFEST_END\n'
553  if [[ -d "$project/app/src/main/jniLibs" ]]; then
554    (cd "$project/app/src/main/jniLibs" && find . -type f | sort | sed 's#^\./#APK_ENTRY=lib/#')
555  fi
556} > "$apk"
557"#,
558        );
559    }
560
561    #[test]
562    fn init_creates_project_files() {
563        let dir = unique_dir("init");
564        run([
565            "fission",
566            "init",
567            dir.to_str().unwrap(),
568            "--name",
569            "hello-fission",
570        ])
571        .unwrap();
572
573        assert!(dir.join("Cargo.toml").exists());
574        assert!(dir.join("src/main.rs").exists());
575        assert!(dir.join("src/lib.rs").exists());
576        assert!(dir.join("src/app.rs").exists());
577        assert!(dir.join("assets/app-icon.png").exists());
578        assert!(dir.join("fission.toml").exists());
579        assert!(dir.join("platforms/windows/README.md").exists());
580        assert!(dir.join("platforms/macos/README.md").exists());
581        assert!(dir.join("platforms/linux/README.md").exists());
582        let readme = std::fs::read_to_string(dir.join("README.md")).unwrap();
583        assert!(readme.contains("fission devices --project-dir ."));
584        assert!(readme.contains("fission run --project-dir ."));
585        assert!(readme.contains("fission logs --target <target>"));
586        assert!(readme.contains("fission build --target <target>"));
587        assert!(readme.contains("fission test --target <target>"));
588        let manifest = std::fs::read_to_string(dir.join("Cargo.toml")).unwrap();
589        assert!(manifest.contains("default-features = false"));
590        assert!(manifest.contains("features = [\"desktop\"]"));
591    }
592
593    #[test]
594    fn add_target_updates_manifest_and_scaffold() {
595        let dir = unique_dir("targets");
596        run(["fission", "init", dir.to_str().unwrap()]).unwrap();
597        run([
598            "fission",
599            "add-target",
600            "web",
601            "ios",
602            "android",
603            "--project-dir",
604            dir.to_str().unwrap(),
605        ])
606        .unwrap();
607
608        let project = read_project_config(&dir).unwrap();
609        assert!(project.targets.contains(&Target::Web));
610        assert!(project.targets.contains(&Target::Ios));
611        assert!(project.targets.contains(&Target::Android));
612        let manifest = std::fs::read_to_string(dir.join("Cargo.toml")).unwrap();
613        assert!(manifest.contains("default-features = false"));
614        assert!(manifest.contains("features = [\"desktop\", \"web\", \"android\", \"ios\"]"));
615        assert!(dir.join("platforms/web/README.md").exists());
616        assert!(dir.join("platforms/web/index.html").exists());
617        assert!(dir.join("platforms/web/bootstrap.mjs").exists());
618        assert!(dir.join("platforms/web/build-wasm.sh").exists());
619        assert!(dir.join("platforms/web/run-browser.sh").exists());
620        assert!(dir.join("platforms/web/test-browser.sh").exists());
621        assert!(dir.join("platforms/ios/README.md").exists());
622        assert!(dir.join("platforms/ios/Info.plist").exists());
623        assert!(dir.join("platforms/ios/LaunchScreen.storyboard").exists());
624        assert!(dir.join("platforms/ios/package-sim.sh").exists());
625        assert!(dir.join("platforms/ios/run-sim.sh").exists());
626        assert!(dir.join("platforms/ios/test-sim.sh").exists());
627        assert!(dir.join("platforms/ios/Package.swift").exists());
628        assert!(dir
629            .join("platforms/ios/Sources/FissionHost/FissionNativeCapabilities.swift")
630            .exists());
631        assert!(dir
632            .join(
633                "platforms/ios/NativeModules/Sources/FissionNativeModules/FissionNativeCapabilities.swift"
634            )
635            .exists());
636        assert!(dir.join("platforms/android/README.md").exists());
637        assert!(dir.join("platforms/android/AndroidManifest.xml").exists());
638        assert!(dir.join("platforms/android/settings.gradle.kts").exists());
639        assert!(dir.join("platforms/android/build.gradle.kts").exists());
640        assert!(dir.join("platforms/android/gradle.properties").exists());
641        assert!(dir.join("platforms/android/app/build.gradle.kts").exists());
642        assert!(dir.join("platforms/android/native-modules.gradle").exists());
643        assert!(dir
644            .join("platforms/android/java/rs/fission/runtime/FissionActivity.java")
645            .exists());
646        assert!(dir
647            .join("platforms/android/native-modules/README.md")
648            .exists());
649        assert!(dir.join("platforms/android/res/values/colors.xml").exists());
650        assert!(dir.join("platforms/android/res/values/styles.xml").exists());
651        assert!(dir
652            .join("platforms/android/res/drawable/fission_splash_background.xml")
653            .exists());
654        assert!(dir.join("platforms/android/package-apk.sh").exists());
655        assert!(dir.join("platforms/android/run-emulator.sh").exists());
656        assert!(dir.join("platforms/android/test-emulator.sh").exists());
657        let android_manifest =
658            std::fs::read_to_string(dir.join("platforms/android/AndroidManifest.xml")).unwrap();
659        assert!(android_manifest.contains("android:icon=\"@drawable/app_icon\""));
660        assert!(android_manifest.contains("android:hasCode=\"true\""));
661        assert!(android_manifest.contains("android:targetSdkVersion=\"35\""));
662        assert!(android_manifest.contains("android:theme=\"@style/FissionLaunchTheme\""));
663        assert!(android_manifest.contains("rs.fission.runtime.FissionActivity"));
664        let android_styles =
665            std::fs::read_to_string(dir.join("platforms/android/res/values/styles.xml")).unwrap();
666        assert!(android_styles.contains("android:windowBackground"));
667        assert!(android_styles.contains("android:windowSplashScreenAnimatedIcon"));
668        let android_package_script =
669            std::fs::read_to_string(dir.join("platforms/android/package-apk.sh")).unwrap();
670        assert!(android_package_script.contains("detect_android_toolchain"));
671        assert!(android_package_script
672            .contains("darwin-aarch64 darwin-x86_64 linux-x86_64 windows-x86_64"));
673        assert!(android_package_script.contains(
674            "ANDROID_MIN_API_LEVEL=\"${ANDROID_MIN_API_LEVEL:-${ANDROID_API_LEVEL:-24}}\""
675        ));
676        assert!(android_package_script.contains("ANDROID_TARGET_API_LEVEL="));
677        assert!(
678            android_package_script.contains("aarch64-linux-android${ANDROID_MIN_API_LEVEL}-clang")
679        );
680        assert!(android_package_script.contains("GRADLE_CMD"));
681        assert!(android_package_script.contains(":app:assemble"));
682        assert!(android_package_script.contains("app/src/main/jniLibs/arm64-v8a"));
683        assert!(android_package_script.contains("FISSION_GRADLE"));
684        assert!(android_package_script.contains("Gradle is required"));
685        let android_run_script =
686            std::fs::read_to_string(dir.join("platforms/android/run-emulator.sh")).unwrap();
687        assert!(android_run_script.contains("ANDROID_EMULATOR_API_LEVEL"));
688        assert!(android_run_script.contains("fission doctor android"));
689        assert!(android_run_script.contains("wait_for_android_boot()"));
690        assert!(android_run_script.contains("cmd package list packages"));
691        assert!(android_run_script.contains("ADB_INSTALL_FLAGS:---no-streaming -r"));
692        assert!(android_run_script.contains("rs.fission.runtime.FissionActivity"));
693        assert!(!android_run_script.contains("wait_for_android_boot() {\n  wait_for_android_boot"));
694        assert!(!android_run_script.contains("  wait_for_android_boot\n  wait_for_android_boot"));
695        assert!(
696            std::fs::read_to_string(dir.join("platforms/android/README.md"))
697                .unwrap()
698                .contains("fission run --target android")
699        );
700        let android_test_script =
701            std::fs::read_to_string(dir.join("platforms/android/test-emulator.sh")).unwrap();
702        assert!(android_test_script.contains("/health"));
703        let ios_package_script =
704            std::fs::read_to_string(dir.join("platforms/ios/package-sim.sh")).unwrap();
705        assert!(ios_package_script.contains("TARGET=\"${IOS_SIM_TARGET:-aarch64-apple-ios-sim}\""));
706        assert!(ios_package_script.contains("PROFILE=\"${IOS_SIM_PROFILE:-debug}\""));
707        assert!(ios_package_script.contains("BUNDLE_ID=\"${IOS_BUNDLE_ID:-com.example."));
708        assert!(ios_package_script.contains("DISPLAY_NAME=\"${IOS_DISPLAY_NAME:-"));
709        assert!(ios_package_script.contains("EXECUTABLE_NAME=\"${IOS_EXECUTABLE_NAME:-"));
710        assert!(ios_package_script.contains("xcrun --find plutil"));
711        assert!(ios_package_script.contains("-replace CFBundleIdentifier -string"));
712        assert!(!ios_package_script.contains("import plistlib"));
713        assert!(ios_package_script.contains("PkgInfo"));
714        assert!(ios_package_script.contains("PLATFORM_APP_ICONS"));
715        assert!(ios_package_script.contains("AppIcon.png"));
716        assert!(ios_package_script.contains("LaunchScreen.storyboard"));
717        assert!(ios_package_script.contains("ibtool"));
718        assert!(ios_package_script.contains("LaunchScreen.storyboardc"));
719        assert!(ios_package_script.contains("SplashImage.png"));
720        let ios_plist = std::fs::read_to_string(dir.join("platforms/ios/Info.plist")).unwrap();
721        assert!(ios_plist.contains("UILaunchStoryboardName"));
722        let ios_run_script = std::fs::read_to_string(dir.join("platforms/ios/run-sim.sh")).unwrap();
723        assert!(ios_run_script.contains("BUNDLE_ID=\"${IOS_BUNDLE_ID:-com.example."));
724        assert!(ios_run_script.contains("IOS_SIM_UNINSTALL_BEFORE_INSTALL"));
725        assert!(ios_run_script.contains(
726            "xcrun simctl launch --terminate-running-process \"$DEVICE_ID\" \"$BUNDLE_ID\""
727        ));
728        assert!(std::fs::read_to_string(dir.join("platforms/ios/README.md"))
729            .unwrap()
730            .contains("fission run --target ios"));
731        assert!(
732            std::fs::read_to_string(dir.join("platforms/ios/test-sim.sh"))
733                .unwrap()
734                .contains("/health")
735        );
736        assert!(
737            std::fs::read_to_string(dir.join("platforms/web/index.html"))
738                .unwrap()
739                .contains("../../assets/app-icon.png")
740        );
741        let web_index = std::fs::read_to_string(dir.join("platforms/web/index.html")).unwrap();
742        assert!(web_index.contains("id=\"fission-web-mount\""));
743        assert!(web_index.contains("height: 100vh"));
744        assert!(web_index.contains("outline: none"));
745        assert!(web_index.contains("touch-action: none"));
746        assert!(!web_index.contains("Generated by"));
747        let web_test_script =
748            std::fs::read_to_string(dir.join("platforms/web/test-browser.sh")).unwrap();
749        assert!(web_test_script.contains("--remote-debugging-port=\"$CDP_PORT\""));
750        assert!(web_test_script.contains("/json/list"));
751        assert!(std::fs::read_to_string(dir.join("platforms/web/README.md"))
752            .unwrap()
753            .contains("fission run --target web"));
754    }
755
756    #[test]
757    fn init_hardens_existing_android_native_only_scaffold() {
758        let dir = unique_dir("android-hardening");
759        run(["fission", "init", dir.to_str().unwrap()]).unwrap();
760        run([
761            "fission",
762            "add-target",
763            "android",
764            "--project-dir",
765            dir.to_str().unwrap(),
766        ])
767        .unwrap();
768
769        let manifest_path = dir.join("platforms/android/AndroidManifest.xml");
770        let package_path = dir.join("platforms/android/package-apk.sh");
771        fs::write(
772            &manifest_path,
773            fs::read_to_string(&manifest_path).unwrap().replace(
774                "rs.fission.runtime.FissionActivity",
775                "android.app.NativeActivity",
776            ),
777        )
778        .unwrap();
779        fs::write(
780            &package_path,
781            fs::read_to_string(&package_path)
782                .unwrap()
783                .replace("import pathlib\n", "")
784                .replace(
785                    r#"has_code = "true" if pathlib.Path(dest).with_name("apk-root").joinpath("classes.dex").exists() else "false"
786manifest = re.sub(r'android:hasCode="(?:true|false)"', f'android:hasCode="{has_code}"', manifest)
787"#,
788                    "",
789                ),
790        )
791        .unwrap();
792
793        run(["fission", "init", dir.to_str().unwrap()]).unwrap();
794
795        let manifest = fs::read_to_string(manifest_path).unwrap();
796        assert!(manifest.contains("android:hasCode=\"false\""));
797        assert!(manifest.contains("android.app.NativeActivity"));
798        let package_script = fs::read_to_string(package_path).unwrap();
799        assert!(package_script.contains(":app:assemble"));
800        assert!(package_script.contains("app/src/main/jniLibs/arm64-v8a"));
801        assert!(!package_script.contains("import pathlib"));
802    }
803
804    #[test]
805    fn init_hardens_existing_android_raw_package_script_resources() {
806        let dir = unique_dir("android-raw-resources");
807        run(["fission", "init", dir.to_str().unwrap()]).unwrap();
808        run([
809            "fission",
810            "add-target",
811            "android",
812            "--project-dir",
813            dir.to_str().unwrap(),
814        ])
815        .unwrap();
816
817        let package_path = dir.join("platforms/android/package-apk.sh");
818        fs::write(
819            &package_path,
820            r#"#!/usr/bin/env bash
821set -euo pipefail
822SCRIPT_DIR=$(cd -- "$(dirname "${BASH_SOURCE[0]}")" && pwd)
823ICON_SOURCE="${FISSION_APP_ICON:-$SCRIPT_DIR/icon.png}"
824APK_ROOT="$SCRIPT_DIR/build/debug/apk-root"
825SO_PATH="$SCRIPT_DIR/libexample.so"
826LIB_NAME="example"
827mkdir -p "$APK_ROOT/lib/arm64-v8a" "$APK_ROOT/res/drawable-nodpi"
828cp "$SO_PATH" "$APK_ROOT/lib/arm64-v8a/lib$LIB_NAME.so"
829cp "$ICON_SOURCE" "$APK_ROOT/res/drawable-nodpi/app_icon.png"
830"#,
831        )
832        .unwrap();
833
834        run(["fission", "init", dir.to_str().unwrap()]).unwrap();
835
836        let package_script = fs::read_to_string(package_path).unwrap();
837        assert!(package_script.contains("SPLASH_IMAGES"));
838        assert!(package_script.contains(
839            "cp \"$ICON_SOURCE\" \"$APK_ROOT/res/drawable-nodpi/fission_splash_image.png\""
840        ));
841        assert!(package_script.contains("cp -R \"$SCRIPT_DIR/res/.\" \"$APK_ROOT/res/\""));
842    }
843
844    #[test]
845    fn init_hardens_existing_ios_package_script_without_replacing_user_files() {
846        let dir = unique_dir("ios-package-hardening");
847        run(["fission", "init", dir.to_str().unwrap()]).unwrap();
848        run([
849            "fission",
850            "add-target",
851            "ios",
852            "--project-dir",
853            dir.to_str().unwrap(),
854        ])
855        .unwrap();
856        let script_path = dir.join("platforms/ios/package-sim.sh");
857        let mut script = fs::read_to_string(&script_path).unwrap();
858        script = script.replace(
859            r#"cp "$SCRIPT_DIR/Info.plist" "$BUNDLE_DIR/Info.plist"
860PLUTIL=$(xcrun --find plutil 2>/dev/null || command -v plutil || true)
861if [[ -z "$PLUTIL" ]]; then
862  printf 'plutil not found. Install Xcode command line tools to package the iOS simulator app.\n' >&2
863  exit 1
864fi
865"$PLUTIL" -replace CFBundleIdentifier -string "$BUNDLE_ID" "$BUNDLE_DIR/Info.plist"
866"$PLUTIL" -replace CFBundleDisplayName -string "$DISPLAY_NAME" "$BUNDLE_DIR/Info.plist"
867"$PLUTIL" -replace CFBundleName -string "$DISPLAY_NAME" "$BUNDLE_DIR/Info.plist"
868"$PLUTIL" -replace CFBundleExecutable -string "$EXECUTABLE_NAME" "$BUNDLE_DIR/Info.plist"
869"#,
870            r#"python3 - <<'PY' "$SCRIPT_DIR/Info.plist" "$BUNDLE_DIR/Info.plist" "$BUNDLE_ID" "$DISPLAY_NAME" "$EXECUTABLE_NAME"
871import plistlib
872import sys
873
874source, dest, bundle_id, display_name, executable_name = sys.argv[1:]
875with open(source, "rb") as handle:
876    plist = plistlib.load(handle)
877plist["CFBundleIdentifier"] = bundle_id
878plist["CFBundleDisplayName"] = display_name
879plist["CFBundleName"] = display_name
880plist["CFBundleExecutable"] = executable_name
881with open(dest, "wb") as handle:
882    plistlib.dump(plist, handle, sort_keys=False)
883PY
884"#,
885        );
886        fs::write(&script_path, script).unwrap();
887
888        run(["fission", "init", dir.to_str().unwrap()]).unwrap();
889
890        let hardened = fs::read_to_string(script_path).unwrap();
891        assert!(hardened.contains("xcrun --find plutil"));
892        assert!(hardened.contains("-replace CFBundleExecutable -string"));
893        assert!(!hardened.contains("import plistlib"));
894    }
895
896    #[cfg(unix)]
897    #[test]
898    fn ios_package_script_executes_without_python_plistlib() {
899        let dir = unique_dir("ios-package-e2e");
900        run([
901            "fission",
902            "init",
903            dir.to_str().unwrap(),
904            "--name",
905            "ios-package-e2e",
906            "--app-id",
907            "com.example.iospackagee2e",
908        ])
909        .unwrap();
910        run([
911            "fission",
912            "add-target",
913            "ios",
914            "--project-dir",
915            dir.to_str().unwrap(),
916        ])
917        .unwrap();
918
919        let fake_bin = dir.join("fake-bin");
920        let fake_target = dir.join("fake-target");
921        fs::create_dir_all(&fake_bin).unwrap();
922        fs::create_dir_all(&fake_target).unwrap();
923        write_fake_cargo(&fake_bin);
924        write_fake_python3(&fake_bin);
925        write_fake_ios_tools(&fake_bin);
926
927        let output = Command::new("bash")
928            .arg("platforms/ios/package-sim.sh")
929            .current_dir(&dir)
930            .env("PATH", path_with_fake_bin(&fake_bin))
931            .env("FAKE_TARGET_DIR", &fake_target)
932            .env("FISSION_FAKE_BIN", &fake_bin)
933            .env("IOS_BUNDLE_ID", "com.example.overridden")
934            .env("IOS_DISPLAY_NAME", "Overridden iOS")
935            .env("IOS_EXECUTABLE_NAME", "OverriddenExecutable")
936            .output()
937            .unwrap();
938        assert!(
939            output.status.success(),
940            "package-sim.sh failed\nstdout:\n{}\nstderr:\n{}",
941            String::from_utf8_lossy(&output.stdout),
942            String::from_utf8_lossy(&output.stderr)
943        );
944
945        let bundle_dir = String::from_utf8(output.stdout).unwrap();
946        let bundle_dir = PathBuf::from(bundle_dir.trim());
947        assert!(bundle_dir.join("OverriddenExecutable").exists());
948        assert!(bundle_dir.join("LaunchScreen.storyboardc").exists());
949        let plist = fs::read_to_string(bundle_dir.join("Info.plist")).unwrap();
950        assert!(plist.contains("<string>com.example.overridden</string>"));
951        assert!(plist.contains("<string>Overridden iOS</string>"));
952        assert!(plist.contains("<string>OverriddenExecutable</string>"));
953    }
954
955    #[cfg(unix)]
956    #[test]
957    fn android_package_script_builds_native_only_apk_metadata() {
958        let dir = unique_dir("android-package-e2e");
959        run([
960            "fission",
961            "init",
962            dir.to_str().unwrap(),
963            "--name",
964            "android-package-e2e",
965            "--app-id",
966            "com.example.androidpackagee2e",
967        ])
968        .unwrap();
969        run([
970            "fission",
971            "add-target",
972            "android",
973            "--project-dir",
974            dir.to_str().unwrap(),
975        ])
976        .unwrap();
977
978        let fake_bin = dir.join("fake-bin");
979        let fake_target = dir.join("fake-target");
980        let android_home = dir.join("fake-android-sdk");
981        fs::create_dir_all(&fake_bin).unwrap();
982        fs::create_dir_all(&fake_target).unwrap();
983        write_fake_cargo(&fake_bin);
984        write_fake_python3(&fake_bin);
985        write_fake_android_tools(&android_home, &fake_bin);
986        let keystore = dir.join("debug.keystore");
987        fs::write(&keystore, "fake debug keystore").unwrap();
988
989        let output = Command::new("bash")
990            .arg("platforms/android/package-apk.sh")
991            .current_dir(&dir)
992            .env("PATH", path_with_fake_bin(&fake_bin))
993            .env("FAKE_TARGET_DIR", &fake_target)
994            .env("ANDROID_HOME", &android_home)
995            .env("ANDROID_DEBUG_KEYSTORE", &keystore)
996            .output()
997            .unwrap();
998        assert!(
999            output.status.success(),
1000            "package-apk.sh failed\nstdout:\n{}\nstderr:\n{}",
1001            String::from_utf8_lossy(&output.stdout),
1002            String::from_utf8_lossy(&output.stderr)
1003        );
1004
1005        let apk = String::from_utf8(output.stdout).unwrap();
1006        let apk = PathBuf::from(apk.trim());
1007        let apk_payload = fs::read_to_string(apk).unwrap();
1008        assert!(apk_payload.contains("android:hasCode=\"true\""));
1009        assert!(apk_payload.contains("android:minSdkVersion=\"24\""));
1010        assert!(apk_payload.contains("android:targetSdkVersion=\"35\""));
1011        assert!(apk_payload.contains("APK_ENTRY=lib/arm64-v8a/libandroid_package_e2e.so"));
1012        assert!(!apk_payload.contains("APK_ENTRY=classes.dex"));
1013    }
1014
1015    #[test]
1016    fn init_existing_project_enables_features_for_detected_mobile_targets() {
1017        let dir = unique_dir("init-mobile-features");
1018        fs::create_dir_all(dir.join("src")).unwrap();
1019        fs::create_dir_all(dir.join("platforms/android")).unwrap();
1020        fs::create_dir_all(dir.join("platforms/ios")).unwrap();
1021        fs::write(dir.join("src/main.rs"), "fn main() {}\n").unwrap();
1022        fs::write(
1023            dir.join("Cargo.toml"),
1024            r#"[package]
1025name = "existing-mobile"
1026version = "0.1.0"
1027edition = "2021"
1028
1029[dependencies]
1030fission = { version = "0.6.1", default-features = false, features = ["desktop"] }
1031"#,
1032        )
1033        .unwrap();
1034
1035        run(["fission", "init", dir.to_str().unwrap()]).unwrap();
1036
1037        let manifest = fs::read_to_string(dir.join("Cargo.toml")).unwrap();
1038        assert!(manifest.contains("default-features = false"));
1039        assert!(manifest.contains(r#"features = ["desktop", "android", "ios"]"#));
1040    }
1041
1042    #[test]
1043    fn add_target_updates_multiline_fission_dependency_features() {
1044        let dir = unique_dir("multiline-features");
1045        run(["fission", "init", dir.to_str().unwrap()]).unwrap();
1046        fs::write(
1047            dir.join("Cargo.toml"),
1048            r#"[package]
1049name = "multiline-features"
1050version = "0.1.0"
1051edition = "2021"
1052
1053[dependencies]
1054anyhow = "1"
1055
1056[dependencies.fission]
1057version = "0.6.1"
1058default-features = true
1059features = ["desktop"]
1060"#,
1061        )
1062        .unwrap();
1063
1064        run([
1065            "fission",
1066            "add-target",
1067            "android",
1068            "ios",
1069            "--project-dir",
1070            dir.to_str().unwrap(),
1071        ])
1072        .unwrap();
1073
1074        let manifest = fs::read_to_string(dir.join("Cargo.toml")).unwrap();
1075        assert!(manifest.contains("[dependencies.fission]"));
1076        assert!(manifest.contains("default-features = false"));
1077        assert!(manifest.contains(r#"features = ["desktop", "android", "ios"]"#));
1078    }
1079
1080    #[test]
1081    fn custom_icon_config_is_preserved_and_applied_to_mobile_scaffolds() {
1082        let dir = unique_dir("custom-icons");
1083        run(["fission", "init", dir.to_str().unwrap()]).unwrap();
1084        fs::copy(
1085            dir.join("assets/app-icon.png"),
1086            dir.join("assets/shared-icon.png"),
1087        )
1088        .unwrap();
1089        fs::copy(
1090            dir.join("assets/app-icon.png"),
1091            dir.join("assets/android-icon.png"),
1092        )
1093        .unwrap();
1094        fs::copy(
1095            dir.join("assets/app-icon.png"),
1096            dir.join("assets/ios-icon.png"),
1097        )
1098        .unwrap();
1099        let mut manifest = fs::read_to_string(dir.join("fission.toml")).unwrap();
1100        manifest.push_str(
1101            r##"
1102
1103[package.icons]
1104mode = "mixed"
1105source = "assets/shared-icon.png"
1106safe_zone = 0.72
1107allow_upscale = false
1108
1109[package.icons.android]
1110source = "assets/android-icon.png"
1111
1112[package.icons.ios]
1113source = "assets/ios-icon.png"
1114"##,
1115        );
1116        fs::write(dir.join("fission.toml"), manifest).unwrap();
1117
1118        run([
1119            "fission",
1120            "add-target",
1121            "ios",
1122            "android",
1123            "--project-dir",
1124            dir.to_str().unwrap(),
1125        ])
1126        .unwrap();
1127
1128        let manifest = fs::read_to_string(dir.join("fission.toml")).unwrap();
1129        assert!(manifest.contains("[package.icons]"));
1130        assert!(manifest.contains("source = \"assets/shared-icon.png\""));
1131        assert!(manifest.contains("[package.icons.android]"));
1132        assert!(manifest.contains("[package.icons.ios]"));
1133        assert!(dir
1134            .join("platforms/android/res/drawable-nodpi/app_icon.png")
1135            .exists());
1136        assert!(dir.join("platforms/ios/AppIcon.png").exists());
1137        let android_manifest =
1138            fs::read_to_string(dir.join("platforms/android/AndroidManifest.xml")).unwrap();
1139        assert!(android_manifest.contains("android:icon=\"@drawable/app_icon\""));
1140        let android_package =
1141            fs::read_to_string(dir.join("platforms/android/package-apk.sh")).unwrap();
1142        assert!(android_package.contains("APP_ICONS"));
1143        let ios_package = fs::read_to_string(dir.join("platforms/ios/package-sim.sh")).unwrap();
1144        assert!(ios_package.contains("PLATFORM_APP_ICONS"));
1145    }
1146
1147    #[test]
1148    fn custom_splash_config_updates_mobile_platform_files() {
1149        let dir = unique_dir("custom-splash");
1150        run(["fission", "init", dir.to_str().unwrap()]).unwrap();
1151        fs::copy(
1152            dir.join("assets/app-icon.png"),
1153            dir.join("assets/custom-splash.png"),
1154        )
1155        .unwrap();
1156        let mut manifest = fs::read_to_string(dir.join("fission.toml")).unwrap();
1157        manifest.push_str(
1158            r##"
1159
1160[app.splash]
1161background_color = "#123456"
1162image = "assets/custom-splash.png"
1163resize_mode = "cover"
1164android_animation_duration_ms = 650
1165"##,
1166        );
1167        fs::write(dir.join("fission.toml"), manifest).unwrap();
1168
1169        run([
1170            "fission",
1171            "add-target",
1172            "ios",
1173            "android",
1174            "--project-dir",
1175            dir.to_str().unwrap(),
1176        ])
1177        .unwrap();
1178
1179        let android_colors =
1180            fs::read_to_string(dir.join("platforms/android/res/values/colors.xml")).unwrap();
1181        assert!(android_colors.contains("#123456"));
1182        let android_styles =
1183            fs::read_to_string(dir.join("platforms/android/res/values/styles.xml")).unwrap();
1184        assert!(android_styles.contains("650"));
1185        assert!(dir
1186            .join("platforms/android/res/drawable-nodpi/fission_splash_image.png")
1187            .exists());
1188        let storyboard =
1189            fs::read_to_string(dir.join("platforms/ios/LaunchScreen.storyboard")).unwrap();
1190        assert!(storyboard.contains("scaleAspectFill"));
1191        assert!(storyboard.contains("red=\"0.070588\""));
1192        assert!(dir.join("platforms/ios/SplashImage.png").exists());
1193    }
1194
1195    #[test]
1196    fn add_capability_updates_project_and_platform_config() {
1197        let dir = unique_dir("capability");
1198        run(["fission", "init", dir.to_str().unwrap()]).unwrap();
1199        run([
1200            "fission",
1201            "add-target",
1202            "ios",
1203            "android",
1204            "--project-dir",
1205            dir.to_str().unwrap(),
1206        ])
1207        .unwrap();
1208        run([
1209            "fission",
1210            "add-capability",
1211            "nfc",
1212            "biometric",
1213            "bluetooth",
1214            "barcode-scanner",
1215            "camera",
1216            "geolocation",
1217            "haptics",
1218            "microphone",
1219            "volume-control",
1220            "wifi",
1221            "--project-dir",
1222            dir.to_str().unwrap(),
1223        ])
1224        .unwrap();
1225
1226        let project = read_project_config(&dir).unwrap();
1227        assert!(project
1228            .capabilities
1229            .contains(&fission_command_core::PlatformCapability::Nfc));
1230        assert!(project
1231            .capabilities
1232            .contains(&fission_command_core::PlatformCapability::Biometric));
1233        assert!(project
1234            .capabilities
1235            .contains(&fission_command_core::PlatformCapability::Bluetooth));
1236        assert!(project
1237            .capabilities
1238            .contains(&fission_command_core::PlatformCapability::BarcodeScanner));
1239        assert!(project
1240            .capabilities
1241            .contains(&fission_command_core::PlatformCapability::Camera));
1242        assert!(project
1243            .capabilities
1244            .contains(&fission_command_core::PlatformCapability::Geolocation));
1245        assert!(project
1246            .capabilities
1247            .contains(&fission_command_core::PlatformCapability::Haptics));
1248        assert!(project
1249            .capabilities
1250            .contains(&fission_command_core::PlatformCapability::Microphone));
1251        assert!(project
1252            .capabilities
1253            .contains(&fission_command_core::PlatformCapability::VolumeControl));
1254        assert!(project
1255            .capabilities
1256            .contains(&fission_command_core::PlatformCapability::Wifi));
1257
1258        let android_manifest =
1259            std::fs::read_to_string(dir.join("platforms/android/AndroidManifest.xml")).unwrap();
1260        assert!(android_manifest.contains("android.permission.NFC"));
1261        assert!(android_manifest.contains("android.hardware.nfc"));
1262        assert!(android_manifest.contains("android.permission.USE_BIOMETRIC"));
1263        assert!(android_manifest.contains("android.permission.BLUETOOTH_SCAN"));
1264        assert!(android_manifest.contains("android.permission.BLUETOOTH_CONNECT"));
1265        assert!(android_manifest.contains("android.hardware.bluetooth_le"));
1266        assert!(android_manifest.contains("android.permission.CAMERA"));
1267        assert!(android_manifest.contains("android.hardware.camera.flash"));
1268        assert!(android_manifest.contains("android.permission.ACCESS_FINE_LOCATION"));
1269        assert!(android_manifest.contains("android.permission.VIBRATE"));
1270        assert!(android_manifest.contains("android.permission.RECORD_AUDIO"));
1271        assert!(android_manifest.contains("android.permission.MODIFY_AUDIO_SETTINGS"));
1272        assert!(android_manifest.contains("android.permission.NEARBY_WIFI_DEVICES"));
1273        assert!(android_manifest.contains("android.permission.ACCESS_WIFI_STATE"));
1274        assert!(dir
1275            .join("platforms/android/java/rs/fission/runtime/FissionAndroidCapabilities.java")
1276            .exists());
1277
1278        let ios_info = std::fs::read_to_string(dir.join("platforms/ios/Info.plist")).unwrap();
1279        assert!(ios_info.contains("NFCReaderUsageDescription"));
1280        assert!(ios_info.contains("NSFaceIDUsageDescription"));
1281        assert!(ios_info.contains("NSBluetoothAlwaysUsageDescription"));
1282        assert!(ios_info.contains("NSCameraUsageDescription"));
1283        assert!(ios_info.contains("NSLocationWhenInUseUsageDescription"));
1284        assert!(ios_info.contains("NSMicrophoneUsageDescription"));
1285        let ios_entitlements =
1286            std::fs::read_to_string(dir.join("platforms/ios/Entitlements.plist")).unwrap();
1287        assert!(ios_entitlements.contains("com.apple.developer.nfc.readersession.formats"));
1288        assert!(ios_entitlements.contains("com.apple.developer.networking.wifi-info"));
1289    }
1290
1291    #[test]
1292    fn init_existing_project_preserves_user_files_and_detects_targets() {
1293        let dir = unique_dir("existing");
1294        fs::create_dir_all(dir.join("src")).unwrap();
1295        fs::create_dir_all(dir.join("platforms/web")).unwrap();
1296        fs::write(
1297            dir.join("Cargo.toml"),
1298            "[package]\nname = \"existing-web\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
1299        )
1300        .unwrap();
1301        fs::write(dir.join("src/main.rs"), "fn main() {}\n").unwrap();
1302        fs::write(dir.join("src/lib.rs"), "pub fn existing() {}\n").unwrap();
1303        fs::write(dir.join("README.md"), "# keep me\n").unwrap();
1304        fs::write(
1305            dir.join("platforms/web/index.html"),
1306            "<!doctype html><title>keep me</title>\n",
1307        )
1308        .unwrap();
1309
1310        run(["fission", "init", dir.to_str().unwrap()]).unwrap();
1311
1312        assert_eq!(
1313            fs::read_to_string(dir.join("README.md")).unwrap(),
1314            "# keep me\n"
1315        );
1316        assert_eq!(
1317            fs::read_to_string(dir.join("src/main.rs")).unwrap(),
1318            "fn main() {}\n"
1319        );
1320        assert_eq!(
1321            fs::read_to_string(dir.join("src/lib.rs")).unwrap(),
1322            "pub fn existing() {}\n"
1323        );
1324        assert_eq!(
1325            fs::read_to_string(dir.join("platforms/web/index.html")).unwrap(),
1326            "<!doctype html><title>keep me</title>\n"
1327        );
1328
1329        let project = read_project_config(&dir).unwrap();
1330        assert_eq!(project.app.name, "existing-web");
1331        assert!(project.targets.contains(&Target::Web));
1332        assert!(project.targets.contains(&Target::Macos));
1333        assert!(project.targets.contains(&Target::Linux));
1334        assert!(project.targets.contains(&Target::Windows));
1335        assert!(dir.join("platforms/web/README.md").exists());
1336        assert!(dir.join("platforms/web/bootstrap.mjs").exists());
1337        assert!(dir.join("assets/app-icon.png").exists());
1338    }
1339
1340    #[test]
1341    fn init_existing_project_is_idempotent() {
1342        let dir = unique_dir("idempotent");
1343        run(["fission", "init", dir.to_str().unwrap(), "--name", "idem"]).unwrap();
1344        let manifest = fs::read_to_string(dir.join("fission.toml")).unwrap();
1345        let main = fs::read_to_string(dir.join("src/main.rs")).unwrap();
1346
1347        run(["fission", "init", dir.to_str().unwrap()]).unwrap();
1348
1349        assert_eq!(
1350            fs::read_to_string(dir.join("fission.toml")).unwrap(),
1351            manifest
1352        );
1353        assert_eq!(fs::read_to_string(dir.join("src/main.rs")).unwrap(), main);
1354    }
1355
1356    #[test]
1357    fn add_target_preserves_existing_target_files() {
1358        let dir = unique_dir("preserve-target");
1359        run([
1360            "fission",
1361            "init",
1362            dir.to_str().unwrap(),
1363            "--name",
1364            "preserve-target",
1365        ])
1366        .unwrap();
1367        fs::create_dir_all(dir.join("platforms/web")).unwrap();
1368        fs::write(
1369            dir.join("platforms/web/index.html"),
1370            "<!doctype html><title>custom</title>\n",
1371        )
1372        .unwrap();
1373        fs::write(dir.join("README.md"), "# custom readme\n").unwrap();
1374
1375        run([
1376            "fission",
1377            "add-target",
1378            "web",
1379            "--project-dir",
1380            dir.to_str().unwrap(),
1381        ])
1382        .unwrap();
1383
1384        assert_eq!(
1385            fs::read_to_string(dir.join("platforms/web/index.html")).unwrap(),
1386            "<!doctype html><title>custom</title>\n"
1387        );
1388        assert_eq!(
1389            fs::read_to_string(dir.join("README.md")).unwrap(),
1390            "# custom readme\n"
1391        );
1392        assert!(dir.join("platforms/web/README.md").exists());
1393        assert!(dir.join("platforms/web/bootstrap.mjs").exists());
1394        let project = read_project_config(&dir).unwrap();
1395        assert!(project.targets.contains(&Target::Web));
1396    }
1397
1398    #[test]
1399    fn cargo_fission_alias_accepts_prefixed_subcommand() {
1400        let dir = unique_dir("cargo-fission");
1401        run([
1402            "cargo-fission",
1403            "fission",
1404            "init",
1405            dir.to_str().unwrap(),
1406            "--name",
1407            "cargo-fission-demo",
1408        ])
1409        .unwrap();
1410
1411        assert!(dir.join("Cargo.toml").exists());
1412        assert!(dir.join("fission.toml").exists());
1413    }
1414
1415    #[test]
1416    fn doctor_command_runs_in_non_strict_mode() {
1417        let dir = unique_dir("doctor");
1418        run(["fission", "init", dir.to_str().unwrap()]).unwrap();
1419        run([
1420            "fission",
1421            "doctor",
1422            "web",
1423            "--project-dir",
1424            dir.to_str().unwrap(),
1425        ])
1426        .unwrap();
1427    }
1428}