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