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 let agents = std::fs::read_to_string(dir.join("AGENTS.md")).unwrap();
584 assert!(agents.contains("# Fission App Guidelines"));
585 assert!(agents.contains("#[fission_component]"));
586 assert!(agents.contains("Use Fission's native Router and RouterParams"));
587 assert!(readme.contains("fission devices --project-dir ."));
588 assert!(readme.contains("fission run --project-dir ."));
589 assert!(readme.contains("fission logs --target <target>"));
590 assert!(readme.contains("fission build --target <target>"));
591 assert!(readme.contains("fission test --target <target>"));
592 let manifest = std::fs::read_to_string(dir.join("Cargo.toml")).unwrap();
593 assert!(manifest.contains("default-features = false"));
594 assert!(manifest.contains("features = [\"desktop\"]"));
595 }
596
597 #[test]
598 fn init_writes_agents_to_git_root() {
599 let repo = unique_dir("init-agents-root");
600 fs::create_dir_all(repo.join(".git")).unwrap();
601 let app = repo.join("apps/todo");
602
603 run(["fission", "init", app.to_str().unwrap(), "--name", "todo"]).unwrap();
604
605 assert!(repo.join("AGENTS.md").exists());
606 assert!(!app.join("AGENTS.md").exists());
607 }
608
609 #[test]
610 fn init_uses_fission_agents_name_when_repo_agents_exists() {
611 let repo = unique_dir("init-agents-existing");
612 fs::create_dir_all(repo.join(".git")).unwrap();
613 fs::write(repo.join("AGENTS.md"), "existing repo instructions").unwrap();
614 let app = repo.join("apps/todo");
615
616 run(["fission", "init", app.to_str().unwrap(), "--name", "todo"]).unwrap();
617
618 assert_eq!(
619 fs::read_to_string(repo.join("AGENTS.md")).unwrap(),
620 "existing repo instructions"
621 );
622 let fission_agents = fs::read_to_string(repo.join("AGENTS.fission.md")).unwrap();
623 assert!(fission_agents.contains("# Fission App Guidelines"));
624 assert!(!app.join("AGENTS.md").exists());
625 }
626
627 #[test]
628 fn add_target_updates_manifest_and_scaffold() {
629 let dir = unique_dir("targets");
630 run(["fission", "init", dir.to_str().unwrap()]).unwrap();
631 run([
632 "fission",
633 "add-target",
634 "web",
635 "ios",
636 "android",
637 "--project-dir",
638 dir.to_str().unwrap(),
639 ])
640 .unwrap();
641
642 let project = read_project_config(&dir).unwrap();
643 assert!(project.targets.contains(&Target::Web));
644 assert!(project.targets.contains(&Target::Ios));
645 assert!(project.targets.contains(&Target::Android));
646 let manifest = std::fs::read_to_string(dir.join("Cargo.toml")).unwrap();
647 assert!(manifest.contains("default-features = false"));
648 assert!(manifest.contains("features = [\"desktop\", \"web\", \"android\", \"ios\"]"));
649 assert!(dir.join("platforms/web/README.md").exists());
650 assert!(dir.join("platforms/web/index.html").exists());
651 assert!(dir.join("platforms/web/bootstrap.mjs").exists());
652 assert!(dir.join("platforms/web/build-wasm.sh").exists());
653 assert!(dir.join("platforms/web/run-browser.sh").exists());
654 assert!(dir.join("platforms/web/test-browser.sh").exists());
655 assert!(dir.join("platforms/ios/README.md").exists());
656 assert!(dir.join("platforms/ios/Info.plist").exists());
657 assert!(dir.join("platforms/ios/LaunchScreen.storyboard").exists());
658 assert!(dir.join("platforms/ios/package-sim.sh").exists());
659 assert!(dir.join("platforms/ios/run-sim.sh").exists());
660 assert!(dir.join("platforms/ios/test-sim.sh").exists());
661 assert!(dir.join("platforms/ios/Package.swift").exists());
662 assert!(dir
663 .join("platforms/ios/Sources/FissionHost/FissionNativeCapabilities.swift")
664 .exists());
665 assert!(dir
666 .join(
667 "platforms/ios/NativeModules/Sources/FissionNativeModules/FissionNativeCapabilities.swift"
668 )
669 .exists());
670 assert!(dir.join("platforms/android/README.md").exists());
671 assert!(dir.join("platforms/android/AndroidManifest.xml").exists());
672 assert!(dir.join("platforms/android/settings.gradle.kts").exists());
673 assert!(dir.join("platforms/android/build.gradle.kts").exists());
674 assert!(dir.join("platforms/android/gradle.properties").exists());
675 assert!(dir.join("platforms/android/app/build.gradle.kts").exists());
676 assert!(dir.join("platforms/android/native-modules.gradle").exists());
677 assert!(dir
678 .join("platforms/android/java/rs/fission/runtime/FissionActivity.java")
679 .exists());
680 assert!(dir
681 .join("platforms/android/native-modules/README.md")
682 .exists());
683 assert!(dir.join("platforms/android/res/values/colors.xml").exists());
684 assert!(dir.join("platforms/android/res/values/styles.xml").exists());
685 assert!(dir
686 .join("platforms/android/res/drawable/fission_splash_background.xml")
687 .exists());
688 assert!(dir.join("platforms/android/package-apk.sh").exists());
689 assert!(dir.join("platforms/android/run-emulator.sh").exists());
690 assert!(dir.join("platforms/android/test-emulator.sh").exists());
691 let android_manifest =
692 std::fs::read_to_string(dir.join("platforms/android/AndroidManifest.xml")).unwrap();
693 assert!(android_manifest.contains("android:icon=\"@drawable/app_icon\""));
694 assert!(android_manifest.contains("android:hasCode=\"true\""));
695 assert!(android_manifest.contains("android:targetSdkVersion=\"35\""));
696 assert!(android_manifest.contains("android:theme=\"@style/FissionLaunchTheme\""));
697 assert!(android_manifest.contains("rs.fission.runtime.FissionActivity"));
698 let android_styles =
699 std::fs::read_to_string(dir.join("platforms/android/res/values/styles.xml")).unwrap();
700 assert!(android_styles.contains("android:windowBackground"));
701 assert!(android_styles.contains("android:windowSplashScreenAnimatedIcon"));
702 let android_package_script =
703 std::fs::read_to_string(dir.join("platforms/android/package-apk.sh")).unwrap();
704 assert!(android_package_script.contains("detect_android_toolchain"));
705 assert!(android_package_script
706 .contains("darwin-aarch64 darwin-x86_64 linux-x86_64 windows-x86_64"));
707 assert!(android_package_script.contains(
708 "ANDROID_MIN_API_LEVEL=\"${ANDROID_MIN_API_LEVEL:-${ANDROID_API_LEVEL:-24}}\""
709 ));
710 assert!(android_package_script.contains("ANDROID_TARGET_API_LEVEL="));
711 assert!(
712 android_package_script.contains("aarch64-linux-android${ANDROID_MIN_API_LEVEL}-clang")
713 );
714 assert!(android_package_script.contains("GRADLE_CMD"));
715 assert!(android_package_script.contains(":app:assemble"));
716 assert!(android_package_script.contains("app/src/main/jniLibs/arm64-v8a"));
717 assert!(android_package_script.contains("FISSION_GRADLE"));
718 assert!(android_package_script.contains("Gradle is required"));
719 let android_run_script =
720 std::fs::read_to_string(dir.join("platforms/android/run-emulator.sh")).unwrap();
721 assert!(android_run_script.contains("ANDROID_EMULATOR_API_LEVEL"));
722 assert!(android_run_script.contains("fission doctor android"));
723 assert!(android_run_script.contains("wait_for_android_boot()"));
724 assert!(android_run_script.contains("cmd package list packages"));
725 assert!(android_run_script.contains("ADB_INSTALL_FLAGS:---no-streaming -r"));
726 assert!(android_run_script.contains("rs.fission.runtime.FissionActivity"));
727 assert!(!android_run_script.contains("wait_for_android_boot() {\n wait_for_android_boot"));
728 assert!(!android_run_script.contains(" wait_for_android_boot\n wait_for_android_boot"));
729 assert!(
730 std::fs::read_to_string(dir.join("platforms/android/README.md"))
731 .unwrap()
732 .contains("fission run --target android")
733 );
734 let android_test_script =
735 std::fs::read_to_string(dir.join("platforms/android/test-emulator.sh")).unwrap();
736 assert!(android_test_script.contains("/health"));
737 let ios_package_script =
738 std::fs::read_to_string(dir.join("platforms/ios/package-sim.sh")).unwrap();
739 assert!(ios_package_script.contains("TARGET=\"${IOS_SIM_TARGET:-aarch64-apple-ios-sim}\""));
740 assert!(ios_package_script.contains("PROFILE=\"${IOS_SIM_PROFILE:-debug}\""));
741 assert!(ios_package_script.contains("BUNDLE_ID=\"${IOS_BUNDLE_ID:-com.example."));
742 assert!(ios_package_script.contains("DISPLAY_NAME=\"${IOS_DISPLAY_NAME:-"));
743 assert!(ios_package_script.contains("EXECUTABLE_NAME=\"${IOS_EXECUTABLE_NAME:-"));
744 assert!(ios_package_script.contains("xcrun --find plutil"));
745 assert!(ios_package_script.contains("-replace CFBundleIdentifier -string"));
746 assert!(!ios_package_script.contains("import plistlib"));
747 assert!(ios_package_script.contains("PkgInfo"));
748 assert!(ios_package_script.contains("PLATFORM_APP_ICONS"));
749 assert!(ios_package_script.contains("AppIcon.png"));
750 assert!(ios_package_script.contains("LaunchScreen.storyboard"));
751 assert!(ios_package_script.contains("ibtool"));
752 assert!(ios_package_script.contains("LaunchScreen.storyboardc"));
753 assert!(ios_package_script.contains("SplashImage.png"));
754 let ios_plist = std::fs::read_to_string(dir.join("platforms/ios/Info.plist")).unwrap();
755 assert!(ios_plist.contains("UILaunchStoryboardName"));
756 let ios_run_script = std::fs::read_to_string(dir.join("platforms/ios/run-sim.sh")).unwrap();
757 assert!(ios_run_script.contains("BUNDLE_ID=\"${IOS_BUNDLE_ID:-com.example."));
758 assert!(ios_run_script.contains("IOS_SIM_UNINSTALL_BEFORE_INSTALL"));
759 assert!(ios_run_script.contains(
760 "xcrun simctl launch --terminate-running-process \"$DEVICE_ID\" \"$BUNDLE_ID\""
761 ));
762 assert!(std::fs::read_to_string(dir.join("platforms/ios/README.md"))
763 .unwrap()
764 .contains("fission run --target ios"));
765 assert!(
766 std::fs::read_to_string(dir.join("platforms/ios/test-sim.sh"))
767 .unwrap()
768 .contains("/health")
769 );
770 assert!(
771 std::fs::read_to_string(dir.join("platforms/web/index.html"))
772 .unwrap()
773 .contains("../../assets/app-icon.png")
774 );
775 let web_index = std::fs::read_to_string(dir.join("platforms/web/index.html")).unwrap();
776 assert!(web_index.contains("id=\"fission-web-mount\""));
777 assert!(web_index.contains("height: 100vh"));
778 assert!(web_index.contains("outline: none"));
779 assert!(web_index.contains("touch-action: none"));
780 assert!(!web_index.contains("Generated by"));
781 let web_test_script =
782 std::fs::read_to_string(dir.join("platforms/web/test-browser.sh")).unwrap();
783 assert!(web_test_script.contains("--remote-debugging-port=\"$CDP_PORT\""));
784 assert!(web_test_script.contains("/json/list"));
785 assert!(std::fs::read_to_string(dir.join("platforms/web/README.md"))
786 .unwrap()
787 .contains("fission run --target web"));
788 }
789
790 #[test]
791 fn init_hardens_existing_android_native_only_scaffold() {
792 let dir = unique_dir("android-hardening");
793 run(["fission", "init", dir.to_str().unwrap()]).unwrap();
794 run([
795 "fission",
796 "add-target",
797 "android",
798 "--project-dir",
799 dir.to_str().unwrap(),
800 ])
801 .unwrap();
802
803 let manifest_path = dir.join("platforms/android/AndroidManifest.xml");
804 let package_path = dir.join("platforms/android/package-apk.sh");
805 fs::write(
806 &manifest_path,
807 fs::read_to_string(&manifest_path).unwrap().replace(
808 "rs.fission.runtime.FissionActivity",
809 "android.app.NativeActivity",
810 ),
811 )
812 .unwrap();
813 fs::write(
814 &package_path,
815 fs::read_to_string(&package_path)
816 .unwrap()
817 .replace("import pathlib\n", "")
818 .replace(
819 r#"has_code = "true" if pathlib.Path(dest).with_name("apk-root").joinpath("classes.dex").exists() else "false"
820manifest = re.sub(r'android:hasCode="(?:true|false)"', f'android:hasCode="{has_code}"', manifest)
821"#,
822 "",
823 ),
824 )
825 .unwrap();
826
827 run(["fission", "init", dir.to_str().unwrap()]).unwrap();
828
829 let manifest = fs::read_to_string(manifest_path).unwrap();
830 assert!(manifest.contains("android:hasCode=\"false\""));
831 assert!(manifest.contains("android.app.NativeActivity"));
832 let package_script = fs::read_to_string(package_path).unwrap();
833 assert!(package_script.contains(":app:assemble"));
834 assert!(package_script.contains("app/src/main/jniLibs/arm64-v8a"));
835 assert!(!package_script.contains("import pathlib"));
836 }
837
838 #[test]
839 fn init_hardens_existing_android_raw_package_script_resources() {
840 let dir = unique_dir("android-raw-resources");
841 run(["fission", "init", dir.to_str().unwrap()]).unwrap();
842 run([
843 "fission",
844 "add-target",
845 "android",
846 "--project-dir",
847 dir.to_str().unwrap(),
848 ])
849 .unwrap();
850
851 let package_path = dir.join("platforms/android/package-apk.sh");
852 fs::write(
853 &package_path,
854 r#"#!/usr/bin/env bash
855set -euo pipefail
856SCRIPT_DIR=$(cd -- "$(dirname "${BASH_SOURCE[0]}")" && pwd)
857ICON_SOURCE="${FISSION_APP_ICON:-$SCRIPT_DIR/icon.png}"
858APK_ROOT="$SCRIPT_DIR/build/debug/apk-root"
859SO_PATH="$SCRIPT_DIR/libexample.so"
860LIB_NAME="example"
861mkdir -p "$APK_ROOT/lib/arm64-v8a" "$APK_ROOT/res/drawable-nodpi"
862cp "$SO_PATH" "$APK_ROOT/lib/arm64-v8a/lib$LIB_NAME.so"
863cp "$ICON_SOURCE" "$APK_ROOT/res/drawable-nodpi/app_icon.png"
864"#,
865 )
866 .unwrap();
867
868 run(["fission", "init", dir.to_str().unwrap()]).unwrap();
869
870 let package_script = fs::read_to_string(package_path).unwrap();
871 assert!(package_script.contains("SPLASH_IMAGES"));
872 assert!(package_script.contains(
873 "cp \"$ICON_SOURCE\" \"$APK_ROOT/res/drawable-nodpi/fission_splash_image.png\""
874 ));
875 assert!(package_script.contains("cp -R \"$SCRIPT_DIR/res/.\" \"$APK_ROOT/res/\""));
876 }
877
878 #[test]
879 fn init_hardens_existing_ios_package_script_without_replacing_user_files() {
880 let dir = unique_dir("ios-package-hardening");
881 run(["fission", "init", dir.to_str().unwrap()]).unwrap();
882 run([
883 "fission",
884 "add-target",
885 "ios",
886 "--project-dir",
887 dir.to_str().unwrap(),
888 ])
889 .unwrap();
890 let script_path = dir.join("platforms/ios/package-sim.sh");
891 let mut script = fs::read_to_string(&script_path).unwrap();
892 script = script.replace(
893 r#"cp "$SCRIPT_DIR/Info.plist" "$BUNDLE_DIR/Info.plist"
894PLUTIL=$(xcrun --find plutil 2>/dev/null || command -v plutil || true)
895if [[ -z "$PLUTIL" ]]; then
896 printf 'plutil not found. Install Xcode command line tools to package the iOS simulator app.\n' >&2
897 exit 1
898fi
899"$PLUTIL" -replace CFBundleIdentifier -string "$BUNDLE_ID" "$BUNDLE_DIR/Info.plist"
900"$PLUTIL" -replace CFBundleDisplayName -string "$DISPLAY_NAME" "$BUNDLE_DIR/Info.plist"
901"$PLUTIL" -replace CFBundleName -string "$DISPLAY_NAME" "$BUNDLE_DIR/Info.plist"
902"$PLUTIL" -replace CFBundleExecutable -string "$EXECUTABLE_NAME" "$BUNDLE_DIR/Info.plist"
903"#,
904 r#"python3 - <<'PY' "$SCRIPT_DIR/Info.plist" "$BUNDLE_DIR/Info.plist" "$BUNDLE_ID" "$DISPLAY_NAME" "$EXECUTABLE_NAME"
905import plistlib
906import sys
907
908source, dest, bundle_id, display_name, executable_name = sys.argv[1:]
909with open(source, "rb") as handle:
910 plist = plistlib.load(handle)
911plist["CFBundleIdentifier"] = bundle_id
912plist["CFBundleDisplayName"] = display_name
913plist["CFBundleName"] = display_name
914plist["CFBundleExecutable"] = executable_name
915with open(dest, "wb") as handle:
916 plistlib.dump(plist, handle, sort_keys=False)
917PY
918"#,
919 );
920 fs::write(&script_path, script).unwrap();
921
922 run(["fission", "init", dir.to_str().unwrap()]).unwrap();
923
924 let hardened = fs::read_to_string(script_path).unwrap();
925 assert!(hardened.contains("xcrun --find plutil"));
926 assert!(hardened.contains("-replace CFBundleExecutable -string"));
927 assert!(!hardened.contains("import plistlib"));
928 }
929
930 #[cfg(unix)]
931 #[test]
932 fn ios_package_script_executes_without_python_plistlib() {
933 let dir = unique_dir("ios-package-e2e");
934 run([
935 "fission",
936 "init",
937 dir.to_str().unwrap(),
938 "--name",
939 "ios-package-e2e",
940 "--app-id",
941 "com.example.iospackagee2e",
942 ])
943 .unwrap();
944 run([
945 "fission",
946 "add-target",
947 "ios",
948 "--project-dir",
949 dir.to_str().unwrap(),
950 ])
951 .unwrap();
952
953 let fake_bin = dir.join("fake-bin");
954 let fake_target = dir.join("fake-target");
955 fs::create_dir_all(&fake_bin).unwrap();
956 fs::create_dir_all(&fake_target).unwrap();
957 write_fake_cargo(&fake_bin);
958 write_fake_python3(&fake_bin);
959 write_fake_ios_tools(&fake_bin);
960
961 let output = Command::new("bash")
962 .arg("platforms/ios/package-sim.sh")
963 .current_dir(&dir)
964 .env("PATH", path_with_fake_bin(&fake_bin))
965 .env("FAKE_TARGET_DIR", &fake_target)
966 .env("FISSION_FAKE_BIN", &fake_bin)
967 .env("IOS_BUNDLE_ID", "com.example.overridden")
968 .env("IOS_DISPLAY_NAME", "Overridden iOS")
969 .env("IOS_EXECUTABLE_NAME", "OverriddenExecutable")
970 .output()
971 .unwrap();
972 assert!(
973 output.status.success(),
974 "package-sim.sh failed\nstdout:\n{}\nstderr:\n{}",
975 String::from_utf8_lossy(&output.stdout),
976 String::from_utf8_lossy(&output.stderr)
977 );
978
979 let bundle_dir = String::from_utf8(output.stdout).unwrap();
980 let bundle_dir = PathBuf::from(bundle_dir.trim());
981 assert!(bundle_dir.join("OverriddenExecutable").exists());
982 assert!(bundle_dir.join("LaunchScreen.storyboardc").exists());
983 let plist = fs::read_to_string(bundle_dir.join("Info.plist")).unwrap();
984 assert!(plist.contains("<string>com.example.overridden</string>"));
985 assert!(plist.contains("<string>Overridden iOS</string>"));
986 assert!(plist.contains("<string>OverriddenExecutable</string>"));
987 }
988
989 #[cfg(unix)]
990 #[test]
991 fn android_package_script_builds_native_only_apk_metadata() {
992 let dir = unique_dir("android-package-e2e");
993 run([
994 "fission",
995 "init",
996 dir.to_str().unwrap(),
997 "--name",
998 "android-package-e2e",
999 "--app-id",
1000 "com.example.androidpackagee2e",
1001 ])
1002 .unwrap();
1003 run([
1004 "fission",
1005 "add-target",
1006 "android",
1007 "--project-dir",
1008 dir.to_str().unwrap(),
1009 ])
1010 .unwrap();
1011
1012 let fake_bin = dir.join("fake-bin");
1013 let fake_target = dir.join("fake-target");
1014 let android_home = dir.join("fake-android-sdk");
1015 fs::create_dir_all(&fake_bin).unwrap();
1016 fs::create_dir_all(&fake_target).unwrap();
1017 write_fake_cargo(&fake_bin);
1018 write_fake_python3(&fake_bin);
1019 write_fake_android_tools(&android_home, &fake_bin);
1020 let keystore = dir.join("debug.keystore");
1021 fs::write(&keystore, "fake debug keystore").unwrap();
1022
1023 let output = Command::new("bash")
1024 .arg("platforms/android/package-apk.sh")
1025 .current_dir(&dir)
1026 .env("PATH", path_with_fake_bin(&fake_bin))
1027 .env("FAKE_TARGET_DIR", &fake_target)
1028 .env("ANDROID_HOME", &android_home)
1029 .env("ANDROID_DEBUG_KEYSTORE", &keystore)
1030 .output()
1031 .unwrap();
1032 assert!(
1033 output.status.success(),
1034 "package-apk.sh failed\nstdout:\n{}\nstderr:\n{}",
1035 String::from_utf8_lossy(&output.stdout),
1036 String::from_utf8_lossy(&output.stderr)
1037 );
1038
1039 let apk = String::from_utf8(output.stdout).unwrap();
1040 let apk = PathBuf::from(apk.trim());
1041 let apk_payload = fs::read_to_string(apk).unwrap();
1042 assert!(apk_payload.contains("android:hasCode=\"true\""));
1043 assert!(apk_payload.contains("android:minSdkVersion=\"24\""));
1044 assert!(apk_payload.contains("android:targetSdkVersion=\"35\""));
1045 assert!(apk_payload.contains("APK_ENTRY=lib/arm64-v8a/libandroid_package_e2e.so"));
1046 assert!(!apk_payload.contains("APK_ENTRY=classes.dex"));
1047 }
1048
1049 #[test]
1050 fn init_existing_project_enables_features_for_detected_mobile_targets() {
1051 let dir = unique_dir("init-mobile-features");
1052 fs::create_dir_all(dir.join("src")).unwrap();
1053 fs::create_dir_all(dir.join("platforms/android")).unwrap();
1054 fs::create_dir_all(dir.join("platforms/ios")).unwrap();
1055 fs::write(dir.join("src/main.rs"), "fn main() {}\n").unwrap();
1056 fs::write(
1057 dir.join("Cargo.toml"),
1058 r#"[package]
1059name = "existing-mobile"
1060version = "0.1.0"
1061edition = "2021"
1062
1063[dependencies]
1064fission = { version = "0.6.3", default-features = false, features = ["desktop"] }
1065"#,
1066 )
1067 .unwrap();
1068
1069 run(["fission", "init", dir.to_str().unwrap()]).unwrap();
1070
1071 let manifest = fs::read_to_string(dir.join("Cargo.toml")).unwrap();
1072 assert!(manifest.contains("default-features = false"));
1073 assert!(manifest.contains(r#"features = ["desktop", "android", "ios"]"#));
1074 }
1075
1076 #[test]
1077 fn add_target_updates_multiline_fission_dependency_features() {
1078 let dir = unique_dir("multiline-features");
1079 run(["fission", "init", dir.to_str().unwrap()]).unwrap();
1080 fs::write(
1081 dir.join("Cargo.toml"),
1082 r#"[package]
1083name = "multiline-features"
1084version = "0.1.0"
1085edition = "2021"
1086
1087[dependencies]
1088anyhow = "1"
1089
1090[dependencies.fission]
1091version = "0.6.3"
1092default-features = true
1093features = ["desktop"]
1094"#,
1095 )
1096 .unwrap();
1097
1098 run([
1099 "fission",
1100 "add-target",
1101 "android",
1102 "ios",
1103 "--project-dir",
1104 dir.to_str().unwrap(),
1105 ])
1106 .unwrap();
1107
1108 let manifest = fs::read_to_string(dir.join("Cargo.toml")).unwrap();
1109 assert!(manifest.contains("[dependencies.fission]"));
1110 assert!(manifest.contains("default-features = false"));
1111 assert!(manifest.contains(r#"features = ["desktop", "android", "ios"]"#));
1112 }
1113
1114 #[test]
1115 fn custom_icon_config_is_preserved_and_applied_to_mobile_scaffolds() {
1116 let dir = unique_dir("custom-icons");
1117 run(["fission", "init", dir.to_str().unwrap()]).unwrap();
1118 fs::copy(
1119 dir.join("assets/app-icon.png"),
1120 dir.join("assets/shared-icon.png"),
1121 )
1122 .unwrap();
1123 fs::copy(
1124 dir.join("assets/app-icon.png"),
1125 dir.join("assets/android-icon.png"),
1126 )
1127 .unwrap();
1128 fs::copy(
1129 dir.join("assets/app-icon.png"),
1130 dir.join("assets/ios-icon.png"),
1131 )
1132 .unwrap();
1133 let mut manifest = fs::read_to_string(dir.join("fission.toml")).unwrap();
1134 manifest.push_str(
1135 r##"
1136
1137[package.icons]
1138mode = "mixed"
1139source = "assets/shared-icon.png"
1140safe_zone = 0.72
1141allow_upscale = false
1142
1143[package.icons.android]
1144source = "assets/android-icon.png"
1145
1146[package.icons.ios]
1147source = "assets/ios-icon.png"
1148"##,
1149 );
1150 fs::write(dir.join("fission.toml"), manifest).unwrap();
1151
1152 run([
1153 "fission",
1154 "add-target",
1155 "ios",
1156 "android",
1157 "--project-dir",
1158 dir.to_str().unwrap(),
1159 ])
1160 .unwrap();
1161
1162 let manifest = fs::read_to_string(dir.join("fission.toml")).unwrap();
1163 assert!(manifest.contains("[package.icons]"));
1164 assert!(manifest.contains("source = \"assets/shared-icon.png\""));
1165 assert!(manifest.contains("[package.icons.android]"));
1166 assert!(manifest.contains("[package.icons.ios]"));
1167 assert!(dir
1168 .join("platforms/android/res/drawable-nodpi/app_icon.png")
1169 .exists());
1170 assert!(dir.join("platforms/ios/AppIcon.png").exists());
1171 let android_manifest =
1172 fs::read_to_string(dir.join("platforms/android/AndroidManifest.xml")).unwrap();
1173 assert!(android_manifest.contains("android:icon=\"@drawable/app_icon\""));
1174 let android_package =
1175 fs::read_to_string(dir.join("platforms/android/package-apk.sh")).unwrap();
1176 assert!(android_package.contains("APP_ICONS"));
1177 let ios_package = fs::read_to_string(dir.join("platforms/ios/package-sim.sh")).unwrap();
1178 assert!(ios_package.contains("PLATFORM_APP_ICONS"));
1179 }
1180
1181 #[test]
1182 fn custom_splash_config_updates_mobile_platform_files() {
1183 let dir = unique_dir("custom-splash");
1184 run(["fission", "init", dir.to_str().unwrap()]).unwrap();
1185 fs::copy(
1186 dir.join("assets/app-icon.png"),
1187 dir.join("assets/custom-splash.png"),
1188 )
1189 .unwrap();
1190 let mut manifest = fs::read_to_string(dir.join("fission.toml")).unwrap();
1191 manifest.push_str(
1192 r##"
1193
1194[app.splash]
1195background_color = "#123456"
1196image = "assets/custom-splash.png"
1197resize_mode = "cover"
1198android_animation_duration_ms = 650
1199"##,
1200 );
1201 fs::write(dir.join("fission.toml"), manifest).unwrap();
1202
1203 run([
1204 "fission",
1205 "add-target",
1206 "ios",
1207 "android",
1208 "--project-dir",
1209 dir.to_str().unwrap(),
1210 ])
1211 .unwrap();
1212
1213 let android_colors =
1214 fs::read_to_string(dir.join("platforms/android/res/values/colors.xml")).unwrap();
1215 assert!(android_colors.contains("#123456"));
1216 let android_styles =
1217 fs::read_to_string(dir.join("platforms/android/res/values/styles.xml")).unwrap();
1218 assert!(android_styles.contains("650"));
1219 assert!(dir
1220 .join("platforms/android/res/drawable-nodpi/fission_splash_image.png")
1221 .exists());
1222 let storyboard =
1223 fs::read_to_string(dir.join("platforms/ios/LaunchScreen.storyboard")).unwrap();
1224 assert!(storyboard.contains("scaleAspectFill"));
1225 assert!(storyboard.contains("red=\"0.070588\""));
1226 assert!(dir.join("platforms/ios/SplashImage.png").exists());
1227 }
1228
1229 #[test]
1230 fn add_capability_updates_project_and_platform_config() {
1231 let dir = unique_dir("capability");
1232 run(["fission", "init", dir.to_str().unwrap()]).unwrap();
1233 run([
1234 "fission",
1235 "add-target",
1236 "ios",
1237 "android",
1238 "--project-dir",
1239 dir.to_str().unwrap(),
1240 ])
1241 .unwrap();
1242 run([
1243 "fission",
1244 "add-capability",
1245 "nfc",
1246 "biometric",
1247 "bluetooth",
1248 "barcode-scanner",
1249 "camera",
1250 "geolocation",
1251 "haptics",
1252 "microphone",
1253 "volume-control",
1254 "wifi",
1255 "--project-dir",
1256 dir.to_str().unwrap(),
1257 ])
1258 .unwrap();
1259
1260 let project = read_project_config(&dir).unwrap();
1261 assert!(project
1262 .capabilities
1263 .contains(&fission_command_core::PlatformCapability::Nfc));
1264 assert!(project
1265 .capabilities
1266 .contains(&fission_command_core::PlatformCapability::Biometric));
1267 assert!(project
1268 .capabilities
1269 .contains(&fission_command_core::PlatformCapability::Bluetooth));
1270 assert!(project
1271 .capabilities
1272 .contains(&fission_command_core::PlatformCapability::BarcodeScanner));
1273 assert!(project
1274 .capabilities
1275 .contains(&fission_command_core::PlatformCapability::Camera));
1276 assert!(project
1277 .capabilities
1278 .contains(&fission_command_core::PlatformCapability::Geolocation));
1279 assert!(project
1280 .capabilities
1281 .contains(&fission_command_core::PlatformCapability::Haptics));
1282 assert!(project
1283 .capabilities
1284 .contains(&fission_command_core::PlatformCapability::Microphone));
1285 assert!(project
1286 .capabilities
1287 .contains(&fission_command_core::PlatformCapability::VolumeControl));
1288 assert!(project
1289 .capabilities
1290 .contains(&fission_command_core::PlatformCapability::Wifi));
1291
1292 let android_manifest =
1293 std::fs::read_to_string(dir.join("platforms/android/AndroidManifest.xml")).unwrap();
1294 assert!(android_manifest.contains("android.permission.NFC"));
1295 assert!(android_manifest.contains("android.hardware.nfc"));
1296 assert!(android_manifest.contains("android.permission.USE_BIOMETRIC"));
1297 assert!(android_manifest.contains("android.permission.BLUETOOTH_SCAN"));
1298 assert!(android_manifest.contains("android.permission.BLUETOOTH_CONNECT"));
1299 assert!(android_manifest.contains("android.hardware.bluetooth_le"));
1300 assert!(android_manifest.contains("android.permission.CAMERA"));
1301 assert!(android_manifest.contains("android.hardware.camera.flash"));
1302 assert!(android_manifest.contains("android.permission.ACCESS_FINE_LOCATION"));
1303 assert!(android_manifest.contains("android.permission.VIBRATE"));
1304 assert!(android_manifest.contains("android.permission.RECORD_AUDIO"));
1305 assert!(android_manifest.contains("android.permission.MODIFY_AUDIO_SETTINGS"));
1306 assert!(android_manifest.contains("android.permission.NEARBY_WIFI_DEVICES"));
1307 assert!(android_manifest.contains("android.permission.ACCESS_WIFI_STATE"));
1308 assert!(dir
1309 .join("platforms/android/java/rs/fission/runtime/FissionAndroidCapabilities.java")
1310 .exists());
1311
1312 let ios_info = std::fs::read_to_string(dir.join("platforms/ios/Info.plist")).unwrap();
1313 assert!(ios_info.contains("NFCReaderUsageDescription"));
1314 assert!(ios_info.contains("NSFaceIDUsageDescription"));
1315 assert!(ios_info.contains("NSBluetoothAlwaysUsageDescription"));
1316 assert!(ios_info.contains("NSCameraUsageDescription"));
1317 assert!(ios_info.contains("NSLocationWhenInUseUsageDescription"));
1318 assert!(ios_info.contains("NSMicrophoneUsageDescription"));
1319 let ios_entitlements =
1320 std::fs::read_to_string(dir.join("platforms/ios/Entitlements.plist")).unwrap();
1321 assert!(ios_entitlements.contains("com.apple.developer.nfc.readersession.formats"));
1322 assert!(ios_entitlements.contains("com.apple.developer.networking.wifi-info"));
1323 }
1324
1325 #[test]
1326 fn init_existing_project_preserves_user_files_and_detects_targets() {
1327 let dir = unique_dir("existing");
1328 fs::create_dir_all(dir.join("src")).unwrap();
1329 fs::create_dir_all(dir.join("platforms/web")).unwrap();
1330 fs::write(
1331 dir.join("Cargo.toml"),
1332 "[package]\nname = \"existing-web\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
1333 )
1334 .unwrap();
1335 fs::write(dir.join("src/main.rs"), "fn main() {}\n").unwrap();
1336 fs::write(dir.join("src/lib.rs"), "pub fn existing() {}\n").unwrap();
1337 fs::write(dir.join("README.md"), "# keep me\n").unwrap();
1338 fs::write(
1339 dir.join("platforms/web/index.html"),
1340 "<!doctype html><title>keep me</title>\n",
1341 )
1342 .unwrap();
1343
1344 run(["fission", "init", dir.to_str().unwrap()]).unwrap();
1345
1346 assert_eq!(
1347 fs::read_to_string(dir.join("README.md")).unwrap(),
1348 "# keep me\n"
1349 );
1350 assert_eq!(
1351 fs::read_to_string(dir.join("src/main.rs")).unwrap(),
1352 "fn main() {}\n"
1353 );
1354 assert_eq!(
1355 fs::read_to_string(dir.join("src/lib.rs")).unwrap(),
1356 "pub fn existing() {}\n"
1357 );
1358 assert_eq!(
1359 fs::read_to_string(dir.join("platforms/web/index.html")).unwrap(),
1360 "<!doctype html><title>keep me</title>\n"
1361 );
1362
1363 let project = read_project_config(&dir).unwrap();
1364 assert_eq!(project.app.name, "existing-web");
1365 assert!(project.targets.contains(&Target::Web));
1366 assert!(project.targets.contains(&Target::Macos));
1367 assert!(project.targets.contains(&Target::Linux));
1368 assert!(project.targets.contains(&Target::Windows));
1369 assert!(dir.join("platforms/web/README.md").exists());
1370 assert!(dir.join("platforms/web/bootstrap.mjs").exists());
1371 assert!(dir.join("assets/app-icon.png").exists());
1372 }
1373
1374 #[test]
1375 fn init_existing_project_is_idempotent() {
1376 let dir = unique_dir("idempotent");
1377 run(["fission", "init", dir.to_str().unwrap(), "--name", "idem"]).unwrap();
1378 let manifest = fs::read_to_string(dir.join("fission.toml")).unwrap();
1379 let main = fs::read_to_string(dir.join("src/main.rs")).unwrap();
1380 let agents = fs::read_to_string(dir.join("AGENTS.md")).unwrap();
1381
1382 run(["fission", "init", dir.to_str().unwrap()]).unwrap();
1383
1384 assert_eq!(
1385 fs::read_to_string(dir.join("fission.toml")).unwrap(),
1386 manifest
1387 );
1388 assert_eq!(fs::read_to_string(dir.join("src/main.rs")).unwrap(), main);
1389 assert_eq!(fs::read_to_string(dir.join("AGENTS.md")).unwrap(), agents);
1390 assert!(!dir.join("AGENTS.fission.md").exists());
1391 }
1392
1393 #[test]
1394 fn add_target_preserves_existing_target_files() {
1395 let dir = unique_dir("preserve-target");
1396 run([
1397 "fission",
1398 "init",
1399 dir.to_str().unwrap(),
1400 "--name",
1401 "preserve-target",
1402 ])
1403 .unwrap();
1404 fs::create_dir_all(dir.join("platforms/web")).unwrap();
1405 fs::write(
1406 dir.join("platforms/web/index.html"),
1407 "<!doctype html><title>custom</title>\n",
1408 )
1409 .unwrap();
1410 fs::write(dir.join("README.md"), "# custom readme\n").unwrap();
1411
1412 run([
1413 "fission",
1414 "add-target",
1415 "web",
1416 "--project-dir",
1417 dir.to_str().unwrap(),
1418 ])
1419 .unwrap();
1420
1421 assert_eq!(
1422 fs::read_to_string(dir.join("platforms/web/index.html")).unwrap(),
1423 "<!doctype html><title>custom</title>\n"
1424 );
1425 assert_eq!(
1426 fs::read_to_string(dir.join("README.md")).unwrap(),
1427 "# custom readme\n"
1428 );
1429 assert!(dir.join("platforms/web/README.md").exists());
1430 assert!(dir.join("platforms/web/bootstrap.mjs").exists());
1431 let project = read_project_config(&dir).unwrap();
1432 assert!(project.targets.contains(&Target::Web));
1433 }
1434
1435 #[test]
1436 fn cargo_fission_alias_accepts_prefixed_subcommand() {
1437 let dir = unique_dir("cargo-fission");
1438 run([
1439 "cargo-fission",
1440 "fission",
1441 "init",
1442 dir.to_str().unwrap(),
1443 "--name",
1444 "cargo-fission-demo",
1445 ])
1446 .unwrap();
1447
1448 assert!(dir.join("Cargo.toml").exists());
1449 assert!(dir.join("fission.toml").exists());
1450 }
1451
1452 #[test]
1453 fn doctor_command_runs_in_non_strict_mode() {
1454 let dir = unique_dir("doctor");
1455 run(["fission", "init", dir.to_str().unwrap()]).unwrap();
1456 run([
1457 "fission",
1458 "doctor",
1459 "web",
1460 "--project-dir",
1461 dir.to_str().unwrap(),
1462 ])
1463 .unwrap();
1464 }
1465}