1use anyhow::{bail, Context, Result};
2use clap::ValueEnum;
3use serde::{Deserialize, Serialize};
4use std::collections::BTreeSet;
5use std::fs;
6use std::path::{Path, PathBuf};
7use toml_edit::{value, Array, DocumentMut, InlineTable, Item, Table, Value};
8
9const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION");
10const ANDROID_GRADLE_PLUGIN_VERSION: &str = "8.13.2";
11const DEFAULT_APP_ICON_PNG: &[u8] = include_bytes!("../assets/fission_logo.png");
12const GENERATED_APP_AGENTS_MARKER: &str = "<!-- fission-cli-generated-agents:v1 -->";
13const GENERATED_APP_AGENTS_MD: &str = include_str!("../assets/AGENTS.md");
14
15mod icons;
16mod linux_native;
17mod macos_native;
18mod macos_signing;
19mod native_cargo;
20mod native_variant;
21mod splash;
22mod windows_native;
23pub use icons::{copy_icon_for_bundle, normalized_extension, resolve_app_icon, ResolvedIcon};
24pub use linux_native::{
25 build_linux_native_modules, stage_linux_native_products, test_linux_native_modules,
26 BuiltLinuxNativeProduct, NativeLinuxModuleConfig, NativeLinuxProductConfig,
27 NativeLinuxProductKind,
28};
29pub use macos_native::{
30 build_macos_native_modules, embed_and_sign_macos_native_modules, test_macos_native_modules,
31 MacosNativeBundleMode, NativeMacosModuleConfig, NativeMacosProductConfig,
32 NativeMacosProductKind, NativeMacosProductSigningConfig,
33};
34pub use macos_signing::{
35 read_macos_package_config, read_macos_package_config_for_profile, read_macos_run_config,
36 read_macos_run_config_for_profile, sign_macos_app_if_configured, MacosPackageConfig,
37};
38pub use native_variant::{ensure_native_variant_target, variant_output_path, NativeVariant};
39pub use splash::{SplashConfig, SplashResizeMode};
40pub use windows_native::{
41 build_windows_native_modules, stage_windows_runtime_products, test_windows_native_modules,
42 BuiltWindowsNativeProduct, NativeWindowsModuleConfig, NativeWindowsProductConfig,
43 NativeWindowsProductKind,
44};
45
46#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, ValueEnum, Serialize, Deserialize)]
47#[serde(rename_all = "kebab-case")]
48pub enum Target {
49 Android,
50 Ios,
51 Linux,
52 Macos,
53 #[value(name = "ssr", alias = "server")]
54 #[serde(rename = "ssr", alias = "server")]
55 Server,
56 #[value(name = "static-site", alias = "site")]
57 #[serde(rename = "static-site", alias = "site")]
58 Site,
59 Terminal,
60 Web,
61 Windows,
62}
63
64#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, ValueEnum, Serialize, Deserialize)]
65#[serde(rename_all = "kebab-case")]
66pub enum PlatformCapability {
67 BarcodeScanner,
68 Biometric,
69 Bluetooth,
70 Camera,
71 Geolocation,
72 Haptics,
73 Microphone,
74 Nfc,
75 Notifications,
76 Passkeys,
77 VolumeControl,
78 Wifi,
79}
80
81impl PlatformCapability {
82 pub fn as_str(self) -> &'static str {
83 match self {
84 Self::BarcodeScanner => "barcode-scanner",
85 Self::Biometric => "biometric",
86 Self::Bluetooth => "bluetooth",
87 Self::Camera => "camera",
88 Self::Geolocation => "geolocation",
89 Self::Haptics => "haptics",
90 Self::Microphone => "microphone",
91 Self::Nfc => "nfc",
92 Self::Notifications => "notifications",
93 Self::Passkeys => "passkeys",
94 Self::VolumeControl => "volume-control",
95 Self::Wifi => "wifi",
96 }
97 }
98}
99
100impl Target {
101 pub fn as_str(self) -> &'static str {
102 match self {
103 Self::Android => "android",
104 Self::Ios => "ios",
105 Self::Linux => "linux",
106 Self::Macos => "macos",
107 Self::Server => "ssr",
108 Self::Site => "static-site",
109 Self::Terminal => "terminal",
110 Self::Web => "web",
111 Self::Windows => "windows",
112 }
113 }
114
115 pub fn scaffold_relative_path(self) -> &'static str {
116 match self {
117 Self::Android => "platforms/android/README.md",
118 Self::Ios => "platforms/ios/README.md",
119 Self::Linux => "platforms/linux/README.md",
120 Self::Macos => "platforms/macos/README.md",
121 Self::Server => "platforms/ssr/README.md",
122 Self::Site => "platforms/site/README.md",
123 Self::Terminal => "platforms/terminal/README.md",
124 Self::Web => "platforms/web/README.md",
125 Self::Windows => "platforms/windows/README.md",
126 }
127 }
128}
129
130#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
131pub enum DistributionProvider {
132 #[value(name = "app-store")]
133 AppStore,
134 #[value(name = "github-pages")]
135 GithubPages,
136 #[value(name = "github-releases")]
137 GithubReleases,
138 #[value(name = "cloudflare-pages")]
139 CloudflarePages,
140 #[value(name = "docker-registry")]
141 DockerRegistry,
142 Dropbox,
143 #[value(name = "google-drive")]
144 GoogleDrive,
145 #[value(name = "microsoft-store")]
146 MicrosoftStore,
147 Netlify,
148 #[value(name = "onedrive")]
149 OneDrive,
150 #[value(name = "play-store")]
151 PlayStore,
152 S3,
153}
154
155impl DistributionProvider {
156 pub fn as_str(self) -> &'static str {
157 match self {
158 Self::AppStore => "app-store",
159 Self::GithubPages => "github-pages",
160 Self::GithubReleases => "github-releases",
161 Self::CloudflarePages => "cloudflare-pages",
162 Self::DockerRegistry => "docker-registry",
163 Self::Dropbox => "dropbox",
164 Self::GoogleDrive => "google-drive",
165 Self::MicrosoftStore => "microsoft-store",
166 Self::Netlify => "netlify",
167 Self::OneDrive => "onedrive",
168 Self::PlayStore => "play-store",
169 Self::S3 => "s3",
170 }
171 }
172}
173
174#[derive(Debug, Serialize, Deserialize)]
175pub struct FissionProject {
176 pub app: AppConfig,
177 pub targets: BTreeSet<Target>,
178 #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
179 pub capabilities: BTreeSet<PlatformCapability>,
180 #[serde(default, skip_serializing_if = "NativeConfig::is_empty")]
181 pub native: NativeConfig,
182}
183
184#[derive(Debug, Serialize, Deserialize)]
185pub struct AppConfig {
186 pub name: String,
187 #[serde(alias = "identifier", alias = "id")]
188 pub app_id: String,
189 #[serde(default, skip_serializing_if = "Option::is_none")]
190 pub splash: Option<SplashConfig>,
191}
192
193#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
194pub struct NativeConfig {
195 #[serde(default, skip_serializing_if = "Vec::is_empty")]
196 pub modules: Vec<NativeModuleConfig>,
197}
198
199#[derive(Clone, Debug, Default, PartialEq, Eq)]
200pub struct ReleaseVersionConfig {
201 pub version: Option<String>,
202 pub build: Option<u64>,
203}
204
205#[derive(Debug, Deserialize, Default)]
206struct ReleaseVersionToml {
207 app: Option<AppReleaseVersionConfig>,
208 package: Option<PackageReleaseVersionConfig>,
209 release: Option<ReleaseRootVersionConfig>,
210 #[serde(default)]
211 releases: Vec<ReleaseEntryVersionConfig>,
212}
213
214#[derive(Debug, Deserialize, Default)]
215struct AppReleaseVersionConfig {
216 version: Option<String>,
217 build: Option<u64>,
218}
219
220#[derive(Debug, Deserialize, Default)]
221struct PackageReleaseVersionConfig {
222 android: Option<AndroidReleaseVersionConfig>,
223 ios: Option<IosReleaseVersionConfig>,
224 macos: Option<MacosReleaseVersionConfig>,
225 windows: Option<WindowsReleaseVersionConfig>,
226}
227
228#[derive(Debug, Deserialize, Default)]
229struct AndroidReleaseVersionConfig {
230 version_code: Option<u64>,
231 version_name: Option<String>,
232}
233
234#[derive(Debug, Deserialize, Default)]
235struct IosReleaseVersionConfig {
236 marketing_version: Option<String>,
237 build_number: Option<String>,
238}
239
240#[derive(Debug, Deserialize, Default)]
241struct MacosReleaseVersionConfig {
242 marketing_version: Option<String>,
243 build_number: Option<String>,
244}
245
246#[derive(Debug, Deserialize, Default)]
247struct WindowsReleaseVersionConfig {
248 version: Option<String>,
249 identity_name: Option<String>,
250 publisher: Option<String>,
251}
252
253#[derive(Debug, Deserialize, Default)]
254struct ReleaseRootVersionConfig {
255 active_release: Option<String>,
256}
257
258#[derive(Debug, Deserialize, Default)]
259struct ReleaseEntryVersionConfig {
260 id: Option<String>,
261 version: Option<String>,
262 build: Option<u64>,
263}
264
265impl NativeConfig {
266 pub fn is_empty(&self) -> bool {
267 self.modules.is_empty()
268 }
269}
270
271#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
272pub struct NativeModuleConfig {
273 pub name: String,
274 #[serde(default, skip_serializing_if = "Option::is_none")]
275 pub path: Option<String>,
276 #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
277 pub variants: BTreeSet<NativeVariant>,
278 #[serde(default, skip_serializing_if = "NativeAndroidModuleConfig::is_empty")]
279 pub android: NativeAndroidModuleConfig,
280 #[serde(default, skip_serializing_if = "NativeIosModuleConfig::is_empty")]
281 pub ios: NativeIosModuleConfig,
282 #[serde(default, skip_serializing_if = "NativeLinuxModuleConfig::is_empty")]
283 pub linux: NativeLinuxModuleConfig,
284 #[serde(default, skip_serializing_if = "NativeMacosModuleConfig::is_empty")]
285 pub macos: NativeMacosModuleConfig,
286 #[serde(default, skip_serializing_if = "NativeWindowsModuleConfig::is_empty")]
287 pub windows: NativeWindowsModuleConfig,
288}
289
290#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
291pub struct NativeAndroidModuleConfig {
292 #[serde(default, skip_serializing_if = "Vec::is_empty")]
293 pub repositories: Vec<String>,
294 #[serde(default, skip_serializing_if = "Vec::is_empty")]
295 pub gradle_dependencies: Vec<String>,
296 #[serde(default, skip_serializing_if = "Vec::is_empty")]
297 pub source_dirs: Vec<String>,
298 #[serde(default, skip_serializing_if = "Vec::is_empty")]
299 pub permissions: Vec<String>,
300 #[serde(default, skip_serializing_if = "Vec::is_empty")]
301 pub manifest_application_entries: Vec<String>,
302}
303
304impl NativeAndroidModuleConfig {
305 pub fn is_empty(&self) -> bool {
306 self.repositories.is_empty()
307 && self.gradle_dependencies.is_empty()
308 && self.source_dirs.is_empty()
309 && self.permissions.is_empty()
310 && self.manifest_application_entries.is_empty()
311 }
312}
313
314#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
315pub struct NativeIosModuleConfig {
316 #[serde(default, skip_serializing_if = "Vec::is_empty")]
317 pub swift_packages: Vec<NativeIosSwiftPackageConfig>,
318 #[serde(default, skip_serializing_if = "Vec::is_empty")]
319 pub source_dirs: Vec<String>,
320 #[serde(default, skip_serializing_if = "Vec::is_empty")]
321 pub linked_frameworks: Vec<String>,
322}
323
324impl NativeIosModuleConfig {
325 pub fn is_empty(&self) -> bool {
326 self.swift_packages.is_empty()
327 && self.source_dirs.is_empty()
328 && self.linked_frameworks.is_empty()
329 }
330}
331
332#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
333pub struct NativeIosSwiftPackageConfig {
334 pub url: String,
335 pub product: String,
336 #[serde(default, skip_serializing_if = "Option::is_none")]
337 pub from: Option<String>,
338}
339
340#[derive(Debug, Deserialize)]
341struct CargoManifest {
342 package: Option<CargoPackage>,
343}
344
345#[derive(Debug, Deserialize)]
346struct CargoPackage {
347 pub name: String,
348}
349
350#[derive(Clone, Copy, Debug, Eq, PartialEq)]
351enum WritePolicy {
352 Overwrite,
353 PreserveExisting,
354}
355
356pub fn init_project(
357 root: &Path,
358 name: Option<String>,
359 app_id: Option<String>,
360 local_path: Option<PathBuf>,
361) -> Result<()> {
362 let existing_project = root.exists() && root.read_dir()?.next().is_some();
363 fs::create_dir_all(root.join("src"))?;
364
365 let write_policy = if existing_project {
366 WritePolicy::PreserveExisting
367 } else {
368 WritePolicy::Overwrite
369 };
370 let project = initial_project_config(root, name, app_id)?;
371
372 write_file_with_policy(
373 &root.join("Cargo.toml"),
374 &render_cargo_toml(&project, local_path.as_deref()),
375 write_policy,
376 )?;
377 write_file_with_policy(
378 &root.join("src/main.rs"),
379 &render_app_main(project.app.name.as_str()),
380 write_policy,
381 )?;
382 write_file_with_policy(&root.join("src/lib.rs"), APP_LIB, write_policy)?;
383 write_file_with_policy(&root.join("src/app.rs"), APP_RS, write_policy)?;
384 write_binary_file_with_policy(
385 &root.join("assets/app-icon.png"),
386 DEFAULT_APP_ICON_PNG,
387 write_policy,
388 )?;
389 write_file_with_policy(
390 &root.join("README.md"),
391 &render_project_readme(&project),
392 write_policy,
393 )?;
394 write_generated_app_agents(root)?;
395 write_file_with_policy(
396 &root.join(".gitignore"),
397 "target/\nplatforms/*/build/\n",
398 write_policy,
399 )?;
400 write_project_config(root, &project)?;
401
402 let targets = project.targets.iter().copied().collect::<Vec<_>>();
403 for target in targets {
404 scaffold_target_with_policy(root, &project, target, write_policy)?;
405 }
406 sync_platform_config(root, &project)?;
407 sync_cargo_fission_dependency(root, &project, local_path.as_deref())?;
408
409 Ok(())
410}
411
412fn initial_project_config(
413 root: &Path,
414 name: Option<String>,
415 app_id: Option<String>,
416) -> Result<FissionProject> {
417 let existing = if root.join("fission.toml").exists() {
418 Some(read_project_config(root)?)
419 } else {
420 None
421 };
422 let cargo_name = cargo_package_name(root);
423 if let (Some(requested), Some(cargo_name)) = (&name, &cargo_name) {
424 let requested = normalize_crate_name(requested);
425 let cargo_name = normalize_crate_name(cargo_name);
426 if requested != cargo_name {
427 bail!(
428 "refusing to set app name `{requested}` for existing Cargo package `{cargo_name}`; rename the package in Cargo.toml first or omit --name"
429 );
430 }
431 }
432 let project_name = cargo_name
433 .or(name)
434 .or_else(|| existing.as_ref().map(|project| project.app.name.clone()))
435 .unwrap_or_else(|| {
436 root.file_name()
437 .and_then(|value| value.to_str())
438 .unwrap_or("fission-app")
439 .to_string()
440 });
441 let normalized_name = normalize_crate_name(&project_name);
442
443 let mut targets = existing
444 .as_ref()
445 .map(|project| project.targets.clone())
446 .unwrap_or_default();
447 targets.extend(detect_project_targets(root));
448 if targets.is_empty() {
449 targets.extend([Target::Windows, Target::Macos, Target::Linux]);
450 }
451
452 Ok(FissionProject {
453 app: AppConfig {
454 name: normalized_name.clone(),
455 app_id: app_id
456 .or_else(|| existing.as_ref().map(|project| project.app.app_id.clone()))
457 .unwrap_or_else(|| format!("com.example.{}", normalized_name.replace('-', "_"))),
458 splash: existing
459 .as_ref()
460 .and_then(|project| project.app.splash.clone()),
461 },
462 targets,
463 capabilities: existing
464 .as_ref()
465 .map(|project| project.capabilities.clone())
466 .unwrap_or_default(),
467 native: existing
468 .as_ref()
469 .map(|project| project.native.clone())
470 .unwrap_or_default(),
471 })
472}
473
474pub fn cargo_package_name(root: &Path) -> Option<String> {
475 let manifest = fs::read_to_string(root.join("Cargo.toml")).ok()?;
476 let manifest: CargoManifest = toml::from_str(&manifest).ok()?;
477 manifest.package.map(|package| package.name)
478}
479
480pub fn cargo_package_version(root: &Path) -> Option<String> {
481 let manifest = fs::read_to_string(root.join("Cargo.toml")).ok()?;
482 let value: toml::Value = toml::from_str(&manifest).ok()?;
483 value
484 .get("package")
485 .and_then(|package| package.get("version"))
486 .and_then(toml::Value::as_str)
487 .map(str::to_string)
488}
489
490fn detect_project_targets(root: &Path) -> BTreeSet<Target> {
491 let mut targets = BTreeSet::new();
492 if root.join("src/main.rs").exists() || root.join("src/lib.rs").exists() {
493 targets.extend([Target::Windows, Target::Macos, Target::Linux]);
494 }
495 for (target, relative) in [
496 (Target::Android, "platforms/android"),
497 (Target::Ios, "platforms/ios"),
498 (Target::Linux, "platforms/linux"),
499 (Target::Macos, "platforms/macos"),
500 (Target::Server, "platforms/ssr"),
501 (Target::Site, "content"),
502 (Target::Terminal, "platforms/terminal"),
503 (Target::Web, "platforms/web"),
504 (Target::Windows, "platforms/windows"),
505 ] {
506 if root.join(relative).exists() {
507 targets.insert(target);
508 }
509 }
510 for (target, relative) in [
511 (Target::Server, "platforms/server"),
512 (Target::Site, "platforms/site"),
513 ] {
514 if root.join(relative).exists() {
515 targets.insert(target);
516 }
517 }
518 targets
519}
520
521pub fn add_targets(project_dir: &Path, targets: &[Target]) -> Result<()> {
522 if targets.is_empty() {
523 bail!("no targets provided");
524 }
525 let mut project = read_project_config(project_dir)?;
526 for target in targets {
527 let target_exists =
528 project.targets.contains(target) || target_scaffold_dir_exists(project_dir, *target);
529 project.targets.insert(*target);
530 let write_policy = if target_exists {
531 WritePolicy::PreserveExisting
532 } else {
533 WritePolicy::Overwrite
534 };
535 scaffold_target_with_policy(project_dir, &project, *target, write_policy)?;
536 }
537 sync_platform_config(project_dir, &project)?;
538 write_project_config(project_dir, &project)?;
539 update_cargo_fission_features(project_dir, &project)?;
540 write_file_with_policy(
541 &project_dir.join("README.md"),
542 &render_project_readme(&project),
543 WritePolicy::PreserveExisting,
544 )?;
545 Ok(())
546}
547
548pub fn add_capabilities(project_dir: &Path, capabilities: &[PlatformCapability]) -> Result<()> {
549 if capabilities.is_empty() {
550 bail!("no capabilities provided");
551 }
552 let mut project = read_project_config(project_dir)?;
553 for capability in capabilities {
554 project.capabilities.insert(*capability);
555 }
556 write_project_config(project_dir, &project)?;
557 sync_platform_config(project_dir, &project)?;
558 Ok(())
559}
560
561pub fn sync_platform_config(root: &Path, project: &FissionProject) -> Result<()> {
562 apply_platform_capability_config(root, project)?;
563 apply_native_module_config(root, project)?;
564 splash::apply_platform_splash_config(root, project)?;
565 icons::apply_platform_icon_config(root, project)?;
566 apply_mobile_run_script_hardening(root, project)?;
567 Ok(())
568}
569
570pub fn resolve_release_version_config(
571 project_dir: &Path,
572 target: Option<Target>,
573) -> Result<ReleaseVersionConfig> {
574 let path = project_dir.join("fission.toml");
575 let data =
576 fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))?;
577 let manifest: ReleaseVersionToml = toml::from_str(&data).unwrap_or_default();
578 let active = manifest
579 .release
580 .as_ref()
581 .and_then(|release| release.active_release.as_deref())
582 .and_then(|id| {
583 manifest
584 .releases
585 .iter()
586 .find(|release| release.id.as_deref() == Some(id))
587 });
588
589 let mut version = active
590 .and_then(|release| release.version.clone())
591 .or_else(|| manifest.app.as_ref().and_then(|app| app.version.clone()));
592 let mut build = active
593 .and_then(|release| release.build)
594 .or_else(|| manifest.app.as_ref().and_then(|app| app.build));
595
596 match target {
597 Some(Target::Android) => {
598 if let Some(android) = manifest
599 .package
600 .as_ref()
601 .and_then(|package| package.android.as_ref())
602 {
603 version = android.version_name.clone().or(version);
604 build = android.version_code.or(build);
605 }
606 }
607 Some(Target::Ios) => {
608 if let Some(ios) = manifest
609 .package
610 .as_ref()
611 .and_then(|package| package.ios.as_ref())
612 {
613 version = ios.marketing_version.clone().or(version);
614 build = ios
615 .build_number
616 .as_deref()
617 .and_then(|value| value.parse::<u64>().ok())
618 .or(build);
619 }
620 }
621 Some(Target::Macos) => {
622 if let Some(macos) = manifest
623 .package
624 .as_ref()
625 .and_then(|package| package.macos.as_ref())
626 {
627 version = macos.marketing_version.clone().or(version);
628 build = macos
629 .build_number
630 .as_deref()
631 .and_then(|value| value.parse::<u64>().ok())
632 .or(build);
633 }
634 }
635 Some(Target::Windows) => {
636 if let Some(windows) = manifest
637 .package
638 .as_ref()
639 .and_then(|package| package.windows.as_ref())
640 {
641 version = windows.version.clone().or(version);
642 }
643 }
644 _ => {}
645 }
646
647 if version.is_none() {
648 version = cargo_package_version(project_dir);
649 }
650 Ok(ReleaseVersionConfig { version, build })
651}
652
653pub fn sync_release_platform_config(
654 project_dir: &Path,
655 target: Target,
656 release: &ReleaseVersionConfig,
657) -> Result<()> {
658 match target {
659 Target::Android => sync_android_release_config(project_dir, release),
660 Target::Ios => sync_ios_release_config(project_dir, release),
661 Target::Macos => sync_macos_release_config(project_dir, release),
662 Target::Windows => sync_windows_release_config(project_dir, release),
663 _ => Ok(()),
664 }
665}
666
667pub fn sync_resolved_release_platform_config(
668 project_dir: &Path,
669 target: Target,
670) -> Result<ReleaseVersionConfig> {
671 let release = resolve_release_version_config(project_dir, Some(target))?;
672 sync_release_platform_config(project_dir, target, &release)?;
673 Ok(release)
674}
675
676fn sync_android_release_config(project_dir: &Path, release: &ReleaseVersionConfig) -> Result<()> {
677 let path = project_dir.join("platforms/android/app/build.gradle.kts");
678 if !path.exists() {
679 return Ok(());
680 }
681 let version = release.version.as_deref().unwrap_or("0.1.0");
682 let build = release.build.unwrap_or(1);
683 rewrite_file_lines(&path, |trimmed| {
684 if trimmed.starts_with("versionCode =") {
685 Some(format!(
686 " versionCode = (System.getenv(\"ANDROID_VERSION_CODE\") ?: \"{build}\").toInt()"
687 ))
688 } else if trimmed.starts_with("versionName =") {
689 Some(format!(
690 " versionName = System.getenv(\"ANDROID_VERSION_NAME\") ?: \"{version}\""
691 ))
692 } else {
693 None
694 }
695 })
696}
697
698fn sync_ios_release_config(project_dir: &Path, release: &ReleaseVersionConfig) -> Result<()> {
699 let path = project_dir.join("platforms/ios/package-sim.sh");
700 if path.exists() {
701 let version = release.version.as_deref().unwrap_or("0.1.0");
702 let build = release.build.unwrap_or(1);
703 let existing = fs::read_to_string(&path)?;
704 let mut data = existing.clone();
705 if !data.contains("IOS_MARKETING_VERSION") {
706 data = data.replace(
707 "BUNDLE_NAME=\"${IOS_BUNDLE_NAME:-$DISPLAY_NAME.app}\"\n",
708 &format!(
709 "BUNDLE_NAME=\"${{IOS_BUNDLE_NAME:-$DISPLAY_NAME.app}}\"\nIOS_MARKETING_VERSION=\"${{IOS_MARKETING_VERSION:-{version}}}\"\nIOS_BUILD_NUMBER=\"${{IOS_BUILD_NUMBER:-{build}}}\"\n"
710 ),
711 );
712 } else {
713 data = data
714 .lines()
715 .map(|line| {
716 if line.starts_with("IOS_MARKETING_VERSION=") {
717 format!("IOS_MARKETING_VERSION=\"${{IOS_MARKETING_VERSION:-{version}}}\"")
718 } else if line.starts_with("IOS_BUILD_NUMBER=") {
719 format!("IOS_BUILD_NUMBER=\"${{IOS_BUILD_NUMBER:-{build}}}\"")
720 } else {
721 line.to_string()
722 }
723 })
724 .collect::<Vec<_>>()
725 .join("\n");
726 data.push('\n');
727 }
728 if data != existing {
729 fs::write(&path, data)?;
730 }
731 }
732 let plist = project_dir.join("platforms/ios/Info.plist");
733 if plist.exists() {
734 let version = release.version.as_deref().unwrap_or("0.1.0");
735 let build = release.build.unwrap_or(1).to_string();
736 rewrite_plist_string(&plist, "CFBundleShortVersionString", version)?;
737 rewrite_plist_string(&plist, "CFBundleVersion", &build)?;
738 }
739 Ok(())
740}
741
742fn sync_macos_release_config(project_dir: &Path, release: &ReleaseVersionConfig) -> Result<()> {
743 let plist = project_dir.join("platforms/macos/Info.plist");
744 if plist.exists() {
745 let version = release.version.as_deref().unwrap_or("0.1.0");
746 let build = release.build.unwrap_or(1).to_string();
747 rewrite_plist_string(&plist, "CFBundleShortVersionString", version)?;
748 rewrite_plist_string(&plist, "CFBundleVersion", &build)?;
749 }
750 Ok(())
751}
752
753fn sync_windows_release_config(project_dir: &Path, release: &ReleaseVersionConfig) -> Result<()> {
754 let config = read_windows_release_config(project_dir)?;
755 let manifests = [
756 project_dir.join("platforms/windows/Package.appxmanifest"),
757 project_dir.join("platforms/windows/AppxManifest.xml"),
758 project_dir.join("platforms/windows/appxmanifest.xml"),
759 ];
760 let has_manifest = manifests.iter().any(|path| path.exists());
761 if !has_manifest {
762 return Ok(());
763 }
764
765 let version = normalized_windows_package_version(release)?;
766 for path in manifests.into_iter().filter(|path| path.exists()) {
767 rewrite_windows_appx_manifest(
768 &path,
769 &version,
770 config.identity_name.as_deref(),
771 config.publisher.as_deref(),
772 )?;
773 }
774 Ok(())
775}
776
777fn read_windows_release_config(project_dir: &Path) -> Result<WindowsReleaseVersionConfig> {
778 let path = project_dir.join("fission.toml");
779 let data = fs::read_to_string(&path).unwrap_or_default();
780 let manifest: ReleaseVersionToml = toml::from_str(&data).unwrap_or_default();
781 Ok(manifest
782 .package
783 .and_then(|package| package.windows)
784 .unwrap_or_default())
785}
786
787pub fn normalize_windows_package_version(
788 version: Option<&str>,
789 build: Option<u64>,
790) -> Result<String> {
791 let version = version.unwrap_or("0.1.0");
792 let parts = version.split('.').collect::<Vec<_>>();
793 if parts.is_empty() || parts.len() > 4 {
794 bail!("Windows package version `{version}` must have one to four numeric components");
795 }
796 let mut normalized = Vec::with_capacity(4);
797 for part in &parts {
798 let value = part
799 .parse::<u16>()
800 .with_context(|| format!("Windows package version `{version}` must be numeric"))?;
801 normalized.push(value.to_string());
802 }
803 while normalized.len() < 3 {
804 normalized.push("0".to_string());
805 }
806 if normalized.len() == 3 {
807 let build = build.unwrap_or(0);
808 if build > u16::MAX as u64 {
809 bail!("Windows package build `{build}` must fit in a 16-bit version component");
810 }
811 normalized.push(build.to_string());
812 }
813 Ok(normalized.join("."))
814}
815
816fn normalized_windows_package_version(release: &ReleaseVersionConfig) -> Result<String> {
817 normalize_windows_package_version(release.version.as_deref(), release.build)
818}
819
820fn rewrite_windows_appx_manifest(
821 path: &Path,
822 version: &str,
823 identity_name: Option<&str>,
824 publisher: Option<&str>,
825) -> Result<()> {
826 let existing = fs::read_to_string(path)?;
827 let mut updated = rewrite_xml_attribute_on_tag(&existing, "Identity", "Version", version);
828 if let Some(identity_name) = identity_name.filter(|value| !value.trim().is_empty()) {
829 updated = rewrite_xml_attribute_on_tag(&updated, "Identity", "Name", identity_name.trim());
830 }
831 if let Some(publisher) = publisher.filter(|value| !value.trim().is_empty()) {
832 updated = rewrite_xml_attribute_on_tag(&updated, "Identity", "Publisher", publisher.trim());
833 }
834 if updated != existing {
835 fs::write(path, updated)?;
836 }
837 Ok(())
838}
839
840fn rewrite_xml_attribute_on_tag(input: &str, tag: &str, attribute: &str, value: &str) -> String {
841 let Some(tag_start) = input.find(&format!("<{tag}")) else {
842 return input.to_string();
843 };
844 let Some(relative_end) = input[tag_start..].find('>') else {
845 return input.to_string();
846 };
847 let tag_end = tag_start + relative_end;
848 let mut output = input.to_string();
849 let tag_text = &input[tag_start..=tag_end];
850 let escaped = escape_xml_attribute(value);
851 let updated_tag = if let Some(attribute_start) = tag_text.find(&format!("{attribute}=\"")) {
852 let value_start = attribute_start + attribute.len() + 2;
853 if let Some(relative_quote) = tag_text[value_start..].find('"') {
854 let value_end = value_start + relative_quote;
855 let mut tag_output = tag_text.to_string();
856 tag_output.replace_range(value_start..value_end, &escaped);
857 tag_output
858 } else {
859 tag_text.to_string()
860 }
861 } else {
862 let insert_at = tag_text
863 .rfind('/')
864 .filter(|slash| *slash + 1 == tag_text.len() - 1)
865 .unwrap_or(tag_text.len() - 1);
866 let mut tag_output = tag_text.to_string();
867 tag_output.insert_str(insert_at, &format!(" {attribute}=\"{escaped}\""));
868 tag_output
869 };
870 output.replace_range(tag_start..=tag_end, &updated_tag);
871 output
872}
873
874fn escape_xml_attribute(value: &str) -> String {
875 value
876 .replace('&', "&")
877 .replace('"', """)
878 .replace('<', "<")
879 .replace('>', ">")
880}
881
882fn rewrite_file_lines<F>(path: &Path, mut replacement: F) -> Result<()>
883where
884 F: FnMut(&str) -> Option<String>,
885{
886 let existing = fs::read_to_string(path)?;
887 let mut updated = String::new();
888 for line in existing.lines() {
889 if let Some(new_line) = replacement(line.trim_start()) {
890 updated.push_str(&new_line);
891 updated.push('\n');
892 } else {
893 updated.push_str(line);
894 updated.push('\n');
895 }
896 }
897 if updated != existing {
898 fs::write(path, updated)?;
899 }
900 Ok(())
901}
902
903fn rewrite_plist_string(path: &Path, key: &str, value: &str) -> Result<()> {
904 let existing = fs::read_to_string(path)?;
905 let mut lines = existing.lines().peekable();
906 let mut updated = String::new();
907 while let Some(line) = lines.next() {
908 updated.push_str(line);
909 updated.push('\n');
910 if line.trim() == format!("<key>{key}</key>") {
911 let _ = lines.next();
912 updated.push_str(&format!(" <string>{value}</string>\n"));
913 }
914 }
915 if updated != existing {
916 fs::write(path, updated)?;
917 }
918 Ok(())
919}
920
921fn apply_native_module_config(root: &Path, project: &FissionProject) -> Result<()> {
922 if project.targets.contains(&Target::Android) {
923 write_file(
924 &root.join("platforms/android/native-modules.gradle"),
925 &render_android_native_modules_gradle(project),
926 )?;
927 apply_android_settings_gradle_hardening(root, project)?;
928 apply_android_native_manifest_entries(root, project)?;
929 }
930 if project.targets.contains(&Target::Ios) {
931 write_file(
932 &root.join("platforms/ios/NativeModules/Package.swift"),
933 &render_ios_native_modules_package(project),
934 )?;
935 write_file(
936 &root.join(
937 "platforms/ios/NativeModules/Sources/FissionNativeModules/FissionNativeCapabilities.swift",
938 ),
939 render_ios_native_capabilities_swift(),
940 )?;
941 sync_ios_native_module_sources(root, project)?;
942 }
943 Ok(())
944}
945
946fn apply_android_native_manifest_entries(root: &Path, project: &FissionProject) -> Result<()> {
947 let entries = render_android_native_application_entries(project);
948 if entries.trim().is_empty() {
949 return Ok(());
950 }
951 let path = root.join("platforms/android/AndroidManifest.xml");
952 if !path.exists() {
953 return Ok(());
954 }
955 let existing =
956 fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))?;
957 let missing = entries
958 .lines()
959 .filter(|entry| !entry.trim().is_empty() && !existing.contains(entry.trim()))
960 .collect::<Vec<_>>();
961 if missing.is_empty() {
962 return Ok(());
963 }
964
965 let insertion = format!("{}\n", missing.join("\n"));
966 let marker =
967 " <activity\n android:name=\"rs.fission.runtime.FissionActivity\"";
968 let updated = if let Some(index) = existing.find(marker) {
969 let mut updated = existing.clone();
970 updated.insert_str(index, &insertion);
971 updated
972 } else if let Some(index) = existing.find("</application>") {
973 let mut updated = existing.clone();
974 updated.insert_str(index, &insertion);
975 updated
976 } else {
977 existing
978 };
979
980 if updated != fs::read_to_string(&path)? {
981 fs::write(&path, updated).with_context(|| format!("failed to write {}", path.display()))?;
982 }
983 Ok(())
984}
985
986fn sync_ios_native_module_sources(root: &Path, project: &FissionProject) -> Result<()> {
987 let generated_root = root.join("platforms/ios/NativeModules/Sources/FissionNativeModules");
988 fs::create_dir_all(&generated_root)
989 .with_context(|| format!("failed to create {}", generated_root.display()))?;
990
991 for module in &project.native.modules {
992 let module_dir = generated_root.join(swift_module_source_dir_name(&module.name));
993 if module_dir.exists() {
994 fs::remove_dir_all(&module_dir)
995 .with_context(|| format!("failed to remove {}", module_dir.display()))?;
996 }
997 if module.ios.source_dirs.is_empty() {
998 continue;
999 }
1000 fs::create_dir_all(&module_dir)
1001 .with_context(|| format!("failed to create {}", module_dir.display()))?;
1002 for source_dir in &module.ios.source_dirs {
1003 let source_dir = source_dir.trim();
1004 if source_dir.is_empty() {
1005 continue;
1006 }
1007 let source = resolve_project_path(root, source_dir);
1008 copy_dir_contents(&source, &module_dir).with_context(|| {
1009 format!(
1010 "failed to copy iOS native module source {} into {}",
1011 source.display(),
1012 module_dir.display()
1013 )
1014 })?;
1015 }
1016 }
1017 Ok(())
1018}
1019
1020fn resolve_project_path(root: &Path, value: &str) -> PathBuf {
1021 let path = Path::new(value);
1022 if path.is_absolute() {
1023 path.to_path_buf()
1024 } else {
1025 root.join(path)
1026 }
1027}
1028
1029fn swift_module_source_dir_name(name: &str) -> String {
1030 let mut output = String::new();
1031 for ch in name.chars() {
1032 if ch.is_ascii_alphanumeric() {
1033 output.push(ch);
1034 } else if !output.ends_with('_') {
1035 output.push('_');
1036 }
1037 }
1038 let output = output.trim_matches('_');
1039 if output.is_empty() {
1040 "module".to_string()
1041 } else {
1042 output.to_string()
1043 }
1044}
1045
1046fn copy_dir_contents(source: &Path, dest: &Path) -> Result<()> {
1047 if source.is_file() {
1048 let file_name = source
1049 .file_name()
1050 .ok_or_else(|| anyhow::anyhow!("source file has no file name"))?;
1051 fs::create_dir_all(dest)?;
1052 fs::copy(source, dest.join(file_name))?;
1053 return Ok(());
1054 }
1055 fs::create_dir_all(dest)?;
1056 for entry in fs::read_dir(source)
1057 .with_context(|| format!("failed to read native source dir {}", source.display()))?
1058 {
1059 let entry = entry?;
1060 let path = entry.path();
1061 let target = dest.join(entry.file_name());
1062 if path.is_dir() {
1063 copy_dir_contents(&path, &target)?;
1064 } else if path.is_file() {
1065 fs::copy(&path, &target)
1066 .with_context(|| format!("failed to copy {}", path.display()))?;
1067 }
1068 }
1069 Ok(())
1070}
1071
1072pub fn stage_project_assets(
1078 project_dir: &Path,
1079 destination_root: &Path,
1080) -> Result<Option<PathBuf>> {
1081 let source = project_dir.join("assets");
1082 if !source.exists() {
1083 return Ok(None);
1084 }
1085 if !source.is_dir() {
1086 bail!(
1087 "project assets path {} must be a directory",
1088 source.display()
1089 );
1090 }
1091 let destination = destination_root.join("assets");
1092 if destination.exists() {
1093 fs::remove_dir_all(&destination).with_context(|| {
1094 format!(
1095 "failed to clear staged project assets {}",
1096 destination.display()
1097 )
1098 })?;
1099 }
1100 copy_dir_contents(&source, &destination).with_context(|| {
1101 format!(
1102 "failed to stage project assets from {} to {}",
1103 source.display(),
1104 destination.display()
1105 )
1106 })?;
1107 Ok(Some(destination))
1108}
1109
1110fn apply_mobile_run_script_hardening(root: &Path, project: &FissionProject) -> Result<()> {
1111 if project.targets.contains(&Target::Ios) {
1112 apply_ios_run_script_hardening(root)?;
1113 apply_ios_package_script_hardening(root)?;
1114 }
1115 if project.targets.contains(&Target::Android) {
1116 apply_android_run_script_hardening(root)?;
1117 apply_android_package_script_hardening(root)?;
1118 apply_android_manifest_hardening(root)?;
1119 apply_android_root_build_gradle_hardening(root)?;
1120 apply_android_app_build_gradle_hardening(root)?;
1121 apply_android_gradle_properties_hardening(root)?;
1122 }
1123 Ok(())
1124}
1125
1126fn apply_ios_run_script_hardening(root: &Path) -> Result<()> {
1127 let path = root.join("platforms/ios/run-sim.sh");
1128 if !path.exists() {
1129 return Ok(());
1130 }
1131 let existing =
1132 fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))?;
1133 if existing.contains("IOS_SIM_UNINSTALL_BEFORE_INSTALL") {
1134 return Ok(());
1135 }
1136 let marker = "xcrun simctl bootstatus \"$DEVICE_ID\" -b\n";
1137 let insertion = "xcrun simctl bootstatus \"$DEVICE_ID\" -b\nif [[ \"${IOS_SIM_UNINSTALL_BEFORE_INSTALL:-1}\" == \"1\" ]]; then\n xcrun simctl uninstall \"$DEVICE_ID\" \"$BUNDLE_ID\" >/dev/null 2>&1 || true\nfi\n";
1138 let updated = existing.replacen(marker, insertion, 1);
1139 fs::write(&path, updated).with_context(|| format!("failed to write {}", path.display()))
1140}
1141
1142fn apply_ios_package_script_hardening(root: &Path) -> Result<()> {
1143 let path = root.join("platforms/ios/package-sim.sh");
1144 if !path.exists() {
1145 return Ok(());
1146 }
1147 let existing =
1148 fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))?;
1149 let mut updated = existing.clone();
1150 if updated.contains("import plistlib") {
1151 let Some(start) = updated.find("python3 - <<'PY' \"$SCRIPT_DIR/Info.plist\"") else {
1152 return Ok(());
1153 };
1154 let Some(relative_end) = updated[start..].find("\nPY") else {
1155 return Ok(());
1156 };
1157 let end = start + relative_end + "\nPY\n".len();
1158 updated.replace_range(start..end, IOS_INFO_PLIST_PLUTIL_PATCH);
1159 }
1160 if !updated.contains("IOS_MARKETING_VERSION") {
1161 updated = updated.replacen(
1162 "BUNDLE_NAME=\"${IOS_BUNDLE_NAME:-$DISPLAY_NAME.app}\"\n",
1163 "BUNDLE_NAME=\"${IOS_BUNDLE_NAME:-$DISPLAY_NAME.app}\"\nIOS_MARKETING_VERSION=\"${IOS_MARKETING_VERSION:-0.1.0}\"\nIOS_BUILD_NUMBER=\"${IOS_BUILD_NUMBER:-1}\"\n",
1164 1,
1165 );
1166 }
1167 if updated != existing {
1168 fs::write(&path, updated).with_context(|| format!("failed to write {}", path.display()))?;
1169 }
1170 Ok(())
1171}
1172
1173fn apply_android_run_script_hardening(root: &Path) -> Result<()> {
1174 let path = root.join("platforms/android/run-emulator.sh");
1175 if !path.exists() {
1176 return Ok(());
1177 }
1178 let existing =
1179 fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))?;
1180 if existing.contains(":app:assemble") {
1181 return Ok(());
1182 }
1183 let mut updated = existing.clone();
1184 let wait_function = android_wait_for_boot_function();
1185 if let Some(start) = updated.find("wait_for_android_boot() {") {
1186 let marker = "\n}\n\nANDROID_EMULATOR_API_LEVEL=";
1187 if let Some(relative_end) = updated[start..].find(marker) {
1188 let end = start + relative_end + "\n}\n\n".len();
1189 updated.replace_range(start..end, &format!("{wait_function}\n\n"));
1190 }
1191 } else {
1192 updated = updated.replacen(
1193 "\nANDROID_EMULATOR_API_LEVEL=",
1194 &format!("\n{wait_function}\n\nANDROID_EMULATOR_API_LEVEL="),
1195 1,
1196 );
1197 }
1198 updated =
1199 replace_android_boot_wait_after(updated, " disown || true\n", " wait_for_android_boot\n");
1200 updated = replace_android_boot_wait_after(
1201 updated,
1202 " \"$EMULATOR_BIN\" \"${EMULATOR_ARGS[@]}\" >/tmp/fission-android-emulator.log 2>&1 &\n",
1203 " wait_for_android_boot\n",
1204 );
1205 if !updated.contains(
1206 "printf 'Using existing emulator %s\\n' \"$RUNNING_EMULATOR\"\n wait_for_android_boot\n",
1207 ) {
1208 updated = updated.replacen(
1209 "printf 'Using existing emulator %s\\n' \"$RUNNING_EMULATOR\"\n",
1210 "printf 'Using existing emulator %s\\n' \"$RUNNING_EMULATOR\"\n wait_for_android_boot\n",
1211 1,
1212 );
1213 }
1214 while updated.contains(" wait_for_android_boot\n wait_for_android_boot\n") {
1215 updated = updated.replace(
1216 " wait_for_android_boot\n wait_for_android_boot\n",
1217 " wait_for_android_boot\n",
1218 );
1219 }
1220 updated = updated.replace(
1221 "\"$ADB\" install -r \"$APK\"",
1222 "read -r -a ADB_INSTALL_FLAGS <<< \"${ADB_INSTALL_FLAGS:---no-streaming -r}\"\n\"$ADB\" install \"${ADB_INSTALL_FLAGS[@]}\" \"$APK\"",
1223 );
1224 if updated != existing {
1225 fs::write(&path, updated).with_context(|| format!("failed to write {}", path.display()))?;
1226 }
1227 Ok(())
1228}
1229
1230fn apply_android_package_script_hardening(root: &Path) -> Result<()> {
1231 let path = root.join("platforms/android/package-apk.sh");
1232 if !path.exists() {
1233 return Ok(());
1234 }
1235 let existing =
1236 fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))?;
1237 let mut updated = existing.clone();
1238 if updated.contains("import re\nimport sys\n") && !updated.contains("import pathlib\n") {
1239 updated = updated.replace(
1240 "import re\nimport sys\n",
1241 "import pathlib\nimport re\nimport sys\n",
1242 );
1243 }
1244 let has_code_line = r#"has_code = "true" if pathlib.Path(dest).with_name("apk-root").joinpath("classes.dex").exists() else "false"
1245manifest = re.sub(r'android:hasCode="(?:true|false)"', f'android:hasCode="{has_code}"', manifest)
1246"#;
1247 if !updated.contains("android:hasCode=") || !updated.contains("with_name(\"apk-root\")") {
1248 updated = updated.replace(
1249 "manifest = re.sub(r'android:targetSdkVersion=\"\\d+\"', f'android:targetSdkVersion=\"{target_api}\"', manifest)\n",
1250 &format!(
1251 "manifest = re.sub(r'android:targetSdkVersion=\"\\d+\"', f'android:targetSdkVersion=\"{{target_api}}\"', manifest)\n{has_code_line}"
1252 ),
1253 );
1254 }
1255 if updated != existing {
1256 fs::write(&path, updated).with_context(|| format!("failed to write {}", path.display()))?;
1257 }
1258 Ok(())
1259}
1260
1261fn apply_android_manifest_hardening(root: &Path) -> Result<()> {
1262 let path = root.join("platforms/android/AndroidManifest.xml");
1263 if !path.exists() {
1264 return Ok(());
1265 }
1266 let existing =
1267 fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))?;
1268 if existing.contains("rs.fission.runtime.FissionActivity") {
1269 return Ok(());
1270 }
1271 let updated = existing.replace(r#"android:hasCode="true""#, r#"android:hasCode="false""#);
1272 if updated != existing {
1273 fs::write(&path, updated).with_context(|| format!("failed to write {}", path.display()))?;
1274 }
1275 Ok(())
1276}
1277
1278fn apply_android_root_build_gradle_hardening(root: &Path) -> Result<()> {
1279 let path = root.join("platforms/android/build.gradle.kts");
1280 if !path.exists() {
1281 return Ok(());
1282 }
1283 let existing =
1284 fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))?;
1285 let mut updated = String::new();
1286 for line in existing.lines() {
1287 if line
1288 .trim_start()
1289 .starts_with("id(\"com.android.application\") version ")
1290 {
1291 let indent = line
1292 .chars()
1293 .take_while(|ch| ch.is_whitespace())
1294 .collect::<String>();
1295 updated.push_str(&format!(
1296 "{indent}id(\"com.android.application\") version \"{ANDROID_GRADLE_PLUGIN_VERSION}\" apply false\n"
1297 ));
1298 } else {
1299 updated.push_str(line);
1300 updated.push('\n');
1301 }
1302 }
1303 if updated != existing {
1304 fs::write(&path, updated).with_context(|| format!("failed to write {}", path.display()))?;
1305 }
1306 Ok(())
1307}
1308
1309fn apply_android_app_build_gradle_hardening(root: &Path) -> Result<()> {
1310 let path = root.join("platforms/android/app/build.gradle.kts");
1311 if !path.exists() {
1312 return Ok(());
1313 }
1314 let existing =
1315 fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))?;
1316 let mut updated = existing.replace("../native-modules.gradle.kts", "../native-modules.gradle");
1317 updated = updated.replace(
1318 "versionCode = 1",
1319 "versionCode = (System.getenv(\"ANDROID_VERSION_CODE\") ?: \"1\").toInt()",
1320 );
1321 updated = updated.replace(
1322 "versionName = \"0.1.0\"",
1323 "versionName = System.getenv(\"ANDROID_VERSION_NAME\") ?: \"0.1.0\"",
1324 );
1325 if !updated.contains("../native-modules.gradle") {
1326 updated.push_str("\napply(from = \"../native-modules.gradle\")\n");
1327 }
1328 if updated != existing {
1329 fs::write(&path, updated).with_context(|| format!("failed to write {}", path.display()))?;
1330 }
1331 Ok(())
1332}
1333
1334fn apply_android_gradle_properties_hardening(root: &Path) -> Result<()> {
1335 let path = root.join("platforms/android/gradle.properties");
1336 if !path.exists() {
1337 return fs::write(&path, render_android_gradle_properties())
1338 .with_context(|| format!("failed to write {}", path.display()));
1339 }
1340 let existing =
1341 fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))?;
1342 let mut saw_androidx = false;
1343 let mut saw_jvmargs = false;
1344 let mut saw_compile_warning = false;
1345 let mut updated = String::new();
1346 for line in existing.lines() {
1347 let trimmed = line.trim_start();
1348 if trimmed.starts_with("android.useAndroidX=") {
1349 updated.push_str("android.useAndroidX=true\n");
1350 saw_androidx = true;
1351 } else if trimmed.starts_with("org.gradle.jvmargs=") {
1352 updated.push_str(line);
1353 updated.push('\n');
1354 saw_jvmargs = true;
1355 } else if trimmed.starts_with("android.javaCompile.suppressSourceTargetDeprecationWarning=")
1356 {
1357 updated.push_str(line);
1358 updated.push('\n');
1359 saw_compile_warning = true;
1360 } else {
1361 updated.push_str(line);
1362 updated.push('\n');
1363 }
1364 }
1365 if !saw_androidx {
1366 if !updated.ends_with('\n') {
1367 updated.push('\n');
1368 }
1369 updated.push_str("android.useAndroidX=true\n");
1370 }
1371 if !saw_jvmargs {
1372 updated.push_str("org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8\n");
1373 }
1374 if !saw_compile_warning {
1375 updated.push_str("android.javaCompile.suppressSourceTargetDeprecationWarning=true\n");
1376 }
1377 if updated != existing {
1378 fs::write(&path, updated).with_context(|| format!("failed to write {}", path.display()))?;
1379 }
1380 Ok(())
1381}
1382
1383fn apply_android_settings_gradle_hardening(root: &Path, project: &FissionProject) -> Result<()> {
1384 let path = root.join("platforms/android/settings.gradle.kts");
1385 if !path.exists() {
1386 return Ok(());
1387 }
1388 let existing =
1389 fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))?;
1390 let missing = android_dependency_repositories(project)
1391 .into_iter()
1392 .filter(|repository| !existing.contains(repository))
1393 .collect::<Vec<_>>();
1394 if missing.is_empty() {
1395 return Ok(());
1396 }
1397 let marker = " repositories {\n";
1398 let Some(index) = existing.find(marker) else {
1399 return Ok(());
1400 };
1401 let mut insertion = String::new();
1402 for repository in missing {
1403 insertion.push_str(" ");
1404 insertion.push_str(&repository);
1405 insertion.push('\n');
1406 }
1407 let mut updated = existing;
1408 updated.insert_str(index + marker.len(), &insertion);
1409 fs::write(&path, updated).with_context(|| format!("failed to write {}", path.display()))
1410}
1411
1412fn android_wait_for_boot_function() -> &'static str {
1413 r#"wait_for_android_boot() {
1414 "$ADB" wait-for-device
1415 until "$ADB" shell getprop sys.boot_completed 2>/dev/null | tr -d '\r' | grep -q '^1$'; do
1416 sleep 1
1417 done
1418 local deadline=$((SECONDS + 180))
1419 until "$ADB" shell cmd package list packages >/dev/null 2>&1; do
1420 if (( SECONDS > deadline )); then
1421 printf 'Android package manager did not become available. Restart the emulator with ANDROID_EMULATOR_RESTART=1 and try again.\n' >&2
1422 exit 1
1423 fi
1424 sleep 1
1425 done
1426}"#
1427}
1428
1429fn replace_android_boot_wait_after(mut text: String, marker: &str, replacement: &str) -> String {
1430 let Some(start) = text.find(marker) else {
1431 return text;
1432 };
1433 let wait_start = start + marker.len();
1434 let old_wait = " \"$ADB\" wait-for-device\n until \"$ADB\" shell getprop sys.boot_completed 2>/dev/null | tr -d '\\r' | grep -q '^1$'; do\n sleep 1\n done\n";
1435 if text[wait_start..].starts_with(old_wait) {
1436 text.replace_range(wait_start..wait_start + old_wait.len(), replacement);
1437 }
1438 text
1439}
1440
1441const IOS_INFO_PLIST_PLUTIL_PATCH: &str = r#"cp "$SCRIPT_DIR/Info.plist" "$BUNDLE_DIR/Info.plist"
1442PLUTIL=$(xcrun --find plutil 2>/dev/null || command -v plutil || true)
1443if [[ -z "$PLUTIL" ]]; then
1444 printf 'plutil not found. Install Xcode command line tools to package the iOS simulator app.\n' >&2
1445 exit 1
1446fi
1447"$PLUTIL" -replace CFBundleIdentifier -string "$BUNDLE_ID" "$BUNDLE_DIR/Info.plist"
1448"$PLUTIL" -replace CFBundleDisplayName -string "$DISPLAY_NAME" "$BUNDLE_DIR/Info.plist"
1449"$PLUTIL" -replace CFBundleName -string "$DISPLAY_NAME" "$BUNDLE_DIR/Info.plist"
1450"$PLUTIL" -replace CFBundleExecutable -string "$EXECUTABLE_NAME" "$BUNDLE_DIR/Info.plist"
1451"$PLUTIL" -replace CFBundleShortVersionString -string "$IOS_MARKETING_VERSION" "$BUNDLE_DIR/Info.plist"
1452"$PLUTIL" -replace CFBundleVersion -string "$IOS_BUILD_NUMBER" "$BUNDLE_DIR/Info.plist"
1453"#;
1454
1455fn apply_platform_capability_config(root: &Path, project: &FissionProject) -> Result<()> {
1456 if project.capabilities.is_empty() {
1457 return Ok(());
1458 }
1459 if project.targets.contains(&Target::Android) {
1460 ensure_android_capability_helper(root)?;
1461 apply_android_capability_config(root, project)?;
1462 }
1463 if project.targets.contains(&Target::Ios) {
1464 apply_ios_capability_config(root, project)?;
1465 }
1466 Ok(())
1467}
1468
1469fn ensure_android_capability_helper(root: &Path) -> Result<()> {
1470 write_file_with_policy(
1471 &root.join("platforms/android/java/rs/fission/runtime/FissionAndroidCapabilities.java"),
1472 render_android_capabilities_java(),
1473 WritePolicy::PreserveExisting,
1474 )
1475}
1476
1477fn apply_android_capability_config(root: &Path, project: &FissionProject) -> Result<()> {
1478 let path = root.join("platforms/android/AndroidManifest.xml");
1479 if !path.exists() {
1480 return Ok(());
1481 }
1482 let existing =
1483 fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))?;
1484 let mut capabilities = String::new();
1485 if project.capabilities.contains(&PlatformCapability::Nfc)
1486 && !existing.contains("android.permission.NFC")
1487 {
1488 capabilities.push_str(&render_android_nfc_manifest_entries());
1489 }
1490 if project
1491 .capabilities
1492 .contains(&PlatformCapability::Notifications)
1493 && !existing.contains("android.permission.POST_NOTIFICATIONS")
1494 {
1495 capabilities.push_str(&render_android_notifications_manifest_entries());
1496 }
1497 if project
1498 .capabilities
1499 .contains(&PlatformCapability::Biometric)
1500 && !existing.contains("android.permission.USE_BIOMETRIC")
1501 {
1502 capabilities.push_str(&render_android_biometric_manifest_entries());
1503 }
1504 if project
1505 .capabilities
1506 .contains(&PlatformCapability::Bluetooth)
1507 {
1508 capabilities.push_str(&render_missing_android_bluetooth_manifest_entries(
1509 &existing,
1510 ));
1511 }
1512 if project
1513 .capabilities
1514 .contains(&PlatformCapability::BarcodeScanner)
1515 && !project.capabilities.contains(&PlatformCapability::Camera)
1516 && !existing.contains("android.permission.CAMERA")
1517 {
1518 capabilities.push_str(&render_android_barcode_camera_manifest_entries());
1519 }
1520 if project.capabilities.contains(&PlatformCapability::Camera) {
1521 capabilities.push_str(&render_missing_android_camera_manifest_entries(&existing));
1522 }
1523 if project
1524 .capabilities
1525 .contains(&PlatformCapability::Geolocation)
1526 && !existing.contains("android.permission.ACCESS_FINE_LOCATION")
1527 {
1528 capabilities.push_str(&render_android_geolocation_manifest_entries());
1529 }
1530 if project.capabilities.contains(&PlatformCapability::Haptics)
1531 && !existing.contains("android.permission.VIBRATE")
1532 {
1533 capabilities.push_str(&render_android_haptics_manifest_entries());
1534 }
1535 if project
1536 .capabilities
1537 .contains(&PlatformCapability::Microphone)
1538 && !existing.contains("android.permission.RECORD_AUDIO")
1539 {
1540 capabilities.push_str(&render_android_microphone_manifest_entries());
1541 }
1542 if project.capabilities.contains(&PlatformCapability::Wifi) {
1543 capabilities.push_str(&render_missing_android_wifi_manifest_entries(&existing));
1544 }
1545 if project
1546 .capabilities
1547 .contains(&PlatformCapability::VolumeControl)
1548 && !existing.contains("android.permission.MODIFY_AUDIO_SETTINGS")
1549 {
1550 capabilities.push_str(&render_android_volume_manifest_entries());
1551 }
1552 if capabilities.is_empty() {
1553 return Ok(());
1554 }
1555 let marker = r#" <uses-permission android:name="android.permission.INTERNET" />"#;
1556 let updated = if existing.contains(marker) {
1557 existing.replacen(marker, &format!("{marker}\n{capabilities}"), 1)
1558 } else {
1559 existing.replacen("<uses-sdk", &format!("{capabilities}\n <uses-sdk"), 1)
1560 };
1561 fs::write(&path, updated).with_context(|| format!("failed to write {}", path.display()))
1562}
1563
1564fn apply_ios_capability_config(root: &Path, project: &FissionProject) -> Result<()> {
1565 let info_path = root.join("platforms/ios/Info.plist");
1566 if info_path.exists() {
1567 let existing = fs::read_to_string(&info_path)
1568 .with_context(|| format!("failed to read {}", info_path.display()))?;
1569 if project.capabilities.contains(&PlatformCapability::Nfc)
1570 && !existing.contains("NFCReaderUsageDescription")
1571 {
1572 let entry = " <key>NFCReaderUsageDescription</key>\n <string>This app uses NFC to scan nearby tags when you request it.</string>\n";
1573 let updated = existing.replacen("</dict>", &format!("{entry}</dict>"), 1);
1574 fs::write(&info_path, updated)
1575 .with_context(|| format!("failed to write {}", info_path.display()))?;
1576 }
1577 }
1578
1579 if project.capabilities.contains(&PlatformCapability::Nfc) {
1580 let entitlements_path = root.join("platforms/ios/Entitlements.plist");
1581 if entitlements_path.exists() {
1582 let existing = fs::read_to_string(&entitlements_path)
1583 .with_context(|| format!("failed to read {}", entitlements_path.display()))?;
1584 if !existing.contains("com.apple.developer.nfc.readersession.formats") {
1585 let entry = " <key>com.apple.developer.nfc.readersession.formats</key>\n <array>\n <string>NDEF</string>\n </array>\n";
1586 let updated = existing.replacen("</dict>", &format!("{entry}</dict>"), 1);
1587 fs::write(&entitlements_path, updated)
1588 .with_context(|| format!("failed to write {}", entitlements_path.display()))?;
1589 }
1590 } else {
1591 write_file_with_policy(
1592 &entitlements_path,
1593 IOS_NFC_ENTITLEMENTS_PLIST,
1594 WritePolicy::PreserveExisting,
1595 )?;
1596 }
1597 }
1598 if project
1599 .capabilities
1600 .contains(&PlatformCapability::Biometric)
1601 && info_path.exists()
1602 {
1603 let existing = fs::read_to_string(&info_path)
1604 .with_context(|| format!("failed to read {}", info_path.display()))?;
1605 if !existing.contains("NSFaceIDUsageDescription") {
1606 let entry = " <key>NSFaceIDUsageDescription</key>\n <string>This app uses biometrics to authenticate you when you request it.</string>\n";
1607 let updated = existing.replacen("</dict>", &format!("{entry}</dict>"), 1);
1608 fs::write(&info_path, updated)
1609 .with_context(|| format!("failed to write {}", info_path.display()))?;
1610 }
1611 }
1612 if project
1613 .capabilities
1614 .contains(&PlatformCapability::Bluetooth)
1615 && info_path.exists()
1616 {
1617 let existing = fs::read_to_string(&info_path)
1618 .with_context(|| format!("failed to read {}", info_path.display()))?;
1619 if !existing.contains("NSBluetoothAlwaysUsageDescription") {
1620 let entry = " <key>NSBluetoothAlwaysUsageDescription</key>\n <string>This app uses Bluetooth when you request nearby-device features.</string>\n";
1621 let updated = existing.replacen("</dict>", &format!("{entry}</dict>"), 1);
1622 fs::write(&info_path, updated)
1623 .with_context(|| format!("failed to write {}", info_path.display()))?;
1624 }
1625 }
1626 if project
1627 .capabilities
1628 .contains(&PlatformCapability::BarcodeScanner)
1629 && info_path.exists()
1630 {
1631 let existing = fs::read_to_string(&info_path)
1632 .with_context(|| format!("failed to read {}", info_path.display()))?;
1633 if !existing.contains("NSCameraUsageDescription") {
1634 let entry = " <key>NSCameraUsageDescription</key>\n <string>This app uses the camera to scan barcodes when you request it.</string>\n";
1635 let updated = existing.replacen("</dict>", &format!("{entry}</dict>"), 1);
1636 fs::write(&info_path, updated)
1637 .with_context(|| format!("failed to write {}", info_path.display()))?;
1638 }
1639 }
1640 if project.capabilities.contains(&PlatformCapability::Camera) && info_path.exists() {
1641 let existing = fs::read_to_string(&info_path)
1642 .with_context(|| format!("failed to read {}", info_path.display()))?;
1643 if !existing.contains("NSCameraUsageDescription") {
1644 let entry = " <key>NSCameraUsageDescription</key>\n <string>This app uses the camera when you request camera features.</string>\n";
1645 let updated = existing.replacen("</dict>", &format!("{entry}</dict>"), 1);
1646 fs::write(&info_path, updated)
1647 .with_context(|| format!("failed to write {}", info_path.display()))?;
1648 }
1649 }
1650 if project
1651 .capabilities
1652 .contains(&PlatformCapability::Geolocation)
1653 && info_path.exists()
1654 {
1655 let existing = fs::read_to_string(&info_path)
1656 .with_context(|| format!("failed to read {}", info_path.display()))?;
1657 if !existing.contains("NSLocationWhenInUseUsageDescription") {
1658 let entry = " <key>NSLocationWhenInUseUsageDescription</key>\n <string>This app uses your location when you request location-aware features.</string>\n";
1659 let updated = existing.replacen("</dict>", &format!("{entry}</dict>"), 1);
1660 fs::write(&info_path, updated)
1661 .with_context(|| format!("failed to write {}", info_path.display()))?;
1662 }
1663 }
1664 if project
1665 .capabilities
1666 .contains(&PlatformCapability::Microphone)
1667 && info_path.exists()
1668 {
1669 let existing = fs::read_to_string(&info_path)
1670 .with_context(|| format!("failed to read {}", info_path.display()))?;
1671 if !existing.contains("NSMicrophoneUsageDescription") {
1672 let entry = " <key>NSMicrophoneUsageDescription</key>\n <string>This app uses the microphone when you request audio capture.</string>\n";
1673 let updated = existing.replacen("</dict>", &format!("{entry}</dict>"), 1);
1674 fs::write(&info_path, updated)
1675 .with_context(|| format!("failed to write {}", info_path.display()))?;
1676 }
1677 }
1678 if project.capabilities.contains(&PlatformCapability::Wifi) && info_path.exists() {
1679 let existing = fs::read_to_string(&info_path)
1680 .with_context(|| format!("failed to read {}", info_path.display()))?;
1681 if !existing.contains("NSLocationWhenInUseUsageDescription") {
1682 let entry = " <key>NSLocationWhenInUseUsageDescription</key>\n <string>This app uses location permission where the platform requires it for Wi-Fi information.</string>\n";
1683 let updated = existing.replacen("</dict>", &format!("{entry}</dict>"), 1);
1684 fs::write(&info_path, updated)
1685 .with_context(|| format!("failed to write {}", info_path.display()))?;
1686 }
1687 }
1688 if project.capabilities.contains(&PlatformCapability::Wifi) {
1689 let entitlements_path = root.join("platforms/ios/Entitlements.plist");
1690 apply_ios_wifi_entitlements(&entitlements_path)?;
1691 }
1692 Ok(())
1693}
1694
1695fn apply_ios_wifi_entitlements(path: &Path) -> Result<()> {
1696 if path.exists() {
1697 let existing = fs::read_to_string(path)
1698 .with_context(|| format!("failed to read {}", path.display()))?;
1699 let mut entry = String::new();
1700 if !existing.contains("com.apple.developer.networking.wifi-info") {
1701 entry.push_str(" <key>com.apple.developer.networking.wifi-info</key>\n <true/>\n");
1702 }
1703 if !existing.contains("com.apple.developer.networking.HotspotConfiguration") {
1704 entry.push_str(
1705 " <key>com.apple.developer.networking.HotspotConfiguration</key>\n <true/>\n",
1706 );
1707 }
1708 if entry.is_empty() {
1709 return Ok(());
1710 }
1711 let updated = existing.replacen("</dict>", &format!("{entry}</dict>"), 1);
1712 fs::write(path, updated).with_context(|| format!("failed to write {}", path.display()))?;
1713 return Ok(());
1714 }
1715 write_file_with_policy(
1716 path,
1717 IOS_WIFI_ENTITLEMENTS_PLIST,
1718 WritePolicy::PreserveExisting,
1719 )
1720}
1721
1722fn target_scaffold_dir_exists(project_dir: &Path, target: Target) -> bool {
1723 if target == Target::Site && project_dir.join("content").exists() {
1724 return true;
1725 }
1726 if target == Target::Site && project_dir.join("platforms/site").exists() {
1727 return true;
1728 }
1729 if target == Target::Server && project_dir.join("platforms/server").exists() {
1730 return true;
1731 }
1732 Path::new(target.scaffold_relative_path())
1733 .parent()
1734 .is_some_and(|relative| project_dir.join(relative).exists())
1735}
1736
1737fn write_project_config(root: &Path, project: &FissionProject) -> Result<()> {
1738 let path = root.join("fission.toml");
1739 let mut doc = if path.exists() {
1740 let existing = fs::read_to_string(&path)
1741 .with_context(|| format!("failed to read {}", path.display()))?;
1742 existing
1743 .parse::<DocumentMut>()
1744 .with_context(|| format!("failed to parse {}", path.display()))?
1745 } else {
1746 toml::to_string_pretty(project)?
1747 .parse::<DocumentMut>()
1748 .context("failed to render initial fission.toml")?
1749 };
1750 update_project_config_document(root, &mut doc, project);
1751 write_file(&path, &doc.to_string())
1752}
1753
1754fn update_project_config_document(root: &Path, doc: &mut DocumentMut, project: &FissionProject) {
1755 doc["targets"] = value(string_array(
1756 project.targets.iter().map(|target| target.as_str()),
1757 ));
1758 if project.capabilities.is_empty() {
1759 doc.as_table_mut().remove("capabilities");
1760 } else {
1761 doc["capabilities"] = value(string_array(
1762 project
1763 .capabilities
1764 .iter()
1765 .map(|capability| capability.as_str()),
1766 ));
1767 }
1768
1769 if !doc["app"].is_table() {
1770 doc["app"] = Item::Table(Table::new());
1771 }
1772 doc["app"]["name"] = value(project.app.name.clone());
1773 doc["app"]["app_id"] = value(project.app.app_id.clone());
1774 if item_field_is_missing(&doc["app"], "version") {
1775 doc["app"]["version"] =
1776 value(cargo_package_version(root).unwrap_or_else(|| "0.1.0".to_string()));
1777 }
1778 if item_field_is_missing(&doc["app"], "build") {
1779 doc["app"]["build"] = value(1);
1780 }
1781 if let Some(splash) = &project.app.splash {
1782 if !doc["app"]["splash"].is_table() {
1783 doc["app"]["splash"] = Item::Table(Table::new());
1784 }
1785 let splash_item = &mut doc["app"]["splash"];
1786 if let Some(background_color) = &splash.background_color {
1787 splash_item["background_color"] = value(background_color.clone());
1788 }
1789 if let Some(image) = &splash.image {
1790 splash_item["image"] = value(image.clone());
1791 }
1792 if let Some(resize_mode) = splash.resize_mode {
1793 splash_item["resize_mode"] = value(match resize_mode {
1794 SplashResizeMode::Center => "center",
1795 SplashResizeMode::Contain => "contain",
1796 SplashResizeMode::Cover => "cover",
1797 });
1798 }
1799 if let Some(animated_icon) = &splash.android_animated_icon {
1800 splash_item["android_animated_icon"] = value(animated_icon.clone());
1801 }
1802 if let Some(duration) = splash.android_animation_duration_ms {
1803 splash_item["android_animation_duration_ms"] = value(i64::from(duration));
1804 }
1805 } else if let Some(app) = doc["app"].as_table_like_mut() {
1806 app.remove("splash");
1807 }
1808 ensure_package_defaults(doc, project);
1809 ensure_distribution_defaults(doc, project);
1810}
1811
1812fn ensure_package_defaults(doc: &mut DocumentMut, project: &FissionProject) {
1813 if project.targets.contains(&Target::Android) {
1814 let version_name = item_field_string(&doc["app"], "version")
1815 .unwrap_or("0.1.0")
1816 .to_string();
1817 let version_code = item_field_integer(&doc["app"], "build").unwrap_or(1);
1818 let android = ensure_package_target_table(doc, "android");
1819 set_default_string(android, "package_name", &project.app.app_id);
1820 set_default_integer(android, "version_code", version_code);
1821 set_default_string(android, "version_name", &version_name);
1822 set_default_integer(android, "min_sdk", 24);
1823 set_default_integer(android, "target_sdk", 35);
1824 set_default_string(android, "keystore_alias", "upload");
1825 set_default_string(android, "keystore_env", "ANDROID_KEYSTORE");
1826 set_default_string(android, "keystore_base64_env", "ANDROID_KEYSTORE_BASE64");
1827 set_default_string(
1828 android,
1829 "keystore_password_env",
1830 "ANDROID_KEYSTORE_PASSWORD",
1831 );
1832 set_default_string(android, "key_password_env", "ANDROID_KEY_PASSWORD");
1833 }
1834
1835 if project.targets.contains(&Target::Ios) {
1836 let marketing_version = item_field_string(&doc["app"], "version")
1837 .unwrap_or("0.1.0")
1838 .to_string();
1839 let build_number = item_field_integer(&doc["app"], "build")
1840 .unwrap_or(1)
1841 .to_string();
1842 let ios = ensure_package_target_table(doc, "ios");
1843 set_default_string(ios, "bundle_id", &project.app.app_id);
1844 set_default_string(ios, "marketing_version", &marketing_version);
1845 set_default_string(ios, "build_number", &build_number);
1846 }
1847
1848 if project.targets.contains(&Target::Macos) {
1849 let marketing_version = item_field_string(&doc["app"], "version")
1850 .unwrap_or("0.1.0")
1851 .to_string();
1852 let build_number = item_field_integer(&doc["app"], "build")
1853 .unwrap_or(1)
1854 .to_string();
1855 let macos = ensure_package_target_table(doc, "macos");
1856 set_default_string(macos, "bundle_id", &project.app.app_id);
1857 set_default_string(macos, "marketing_version", &marketing_version);
1858 set_default_string(macos, "build_number", &build_number);
1859 set_default_string(macos, "minimum_os", "13.0");
1860 }
1861
1862 if project.targets.contains(&Target::Windows) {
1863 let package_version = item_field_string(&doc["app"], "version")
1864 .unwrap_or("0.1.0")
1865 .to_string();
1866 let windows = ensure_package_target_table(doc, "windows");
1867 set_default_string(windows, "identity_name", &windows_identity_name(project));
1868 set_default_string(windows, "publisher", windows_publisher_name());
1869 set_default_string(windows, "version", &package_version);
1870 set_default_string(windows, "installer", "msix");
1871 set_default_string(
1872 windows,
1873 "certificate_thumbprint_env",
1874 "WINDOWS_CERTIFICATE_THUMBPRINT",
1875 );
1876 set_default_string(
1877 windows,
1878 "certificate_base64_env",
1879 "WINDOWS_CERTIFICATE_BASE64",
1880 );
1881 set_default_string(
1882 windows,
1883 "certificate_password_env",
1884 "WINDOWS_CERTIFICATE_PASSWORD",
1885 );
1886 }
1887
1888 if let Some(package) = doc
1889 .as_table_mut()
1890 .get_mut("package")
1891 .and_then(Item::as_table_mut)
1892 {
1893 if package
1894 .iter()
1895 .all(|(_, item)| item.as_table().is_some() || item.as_array_of_tables().is_some())
1896 {
1897 package.set_implicit(true);
1898 }
1899 }
1900}
1901
1902fn ensure_distribution_defaults(doc: &mut DocumentMut, project: &FissionProject) {
1903 if project.targets.contains(&Target::Android) {
1904 let play_store = ensure_distribution_target_table(doc, "play_store");
1905 set_default_string(play_store, "package_name", &project.app.app_id);
1906 set_default_string(play_store, "default_track", "internal");
1907 set_default_string(play_store, "release_status", "completed");
1908 set_default_string(play_store, "access_token_env", "PLAY_STORE_ACCESS_TOKEN");
1909 set_default_string(
1910 play_store,
1911 "service_account_json_env",
1912 "PLAY_STORE_SERVICE_ACCOUNT_JSON",
1913 );
1914 set_default_string(
1915 play_store,
1916 "service_account_json_base64_env",
1917 "PLAY_STORE_SERVICE_ACCOUNT_JSON_BASE64",
1918 );
1919 set_default_string(
1920 play_store,
1921 "google_application_credentials_env",
1922 "GOOGLE_APPLICATION_CREDENTIALS",
1923 );
1924 }
1925
1926 if project.targets.contains(&Target::Ios) {
1927 let app_store = ensure_distribution_target_table(doc, "app_store");
1928 set_default_string(app_store, "bundle_id", &project.app.app_id);
1929 set_default_string(
1930 app_store,
1931 "access_token_env",
1932 "APP_STORE_CONNECT_ACCESS_TOKEN",
1933 );
1934 set_default_string(app_store, "issuer_id_env", "APP_STORE_CONNECT_ISSUER_ID");
1935 set_default_string(app_store, "key_id_env", "APP_STORE_CONNECT_KEY_ID");
1936 set_default_string(app_store, "api_key_env", "APP_STORE_CONNECT_API_KEY");
1937 set_default_string(
1938 app_store,
1939 "api_key_base64_env",
1940 "APP_STORE_CONNECT_API_KEY_BASE64",
1941 );
1942 set_default_string(
1943 app_store,
1944 "api_key_path_env",
1945 "APP_STORE_CONNECT_API_KEY_PATH",
1946 );
1947 set_default_string(app_store, "default_track", "testflight");
1948 }
1949
1950 if project.targets.contains(&Target::Windows) {
1951 let microsoft_store = ensure_distribution_target_table(doc, "microsoft_store");
1952 set_default_string(
1953 microsoft_store,
1954 "package_identity_name",
1955 &windows_identity_name(project),
1956 );
1957 set_default_string(microsoft_store, "package_type", "msix");
1958 set_default_string(microsoft_store, "token_env", "MICROSOFT_STORE_TOKEN");
1959 set_default_string(microsoft_store, "tenant_id_env", "AZURE_TENANT_ID");
1960 set_default_string(microsoft_store, "client_id_env", "AZURE_CLIENT_ID");
1961 set_default_string(
1962 microsoft_store,
1963 "client_secret_env",
1964 "MICROSOFT_STORE_CLIENT_SECRET",
1965 );
1966 set_default_string(
1967 microsoft_store,
1968 "seller_id_env",
1969 "MICROSOFT_STORE_SELLER_ID",
1970 );
1971 }
1972
1973 if let Some(distribution) = doc
1974 .as_table_mut()
1975 .get_mut("distribution")
1976 .and_then(Item::as_table_mut)
1977 {
1978 if distribution
1979 .iter()
1980 .all(|(_, item)| item.as_table().is_some() || item.as_array_of_tables().is_some())
1981 {
1982 distribution.set_implicit(true);
1983 }
1984 }
1985}
1986
1987fn ensure_package_target_table<'a>(doc: &'a mut DocumentMut, target: &str) -> &'a mut Item {
1988 let missing_or_not_table = match doc.as_table().get("package") {
1989 Some(item) => !item.is_table(),
1990 None => true,
1991 };
1992 if missing_or_not_table {
1993 let mut table = Table::new();
1994 table.set_implicit(true);
1995 doc["package"] = Item::Table(table);
1996 }
1997 let target_missing_or_not_table = match doc["package"].as_table_like() {
1998 Some(package) => package.get(target).is_none_or(|item| !item.is_table()),
1999 None => true,
2000 };
2001 if target_missing_or_not_table {
2002 doc["package"][target] = Item::Table(Table::new());
2003 }
2004 &mut doc["package"][target]
2005}
2006
2007fn ensure_distribution_target_table<'a>(doc: &'a mut DocumentMut, provider: &str) -> &'a mut Item {
2008 let missing_or_not_table = match doc.as_table().get("distribution") {
2009 Some(item) => !item.is_table(),
2010 None => true,
2011 };
2012 if missing_or_not_table {
2013 let mut table = Table::new();
2014 table.set_implicit(true);
2015 doc["distribution"] = Item::Table(table);
2016 }
2017 let provider_missing_or_not_table = match doc["distribution"].as_table_like() {
2018 Some(distribution) => distribution
2019 .get(provider)
2020 .is_none_or(|item| !item.is_table()),
2021 None => true,
2022 };
2023 if provider_missing_or_not_table {
2024 doc["distribution"][provider] = Item::Table(Table::new());
2025 }
2026 &mut doc["distribution"][provider]
2027}
2028
2029fn set_default_string(item: &mut Item, key: &str, value_: &str) {
2030 if item_field_is_missing(item, key) {
2031 item[key] = value(value_.to_string());
2032 }
2033}
2034
2035fn set_default_integer(item: &mut Item, key: &str, value_: i64) {
2036 if item_field_is_missing(item, key) {
2037 item[key] = value(value_);
2038 }
2039}
2040
2041fn item_field_is_missing(item: &Item, key: &str) -> bool {
2042 item.as_table_like()
2043 .and_then(|table| table.get(key))
2044 .is_none()
2045}
2046
2047fn item_field_string<'a>(item: &'a Item, key: &str) -> Option<&'a str> {
2048 item.as_table_like()
2049 .and_then(|table| table.get(key))
2050 .and_then(Item::as_value)
2051 .and_then(Value::as_str)
2052}
2053
2054fn item_field_integer(item: &Item, key: &str) -> Option<i64> {
2055 item.as_table_like()
2056 .and_then(|table| table.get(key))
2057 .and_then(Item::as_value)
2058 .and_then(Value::as_integer)
2059}
2060
2061fn string_array<'a>(values: impl Iterator<Item = &'a str>) -> Array {
2062 let mut array = Array::new();
2063 for value in values {
2064 let mut value = Value::from(value);
2065 value.decor_mut().set_prefix("\n ");
2066 array.push_formatted(value);
2067 }
2068 array.set_trailing("\n");
2069 array.set_trailing_comma(true);
2070 array
2071}
2072
2073pub fn read_project_config(root: &Path) -> Result<FissionProject> {
2074 let path = root.join("fission.toml");
2075 let data = fs::read_to_string(&path).with_context(|| {
2076 format!(
2077 "failed to read {}; run `fission init {}` to register this project without overwriting existing files",
2078 path.display(),
2079 root.display()
2080 )
2081 })?;
2082 toml::from_str(&data).with_context(|| format!("failed to parse {}", path.display()))
2083}
2084
2085fn update_cargo_fission_features(root: &Path, project: &FissionProject) -> Result<()> {
2086 sync_cargo_fission_dependency(root, project, None)
2087}
2088
2089fn sync_cargo_fission_dependency(
2090 root: &Path,
2091 project: &FissionProject,
2092 local_path: Option<&Path>,
2093) -> Result<()> {
2094 let path = root.join("Cargo.toml");
2095 let Ok(text) = fs::read_to_string(&path) else {
2096 return Ok(());
2097 };
2098
2099 let mut doc = text
2100 .parse::<DocumentMut>()
2101 .with_context(|| format!("failed to parse {}", path.display()))?;
2102 let features = fission_features_for_targets(&project.targets);
2103 let mut changed = false;
2104
2105 if !doc.get("dependencies").is_some_and(Item::is_table_like) {
2106 doc["dependencies"] = Item::Table(Table::new());
2107 changed = true;
2108 }
2109
2110 let use_workspace_fission = local_path.is_none()
2111 && workspace_has_fission_dependency(&doc)
2112 && doc
2113 .get("dependencies")
2114 .and_then(Item::as_table_like)
2115 .is_none_or(|dependencies| !dependencies.contains_key("fission"));
2116 let deps = doc["dependencies"]
2117 .as_table_like_mut()
2118 .expect("dependencies table was just created");
2119 let dep = deps.entry("fission").or_insert(Item::None);
2120 changed |= sync_fission_dependency_item(dep, &features, local_path, use_workspace_fission)?;
2121
2122 if changed {
2123 fs::write(&path, doc.to_string())
2124 .with_context(|| format!("failed to update {}", path.display()))?;
2125 }
2126 Ok(())
2127}
2128
2129fn workspace_has_fission_dependency(doc: &DocumentMut) -> bool {
2130 doc.get("workspace")
2131 .and_then(Item::as_table_like)
2132 .and_then(|workspace| workspace.get("dependencies"))
2133 .and_then(Item::as_table_like)
2134 .is_some_and(|dependencies| dependencies.contains_key("fission"))
2135}
2136
2137fn sync_fission_dependency_item(
2138 item: &mut Item,
2139 features: &[&'static str],
2140 local_path: Option<&Path>,
2141 use_workspace_fission: bool,
2142) -> Result<bool> {
2143 match item {
2144 Item::None => {
2145 *item = Item::Value(Value::InlineTable(new_fission_dependency_table(
2146 features,
2147 local_path,
2148 use_workspace_fission,
2149 )));
2150 Ok(true)
2151 }
2152 Item::Value(Value::String(version)) => {
2153 let mut table = InlineTable::new();
2154 table.insert("version", Value::String(version.clone()));
2155 sync_fission_inline_table(&mut table, features, local_path, use_workspace_fission);
2156 *item = Item::Value(Value::InlineTable(table));
2157 Ok(true)
2158 }
2159 Item::Value(Value::InlineTable(table)) => Ok(sync_fission_inline_table(
2160 table,
2161 features,
2162 local_path,
2163 use_workspace_fission,
2164 )),
2165 Item::Table(table) => Ok(sync_fission_table(
2166 table,
2167 features,
2168 local_path,
2169 use_workspace_fission,
2170 )),
2171 _ => bail!("unsupported fission dependency format in Cargo.toml"),
2172 }
2173}
2174
2175fn new_fission_dependency_table(
2176 features: &[&'static str],
2177 local_path: Option<&Path>,
2178 use_workspace_fission: bool,
2179) -> InlineTable {
2180 let mut table = InlineTable::new();
2181 if let Some(root) = local_path {
2182 table.insert(
2183 "path",
2184 Value::from(
2185 root.join("crates/authoring/fission")
2186 .to_string_lossy()
2187 .to_string(),
2188 ),
2189 );
2190 } else if use_workspace_fission {
2191 table.insert("workspace", Value::from(true));
2192 } else {
2193 table.insert("version", Value::from(CURRENT_VERSION));
2194 }
2195 table.insert("default-features", Value::from(false));
2196 table.insert("features", cargo_feature_array_value(features));
2197 table
2198}
2199
2200fn sync_fission_inline_table(
2201 table: &mut InlineTable,
2202 features: &[&'static str],
2203 local_path: Option<&Path>,
2204 use_workspace_fission: bool,
2205) -> bool {
2206 let before = table.to_string();
2207 if let Some(root) = local_path {
2208 table.insert(
2209 "path",
2210 Value::from(
2211 root.join("crates/authoring/fission")
2212 .to_string_lossy()
2213 .to_string(),
2214 ),
2215 );
2216 table.remove("version");
2217 table.remove("workspace");
2218 } else if use_workspace_fission
2219 && !table.contains_key("path")
2220 && !table.contains_key("version")
2221 && !table.contains_key("git")
2222 {
2223 table.insert("workspace", Value::from(true));
2224 } else if !table.contains_key("path")
2225 && !table.contains_key("version")
2226 && !table.contains_key("workspace")
2227 && !table.contains_key("git")
2228 {
2229 table.insert("version", Value::from(CURRENT_VERSION));
2230 }
2231 table.insert("default-features", Value::from(false));
2232 table.insert("features", cargo_feature_array_value(features));
2233 table.to_string() != before
2234}
2235
2236fn sync_fission_table(
2237 table: &mut Table,
2238 features: &[&'static str],
2239 local_path: Option<&Path>,
2240 use_workspace_fission: bool,
2241) -> bool {
2242 let before = table.to_string();
2243 if let Some(root) = local_path {
2244 table["path"] = value(
2245 root.join("crates/authoring/fission")
2246 .to_string_lossy()
2247 .to_string(),
2248 );
2249 table.remove("version");
2250 table.remove("workspace");
2251 } else if use_workspace_fission
2252 && !table.contains_key("path")
2253 && !table.contains_key("version")
2254 && !table.contains_key("git")
2255 {
2256 table["workspace"] = value(true);
2257 } else if !table.contains_key("path")
2258 && !table.contains_key("version")
2259 && !table.contains_key("workspace")
2260 && !table.contains_key("git")
2261 {
2262 table["version"] = value(CURRENT_VERSION);
2263 }
2264 table["default-features"] = value(false);
2265 table["features"] = Item::Value(cargo_feature_array_value(features));
2266 table.to_string() != before
2267}
2268
2269fn cargo_feature_array_value(features: &[&'static str]) -> Value {
2270 let mut array = Array::new();
2271 for feature in features {
2272 array.push(*feature);
2273 }
2274 Value::Array(array)
2275}
2276
2277fn scaffold_target_with_policy(
2278 root: &Path,
2279 project: &FissionProject,
2280 target: Target,
2281 write_policy: WritePolicy,
2282) -> Result<()> {
2283 let relative = Path::new(target.scaffold_relative_path());
2284 let text = match target {
2285 Target::Android => {
2286 scaffold_android_bundle(root, project, write_policy)?;
2287 platform_readme(
2288 "Android",
2289 "Runnable emulator target. The CLI generates a Gradle Android project shell plus scripts that build, install, and launch the Fission app on an Android emulator.",
2290 &[
2291 "Install the Rust target: `rustup target add aarch64-linux-android`.",
2292 "Run `fission doctor android --project-dir .` to check SDK, NDK, emulator, and Rust target setup.",
2293 "Run `fission devices --project-dir .` to list connected Android devices and configured emulators.",
2294 "Run `fission run --target android --project-dir .` to build, install, launch, and attach to logs.",
2295 "Run `fission run --target android --device <adb-serial> --project-dir .` to launch on a specific device.",
2296 "Run `fission test --target android --project-dir .` for an emulator launch plus test-control health check.",
2297 "Run `./platforms/android/run-emulator.sh` from the project root to build, package, install, and launch the app on the configured emulator.",
2298 "Run `fission package --target android --format aab --release --project-dir .` or `./platforms/android/package-aab.sh` to create the signed Play Store app bundle.",
2299 "Override `ANDROID_HOME`, `ANDROID_NDK`, `ANDROID_MIN_API_LEVEL`, `ANDROID_TARGET_API_LEVEL`, `ANDROID_AVD_NAME`, or `ANDROID_SYSTEM_IMAGE` if your local SDK setup differs.",
2300 "Set `ANDROID_EMULATOR_HEADLESS=1` for background/CI runs, or `ANDROID_EMULATOR_RESTART=1` to relaunch a hidden emulator visibly.",
2301 "The generated package uses `assets/app-icon.png` as its default launcher icon.",
2302 "Configure `[app.splash]` in `fission.toml` to generate the native Android launch theme, splash background, static image, and optional Android animated drawable.",
2303 "Run `fission add-capability nfc --project-dir .` to add NFC manifest permission and feature declarations.",
2304 "Run `fission add-capability notifications --project-dir .` to add Android notification permission for API 33 and newer.",
2305 "Run `fission add-capability biometric --project-dir .` to add biometric manifest permissions.",
2306 "Run `fission add-capability passkeys --project-dir .` to record passkey/WebAuthn use. Android passkeys also require Digital Asset Links and host Credential Manager integration for production sign-in.",
2307 "Run `fission add-capability bluetooth --project-dir .` to add Bluetooth permissions and optional hardware feature declarations.",
2308 "Run `fission add-capability barcode-scanner --project-dir .` to add camera permission for barcode scanning.",
2309 "Run `fission add-capability camera --project-dir .` to add camera permission and optional camera/flash hardware feature declarations.",
2310 "Run `fission add-capability geolocation --project-dir .` to add location permissions.",
2311 "Run `fission add-capability haptics --project-dir .` to add the vibration permission.",
2312 "Run `fission add-capability microphone --project-dir .` to add audio recording permission.",
2313 "Run `fission add-capability volume-control --project-dir .` to add Android audio settings permission.",
2314 "Run `fission add-capability wifi --project-dir .` to add Wi-Fi permissions and optional hardware feature declarations.",
2315 "Set `FISSION_TEST_CONTROL_PORT=<host-port>` before `run-emulator.sh`; the script forwards it to the fixed in-app device port.",
2316 ],
2317 )
2318 }
2319 Target::Ios => {
2320 scaffold_ios_bundle(root, project, write_policy)?;
2321 platform_readme(
2322 "iOS",
2323 "Simulator target. The CLI generates a simulator app bundle template plus shell scripts that build, install, launch, and smoke-test the Fission app with `simctl`.",
2324 &[
2325 "Install the Rust targets: `rustup target add aarch64-apple-ios aarch64-apple-ios-sim`.",
2326 "Run `fission doctor ios --project-dir .` to check Xcode, simulator, and Rust target setup.",
2327 "Confirm the simulator SDK path with `xcrun --sdk iphonesimulator --show-sdk-path`.",
2328 "Run `fission devices --project-dir .` to list available iOS simulators.",
2329 "Run `fission run --target ios --project-dir .` to build, install, launch, and attach to simulator logs.",
2330 "Run `fission run --target ios --device <simulator-udid> --project-dir .` to launch on a specific simulator.",
2331 "Run `fission test --target ios --project-dir .` for a simulator launch plus test-control health check.",
2332 "Run `./platforms/ios/run-sim.sh` from the project root to build, install, and launch the app on the first available iPhone simulator.",
2333 "Run `fission package --target ios --format ipa --release --project-dir .` or `./platforms/ios/package-ipa.sh` to create a signed IPA when IOS_SIGNING_IDENTITY is configured.",
2334 "The generated bundle uses `assets/app-icon.png` as its default app icon.",
2335 "Configure `[app.splash]` in `fission.toml` to generate the native iOS launch storyboard and splash image copied into the simulator bundle.",
2336 "Run `fission add-capability nfc --project-dir .` to add the NFC usage description and entitlements file.",
2337 "Run `fission add-capability notifications --project-dir .` to record local-notification use. iOS prompts at runtime and does not require an Info.plist usage key for local notifications.",
2338 "Run `fission add-capability biometric --project-dir .` to add the Face ID usage description.",
2339 "Run `fission add-capability passkeys --project-dir .` to record passkey/WebAuthn use. iOS production passkeys require associated domains such as `webcredentials:example.com` in the app entitlements.",
2340 "Run `fission add-capability bluetooth --project-dir .` to add the Bluetooth usage description.",
2341 "Run `fission add-capability barcode-scanner --project-dir .` to add the camera usage description for barcode scanning.",
2342 "Run `fission add-capability camera --project-dir .` to add the camera usage description.",
2343 "Run `fission add-capability geolocation --project-dir .` to add the location usage description.",
2344 "Run `fission add-capability microphone --project-dir .` to add the microphone usage description.",
2345 "Run `fission add-capability wifi --project-dir .` to add Wi-Fi entitlements and the location usage description required by current-network information APIs.",
2346 "Volume control does not require an iOS Info.plist key in the generated scaffold.",
2347 "Haptics do not require an iOS Info.plist key in the generated scaffold.",
2348 "Set `FISSION_TEST_CONTROL_PORT=<port>` before `run-sim.sh` to expose the in-app test control server on the host.",
2349 "Set `IOS_SIM_DEVICE_ID=<udid>` if you want a specific simulator device.",
2350 "Set `IOS_SIM_HEADLESS=1` for CI or background-only simulator runs; otherwise the script opens Simulator visibly.",
2351 ],
2352 )
2353 }
2354 Target::Web => {
2355 scaffold_web_bundle(root, project, write_policy)?;
2356 platform_readme(
2357 "Web",
2358 "Runnable browser target. The CLI generates a WASM host page plus helper scripts that build the app with `wasm-pack` and serve it locally.",
2359 &[
2360 "Install the Rust target: `rustup target add wasm32-unknown-unknown`.",
2361 "Install `wasm-pack` once: `cargo install wasm-pack`.",
2362 "Install Node.js 22+ so the smoke test can inspect Chrome/Chromium CDP runtime and console output.",
2363 "Run `fission doctor web --project-dir .` to check wasm-pack, generated JavaScript glue, Chrome/Chromium, and Rust target setup.",
2364 "Run `fission devices --project-dir .` to confirm Chrome/Chromium detection.",
2365 "Run `fission run --target web --project-dir .` to build, serve, open, and attach to the local server.",
2366 "Run `fission run --target web --detach --project-dir .` to keep the local server running in the background.",
2367 "Run `fission test --target web --project-dir .` for a headless Chrome/Chromium CDP smoke test.",
2368 "Run `./platforms/web/run-browser.sh` from the project root to build the wasm package and serve the app locally.",
2369 "Set `FISSION_WEB_PORT=<port>` or `FISSION_WEB_HOST=<host>` if the default `127.0.0.1:8123` does not suit your machine.",
2370 "Set `FISSION_WEB_OPEN=1` if you want the helper script to open a browser tab automatically.",
2371 "The generated page uses `assets/app-icon.png` as its default favicon/app icon seed.",
2372 ],
2373 )
2374 }
2375 Target::Server => platform_readme(
2376 "SSR",
2377 "Server-rendered Fission target. The CLI runs the app through the server shell for dynamic HTML, revalidated pages, server jobs, signed actions, worker artifacts, and focused browser islands.",
2378 &[
2379 "Configure `[server].entry` in `fission.toml` so the CLI can invoke the server app.",
2380 "Run `fission server check --project-dir .` to render all declared server routes.",
2381 "Run `fission server serve --project-dir .` to serve the app locally.",
2382 "Run `fission server artifacts --project-dir .` to generate browser worker and island WASM shims.",
2383 "Run `fission package --target ssr --format docker-image --release --project-dir .` to package the server app as an OCI/Docker image.",
2384 ],
2385 ),
2386 Target::Site => {
2387 write_file_with_policy(
2388 &root.join("content/getting-started.md"),
2389 "---\ntitle: Site content\ndescription: Static site content rendered by the Fission static site shell.\n---\n\n# Site content\n\nAdd Markdown files under `content/`. `fission site build` renders them through real Fission widgets, lowers the nodes to Core IR, and emits static HTML.\n",
2390 write_policy,
2391 )?;
2392 platform_readme(
2393 "Static site",
2394 "Static multi-page website target. The site shell renders Markdown content through real Fission widgets, lowers nodes to Core IR, and emits semantic static HTML.",
2395 &[
2396 "Add Markdown or MDX content under `content/`.",
2397 "Run `fission site routes --project-dir .` to list generated routes.",
2398 "Run `fission site build --project-dir .` to render HTML into `target/fission/site`.",
2399 "Run `fission site serve --project-dir .` to build and serve the generated site locally.",
2400 "Run `fission package --target static-site --format static --release --project-dir .` to package the generated site.",
2401 "Unsupported interactive widgets fail during the static render instead of silently falling back to JavaScript.",
2402 ],
2403 )
2404 }
2405 Target::Terminal => platform_readme(
2406 "Terminal",
2407 "Terminal target. The CLI treats this as a terminal-shell app using the project's normal Rust entrypoint and terminal-shell feature.",
2408 &[
2409 "Use `fission::terminal::TerminalApp` or a target-aware app entrypoint for terminal rendering.",
2410 "Run `fission run --target terminal --project-dir .` to execute the app in the current terminal.",
2411 "Run `fission test --target terminal --project-dir .` for Rust tests until terminal-shell package formats are defined by the terminal-shell RFC.",
2412 "This target enables the `terminal-shell` Fission feature but does not imply native desktop, web, or mobile shells.",
2413 ],
2414 ),
2415 Target::Windows => {
2416 scaffold_windows_bundle(root, project, write_policy)?;
2417 platform_readme(
2418 "Windows",
2419 "Runnable desktop target with release packaging scaffolds for EXE, MSI, and MSIX distribution.",
2420 &[
2421 "Run `fission run --project-dir .` from the project root to launch the desktop app and attach output.",
2422 "Run `fission build --project-dir . --release` for a release desktop build.",
2423 "Run `fission package --target windows --format exe --release --project-dir .` to copy the signed release executable into a package artifact.",
2424 "Run `fission package --target windows --format msix --release --project-dir .` or `./platforms/windows/package-msix.ps1` to create an MSIX package with `makeappx`.",
2425 "Run `fission package --target windows --format msi --release --project-dir .` or `./platforms/windows/package-msi.ps1` to create an MSI package with WiX.",
2426 "Set `WINDOWS_CERTIFICATE`, `WINDOWS_CERTIFICATE_BASE64`, or `WINDOWS_CERTIFICATE_THUMBPRINT` plus `WINDOWS_CERTIFICATE_PASSWORD` where needed; never commit certificate files or passwords.",
2427 "Edit `[package.windows]` in `fission.toml` for Store package identity, publisher identity, package version, and installer preference.",
2428 "The generated MSIX manifest stages the desktop executable as a full-trust Windows app and copies `assets/app-icon.png` into the package asset set by default.",
2429 ],
2430 )
2431 }
2432 Target::Linux | Target::Macos => platform_readme(
2433 match target {
2434 Target::Linux => "Linux",
2435 Target::Macos => "macOS",
2436 _ => unreachable!(),
2437 },
2438 "Runnable target. Desktop platforms share the default `src/main.rs` entrypoint through `DesktopApp`.",
2439 &[
2440 "Run `fission run --project-dir .` from the project root to launch the desktop app and attach output.",
2441 "Run `fission build --project-dir . --release` for a release desktop build.",
2442 "Run `fission test --project-dir .` for the app crate's Rust tests.",
2443 "This target uses the default Vello desktop shell path.",
2444 ],
2445 ),
2446 };
2447 write_file_with_policy(&root.join(relative), &text, write_policy)
2448}
2449
2450fn scaffold_ios_bundle(
2451 root: &Path,
2452 project: &FissionProject,
2453 write_policy: WritePolicy,
2454) -> Result<()> {
2455 let executable = ios_executable_name(project);
2456 let bundle_name = ios_bundle_name(project);
2457 let plist = render_ios_plist(project, &executable);
2458 let package_script = render_ios_package_script(project, &bundle_name, &executable);
2459 let ipa_script = render_ios_ipa_package_script(project);
2460 let run_script = render_ios_run_script(project);
2461 let test_script = render_ios_test_script();
2462
2463 write_file_with_policy(&root.join("platforms/ios/Info.plist"), &plist, write_policy)?;
2464 write_file_with_policy(
2465 &root.join("platforms/ios/Package.swift"),
2466 &render_ios_host_package(project),
2467 write_policy,
2468 )?;
2469 write_file_with_policy(
2470 &root.join("platforms/ios/Sources/FissionHost/FissionNativeCapabilities.swift"),
2471 render_ios_host_native_capabilities_swift(),
2472 write_policy,
2473 )?;
2474 write_file_with_policy(
2475 &root.join("platforms/ios/NativeModules/README.md"),
2476 IOS_NATIVE_MODULES_README,
2477 write_policy,
2478 )?;
2479 write_file_with_policy(
2480 &root.join("platforms/ios/NativeModules/Package.swift"),
2481 &render_ios_native_modules_package(project),
2482 write_policy,
2483 )?;
2484 write_file_with_policy(
2485 &root.join(
2486 "platforms/ios/NativeModules/Sources/FissionNativeModules/FissionNativeCapabilities.swift",
2487 ),
2488 render_ios_native_capabilities_swift(),
2489 write_policy,
2490 )?;
2491 sync_ios_native_module_sources(root, project)?;
2492 if project.capabilities.contains(&PlatformCapability::Nfc)
2493 || project.capabilities.contains(&PlatformCapability::Wifi)
2494 {
2495 write_file_with_policy(
2496 &root.join("platforms/ios/Entitlements.plist"),
2497 &render_ios_entitlements_plist(project),
2498 write_policy,
2499 )?;
2500 }
2501 write_file_with_policy(
2502 &root.join("platforms/ios/package-sim.sh"),
2503 &package_script,
2504 write_policy,
2505 )?;
2506 write_file_with_policy(
2507 &root.join("platforms/ios/package-ipa.sh"),
2508 &ipa_script,
2509 write_policy,
2510 )?;
2511 write_file_with_policy(
2512 &root.join("platforms/ios/run-sim.sh"),
2513 &run_script,
2514 write_policy,
2515 )?;
2516 write_file_with_policy(
2517 &root.join("platforms/ios/test-sim.sh"),
2518 &test_script,
2519 write_policy,
2520 )?;
2521 #[cfg(unix)]
2522 {
2523 use std::os::unix::fs::PermissionsExt;
2524 for relative in [
2525 "platforms/ios/package-sim.sh",
2526 "platforms/ios/package-ipa.sh",
2527 "platforms/ios/run-sim.sh",
2528 "platforms/ios/test-sim.sh",
2529 ] {
2530 let path = root.join(relative);
2531 if path.exists() {
2532 fs::set_permissions(path, fs::Permissions::from_mode(0o755))?;
2533 }
2534 }
2535 }
2536 Ok(())
2537}
2538
2539fn scaffold_android_bundle(
2540 root: &Path,
2541 project: &FissionProject,
2542 write_policy: WritePolicy,
2543) -> Result<()> {
2544 let manifest = render_android_manifest(project);
2545 let package_script = render_android_package_script(project);
2546 let package_aab_script = render_android_aab_package_script(project);
2547 let run_script = render_android_run_script(project);
2548 let test_script = render_android_test_script();
2549
2550 write_file_with_policy(
2551 &root.join("platforms/android/settings.gradle.kts"),
2552 &render_android_settings_gradle(project),
2553 write_policy,
2554 )?;
2555 write_file_with_policy(
2556 &root.join("platforms/android/build.gradle.kts"),
2557 &render_android_root_build_gradle(),
2558 write_policy,
2559 )?;
2560 write_file_with_policy(
2561 &root.join("platforms/android/gradle.properties"),
2562 render_android_gradle_properties(),
2563 write_policy,
2564 )?;
2565 write_file_with_policy(
2566 &root.join("platforms/android/app/build.gradle.kts"),
2567 &render_android_app_build_gradle(project),
2568 write_policy,
2569 )?;
2570 write_file_with_policy(
2571 &root.join("platforms/android/native-modules.gradle"),
2572 &render_android_native_modules_gradle(project),
2573 write_policy,
2574 )?;
2575 write_file_with_policy(
2576 &root.join("platforms/android/AndroidManifest.xml"),
2577 &manifest,
2578 write_policy,
2579 )?;
2580 write_file_with_policy(
2581 &root.join("platforms/android/package-apk.sh"),
2582 &package_script,
2583 write_policy,
2584 )?;
2585 write_file_with_policy(
2586 &root.join("platforms/android/package-aab.sh"),
2587 &package_aab_script,
2588 write_policy,
2589 )?;
2590 write_file_with_policy(
2591 &root.join("platforms/android/run-emulator.sh"),
2592 &run_script,
2593 write_policy,
2594 )?;
2595 write_file_with_policy(
2596 &root.join("platforms/android/test-emulator.sh"),
2597 &test_script,
2598 write_policy,
2599 )?;
2600 write_file_with_policy(
2601 &root.join("platforms/android/java/rs/fission/runtime/FissionActivity.java"),
2602 render_android_activity_java(),
2603 write_policy,
2604 )?;
2605 write_file_with_policy(
2606 &root.join("platforms/android/native-modules/README.md"),
2607 ANDROID_NATIVE_MODULES_README,
2608 write_policy,
2609 )?;
2610 #[cfg(unix)]
2611 {
2612 use std::os::unix::fs::PermissionsExt;
2613 for relative in [
2614 "platforms/android/package-apk.sh",
2615 "platforms/android/package-aab.sh",
2616 "platforms/android/run-emulator.sh",
2617 "platforms/android/test-emulator.sh",
2618 ] {
2619 let path = root.join(relative);
2620 if path.exists() {
2621 fs::set_permissions(path, fs::Permissions::from_mode(0o755))?;
2622 }
2623 }
2624 }
2625 Ok(())
2626}
2627
2628fn scaffold_windows_bundle(
2629 root: &Path,
2630 project: &FissionProject,
2631 write_policy: WritePolicy,
2632) -> Result<()> {
2633 let executable = windows_executable_name(root, project);
2634 write_file_with_policy(
2635 &root.join("platforms/windows/Package.appxmanifest"),
2636 &render_windows_appx_manifest(project, &executable),
2637 write_policy,
2638 )?;
2639 write_file_with_policy(
2640 &root.join("platforms/windows/package-msix.ps1"),
2641 &render_windows_msix_package_script(project, &executable),
2642 write_policy,
2643 )?;
2644 write_file_with_policy(
2645 &root.join("platforms/windows/package-msi.ps1"),
2646 &render_windows_msi_package_script(project, &executable),
2647 write_policy,
2648 )?;
2649 Ok(())
2650}
2651
2652fn windows_executable_name(root: &Path, project: &FissionProject) -> String {
2653 let stem = cargo_package_name(root).unwrap_or_else(|| sanitize_file_stem(&project.app.name));
2654 format!("{stem}.exe")
2655}
2656
2657fn windows_identity_name(project: &FissionProject) -> String {
2658 let mut out = project
2659 .app
2660 .app_id
2661 .chars()
2662 .map(|ch| match ch {
2663 'A'..='Z' | 'a'..='z' | '0'..='9' | '.' | '-' => ch,
2664 '_' => '.',
2665 _ => '.',
2666 })
2667 .collect::<String>();
2668 while out.contains("..") {
2669 out = out.replace("..", ".");
2670 }
2671 out = out.trim_matches(['.', '-']).to_string();
2672 if out.is_empty() {
2673 "Fission.App".to_string()
2674 } else {
2675 out
2676 }
2677}
2678
2679fn windows_publisher_name() -> &'static str {
2680 "CN=Fission Developer"
2681}
2682
2683fn render_windows_appx_manifest(project: &FissionProject, executable: &str) -> String {
2684 let display_name = escape_xml_attribute(&project.app.name);
2685 let identity_name = escape_xml_attribute(&windows_identity_name(project));
2686 let publisher = escape_xml_attribute(windows_publisher_name());
2687 let install_dir = escape_xml_attribute(&sanitize_file_stem(&project.app.name));
2688 let executable = escape_xml_attribute(executable);
2689 format!(
2690 r#"<?xml version="1.0" encoding="utf-8"?>
2691<Package
2692 xmlns="http://schemas.microsoft.com/appx/manifest/foundation/windows10"
2693 xmlns:uap="http://schemas.microsoft.com/appx/manifest/uap/windows10"
2694 xmlns:rescap="http://schemas.microsoft.com/appx/manifest/foundation/windows10/restrictedcapabilities"
2695 IgnorableNamespaces="uap rescap">
2696 <Identity Name="{identity_name}" Publisher="{publisher}" Version="0.1.0.1" ProcessorArchitecture="x64" />
2697 <Properties>
2698 <DisplayName>{display_name}</DisplayName>
2699 <PublisherDisplayName>Fission Developer</PublisherDisplayName>
2700 <Logo>Assets\StoreLogo.png</Logo>
2701 </Properties>
2702 <Dependencies>
2703 <TargetDeviceFamily Name="Windows.Desktop" MinVersion="10.0.17763.0" MaxVersionTested="10.0.22621.0" />
2704 </Dependencies>
2705 <Resources>
2706 <Resource Language="en-us" />
2707 </Resources>
2708 <Applications>
2709 <Application Id="App" Executable="VFS\ProgramFilesX64\{install_dir}\{executable}" EntryPoint="Windows.FullTrustApplication">
2710 <uap:VisualElements DisplayName="{display_name}" Description="{display_name}" BackgroundColor="transparent" Square150x150Logo="Assets\Square150x150Logo.png" Square44x44Logo="Assets\Square44x44Logo.png" />
2711 </Application>
2712 </Applications>
2713 <Capabilities>
2714 <rescap:Capability Name="runFullTrust" />
2715 </Capabilities>
2716</Package>
2717"#
2718 )
2719}
2720
2721fn render_windows_msix_package_script(project: &FissionProject, executable: &str) -> String {
2722 let app_name = sanitize_file_stem(&project.app.name);
2723 let package_name = windows_identity_name(project);
2724 let template = r#"$ErrorActionPreference = "Stop"
2725Set-StrictMode -Version Latest
2726
2727$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
2728$ProjectDir = Resolve-Path (Join-Path $ScriptDir "..\..")
2729$Profile = if ($env:WINDOWS_PROFILE) { $env:WINDOWS_PROFILE } else { "debug" }
2730$CargoProfileArg = if ($Profile -eq "release") { @("--release") } else { @() }
2731$ExecutableName = if ($env:WINDOWS_EXECUTABLE_NAME) { $env:WINDOWS_EXECUTABLE_NAME } else { "__EXECUTABLE__" }
2732$BinaryPath = if ($env:WINDOWS_BINARY) { $env:WINDOWS_BINARY } else { Join-Path $ProjectDir "target\$Profile\$ExecutableName" }
2733$OutRoot = Join-Path $ProjectDir "target\fission\windows\msix"
2734$LayoutDir = Join-Path $OutRoot "layout"
2735$AppDir = Join-Path $LayoutDir "VFS\ProgramFilesX64\__APP_NAME__"
2736$AssetsDir = Join-Path $LayoutDir "Assets"
2737$MsixPath = Join-Path $OutRoot "__PACKAGE_NAME__-$Profile.msix"
2738
2739if (-not $env:WINDOWS_BINARY) {
2740 cargo build @CargoProfileArg --manifest-path (Join-Path $ProjectDir "Cargo.toml")
2741}
2742if (-not (Test-Path $BinaryPath)) {
2743 throw "Windows executable was not found at $BinaryPath. Set WINDOWS_BINARY or WINDOWS_EXECUTABLE_NAME if the crate name changed."
2744}
2745$MakeAppx = Get-Command makeappx -ErrorAction SilentlyContinue
2746if (-not $MakeAppx) {
2747 throw "makeappx was not found. Install Windows SDK MSIX packaging tools and ensure makeappx is on PATH."
2748}
2749
2750Remove-Item -Recurse -Force $LayoutDir -ErrorAction SilentlyContinue
2751New-Item -ItemType Directory -Force $AppDir, $AssetsDir | Out-Null
2752Copy-Item $BinaryPath (Join-Path $AppDir $ExecutableName) -Force
2753if ($env:FISSION_WINDOWS_NATIVE_PRODUCTS_MANIFEST) {
2754 $NativeManifest = Get-Content -Raw $env:FISSION_WINDOWS_NATIVE_PRODUCTS_MANIFEST | ConvertFrom-Json
2755 foreach ($Product in $NativeManifest.products) {
2756 if ($Product.kind -eq "driver-package") {
2757 throw "MSIX native product manifest must not contain driver package $($Product.name)."
2758 }
2759 $NativeDestination = Join-Path $AppDir $Product.destination
2760 $NativeParent = Split-Path -Parent $NativeDestination
2761 New-Item -ItemType Directory -Force $NativeParent | Out-Null
2762 if (Test-Path $Product.source -PathType Container) {
2763 Copy-Item $Product.source $NativeDestination -Recurse -Force
2764 } else {
2765 Copy-Item $Product.source $NativeDestination -Force
2766 }
2767 }
2768}
2769Copy-Item (Join-Path $ScriptDir "Package.appxmanifest") (Join-Path $LayoutDir "AppxManifest.xml") -Force
2770
2771$IconSource = if ($env:WINDOWS_APP_ICON) { $env:WINDOWS_APP_ICON } else { Join-Path $ProjectDir "assets\app-icon.png" }
2772if (Test-Path $IconSource) {
2773 Copy-Item $IconSource (Join-Path $AssetsDir "StoreLogo.png") -Force
2774 Copy-Item $IconSource (Join-Path $AssetsDir "Square44x44Logo.png") -Force
2775 Copy-Item $IconSource (Join-Path $AssetsDir "Square150x150Logo.png") -Force
2776}
2777
2778& $MakeAppx.Source pack /d $LayoutDir /p $MsixPath /overwrite | Out-Host
2779
2780$Certificate = $env:WINDOWS_CERTIFICATE
2781$TempCertificate = $null
2782try {
2783 if (-not $Certificate -and $env:WINDOWS_CERTIFICATE_BASE64) {
2784 $TempCertificate = Join-Path ([System.IO.Path]::GetTempPath()) ("fission-windows-cert-" + [System.Guid]::NewGuid().ToString() + ".pfx")
2785 [System.IO.File]::WriteAllBytes($TempCertificate, [System.Convert]::FromBase64String($env:WINDOWS_CERTIFICATE_BASE64))
2786 $Certificate = $TempCertificate
2787 }
2788 $Thumbprint = $env:WINDOWS_CERTIFICATE_THUMBPRINT
2789 if ($Certificate -or $Thumbprint) {
2790 $SignTool = Get-Command signtool -ErrorAction SilentlyContinue
2791 if (-not $SignTool) {
2792 throw "signtool was not found. Install Windows SDK signing tools or set WINDOWS_SKIP_SIGNING=1 for unsigned local packages."
2793 }
2794 $SignArgs = @("sign", "/fd", "SHA256")
2795 if ($Certificate) {
2796 $SignArgs += @("/f", $Certificate)
2797 if ($env:WINDOWS_CERTIFICATE_PASSWORD) { $SignArgs += @("/p", $env:WINDOWS_CERTIFICATE_PASSWORD) }
2798 } else {
2799 $SignArgs += @("/sha1", $Thumbprint)
2800 }
2801 $SignArgs += $MsixPath
2802 & $SignTool.Source @SignArgs | Out-Host
2803 } elseif ($Profile -eq "release" -and $env:WINDOWS_SKIP_SIGNING -ne "1") {
2804 throw "Release MSIX packaging requires WINDOWS_CERTIFICATE, WINDOWS_CERTIFICATE_BASE64, or WINDOWS_CERTIFICATE_THUMBPRINT from a secure secret source. Set WINDOWS_SKIP_SIGNING=1 only for local unsigned validation."
2805 }
2806} finally {
2807 if ($TempCertificate) { Remove-Item -Force $TempCertificate -ErrorAction SilentlyContinue }
2808}
2809
2810Write-Output $MsixPath
2811"#;
2812 template
2813 .replace("__APP_NAME__", &app_name)
2814 .replace("__PACKAGE_NAME__", &package_name)
2815 .replace("__EXECUTABLE__", executable)
2816}
2817
2818fn render_windows_msi_package_script(project: &FissionProject, executable: &str) -> String {
2819 let app_name = sanitize_file_stem(&project.app.name);
2820 let display_name = project.app.name.clone();
2821 let upgrade_code = deterministic_guid(&project.app.app_id);
2822 let manufacturer = "Fission Developer";
2823 let template = r#"$ErrorActionPreference = "Stop"
2824Set-StrictMode -Version Latest
2825
2826$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
2827$ProjectDir = Resolve-Path (Join-Path $ScriptDir "..\..")
2828$Profile = if ($env:WINDOWS_PROFILE) { $env:WINDOWS_PROFILE } else { "debug" }
2829$CargoProfileArg = if ($Profile -eq "release") { @("--release") } else { @() }
2830$ExecutableName = if ($env:WINDOWS_EXECUTABLE_NAME) { $env:WINDOWS_EXECUTABLE_NAME } else { "__EXECUTABLE__" }
2831$BinaryPath = if ($env:WINDOWS_BINARY) { $env:WINDOWS_BINARY } else { Join-Path $ProjectDir "target\$Profile\$ExecutableName" }
2832$OutRoot = Join-Path $ProjectDir "target\fission\windows\msi"
2833$MsiPath = Join-Path $OutRoot "__APP_NAME__-$Profile.msi"
2834$Version = if ($env:WINDOWS_MSI_VERSION) { $env:WINDOWS_MSI_VERSION } else { "0.1.0" }
2835$UpgradeCode = if ($env:WINDOWS_MSI_UPGRADE_CODE) { $env:WINDOWS_MSI_UPGRADE_CODE } else { "__UPGRADE_CODE__" }
2836
2837if (-not $env:WINDOWS_BINARY) {
2838 cargo build @CargoProfileArg --manifest-path (Join-Path $ProjectDir "Cargo.toml")
2839}
2840if (-not (Test-Path $BinaryPath)) {
2841 throw "Windows executable was not found at $BinaryPath. Set WINDOWS_BINARY or WINDOWS_EXECUTABLE_NAME if the crate name changed."
2842}
2843New-Item -ItemType Directory -Force $OutRoot | Out-Null
2844
2845$Wix = Get-Command wix -ErrorAction SilentlyContinue
2846$Candle = Get-Command candle -ErrorAction SilentlyContinue
2847$Light = Get-Command light -ErrorAction SilentlyContinue
2848if ($Wix) {
2849 $WxsPath = Join-Path $OutRoot "package.wxs"
2850 @"
2851<Wix xmlns="http://wixtoolset.org/schemas/v4/wxs">
2852 <Package Name="__DISPLAY_NAME__" Manufacturer="__MANUFACTURER__" Version="$Version" UpgradeCode="$UpgradeCode" Scope="perMachine">
2853 <MajorUpgrade DowngradeErrorMessage="A newer version of __DISPLAY_NAME__ is already installed." />
2854 <MediaTemplate EmbedCab="yes" />
2855 <StandardDirectory Id="ProgramFiles6432Folder">
2856 <Directory Id="INSTALLFOLDER" Name="__APP_NAME__">
2857 <Component Id="MainExecutable" Guid="*">
2858 <File Id="AppExe" Source="$BinaryPath" KeyPath="yes" />
2859 </Component>
2860 </Directory>
2861 </StandardDirectory>
2862 <Feature Id="MainFeature" Title="__DISPLAY_NAME__" Level="1">
2863 <ComponentRef Id="MainExecutable" />
2864 </Feature>
2865 </Package>
2866</Wix>
2867"@ | Set-Content -Encoding UTF8 $WxsPath
2868 & $Wix.Source build $WxsPath -o $MsiPath | Out-Host
2869} elseif ($Candle -and $Light) {
2870 $WxsPath = Join-Path $OutRoot "package-wix3.wxs"
2871 $WixObj = Join-Path $OutRoot "package.wixobj"
2872 @"
2873<Wix xmlns="http://schemas.microsoft.com/wix/2006/wi">
2874 <Product Id="*" Name="__DISPLAY_NAME__" Language="1033" Version="$Version" Manufacturer="__MANUFACTURER__" UpgradeCode="$UpgradeCode">
2875 <Package InstallerVersion="500" Compressed="yes" InstallScope="perMachine" />
2876 <MajorUpgrade DowngradeErrorMessage="A newer version of __DISPLAY_NAME__ is already installed." />
2877 <MediaTemplate EmbedCab="yes" />
2878 <Directory Id="TARGETDIR" Name="SourceDir">
2879 <Directory Id="ProgramFiles64Folder">
2880 <Directory Id="INSTALLFOLDER" Name="__APP_NAME__">
2881 <Component Id="MainExecutable" Guid="*">
2882 <File Id="AppExe" Source="$BinaryPath" KeyPath="yes" />
2883 </Component>
2884 </Directory>
2885 </Directory>
2886 </Directory>
2887 <Feature Id="MainFeature" Title="__DISPLAY_NAME__" Level="1">
2888 <ComponentRef Id="MainExecutable" />
2889 </Feature>
2890 </Product>
2891</Wix>
2892"@ | Set-Content -Encoding UTF8 $WxsPath
2893 & $Candle.Source -nologo -arch x64 -out $WixObj $WxsPath | Out-Host
2894 & $Light.Source -nologo -out $MsiPath $WixObj | Out-Host
2895} else {
2896 throw "WiX was not found. Install WiX Toolset (`wix`) or WiX 3 (`candle` and `light`) to package an MSI."
2897}
2898
2899$Certificate = $env:WINDOWS_CERTIFICATE
2900$TempCertificate = $null
2901try {
2902 if (-not $Certificate -and $env:WINDOWS_CERTIFICATE_BASE64) {
2903 $TempCertificate = Join-Path ([System.IO.Path]::GetTempPath()) ("fission-windows-cert-" + [System.Guid]::NewGuid().ToString() + ".pfx")
2904 [System.IO.File]::WriteAllBytes($TempCertificate, [System.Convert]::FromBase64String($env:WINDOWS_CERTIFICATE_BASE64))
2905 $Certificate = $TempCertificate
2906 }
2907 $Thumbprint = $env:WINDOWS_CERTIFICATE_THUMBPRINT
2908 if ($Certificate -or $Thumbprint) {
2909 $SignTool = Get-Command signtool -ErrorAction SilentlyContinue
2910 if (-not $SignTool) {
2911 throw "signtool was not found. Install Windows SDK signing tools or set WINDOWS_SKIP_SIGNING=1 for unsigned local packages."
2912 }
2913 $SignArgs = @("sign", "/fd", "SHA256")
2914 if ($Certificate) {
2915 $SignArgs += @("/f", $Certificate)
2916 if ($env:WINDOWS_CERTIFICATE_PASSWORD) { $SignArgs += @("/p", $env:WINDOWS_CERTIFICATE_PASSWORD) }
2917 } else {
2918 $SignArgs += @("/sha1", $Thumbprint)
2919 }
2920 $SignArgs += $MsiPath
2921 & $SignTool.Source @SignArgs | Out-Host
2922 } elseif ($Profile -eq "release" -and $env:WINDOWS_SKIP_SIGNING -ne "1") {
2923 throw "Release MSI packaging requires WINDOWS_CERTIFICATE, WINDOWS_CERTIFICATE_BASE64, or WINDOWS_CERTIFICATE_THUMBPRINT from a secure secret source. Set WINDOWS_SKIP_SIGNING=1 only for local unsigned validation."
2924 }
2925} finally {
2926 if ($TempCertificate) { Remove-Item -Force $TempCertificate -ErrorAction SilentlyContinue }
2927}
2928
2929Write-Output $MsiPath
2930"#;
2931 template
2932 .replace("__APP_NAME__", &app_name)
2933 .replace("__DISPLAY_NAME__", &display_name)
2934 .replace("__MANUFACTURER__", manufacturer)
2935 .replace("__UPGRADE_CODE__", &upgrade_code)
2936 .replace("__EXECUTABLE__", executable)
2937}
2938
2939fn sanitize_file_stem(value: &str) -> String {
2940 let stem = value
2941 .chars()
2942 .map(|ch| match ch {
2943 'A'..='Z' | 'a'..='z' | '0'..='9' | '-' | '_' | '.' => ch,
2944 _ => '-',
2945 })
2946 .collect::<String>()
2947 .trim_matches(['-', '.', '_'])
2948 .to_string();
2949 if stem.is_empty() {
2950 "app".to_string()
2951 } else {
2952 stem
2953 }
2954}
2955
2956fn deterministic_guid(value: &str) -> String {
2957 fn fnv64(seed: u64, value: &str) -> u64 {
2958 let mut hash = seed;
2959 for byte in value.as_bytes() {
2960 hash ^= u64::from(*byte);
2961 hash = hash.wrapping_mul(0x100000001b3);
2962 }
2963 hash
2964 }
2965 let left = fnv64(0xcbf29ce484222325, value);
2966 let right = fnv64(0x84222325cbf29ce4, value);
2967 let mut bytes = [0u8; 16];
2968 bytes[..8].copy_from_slice(&left.to_be_bytes());
2969 bytes[8..].copy_from_slice(&right.to_be_bytes());
2970 bytes[6] = (bytes[6] & 0x0f) | 0x40;
2971 bytes[8] = (bytes[8] & 0x3f) | 0x80;
2972 format!(
2973 "{:02x}{:02x}{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}",
2974 bytes[0],
2975 bytes[1],
2976 bytes[2],
2977 bytes[3],
2978 bytes[4],
2979 bytes[5],
2980 bytes[6],
2981 bytes[7],
2982 bytes[8],
2983 bytes[9],
2984 bytes[10],
2985 bytes[11],
2986 bytes[12],
2987 bytes[13],
2988 bytes[14],
2989 bytes[15]
2990 )
2991}
2992
2993fn scaffold_web_bundle(
2994 root: &Path,
2995 project: &FissionProject,
2996 write_policy: WritePolicy,
2997) -> Result<()> {
2998 let index_html = render_web_index(project);
2999 let bootstrap = render_web_bootstrap(project);
3000 let build_script = render_web_build_script();
3001 let run_script = render_web_run_script(project);
3002 let test_script = render_web_test_script(project);
3003
3004 write_file_with_policy(
3005 &root.join("platforms/web/index.html"),
3006 &index_html,
3007 write_policy,
3008 )?;
3009 write_file_with_policy(
3010 &root.join("platforms/web/bootstrap.mjs"),
3011 &bootstrap,
3012 write_policy,
3013 )?;
3014 write_file_with_policy(
3015 &root.join("platforms/web/build-wasm.sh"),
3016 &build_script,
3017 write_policy,
3018 )?;
3019 write_file_with_policy(
3020 &root.join("platforms/web/run-browser.sh"),
3021 &run_script,
3022 write_policy,
3023 )?;
3024 write_file_with_policy(
3025 &root.join("platforms/web/test-browser.sh"),
3026 &test_script,
3027 write_policy,
3028 )?;
3029
3030 #[cfg(unix)]
3031 {
3032 use std::os::unix::fs::PermissionsExt;
3033 for relative in [
3034 "platforms/web/build-wasm.sh",
3035 "platforms/web/run-browser.sh",
3036 "platforms/web/test-browser.sh",
3037 ] {
3038 let path = root.join(relative);
3039 if path.exists() {
3040 let mut perms = fs::metadata(&path)?.permissions();
3041 perms.set_mode(0o755);
3042 fs::set_permissions(path, perms)?;
3043 }
3044 }
3045 }
3046
3047 Ok(())
3048}
3049
3050fn write_generated_app_agents(project_root: &Path) -> Result<()> {
3051 let repo_root = find_git_root(project_root).unwrap_or_else(|| project_root.to_path_buf());
3052 let root_agents = repo_root.join("AGENTS.md");
3053 if let Some(existing) = read_optional_string(&root_agents)? {
3054 if is_generated_app_agents(&existing) {
3055 return write_file_with_policy(
3056 &root_agents,
3057 GENERATED_APP_AGENTS_MD,
3058 WritePolicy::Overwrite,
3059 );
3060 }
3061
3062 let fission_agents = repo_root.join("AGENTS.fission.md");
3063 let write_policy = read_optional_string(&fission_agents)?
3064 .filter(|existing| is_generated_app_agents(existing))
3065 .map(|_| WritePolicy::Overwrite)
3066 .unwrap_or(WritePolicy::PreserveExisting);
3067
3068 return write_file_with_policy(&fission_agents, GENERATED_APP_AGENTS_MD, write_policy);
3069 }
3070
3071 write_file_with_policy(
3072 &root_agents,
3073 GENERATED_APP_AGENTS_MD,
3074 WritePolicy::Overwrite,
3075 )
3076}
3077
3078fn read_optional_string(path: &Path) -> Result<Option<String>> {
3079 match fs::read_to_string(path) {
3080 Ok(contents) => Ok(Some(contents)),
3081 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
3082 Err(error) => Err(error).with_context(|| format!("failed to read {}", path.display())),
3083 }
3084}
3085
3086fn is_generated_app_agents(contents: &str) -> bool {
3087 contents.contains(GENERATED_APP_AGENTS_MARKER)
3088 || contents == GENERATED_APP_AGENTS_MD
3089 || (contents.contains("# Fission App Guidelines")
3090 && contents.contains(
3091 "These instructions apply when building or reviewing a Fission-based app",
3092 )
3093 && contents.contains("## Source-Grounded Work")
3094 && contents.contains("## Validation"))
3095}
3096
3097fn find_git_root(start: &Path) -> Option<PathBuf> {
3098 let mut current = fs::canonicalize(start).ok()?;
3099 loop {
3100 if current.join(".git").exists() {
3101 return Some(current);
3102 }
3103 if !current.pop() {
3104 return None;
3105 }
3106 }
3107}
3108
3109pub(crate) fn write_file(path: &Path, contents: &str) -> Result<()> {
3110 write_file_with_policy(path, contents, WritePolicy::Overwrite)
3111}
3112
3113fn write_file_with_policy(path: &Path, contents: &str, write_policy: WritePolicy) -> Result<()> {
3114 if write_policy == WritePolicy::PreserveExisting && path.exists() {
3115 return Ok(());
3116 }
3117 if let Some(parent) = path.parent() {
3118 fs::create_dir_all(parent)?;
3119 }
3120 fs::write(path, contents).with_context(|| format!("failed to write {}", path.display()))
3121}
3122
3123fn write_binary_file_with_policy(
3124 path: &Path,
3125 contents: &[u8],
3126 write_policy: WritePolicy,
3127) -> Result<()> {
3128 if write_policy == WritePolicy::PreserveExisting && path.exists() {
3129 return Ok(());
3130 }
3131 if let Some(parent) = path.parent() {
3132 fs::create_dir_all(parent)?;
3133 }
3134 fs::write(path, contents).with_context(|| format!("failed to write {}", path.display()))
3135}
3136
3137fn render_cargo_toml(project: &FissionProject, local_path: Option<&Path>) -> String {
3138 let feature_list = render_fission_feature_list(&project.targets);
3139 let deps = if let Some(root) = local_path {
3140 let fission_path = root.join("crates/authoring/fission");
3141 format!(
3142 "fission = {{ path = {:?}, default-features = false, features = [{}] }}\n",
3143 fission_path.to_string_lossy().to_string(),
3144 feature_list
3145 )
3146 } else {
3147 format!(
3148 "fission = {{ version = \"{}\", default-features = false, features = [{}] }}\n",
3149 CURRENT_VERSION, feature_list
3150 )
3151 };
3152 let lib_name = project.app.name.replace('-', "_");
3153
3154 format!(
3155 "[package]\nname = \"{}\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n[lib]\nname = \"{}\"\ncrate-type = [\"cdylib\", \"rlib\"]\n\n[dependencies]\nanyhow = \"1\"\nserde = {{ version = \"1\", features = [\"derive\"] }}\n{}\n[target.'cfg(target_arch = \"wasm32\")'.dependencies]\nconsole_error_panic_hook = \"0.1\"\nwasm-bindgen = \"0.2\"\n",
3156 project.app.name, lib_name, deps
3157 )
3158}
3159
3160fn render_fission_feature_list(targets: &BTreeSet<Target>) -> String {
3161 fission_features_for_targets(targets)
3162 .into_iter()
3163 .map(|feature| format!("\"{feature}\""))
3164 .collect::<Vec<_>>()
3165 .join(", ")
3166}
3167
3168fn fission_features_for_targets(targets: &BTreeSet<Target>) -> Vec<&'static str> {
3169 let mut features = Vec::new();
3170 if targets
3171 .iter()
3172 .any(|target| matches!(target, Target::Linux | Target::Macos | Target::Windows))
3173 {
3174 features.push("desktop");
3175 }
3176 if targets.contains(&Target::Web) {
3177 features.push("web");
3178 }
3179 if targets.contains(&Target::Android) {
3180 features.push("android");
3181 }
3182 if targets.contains(&Target::Ios) {
3183 features.push("ios");
3184 }
3185 if targets.contains(&Target::Site) {
3186 features.push("site");
3187 }
3188 if targets.contains(&Target::Server) {
3189 features.push("server");
3190 }
3191 if targets.contains(&Target::Terminal) {
3192 features.push("terminal-shell");
3193 }
3194 features
3195}
3196
3197fn render_project_readme(project: &FissionProject) -> String {
3198 let mut targets = String::new();
3199 for target in &project.targets {
3200 targets.push_str(&format!("- `{}`\n", target.as_str()));
3201 }
3202 format!(
3203 "# {}\n\nGenerated by `fission init`.\n\n## Targets\n\n{}\n## Commands\n\n- `fission doctor --project-dir .` -- check local SDKs, browsers, emulators, and Rust targets\n- `fission devices --project-dir .` -- list runnable desktop, browser, simulator, emulator, and device targets\n- `fission run --project-dir .` -- launch the desktop app and attach to output\n- `fission run --target web --project-dir .` -- launch the web app and attach to the local server\n- `fission run --target ios --project-dir .` -- build, install, launch, and attach to simulator logs\n- `fission run --target android --project-dir .` -- build, install, launch, and attach to Android logs\n- `fission run --target <target> --device <id> --detach --project-dir .` -- launch without attaching\n- `fission logs --target <target> --device <id> --project-dir . --follow` -- attach later where supported\n- `fission build --target <target> --project-dir . --release` -- build a target without launching it\n- `fission test --target <target> --project-dir .` -- run the generated platform smoke test\n- `fission add-target web ios android --project-dir .` -- scaffold more targets\n- `fission add-capability nfc notifications biometric passkeys bluetooth barcode-scanner camera geolocation haptics microphone volume-control wifi --project-dir .` -- declare host capabilities and update platform config where possible\n- `cat platforms/<target>/README.md` -- inspect target-specific prerequisites and environment variables\n\n## Assets\n\n- `assets/app-icon.png` is the default app icon seed copied from Fission's `docs/fission_logo.png`\n\n## Status\n\nDesktop, web, iOS simulator, and Android emulator workflows are runnable through `fission run`. The platform scripts remain checked in so CI and advanced users can call the lower-level build, run, and smoke-test steps directly when needed.\n",
3204 project.app.name, targets
3205 )
3206}
3207
3208fn platform_readme(title: &str, summary: &str, bullets: &[&str]) -> String {
3209 let mut out = format!("# {} target\n\n{}\n", title, summary);
3210 for bullet in bullets {
3211 out.push_str(&format!("\n- {}", bullet));
3212 }
3213 out.push('\n');
3214 out
3215}
3216
3217fn normalize_crate_name(name: &str) -> String {
3218 name.chars()
3219 .map(|ch| match ch {
3220 'A'..='Z' => ch.to_ascii_lowercase(),
3221 'a'..='z' | '0'..='9' => ch,
3222 _ => '-',
3223 })
3224 .collect::<String>()
3225 .trim_matches('-')
3226 .to_string()
3227}
3228
3229pub fn ios_executable_name(project: &FissionProject) -> String {
3230 project.app.name.replace('-', "_")
3231}
3232
3233fn ios_bundle_name(project: &FissionProject) -> String {
3234 let mut out = String::new();
3235 let mut uppercase_next = true;
3236 for ch in project.app.name.chars() {
3237 match ch {
3238 '-' | '_' | ' ' => uppercase_next = true,
3239 _ if uppercase_next => {
3240 out.extend(ch.to_uppercase());
3241 uppercase_next = false;
3242 }
3243 _ => out.push(ch),
3244 }
3245 }
3246 if out.is_empty() {
3247 "FissionApp".to_string()
3248 } else {
3249 out
3250 }
3251}
3252
3253fn android_library_name(project: &FissionProject) -> String {
3254 project.app.name.replace('-', "_")
3255}
3256
3257fn android_root_project_name(project: &FissionProject) -> String {
3258 project.app.name.replace('-', "_")
3259}
3260
3261fn render_android_settings_gradle(project: &FissionProject) -> String {
3262 let repositories = android_dependency_repositories(project)
3263 .into_iter()
3264 .map(|repository| format!(" {repository}\n"))
3265 .collect::<String>();
3266 format!(
3267 r#"pluginManagement {{
3268 repositories {{
3269 google()
3270 mavenCentral()
3271 gradlePluginPortal()
3272 }}
3273}}
3274
3275dependencyResolutionManagement {{
3276 repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
3277 repositories {{
3278{repositories}
3279 }}
3280}}
3281
3282rootProject.name = "{name}-android"
3283include(":app")
3284"#,
3285 name = android_root_project_name(project),
3286 )
3287}
3288
3289fn render_android_root_build_gradle() -> String {
3290 format!(
3291 r#"plugins {{
3292 id("com.android.application") version "{ANDROID_GRADLE_PLUGIN_VERSION}" apply false
3293}}
3294"#
3295 )
3296}
3297
3298fn render_android_gradle_properties() -> &'static str {
3299 "android.useAndroidX=true\norg.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8\nandroid.javaCompile.suppressSourceTargetDeprecationWarning=true\n"
3300}
3301
3302fn render_android_app_build_gradle(project: &FissionProject) -> String {
3303 format!(
3304 r#"plugins {{
3305 id("com.android.application")
3306}}
3307
3308val releaseKeystore = System.getenv("ANDROID_KEYSTORE")
3309val releaseStorePassword = System.getenv("ANDROID_KEYSTORE_PASSWORD")
3310val releaseKeyAlias = System.getenv("ANDROID_KEYSTORE_ALIAS") ?: "upload"
3311val releaseKeyPassword = System.getenv("ANDROID_KEY_PASSWORD") ?: releaseStorePassword
3312val hasReleaseSigning = !releaseKeystore.isNullOrBlank() &&
3313 !releaseStorePassword.isNullOrBlank() &&
3314 !releaseKeyAlias.isNullOrBlank() &&
3315 !releaseKeyPassword.isNullOrBlank()
3316
3317android {{
3318 namespace = "{app_id}"
3319 compileSdk = (System.getenv("ANDROID_TARGET_API_LEVEL") ?: "35").toInt()
3320
3321 defaultConfig {{
3322 applicationId = "{app_id}"
3323 minSdk = (System.getenv("ANDROID_MIN_API_LEVEL") ?: "24").toInt()
3324 targetSdk = (System.getenv("ANDROID_TARGET_API_LEVEL") ?: "35").toInt()
3325 versionCode = (System.getenv("ANDROID_VERSION_CODE") ?: "1").toInt()
3326 versionName = System.getenv("ANDROID_VERSION_NAME") ?: "0.1.0"
3327 }}
3328
3329 sourceSets {{
3330 getByName("main") {{
3331 manifest.srcFile("../AndroidManifest.xml")
3332 java.srcDirs("../java")
3333 res.srcDirs("../res", "src/main/res")
3334 jniLibs.srcDirs("src/main/jniLibs")
3335 }}
3336 }}
3337
3338 signingConfigs {{
3339 create("release") {{
3340 if (hasReleaseSigning) {{
3341 storeFile = file(releaseKeystore!!)
3342 storePassword = releaseStorePassword
3343 keyAlias = releaseKeyAlias
3344 keyPassword = releaseKeyPassword
3345 }}
3346 }}
3347 }}
3348
3349 buildTypes {{
3350 getByName("debug") {{
3351 isDebuggable = true
3352 }}
3353 getByName("release") {{
3354 isDebuggable = false
3355 if (hasReleaseSigning) {{
3356 signingConfig = signingConfigs.getByName("release")
3357 }}
3358 }}
3359 }}
3360}}
3361
3362apply(from = "../native-modules.gradle")
3363"#,
3364 app_id = project.app.app_id,
3365 )
3366}
3367
3368fn render_android_native_modules_gradle(project: &FissionProject) -> String {
3369 let mut dependencies = Vec::new();
3370 let mut source_dirs = Vec::new();
3371 for module in &project.native.modules {
3372 for dependency in &module.android.gradle_dependencies {
3373 if let Some(dependency) = normalize_gradle_dependency(dependency) {
3374 dependencies.push((module.name.as_str(), dependency));
3375 }
3376 }
3377 for source_dir in &module.android.source_dirs {
3378 let source_dir = source_dir.trim();
3379 if !source_dir.is_empty() {
3380 source_dirs.push((module.name.as_str(), source_dir.to_string()));
3381 }
3382 }
3383 }
3384
3385 let mut out = String::from(
3386 "// Generated by Fission. Native capability modules append Android SDK wiring here.\n",
3387 );
3388 if dependencies.is_empty() && source_dirs.is_empty() {
3389 out.push_str("// No Android native modules are configured in fission.toml.\n");
3390 return out;
3391 }
3392 if !source_dirs.is_empty() {
3393 out.push_str("\ndef fissionProjectDir = rootProject.projectDir.toPath().resolve('../..').normalize().toFile()\n");
3394 out.push_str("android {\n");
3395 out.push_str(" sourceSets {\n");
3396 out.push_str(" main {\n");
3397 for (module, source_dir) in &source_dirs {
3398 out.push_str(" // ");
3399 out.push_str(module);
3400 out.push('\n');
3401 out.push_str(" java.srcDir(new File(fissionProjectDir, ");
3402 out.push_str(&groovy_string_literal(source_dir));
3403 out.push_str("))\n");
3404 }
3405 out.push_str(" }\n");
3406 out.push_str(" }\n");
3407 out.push_str("}\n");
3408 }
3409 if !dependencies.is_empty() {
3410 out.push_str("\ndependencies {\n");
3411 for (module, dependency) in dependencies {
3412 out.push_str(" // ");
3413 out.push_str(module);
3414 out.push('\n');
3415 out.push_str(" ");
3416 out.push_str(&dependency);
3417 out.push('\n');
3418 }
3419 out.push_str("}\n");
3420 }
3421 out
3422}
3423
3424fn android_dependency_repositories(project: &FissionProject) -> BTreeSet<String> {
3425 let mut repositories = BTreeSet::new();
3426 repositories.insert("google()".to_string());
3427 repositories.insert("mavenCentral()".to_string());
3428 for module in &project.native.modules {
3429 for repository in &module.android.repositories {
3430 if let Some(repository) = normalize_gradle_repository(repository) {
3431 repositories.insert(repository);
3432 }
3433 }
3434 }
3435 repositories
3436}
3437
3438fn normalize_gradle_repository(value: &str) -> Option<String> {
3439 let value = value.trim();
3440 if value.is_empty() {
3441 return None;
3442 }
3443 match value {
3444 "google" | "google()" => Some("google()".to_string()),
3445 "mavenCentral" | "mavenCentral()" => Some("mavenCentral()".to_string()),
3446 "gradlePluginPortal" | "gradlePluginPortal()" => Some("gradlePluginPortal()".to_string()),
3447 _ if value.contains('(') => Some(value.to_string()),
3448 _ => Some(format!("maven(\"{value}\")")),
3449 }
3450}
3451
3452fn normalize_gradle_dependency(value: &str) -> Option<String> {
3453 let value = value.trim();
3454 if value.is_empty() {
3455 return None;
3456 }
3457 if let Some((configuration, dependency)) = split_gradle_dependency_invocation(value) {
3458 Some(format!("{configuration} {}", dependency.trim()))
3459 } else if value.contains('(') {
3460 Some(format!("implementation {value}"))
3461 } else {
3462 Some(format!("implementation {}", groovy_string_literal(value)))
3463 }
3464}
3465
3466fn split_gradle_dependency_invocation(value: &str) -> Option<(&str, &str)> {
3467 let open = value.find('(')?;
3468 if !value.ends_with(')') {
3469 return None;
3470 }
3471 let configuration = value[..open].trim();
3472 if !is_gradle_dependency_configuration(configuration) {
3473 return None;
3474 }
3475 let dependency = value[open + 1..value.len() - 1].trim();
3476 if dependency.is_empty() {
3477 return None;
3478 }
3479 Some((configuration, dependency))
3480}
3481
3482fn is_gradle_dependency_configuration(value: &str) -> bool {
3483 matches!(
3484 value,
3485 "implementation"
3486 | "api"
3487 | "compileOnly"
3488 | "runtimeOnly"
3489 | "testImplementation"
3490 | "testCompileOnly"
3491 | "testRuntimeOnly"
3492 | "androidTestImplementation"
3493 | "androidTestCompileOnly"
3494 | "androidTestRuntimeOnly"
3495 | "debugImplementation"
3496 | "debugCompileOnly"
3497 | "debugRuntimeOnly"
3498 | "releaseImplementation"
3499 | "releaseCompileOnly"
3500 | "releaseRuntimeOnly"
3501 | "kapt"
3502 | "ksp"
3503 )
3504}
3505
3506fn groovy_string_literal(value: &str) -> String {
3507 format!("'{}'", value.replace('\\', "\\\\").replace('\'', "\\'"))
3508}
3509
3510fn render_android_activity_java() -> &'static str {
3511 r#"package rs.fission.runtime;
3512
3513import android.app.NativeActivity;
3514import android.media.MediaPlayer;
3515import android.media.PlaybackParams;
3516import android.os.Bundle;
3517import android.view.View;
3518import android.view.ViewGroup;
3519import android.widget.FrameLayout;
3520import android.widget.VideoView;
3521
3522import java.util.HashMap;
3523import java.util.Map;
3524
3525public final class FissionActivity extends NativeActivity {
3526 private static volatile FissionActivity INSTANCE;
3527 private static final Map<Long, FissionVideoSlot> VIDEOS = new HashMap<>();
3528
3529 @Override
3530 protected void onCreate(Bundle savedInstanceState) {
3531 super.onCreate(savedInstanceState);
3532 INSTANCE = this;
3533 }
3534
3535 @Override
3536 protected void onDestroy() {
3537 runOnUiThread(() -> {
3538 synchronized (VIDEOS) {
3539 for (FissionVideoSlot slot : VIDEOS.values()) {
3540 slot.destroy();
3541 }
3542 VIDEOS.clear();
3543 }
3544 });
3545 INSTANCE = null;
3546 super.onDestroy();
3547 }
3548
3549 public static void fissionCreateVideo(long id, String source) {
3550 runOnUiThreadOrRecordError(id, () -> {
3551 synchronized (VIDEOS) {
3552 FissionVideoSlot previous = VIDEOS.remove(id);
3553 if (previous != null) {
3554 previous.destroy();
3555 }
3556 FissionVideoSlot slot = new FissionVideoSlot(INSTANCE, source);
3557 VIDEOS.put(id, slot);
3558 }
3559 });
3560 }
3561
3562 public static void fissionUpdateVideoSurface(
3563 long id,
3564 int left,
3565 int top,
3566 int width,
3567 int height,
3568 boolean visible
3569 ) {
3570 runOnUiThreadOrRecordError(id, () -> {
3571 FissionVideoSlot slot = slot(id);
3572 if (slot != null) {
3573 slot.update(left, top, width, height, visible);
3574 }
3575 });
3576 }
3577
3578 public static void fissionSetVideoVisible(long id, boolean visible) {
3579 runOnUiThreadOrRecordError(id, () -> {
3580 FissionVideoSlot slot = slot(id);
3581 if (slot != null && slot.view != null) {
3582 slot.view.setVisibility(visible ? View.VISIBLE : View.GONE);
3583 }
3584 });
3585 }
3586
3587 public static void fissionDestroyVideo(long id) {
3588 runOnUiThreadOrRecordError(id, () -> {
3589 synchronized (VIDEOS) {
3590 FissionVideoSlot slot = VIDEOS.remove(id);
3591 if (slot != null) {
3592 slot.destroy();
3593 }
3594 }
3595 });
3596 }
3597
3598 public static void fissionPlayVideo(long id) {
3599 runOnUiThreadOrRecordError(id, () -> {
3600 FissionVideoSlot slot = slot(id);
3601 if (slot != null) {
3602 slot.ended = false;
3603 slot.view.start();
3604 }
3605 });
3606 }
3607
3608 public static void fissionPauseVideo(long id) {
3609 runOnUiThreadOrRecordError(id, () -> {
3610 FissionVideoSlot slot = slot(id);
3611 if (slot != null) {
3612 slot.view.pause();
3613 }
3614 });
3615 }
3616
3617 public static void fissionStopVideo(long id) {
3618 runOnUiThreadOrRecordError(id, () -> {
3619 FissionVideoSlot slot = slot(id);
3620 if (slot != null) {
3621 slot.view.pause();
3622 slot.view.seekTo(0);
3623 slot.ended = false;
3624 }
3625 });
3626 }
3627
3628 public static void fissionSeekVideo(long id, long positionMs) {
3629 runOnUiThreadOrRecordError(id, () -> {
3630 FissionVideoSlot slot = slot(id);
3631 if (slot != null) {
3632 slot.view.seekTo((int)Math.max(0L, Math.min(positionMs, Integer.MAX_VALUE)));
3633 }
3634 });
3635 }
3636
3637 public static void fissionSetVideoRate(long id, float rate) {
3638 runOnUiThreadOrRecordError(id, () -> {
3639 FissionVideoSlot slot = slot(id);
3640 if (slot != null) {
3641 slot.rate = Math.max(0.1f, rate);
3642 slot.applyPlaybackParams();
3643 }
3644 });
3645 }
3646
3647 public static void fissionSetVideoVolume(long id, float volume) {
3648 runOnUiThreadOrRecordError(id, () -> {
3649 FissionVideoSlot slot = slot(id);
3650 if (slot != null) {
3651 slot.volume = Math.max(0.0f, Math.min(volume, 1.0f));
3652 slot.applyVolume();
3653 }
3654 });
3655 }
3656
3657 public static void fissionSetVideoMuted(long id, boolean muted) {
3658 runOnUiThreadOrRecordError(id, () -> {
3659 FissionVideoSlot slot = slot(id);
3660 if (slot != null) {
3661 slot.muted = muted;
3662 slot.applyVolume();
3663 }
3664 });
3665 }
3666
3667 public static long fissionVideoPosition(long id) {
3668 FissionVideoSlot slot = slot(id);
3669 return slot == null || slot.view == null ? 0L : Math.max(0, slot.view.getCurrentPosition());
3670 }
3671
3672 public static long fissionVideoDuration(long id) {
3673 FissionVideoSlot slot = slot(id);
3674 return slot == null || !slot.ready ? -1L : Math.max(0, slot.durationMs);
3675 }
3676
3677 public static boolean fissionVideoReady(long id) {
3678 FissionVideoSlot slot = slot(id);
3679 return slot != null && slot.ready;
3680 }
3681
3682 public static boolean fissionVideoEnded(long id) {
3683 FissionVideoSlot slot = slot(id);
3684 return slot != null && slot.ended;
3685 }
3686
3687 public static String fissionVideoError(long id) {
3688 FissionVideoSlot slot = slot(id);
3689 return slot == null ? null : slot.error;
3690 }
3691
3692 private static FissionVideoSlot slot(long id) {
3693 synchronized (VIDEOS) {
3694 return VIDEOS.get(id);
3695 }
3696 }
3697
3698 private static void runOnUiThreadOrRecordError(long id, Runnable action) {
3699 FissionActivity activity = INSTANCE;
3700 if (activity == null) {
3701 recordError(id, "Fission Android video host is not attached to FissionActivity");
3702 return;
3703 }
3704 activity.runOnUiThread(() -> {
3705 try {
3706 action.run();
3707 } catch (Throwable error) {
3708 recordError(id, "Android video host error: " + error);
3709 }
3710 });
3711 }
3712
3713 private static void recordError(long id, String error) {
3714 synchronized (VIDEOS) {
3715 FissionVideoSlot slot = VIDEOS.get(id);
3716 if (slot == null) {
3717 slot = new FissionVideoSlot(error);
3718 VIDEOS.put(id, slot);
3719 } else {
3720 slot.error = error;
3721 }
3722 }
3723 }
3724
3725 private static final class FissionVideoSlot {
3726 final VideoView view;
3727 MediaPlayer mediaPlayer;
3728 volatile boolean ready;
3729 volatile boolean ended;
3730 volatile int durationMs = -1;
3731 volatile String error;
3732 volatile float rate = 1.0f;
3733 volatile float volume = 1.0f;
3734 volatile boolean muted;
3735
3736 FissionVideoSlot(String error) {
3737 this.view = null;
3738 this.error = error;
3739 }
3740
3741 FissionVideoSlot(FissionActivity activity, String source) {
3742 this.view = new VideoView(activity);
3743 this.view.setVisibility(View.GONE);
3744 this.view.setZOrderOnTop(true);
3745 this.view.setOnPreparedListener(player -> {
3746 mediaPlayer = player;
3747 ready = true;
3748 ended = false;
3749 durationMs = Math.max(0, view.getDuration());
3750 applyVolume();
3751 applyPlaybackParams();
3752 });
3753 this.view.setOnCompletionListener(player -> ended = true);
3754 this.view.setOnErrorListener((player, what, extra) -> {
3755 error = "Android MediaCodec playback error: what=" + what + ", extra=" + extra;
3756 return true;
3757 });
3758 this.view.setVideoPath(source);
3759 FrameLayout.LayoutParams params = new FrameLayout.LayoutParams(1, 1);
3760 activity.addContentView(this.view, params);
3761 }
3762
3763 void update(int left, int top, int width, int height, boolean visible) {
3764 if (view == null) {
3765 return;
3766 }
3767 FrameLayout.LayoutParams params = new FrameLayout.LayoutParams(
3768 Math.max(1, width),
3769 Math.max(1, height)
3770 );
3771 view.setLayoutParams(params);
3772 view.setX(left);
3773 view.setY(top);
3774 view.setVisibility(visible ? View.VISIBLE : View.GONE);
3775 }
3776
3777 void applyPlaybackParams() {
3778 if (mediaPlayer == null) {
3779 return;
3780 }
3781 PlaybackParams params = mediaPlayer.getPlaybackParams();
3782 params.setSpeed(rate);
3783 mediaPlayer.setPlaybackParams(params);
3784 }
3785
3786 void applyVolume() {
3787 if (mediaPlayer == null) {
3788 return;
3789 }
3790 float effective = muted ? 0.0f : volume;
3791 mediaPlayer.setVolume(effective, effective);
3792 }
3793
3794 void destroy() {
3795 if (view == null) {
3796 return;
3797 }
3798 view.stopPlayback();
3799 ViewGroup parent = (ViewGroup)view.getParent();
3800 if (parent != null) {
3801 parent.removeView(view);
3802 }
3803 }
3804 }
3805}
3806"#
3807}
3808
3809const ANDROID_NATIVE_MODULES_README: &str = r#"# Android native modules
3810
3811This directory is reserved for native capability module sources copied or owned by the app shell.
3812
3813Generic dependency and repository wiring is generated into `../native-modules.gradle` from
3814`fission.toml` `[native]` module declarations. Fission does not ship payment, camera-addon,
3815scanner-addon, or other app-specific modules in core; those crates provide their native adapters.
3816"#;
3817
3818fn render_ios_host_package(project: &FissionProject) -> String {
3819 format!(
3820 r#"// swift-tools-version: 5.9
3821import PackageDescription
3822
3823let package = Package(
3824 name: "{name}FissionHost",
3825 platforms: [
3826 .iOS(.v16),
3827 ],
3828 products: [
3829 .library(name: "FissionHost", targets: ["FissionHost"]),
3830 ],
3831 dependencies: [
3832 .package(path: "NativeModules"),
3833 ],
3834 targets: [
3835 .target(
3836 name: "FissionHost",
3837 dependencies: [
3838 .product(name: "FissionNativeModules", package: "NativeModules"),
3839 ],
3840 path: "Sources/FissionHost"
3841 ),
3842 ]
3843)
3844"#,
3845 name = ios_bundle_name(project),
3846 )
3847}
3848
3849fn render_ios_native_modules_package(project: &FissionProject) -> String {
3850 let package_dependencies = project
3851 .native
3852 .modules
3853 .iter()
3854 .flat_map(|module| module.ios.swift_packages.iter())
3855 .map(render_ios_swift_package_dependency)
3856 .collect::<Vec<_>>();
3857 let target_dependencies = project
3858 .native
3859 .modules
3860 .iter()
3861 .flat_map(|module| module.ios.swift_packages.iter())
3862 .map(render_ios_swift_product_dependency)
3863 .collect::<Vec<_>>();
3864
3865 let dependencies = if package_dependencies.is_empty() {
3866 String::new()
3867 } else {
3868 format!(
3869 "\n {}\n ",
3870 package_dependencies.join(",\n ")
3871 )
3872 };
3873 let target_dependencies = if target_dependencies.is_empty() {
3874 String::new()
3875 } else {
3876 format!(
3877 "\n {}\n ",
3878 target_dependencies.join(",\n ")
3879 )
3880 };
3881
3882 format!(
3883 r#"// swift-tools-version: 5.9
3884import PackageDescription
3885
3886let package = Package(
3887 name: "NativeModules",
3888 platforms: [
3889 .iOS(.v16),
3890 ],
3891 products: [
3892 .library(name: "FissionNativeModules", targets: ["FissionNativeModules"]),
3893 ],
3894 dependencies: [{dependencies}],
3895 targets: [
3896 .target(
3897 name: "FissionNativeModules",
3898 dependencies: [{target_dependencies}],
3899 path: "Sources/FissionNativeModules"
3900 ),
3901 ]
3902)
3903"#
3904 )
3905}
3906
3907fn render_ios_swift_package_dependency(package: &NativeIosSwiftPackageConfig) -> String {
3908 let version = package
3909 .from
3910 .as_deref()
3911 .filter(|value| !value.trim().is_empty())
3912 .unwrap_or("0.0.0");
3913 format!(".package(url: {:?}, from: {:?})", package.url, version)
3914}
3915
3916fn render_ios_swift_product_dependency(package: &NativeIosSwiftPackageConfig) -> String {
3917 let package_name = package
3918 .url
3919 .trim_end_matches('/')
3920 .rsplit('/')
3921 .next()
3922 .unwrap_or(package.product.as_str())
3923 .trim_end_matches(".git");
3924 format!(
3925 ".product(name: {:?}, package: {:?})",
3926 package.product, package_name
3927 )
3928}
3929
3930fn render_ios_host_native_capabilities_swift() -> &'static str {
3931 r#"import Foundation
3932import FissionNativeModules
3933
3934public enum FissionHostNativeCapabilities {
3935 public static func present(name: String, requestID: UInt64, payload: Data, completion: @escaping (Result<Data, Error>) -> Void) -> Bool {
3936 FissionNativeCapabilityRegistry.shared.present(name: name, requestID: requestID, payload: payload, completion: completion)
3937 }
3938}
3939"#
3940}
3941
3942fn render_ios_native_capabilities_swift() -> &'static str {
3943 r#"import Foundation
3944
3945public protocol FissionNativeCapability {
3946 var name: String { get }
3947 func present(requestID: UInt64, payload: Data, completion: @escaping (Result<Data, Error>) -> Void)
3948}
3949
3950public final class FissionNativeCapabilityRegistry {
3951 public static let shared = FissionNativeCapabilityRegistry()
3952 private var capabilities: [String: FissionNativeCapability] = [:]
3953
3954 private init() {}
3955
3956 public func register(_ capability: FissionNativeCapability) {
3957 capabilities[capability.name] = capability
3958 }
3959
3960 public func present(name: String, requestID: UInt64, payload: Data, completion: @escaping (Result<Data, Error>) -> Void) -> Bool {
3961 guard let capability = capabilities[name] else {
3962 return false
3963 }
3964 capability.present(requestID: requestID, payload: payload, completion: completion)
3965 return true
3966 }
3967}
3968"#
3969}
3970
3971const IOS_NATIVE_MODULES_README: &str = r#"# iOS native modules
3972
3973This Swift package is the app-owned integration point for native capability modules.
3974
3975Fission generates `Package.swift` from `fission.toml` `[native]` module declarations. Capability
3976crates can provide Swift sources or package dependencies here without adding product-specific
3977logic to Fission itself.
3978"#;
3979
3980fn render_ios_plist(project: &FissionProject, executable: &str) -> String {
3981 let capability_entries = render_ios_info_plist_capability_entries(project);
3982 format!(
3983 r#"<?xml version="1.0" encoding="UTF-8"?>
3984<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3985<plist version="1.0">
3986<dict>
3987 <key>CFBundleDevelopmentRegion</key>
3988 <string>en</string>
3989 <key>CFBundleDisplayName</key>
3990 <string>{display_name}</string>
3991 <key>CFBundleExecutable</key>
3992 <string>{executable}</string>
3993 <key>CFBundleIdentifier</key>
3994 <string>{bundle_id}</string>
3995 <key>CFBundleInfoDictionaryVersion</key>
3996 <string>6.0</string>
3997 <key>CFBundleName</key>
3998 <string>{display_name}</string>
3999 <key>CFBundlePackageType</key>
4000 <string>APPL</string>
4001 <key>CFBundleShortVersionString</key>
4002 <string>0.1.0</string>
4003 <key>CFBundleVersion</key>
4004 <string>1</string>
4005 <key>CFBundleIconFile</key>
4006 <string>AppIcon</string>
4007 <key>UILaunchStoryboardName</key>
4008 <string>LaunchScreen</string>
4009 <key>LSRequiresIPhoneOS</key>
4010 <true/>
4011 <key>MinimumOSVersion</key>
4012 <string>18.0</string>
4013{capability_entries}
4014 <key>UIDeviceFamily</key>
4015 <array>
4016 <integer>1</integer>
4017 <integer>2</integer>
4018 </array>
4019</dict>
4020</plist>
4021"#,
4022 display_name = ios_bundle_name(project),
4023 executable = executable,
4024 bundle_id = project.app.app_id,
4025 capability_entries = capability_entries,
4026 )
4027}
4028
4029fn render_ios_info_plist_capability_entries(project: &FissionProject) -> String {
4030 let mut out = String::new();
4031 if project.capabilities.contains(&PlatformCapability::Nfc) {
4032 out.push_str(" <key>NFCReaderUsageDescription</key>\n <string>This app uses NFC to scan nearby tags when you request it.</string>\n");
4033 }
4034 if project
4035 .capabilities
4036 .contains(&PlatformCapability::Biometric)
4037 {
4038 out.push_str(" <key>NSFaceIDUsageDescription</key>\n <string>This app uses biometrics to authenticate you when you request it.</string>\n");
4039 }
4040 if project
4041 .capabilities
4042 .contains(&PlatformCapability::Bluetooth)
4043 {
4044 out.push_str(" <key>NSBluetoothAlwaysUsageDescription</key>\n <string>This app uses Bluetooth when you request nearby-device features.</string>\n");
4045 }
4046 if project
4047 .capabilities
4048 .contains(&PlatformCapability::BarcodeScanner)
4049 {
4050 out.push_str(" <key>NSCameraUsageDescription</key>\n <string>This app uses the camera to scan barcodes when you request it.</string>\n");
4051 }
4052 if project.capabilities.contains(&PlatformCapability::Camera)
4053 && !project
4054 .capabilities
4055 .contains(&PlatformCapability::BarcodeScanner)
4056 {
4057 out.push_str(" <key>NSCameraUsageDescription</key>\n <string>This app uses the camera when you request camera features.</string>\n");
4058 }
4059 if project
4060 .capabilities
4061 .contains(&PlatformCapability::Geolocation)
4062 {
4063 out.push_str(" <key>NSLocationWhenInUseUsageDescription</key>\n <string>This app uses your location when you request location-aware features.</string>\n");
4064 }
4065 if project
4066 .capabilities
4067 .contains(&PlatformCapability::Microphone)
4068 {
4069 out.push_str(" <key>NSMicrophoneUsageDescription</key>\n <string>This app uses the microphone when you request audio capture.</string>\n");
4070 }
4071 if project.capabilities.contains(&PlatformCapability::Wifi)
4072 && !project
4073 .capabilities
4074 .contains(&PlatformCapability::Geolocation)
4075 {
4076 out.push_str(" <key>NSLocationWhenInUseUsageDescription</key>\n <string>This app uses location permission where the platform requires it for Wi-Fi information.</string>\n");
4077 }
4078 out
4079}
4080
4081fn render_ios_package_script(
4082 project: &FissionProject,
4083 bundle_name: &str,
4084 executable: &str,
4085) -> String {
4086 format!(
4087 r#"#!/usr/bin/env bash
4088set -euo pipefail
4089
4090SCRIPT_DIR=$(cd -- "$(dirname "${{BASH_SOURCE[0]}}")" && pwd)
4091PROJECT_DIR=$(cd -- "$SCRIPT_DIR/../.." && pwd)
4092TARGET="${{IOS_SIM_TARGET:-aarch64-apple-ios-sim}}"
4093PROFILE="${{IOS_SIM_PROFILE:-debug}}"
4094PACKAGE_NAME="{package_name}"
4095BUNDLE_ID="${{IOS_BUNDLE_ID:-{bundle_id}}}"
4096DISPLAY_NAME="${{IOS_DISPLAY_NAME:-{bundle_name}}}"
4097EXECUTABLE_NAME="${{IOS_EXECUTABLE_NAME:-{executable}}}"
4098BUNDLE_NAME="${{IOS_BUNDLE_NAME:-$DISPLAY_NAME.app}}"
4099IOS_MARKETING_VERSION="${{IOS_MARKETING_VERSION:-0.1.0}}"
4100IOS_BUILD_NUMBER="${{IOS_BUILD_NUMBER:-1}}"
4101BUILD_DIR="$SCRIPT_DIR/build/$PROFILE"
4102BUNDLE_DIR="$BUILD_DIR/$BUNDLE_NAME"
4103
4104BUILD_ARGS=(build --manifest-path "$PROJECT_DIR/Cargo.toml" --target "$TARGET" --package "$PACKAGE_NAME")
4105ARTIFACT_DIR=debug
4106if [[ "$PROFILE" == "release" ]]; then
4107 BUILD_ARGS+=(--release)
4108 ARTIFACT_DIR=release
4109fi
4110
4111cargo "${{BUILD_ARGS[@]}}"
4112TARGET_DIR=$(python3 - <<'PY' "$PROJECT_DIR/Cargo.toml"
4113import json
4114import subprocess
4115import sys
4116
4117manifest = sys.argv[1]
4118metadata = json.loads(
4119 subprocess.check_output(
4120 ["cargo", "metadata", "--manifest-path", manifest, "--format-version", "1", "--no-deps"]
4121 )
4122)
4123print(metadata["target_directory"])
4124PY
4125)
4126
4127rm -rf "$BUNDLE_DIR"
4128mkdir -p "$BUNDLE_DIR"
4129cp "$TARGET_DIR/$TARGET/$ARTIFACT_DIR/$PACKAGE_NAME" "$BUNDLE_DIR/$EXECUTABLE_NAME"
4130chmod +x "$BUNDLE_DIR/$EXECUTABLE_NAME"
4131{plist_patch}
4132shopt -s nullglob
4133PLATFORM_APP_ICONS=("$SCRIPT_DIR"/AppIcon.*)
4134if (( ${{#PLATFORM_APP_ICONS[@]}} == 0 )); then
4135 cp "$PROJECT_DIR/assets/app-icon.png" "$BUNDLE_DIR/AppIcon.png"
4136else
4137 app_icon="${{PLATFORM_APP_ICONS[0]}}"
4138 cp "$app_icon" "$BUNDLE_DIR/$(basename "$app_icon")"
4139fi
4140shopt -u nullglob
4141shopt -s nullglob
4142SPLASH_IMAGES=("$SCRIPT_DIR"/SplashImage.*)
4143if (( ${{#SPLASH_IMAGES[@]}} == 0 )); then
4144 cp "$PROJECT_DIR/assets/app-icon.png" "$BUNDLE_DIR/SplashImage.png"
4145else
4146 for splash_image in "${{SPLASH_IMAGES[@]}}"; do
4147 cp "$splash_image" "$BUNDLE_DIR/"
4148 done
4149fi
4150shopt -u nullglob
4151if [[ -f "$SCRIPT_DIR/LaunchScreen.storyboard" ]]; then
4152 IBTOOL=$(xcrun --find ibtool 2>/dev/null || true)
4153 if [[ -z "$IBTOOL" ]]; then
4154 printf 'ibtool not found. Install Xcode command line tools to compile the iOS launch screen storyboard.\n' >&2
4155 exit 1
4156 fi
4157 "$IBTOOL" \
4158 --errors \
4159 --warnings \
4160 --notices \
4161 --target-device iphone \
4162 --target-device ipad \
4163 --minimum-deployment-target 18.0 \
4164 --output-format human-readable-text \
4165 --compile "$BUNDLE_DIR/LaunchScreen.storyboardc" \
4166 "$SCRIPT_DIR/LaunchScreen.storyboard"
4167fi
4168printf 'APPL????' > "$BUNDLE_DIR/PkgInfo"
4169printf '%s\n' "$BUNDLE_DIR"
4170"#,
4171 package_name = project.app.name,
4172 bundle_id = project.app.app_id,
4173 bundle_name = bundle_name,
4174 executable = executable,
4175 plist_patch = IOS_INFO_PLIST_PLUTIL_PATCH,
4176 )
4177}
4178
4179fn render_ios_ipa_package_script(project: &FissionProject) -> String {
4180 format!(
4181 r#"#!/usr/bin/env bash
4182set -euo pipefail
4183
4184SCRIPT_DIR=$(cd -- "$(dirname "${{BASH_SOURCE[0]}}")" && pwd)
4185PROJECT_DIR=$(cd -- "$SCRIPT_DIR/../.." && pwd)
4186IOS_TARGET="${{IOS_TARGET:-aarch64-apple-ios}}"
4187IOS_PROFILE="${{IOS_PROFILE:-release}}"
4188IOS_SIGNING_IDENTITY="${{IOS_SIGNING_IDENTITY:-}}"
4189IOS_PROVISIONING_PROFILE="${{IOS_PROVISIONING_PROFILE:-}}"
4190IOS_REQUIRE_PROVISIONING_PROFILE="${{IOS_REQUIRE_PROVISIONING_PROFILE:-1}}"
4191IPA_DIR="$SCRIPT_DIR/build/ipa"
4192PAYLOAD_DIR="$IPA_DIR/Payload"
4193IPA_PATH="$IPA_DIR/{package_name}.ipa"
4194
4195if [[ "$IOS_PROFILE" == "release" && -z "$IOS_SIGNING_IDENTITY" ]]; then
4196 printf 'Release IPA packaging requires IOS_SIGNING_IDENTITY from a secure local or CI secret source.\n' >&2
4197 exit 1
4198fi
4199
4200BUNDLE_DIR=$(IOS_SIM_TARGET="$IOS_TARGET" IOS_SIM_PROFILE="$IOS_PROFILE" "$SCRIPT_DIR/package-sim.sh")
4201
4202if [[ -n "$IOS_PROVISIONING_PROFILE" ]]; then
4203 cp "$IOS_PROVISIONING_PROFILE" "$BUNDLE_DIR/embedded.mobileprovision"
4204elif [[ "$IOS_PROFILE" == "release" && "$IOS_REQUIRE_PROVISIONING_PROFILE" == "1" ]]; then
4205 printf 'Release IPA packaging requires IOS_PROVISIONING_PROFILE, or set IOS_REQUIRE_PROVISIONING_PROFILE=0 for an explicitly unsigned-profile test package.\n' >&2
4206 exit 1
4207fi
4208
4209if [[ -n "$IOS_SIGNING_IDENTITY" ]]; then
4210 CODESIGN_ARGS=(--force --sign "$IOS_SIGNING_IDENTITY")
4211 if [[ -n "${{IOS_ENTITLEMENTS:-}}" ]]; then
4212 CODESIGN_ARGS+=(--entitlements "$IOS_ENTITLEMENTS")
4213 elif [[ -f "$SCRIPT_DIR/Entitlements.plist" ]]; then
4214 CODESIGN_ARGS+=(--entitlements "$SCRIPT_DIR/Entitlements.plist")
4215 fi
4216 codesign "${{CODESIGN_ARGS[@]}}" "$BUNDLE_DIR"
4217 codesign --verify --deep --strict "$BUNDLE_DIR"
4218fi
4219
4220rm -rf "$PAYLOAD_DIR"
4221mkdir -p "$PAYLOAD_DIR"
4222cp -R "$BUNDLE_DIR" "$PAYLOAD_DIR/"
4223rm -f "$IPA_PATH"
4224(cd "$IPA_DIR" && zip -qry "$IPA_PATH" Payload)
4225printf '%s\n' "$IPA_PATH"
4226"#,
4227 package_name = project.app.name,
4228 )
4229}
4230
4231fn render_ios_run_script(project: &FissionProject) -> String {
4232 format!(
4233 r#"#!/usr/bin/env bash
4234set -euo pipefail
4235
4236SCRIPT_DIR=$(cd -- "$(dirname "${{BASH_SOURCE[0]}}")" && pwd)
4237BUNDLE_DIR=$("$SCRIPT_DIR/package-sim.sh")
4238BUNDLE_ID="${{IOS_BUNDLE_ID:-{bundle_id}}}"
4239DEVICE_ID="${{IOS_SIM_DEVICE_ID:-}}"
4240
4241if [[ -z "$DEVICE_ID" ]]; then
4242 DEVICE_ID=$(python3 - <<'PY'
4243import json
4244import subprocess
4245payload = json.loads(subprocess.check_output(["xcrun", "simctl", "list", "devices", "available", "-j"]))
4246for runtime, devices in payload["devices"].items():
4247 if not runtime.startswith("com.apple.CoreSimulator.SimRuntime.iOS-"):
4248 continue
4249 for device in devices:
4250 if device.get("isAvailable") and "iPhone" in device["name"]:
4251 print(device["udid"])
4252 raise SystemExit(0)
4253raise SystemExit("no available iPhone simulator found")
4254PY
4255)
4256fi
4257
4258if [[ "${{IOS_SIM_HEADLESS:-0}}" != "1" ]] && command -v open >/dev/null 2>&1; then
4259 open -a Simulator --args -CurrentDeviceUDID "$DEVICE_ID" >/dev/null 2>&1 \
4260 || open -a Simulator >/dev/null 2>&1 \
4261 || true
4262fi
4263
4264xcrun simctl boot "$DEVICE_ID" >/dev/null 2>&1 || true
4265xcrun simctl bootstatus "$DEVICE_ID" -b
4266if [[ "${{IOS_SIM_UNINSTALL_BEFORE_INSTALL:-1}}" == "1" ]]; then
4267 xcrun simctl uninstall "$DEVICE_ID" "$BUNDLE_ID" >/dev/null 2>&1 || true
4268fi
4269xcrun simctl install "$DEVICE_ID" "$BUNDLE_DIR"
4270
4271if [[ -n "${{FISSION_TEST_CONTROL_PORT:-}}" ]]; then
4272 SIMCTL_CHILD_FISSION_TEST_CONTROL_PORT="${{FISSION_TEST_CONTROL_PORT}}" \
4273 xcrun simctl launch --terminate-running-process "$DEVICE_ID" "$BUNDLE_ID"
4274else
4275 xcrun simctl launch --terminate-running-process "$DEVICE_ID" "$BUNDLE_ID"
4276fi
4277"#,
4278 bundle_id = project.app.app_id,
4279 )
4280}
4281
4282fn render_ios_test_script() -> String {
4283 r#"#!/usr/bin/env bash
4284set -euo pipefail
4285
4286SCRIPT_DIR=$(cd -- "$(dirname "${BASH_SOURCE[0]}")" && pwd)
4287export FISSION_TEST_CONTROL_PORT="${FISSION_TEST_CONTROL_PORT:-48711}"
4288
4289"$SCRIPT_DIR/run-sim.sh"
4290
4291python3 - <<'PY' "$FISSION_TEST_CONTROL_PORT"
4292import sys
4293import time
4294import urllib.request
4295
4296port = sys.argv[1]
4297url = f"http://127.0.0.1:{port}/health"
4298deadline = time.time() + 90
4299last_error = None
4300while time.time() < deadline:
4301 try:
4302 with urllib.request.urlopen(url, timeout=1) as response:
4303 body = response.read().decode("utf-8", "replace")
4304 if response.status == 200 and '"status":"ok"' in body:
4305 print(f"iOS simulator test control is healthy on {url}")
4306 raise SystemExit(0)
4307 except Exception as error:
4308 last_error = error
4309 time.sleep(1)
4310raise SystemExit(f"iOS simulator test control did not become healthy on {url}: {last_error}")
4311PY
4312"#
4313 .to_string()
4314}
4315
4316fn render_android_manifest(project: &FissionProject) -> String {
4317 let capability_entries = render_android_capability_manifest_entries(project);
4318 let native_application_entries = render_android_native_application_entries(project);
4319 format!(
4320 r#"<?xml version="1.0" encoding="utf-8"?>
4321<manifest xmlns:android="http://schemas.android.com/apk/res/android"
4322 package="{app_id}">
4323
4324 <uses-permission android:name="android.permission.INTERNET" />
4325{capability_entries}
4326
4327 <uses-sdk
4328 android:minSdkVersion="24"
4329 android:targetSdkVersion="35" />
4330
4331 <application
4332 android:extractNativeLibs="true"
4333 android:hasCode="true"
4334 android:icon="@drawable/app_icon"
4335 android:label="{label}">
4336{native_application_entries}
4337 <activity
4338 android:name="rs.fission.runtime.FissionActivity"
4339 android:configChanges="orientation|keyboardHidden|screenSize|screenLayout|smallestScreenSize|uiMode|density"
4340 android:exported="true"
4341 android:launchMode="singleTask"
4342 android:theme="@style/FissionLaunchTheme">
4343 <meta-data
4344 android:name="android.app.lib_name"
4345 android:value="{lib_name}" />
4346 <intent-filter>
4347 <action android:name="android.intent.action.MAIN" />
4348 <category android:name="android.intent.category.LAUNCHER" />
4349 </intent-filter>
4350 </activity>
4351 </application>
4352
4353</manifest>
4354"#,
4355 app_id = project.app.app_id,
4356 label = ios_bundle_name(project),
4357 lib_name = android_library_name(project),
4358 capability_entries = capability_entries,
4359 native_application_entries = native_application_entries,
4360 )
4361}
4362
4363fn render_android_native_application_entries(project: &FissionProject) -> String {
4364 let mut out = String::new();
4365 for module in &project.native.modules {
4366 for entry in &module.android.manifest_application_entries {
4367 let entry = entry.trim();
4368 if entry.is_empty() {
4369 continue;
4370 }
4371 out.push_str(" ");
4372 out.push_str(entry);
4373 if !entry.ends_with('\n') {
4374 out.push('\n');
4375 }
4376 }
4377 }
4378 out
4379}
4380
4381fn render_android_capability_manifest_entries(project: &FissionProject) -> String {
4382 let mut out = String::new();
4383 if project.capabilities.contains(&PlatformCapability::Nfc) {
4384 out.push_str(&render_android_nfc_manifest_entries());
4385 }
4386 if project
4387 .capabilities
4388 .contains(&PlatformCapability::Notifications)
4389 {
4390 out.push_str(&render_android_notifications_manifest_entries());
4391 }
4392 if project
4393 .capabilities
4394 .contains(&PlatformCapability::Biometric)
4395 {
4396 out.push_str(&render_android_biometric_manifest_entries());
4397 }
4398 if project
4399 .capabilities
4400 .contains(&PlatformCapability::Bluetooth)
4401 {
4402 out.push_str(&render_android_bluetooth_manifest_entries());
4403 }
4404 if project.capabilities.contains(&PlatformCapability::Camera) {
4405 out.push_str(&render_android_camera_manifest_entries());
4406 } else if project
4407 .capabilities
4408 .contains(&PlatformCapability::BarcodeScanner)
4409 {
4410 out.push_str(&render_android_barcode_camera_manifest_entries());
4411 }
4412 if project
4413 .capabilities
4414 .contains(&PlatformCapability::Geolocation)
4415 {
4416 out.push_str(&render_android_geolocation_manifest_entries());
4417 }
4418 if project.capabilities.contains(&PlatformCapability::Haptics) {
4419 out.push_str(&render_android_haptics_manifest_entries());
4420 }
4421 if project
4422 .capabilities
4423 .contains(&PlatformCapability::Microphone)
4424 {
4425 out.push_str(&render_android_microphone_manifest_entries());
4426 }
4427 if project
4428 .capabilities
4429 .contains(&PlatformCapability::VolumeControl)
4430 {
4431 out.push_str(&render_android_volume_manifest_entries());
4432 }
4433 if project.capabilities.contains(&PlatformCapability::Wifi) {
4434 out.push_str(&render_android_wifi_manifest_entries());
4435 }
4436 for permission in android_native_module_permissions(project) {
4437 out.push_str(&format!(
4438 " <uses-permission android:name=\"{}\" />\n",
4439 permission
4440 ));
4441 }
4442 out
4443}
4444
4445fn android_native_module_permissions(project: &FissionProject) -> BTreeSet<String> {
4446 project
4447 .native
4448 .modules
4449 .iter()
4450 .flat_map(|module| module.android.permissions.iter())
4451 .map(|permission| permission.trim().to_string())
4452 .filter(|permission| !permission.is_empty())
4453 .collect()
4454}
4455
4456fn render_android_nfc_manifest_entries() -> String {
4457 let mut out = String::new();
4458 out.push_str(" <uses-permission android:name=\"android.permission.NFC\" />\n");
4459 out.push_str(
4460 " <uses-feature android:name=\"android.hardware.nfc\" android:required=\"false\" />\n",
4461 );
4462 out
4463}
4464
4465fn render_android_notifications_manifest_entries() -> String {
4466 " <uses-permission android:name=\"android.permission.POST_NOTIFICATIONS\" />\n".to_string()
4467}
4468
4469fn render_android_biometric_manifest_entries() -> String {
4470 let mut out = String::new();
4471 out.push_str(" <uses-permission android:name=\"android.permission.USE_BIOMETRIC\" />\n");
4472 out.push_str(" <uses-permission android:name=\"android.permission.USE_FINGERPRINT\" android:maxSdkVersion=\"28\" />\n");
4473 out
4474}
4475
4476fn render_android_bluetooth_manifest_entries() -> String {
4477 let mut out = String::new();
4478 out.push_str(" <uses-permission android:name=\"android.permission.BLUETOOTH\" android:maxSdkVersion=\"30\" />\n");
4479 out.push_str(" <uses-permission android:name=\"android.permission.BLUETOOTH_ADMIN\" android:maxSdkVersion=\"30\" />\n");
4480 out.push_str(" <uses-permission android:name=\"android.permission.BLUETOOTH_SCAN\" android:usesPermissionFlags=\"neverForLocation\" />\n");
4481 out.push_str(" <uses-permission android:name=\"android.permission.BLUETOOTH_CONNECT\" />\n");
4482 out.push_str(
4483 " <uses-permission android:name=\"android.permission.BLUETOOTH_ADVERTISE\" />\n",
4484 );
4485 out.push_str(
4486 " <uses-feature android:name=\"android.hardware.bluetooth\" android:required=\"false\" />\n",
4487 );
4488 out.push_str(
4489 " <uses-feature android:name=\"android.hardware.bluetooth_le\" android:required=\"false\" />\n",
4490 );
4491 out
4492}
4493
4494fn render_missing_android_bluetooth_manifest_entries(existing: &str) -> String {
4495 let mut out = String::new();
4496 if !existing.contains("android.permission.BLUETOOTH\"") {
4497 out.push_str(" <uses-permission android:name=\"android.permission.BLUETOOTH\" android:maxSdkVersion=\"30\" />\n");
4498 }
4499 if !existing.contains("android.permission.BLUETOOTH_ADMIN") {
4500 out.push_str(" <uses-permission android:name=\"android.permission.BLUETOOTH_ADMIN\" android:maxSdkVersion=\"30\" />\n");
4501 }
4502 if !existing.contains("android.permission.BLUETOOTH_SCAN") {
4503 out.push_str(" <uses-permission android:name=\"android.permission.BLUETOOTH_SCAN\" android:usesPermissionFlags=\"neverForLocation\" />\n");
4504 }
4505 if !existing.contains("android.permission.BLUETOOTH_CONNECT") {
4506 out.push_str(
4507 " <uses-permission android:name=\"android.permission.BLUETOOTH_CONNECT\" />\n",
4508 );
4509 }
4510 if !existing.contains("android.permission.BLUETOOTH_ADVERTISE") {
4511 out.push_str(
4512 " <uses-permission android:name=\"android.permission.BLUETOOTH_ADVERTISE\" />\n",
4513 );
4514 }
4515 if !existing.contains("android.hardware.bluetooth\"") {
4516 out.push_str(
4517 " <uses-feature android:name=\"android.hardware.bluetooth\" android:required=\"false\" />\n",
4518 );
4519 }
4520 if !existing.contains("android.hardware.bluetooth_le") {
4521 out.push_str(
4522 " <uses-feature android:name=\"android.hardware.bluetooth_le\" android:required=\"false\" />\n",
4523 );
4524 }
4525 out
4526}
4527
4528fn render_android_barcode_camera_manifest_entries() -> String {
4529 let mut out = String::new();
4530 out.push_str(" <uses-permission android:name=\"android.permission.CAMERA\" />\n");
4531 out.push_str(
4532 " <uses-feature android:name=\"android.hardware.camera.any\" android:required=\"false\" />\n",
4533 );
4534 out
4535}
4536
4537fn render_android_camera_manifest_entries() -> String {
4538 let mut out = String::new();
4539 out.push_str(" <uses-permission android:name=\"android.permission.CAMERA\" />\n");
4540 out.push_str(
4541 " <uses-feature android:name=\"android.hardware.camera.any\" android:required=\"false\" />\n",
4542 );
4543 out.push_str(
4544 " <uses-feature android:name=\"android.hardware.camera\" android:required=\"false\" />\n",
4545 );
4546 out.push_str(
4547 " <uses-feature android:name=\"android.hardware.camera.front\" android:required=\"false\" />\n",
4548 );
4549 out.push_str(
4550 " <uses-feature android:name=\"android.hardware.camera.flash\" android:required=\"false\" />\n",
4551 );
4552 out
4553}
4554
4555fn render_missing_android_camera_manifest_entries(existing: &str) -> String {
4556 let mut out = String::new();
4557 if !existing.contains("android.permission.CAMERA") {
4558 out.push_str(" <uses-permission android:name=\"android.permission.CAMERA\" />\n");
4559 }
4560 if !existing.contains("android.hardware.camera.any") {
4561 out.push_str(
4562 " <uses-feature android:name=\"android.hardware.camera.any\" android:required=\"false\" />\n",
4563 );
4564 }
4565 if !existing.contains("android.hardware.camera\"") {
4566 out.push_str(
4567 " <uses-feature android:name=\"android.hardware.camera\" android:required=\"false\" />\n",
4568 );
4569 }
4570 if !existing.contains("android.hardware.camera.front") {
4571 out.push_str(
4572 " <uses-feature android:name=\"android.hardware.camera.front\" android:required=\"false\" />\n",
4573 );
4574 }
4575 if !existing.contains("android.hardware.camera.flash") {
4576 out.push_str(
4577 " <uses-feature android:name=\"android.hardware.camera.flash\" android:required=\"false\" />\n",
4578 );
4579 }
4580 out
4581}
4582
4583fn render_android_geolocation_manifest_entries() -> String {
4584 let mut out = String::new();
4585 out.push_str(
4586 " <uses-permission android:name=\"android.permission.ACCESS_COARSE_LOCATION\" />\n",
4587 );
4588 out.push_str(
4589 " <uses-permission android:name=\"android.permission.ACCESS_FINE_LOCATION\" />\n",
4590 );
4591 out
4592}
4593
4594fn render_android_haptics_manifest_entries() -> String {
4595 " <uses-permission android:name=\"android.permission.VIBRATE\" />\n".to_string()
4596}
4597
4598fn render_android_microphone_manifest_entries() -> String {
4599 " <uses-permission android:name=\"android.permission.RECORD_AUDIO\" />\n".to_string()
4600}
4601
4602fn render_android_volume_manifest_entries() -> String {
4603 " <uses-permission android:name=\"android.permission.MODIFY_AUDIO_SETTINGS\" />\n"
4604 .to_string()
4605}
4606
4607fn render_android_wifi_manifest_entries() -> String {
4608 let mut out = String::new();
4609 out.push_str(" <uses-permission android:name=\"android.permission.ACCESS_WIFI_STATE\" />\n");
4610 out.push_str(" <uses-permission android:name=\"android.permission.CHANGE_WIFI_STATE\" />\n");
4611 out.push_str(
4612 " <uses-permission android:name=\"android.permission.ACCESS_NETWORK_STATE\" />\n",
4613 );
4614 out.push_str(
4615 " <uses-permission android:name=\"android.permission.CHANGE_NETWORK_STATE\" />\n",
4616 );
4617 out.push_str(" <uses-permission android:name=\"android.permission.NEARBY_WIFI_DEVICES\" android:usesPermissionFlags=\"neverForLocation\" />\n");
4618 out.push_str(" <uses-permission android:name=\"android.permission.ACCESS_FINE_LOCATION\" android:maxSdkVersion=\"32\" />\n");
4619 out.push_str(
4620 " <uses-feature android:name=\"android.hardware.wifi\" android:required=\"false\" />\n",
4621 );
4622 out.push_str(
4623 " <uses-feature android:name=\"android.hardware.wifi.direct\" android:required=\"false\" />\n",
4624 );
4625 out
4626}
4627
4628fn render_missing_android_wifi_manifest_entries(existing: &str) -> String {
4629 let mut out = String::new();
4630 if !existing.contains("android.permission.ACCESS_WIFI_STATE") {
4631 out.push_str(
4632 " <uses-permission android:name=\"android.permission.ACCESS_WIFI_STATE\" />\n",
4633 );
4634 }
4635 if !existing.contains("android.permission.CHANGE_WIFI_STATE") {
4636 out.push_str(
4637 " <uses-permission android:name=\"android.permission.CHANGE_WIFI_STATE\" />\n",
4638 );
4639 }
4640 if !existing.contains("android.permission.ACCESS_NETWORK_STATE") {
4641 out.push_str(
4642 " <uses-permission android:name=\"android.permission.ACCESS_NETWORK_STATE\" />\n",
4643 );
4644 }
4645 if !existing.contains("android.permission.CHANGE_NETWORK_STATE") {
4646 out.push_str(
4647 " <uses-permission android:name=\"android.permission.CHANGE_NETWORK_STATE\" />\n",
4648 );
4649 }
4650 if !existing.contains("android.permission.NEARBY_WIFI_DEVICES") {
4651 out.push_str(" <uses-permission android:name=\"android.permission.NEARBY_WIFI_DEVICES\" android:usesPermissionFlags=\"neverForLocation\" />\n");
4652 }
4653 if !existing.contains("android.permission.ACCESS_FINE_LOCATION") {
4654 out.push_str(" <uses-permission android:name=\"android.permission.ACCESS_FINE_LOCATION\" android:maxSdkVersion=\"32\" />\n");
4655 }
4656 if !existing.contains("android.hardware.wifi\"") {
4657 out.push_str(
4658 " <uses-feature android:name=\"android.hardware.wifi\" android:required=\"false\" />\n",
4659 );
4660 }
4661 if !existing.contains("android.hardware.wifi.direct") {
4662 out.push_str(
4663 " <uses-feature android:name=\"android.hardware.wifi.direct\" android:required=\"false\" />\n",
4664 );
4665 }
4666 out
4667}
4668
4669fn render_ios_entitlements_plist(project: &FissionProject) -> String {
4670 let mut entries = String::new();
4671 if project.capabilities.contains(&PlatformCapability::Nfc) {
4672 entries.push_str(" <key>com.apple.developer.nfc.readersession.formats</key>\n <array>\n <string>NDEF</string>\n </array>\n");
4673 }
4674 if project.capabilities.contains(&PlatformCapability::Wifi) {
4675 entries.push_str(" <key>com.apple.developer.networking.wifi-info</key>\n <true/>\n");
4676 entries.push_str(
4677 " <key>com.apple.developer.networking.HotspotConfiguration</key>\n <true/>\n",
4678 );
4679 }
4680 format!(
4681 "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n<plist version=\"1.0\">\n<dict>\n{entries}</dict>\n</plist>\n"
4682 )
4683}
4684
4685const IOS_NFC_ENTITLEMENTS_PLIST: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
4686<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
4687<plist version="1.0">
4688<dict>
4689 <key>com.apple.developer.nfc.readersession.formats</key>
4690 <array>
4691 <string>NDEF</string>
4692 </array>
4693</dict>
4694</plist>
4695"#;
4696
4697const IOS_WIFI_ENTITLEMENTS_PLIST: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
4698<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
4699<plist version="1.0">
4700<dict>
4701 <key>com.apple.developer.networking.wifi-info</key>
4702 <true/>
4703 <key>com.apple.developer.networking.HotspotConfiguration</key>
4704 <true/>
4705</dict>
4706</plist>
4707"#;
4708
4709fn render_android_capabilities_java() -> &'static str {
4710 include_str!("../assets/android/rs/fission/runtime/FissionAndroidCapabilities.java")
4711}
4712
4713fn render_android_package_script(project: &FissionProject) -> String {
4714 render_android_gradle_package_script(
4715 project,
4716 AndroidGradlePackageKind {
4717 task_prefix: "assemble",
4718 output_subdir: "apk",
4719 extension: "apk",
4720 label: "APK",
4721 },
4722 )
4723}
4724
4725fn render_android_aab_package_script(project: &FissionProject) -> String {
4726 render_android_gradle_package_script(
4727 project,
4728 AndroidGradlePackageKind {
4729 task_prefix: "bundle",
4730 output_subdir: "bundle",
4731 extension: "aab",
4732 label: "AAB",
4733 },
4734 )
4735}
4736
4737struct AndroidGradlePackageKind {
4738 task_prefix: &'static str,
4739 output_subdir: &'static str,
4740 extension: &'static str,
4741 label: &'static str,
4742}
4743
4744fn render_android_gradle_package_script(
4745 project: &FissionProject,
4746 kind: AndroidGradlePackageKind,
4747) -> String {
4748 let lib_name = android_library_name(project);
4749 format!(
4750 r#"#!/usr/bin/env bash
4751set -euo pipefail
4752
4753SCRIPT_DIR=$(cd -- "$(dirname "${{BASH_SOURCE[0]}}")" && pwd)
4754PROJECT_DIR=$(cd -- "$SCRIPT_DIR/../.." && pwd)
4755TARGET="${{ANDROID_TARGET_TRIPLE:-aarch64-linux-android}}"
4756PACKAGE_NAME="{package_name}"
4757LIB_NAME="{lib_name}"
4758PROFILE="${{ANDROID_PROFILE:-debug}}"
4759ANDROID_HOME="${{ANDROID_HOME:-${{ANDROID_SDK_ROOT:-$HOME/Library/Android/sdk}}}}"
4760ANDROID_MIN_API_LEVEL="${{ANDROID_MIN_API_LEVEL:-${{ANDROID_API_LEVEL:-24}}}}"
4761
4762find_android_ndk() {{
4763 if [[ -n "${{ANDROID_NDK:-}}" ]]; then
4764 printf '%s\n' "$ANDROID_NDK"
4765 return
4766 fi
4767 local ndk_root="$ANDROID_HOME/ndk"
4768 if [[ ! -d "$ndk_root" ]]; then
4769 printf 'Android NDK not found. Set ANDROID_NDK or install one under %s.\n' "$ndk_root" >&2
4770 return 1
4771 fi
4772 local ndk
4773 ndk=$(find "$ndk_root" -maxdepth 1 -mindepth 1 -type d | sort -V | tail -1)
4774 if [[ -z "$ndk" ]]; then
4775 printf 'Android NDK not found. Set ANDROID_NDK or install one under %s.\n' "$ndk_root" >&2
4776 return 1
4777 fi
4778 printf '%s\n' "$ndk"
4779}}
4780
4781detect_android_toolchain() {{
4782 local prebuilt_root="$ANDROID_NDK/toolchains/llvm/prebuilt"
4783 local host
4784 for host in darwin-aarch64 darwin-x86_64 linux-x86_64 windows-x86_64; do
4785 if [[ -d "$prebuilt_root/$host/bin" ]]; then
4786 printf '%s\n' "$prebuilt_root/$host/bin"
4787 return
4788 fi
4789 done
4790 local fallback
4791 fallback=$(find "$prebuilt_root" -maxdepth 1 -mindepth 1 -type d 2>/dev/null | sort | head -1 || true)
4792 if [[ -n "$fallback" && -d "$fallback/bin" ]]; then
4793 printf '%s\n' "$fallback/bin"
4794 return
4795 fi
4796 printf 'No Android NDK LLVM prebuilt toolchain found under %s. Expected a prebuilt host directory such as darwin-x86_64 or linux-x86_64.\n' "$prebuilt_root" >&2
4797 return 1
4798}}
4799
4800detect_latest_android_api() {{
4801 find "$ANDROID_HOME/platforms" -maxdepth 1 -type d -name 'android-*' 2>/dev/null \
4802 | sed 's#.*android-##' \
4803 | sort -n \
4804 | tail -1
4805}}
4806
4807ANDROID_TARGET_API_LEVEL="${{ANDROID_TARGET_API_LEVEL:-$(detect_latest_android_api)}}"
4808if [[ -z "$ANDROID_TARGET_API_LEVEL" ]]; then
4809 printf 'No Android platform found under %s/platforms. Install one with sdkmanager "platforms;android-35" or newer.\n' "$ANDROID_HOME" >&2
4810 exit 1
4811fi
4812
4813ANDROID_NDK=$(find_android_ndk)
4814ANDROID_TOOLCHAIN="${{ANDROID_TOOLCHAIN:-$(detect_android_toolchain)}}"
4815CC_aarch64_linux_android="${{CC_aarch64_linux_android:-$ANDROID_TOOLCHAIN/aarch64-linux-android${{ANDROID_MIN_API_LEVEL}}-clang}}"
4816AR_aarch64_linux_android="${{AR_aarch64_linux_android:-$ANDROID_TOOLCHAIN/llvm-ar}}"
4817CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER="${{CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER:-$CC_aarch64_linux_android}}"
4818CARGO_TARGET_AARCH64_LINUX_ANDROID_AR="${{CARGO_TARGET_AARCH64_LINUX_ANDROID_AR:-$AR_aarch64_linux_android}}"
4819export ANDROID_HOME ANDROID_NDK ANDROID_MIN_API_LEVEL ANDROID_TARGET_API_LEVEL ANDROID_TOOLCHAIN CC_aarch64_linux_android AR_aarch64_linux_android
4820export CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER CARGO_TARGET_AARCH64_LINUX_ANDROID_AR
4821
4822if [[ -n "${{FISSION_GRADLE:-}}" ]]; then
4823 read -r -a GRADLE_CMD <<< "$FISSION_GRADLE"
4824elif [[ -x "$SCRIPT_DIR/gradlew" ]]; then
4825 GRADLE_CMD=("$SCRIPT_DIR/gradlew")
4826else
4827 if ! command -v gradle >/dev/null 2>&1; then
4828 printf 'Gradle is required for the generated Android project shell. Install Gradle or add a wrapper under %s.\n' "$SCRIPT_DIR" >&2
4829 exit 1
4830 fi
4831 GRADLE_CMD=(gradle)
4832fi
4833
4834BUILD_ARGS=(build --manifest-path "$PROJECT_DIR/Cargo.toml" --lib --target "$TARGET" --package "$PACKAGE_NAME")
4835ARTIFACT_DIR=debug
4836GRADLE_VARIANT=Debug
4837GRADLE_OUTPUT_DIR=debug
4838if [[ "$PROFILE" == "release" ]]; then
4839 BUILD_ARGS+=(--release)
4840 ARTIFACT_DIR=release
4841 GRADLE_VARIANT=Release
4842 GRADLE_OUTPUT_DIR=release
4843fi
4844
4845SIGNING_TEMP_DIR=""
4846cleanup_android_signing_temp() {{
4847 if [[ -n "$SIGNING_TEMP_DIR" ]]; then
4848 rm -rf "$SIGNING_TEMP_DIR"
4849 fi
4850}}
4851trap cleanup_android_signing_temp EXIT
4852
4853if [[ "$PROFILE" == "release" ]]; then
4854 if [[ -z "${{ANDROID_KEYSTORE:-}}" && -n "${{ANDROID_KEYSTORE_BASE64:-}}" ]]; then
4855 SIGNING_TEMP_DIR=$(mktemp -d)
4856 ANDROID_KEYSTORE="$SIGNING_TEMP_DIR/upload.jks"
4857 export ANDROID_KEYSTORE
4858 python3 - "$ANDROID_KEYSTORE" <<'PY'
4859import base64
4860import os
4861import sys
4862
4863out_path = sys.argv[1]
4864raw = os.environ["ANDROID_KEYSTORE_BASE64"]
4865with open(out_path, "wb") as handle:
4866 handle.write(base64.b64decode(raw))
4867PY
4868 fi
4869 if [[ -z "${{ANDROID_KEYSTORE:-}}" ]]; then
4870 printf 'Release Android builds require ANDROID_KEYSTORE or ANDROID_KEYSTORE_BASE64 from a secret source.\n' >&2
4871 exit 1
4872 fi
4873 if [[ -z "${{ANDROID_KEYSTORE_PASSWORD:-}}" ]]; then
4874 printf 'Release Android builds require ANDROID_KEYSTORE_PASSWORD from a secret source.\n' >&2
4875 exit 1
4876 fi
4877 if [[ -z "${{ANDROID_KEYSTORE_ALIAS:-}}" ]]; then
4878 ANDROID_KEYSTORE_ALIAS=upload
4879 export ANDROID_KEYSTORE_ALIAS
4880 fi
4881 if [[ -z "${{ANDROID_KEY_PASSWORD:-}}" ]]; then
4882 ANDROID_KEY_PASSWORD="$ANDROID_KEYSTORE_PASSWORD"
4883 export ANDROID_KEY_PASSWORD
4884 fi
4885fi
4886
4887cargo "${{BUILD_ARGS[@]}}"
4888TARGET_DIR=$(python3 - <<'PY' "$PROJECT_DIR/Cargo.toml"
4889import json
4890import subprocess
4891import sys
4892
4893manifest = sys.argv[1]
4894metadata = json.loads(
4895 subprocess.check_output(
4896 ["cargo", "metadata", "--manifest-path", manifest, "--format-version", "1", "--no-deps"]
4897 )
4898)
4899print(metadata["target_directory"])
4900PY
4901)
4902
4903SO_PATH="$TARGET_DIR/$TARGET/$ARTIFACT_DIR/lib$LIB_NAME.so"
4904JNI_DIR="$SCRIPT_DIR/app/src/main/jniLibs/arm64-v8a"
4905GENERATED_RES_DIR="$SCRIPT_DIR/app/src/main/res/drawable-nodpi"
4906mkdir -p "$JNI_DIR" "$GENERATED_RES_DIR"
4907cp "$SO_PATH" "$JNI_DIR/lib$LIB_NAME.so"
4908shopt -s nullglob
4909APP_ICONS=("$SCRIPT_DIR"/res/drawable-nodpi/app_icon.* "$SCRIPT_DIR"/res/drawable/app_icon.*)
4910if (( ${{#APP_ICONS[@]}} == 0 )); then
4911 cp "$PROJECT_DIR/assets/app-icon.png" "$GENERATED_RES_DIR/app_icon.png"
4912fi
4913shopt -u nullglob
4914shopt -s nullglob
4915SPLASH_IMAGES=("$SCRIPT_DIR"/res/drawable-nodpi/fission_splash_image.*)
4916if (( ${{#SPLASH_IMAGES[@]}} == 0 )); then
4917 cp "$PROJECT_DIR/assets/app-icon.png" "$GENERATED_RES_DIR/fission_splash_image.png"
4918fi
4919shopt -u nullglob
4920
4921"${{GRADLE_CMD[@]}}" -p "$SCRIPT_DIR" ":app:{task_prefix}$GRADLE_VARIANT"
4922
4923ARTIFACT="$SCRIPT_DIR/app/build/outputs/{output_subdir}/$GRADLE_OUTPUT_DIR/app-$GRADLE_OUTPUT_DIR.{extension}"
4924if [[ ! -f "$ARTIFACT" ]]; then
4925 printf 'Gradle did not produce the expected {label}: %s\n' "$ARTIFACT" >&2
4926 exit 1
4927fi
4928printf '%s\n' "$ARTIFACT"
4929"#,
4930 package_name = project.app.name,
4931 lib_name = lib_name,
4932 task_prefix = kind.task_prefix,
4933 output_subdir = kind.output_subdir,
4934 extension = kind.extension,
4935 label = kind.label,
4936 )
4937}
4938
4939fn render_android_run_script(project: &FissionProject) -> String {
4940 format!(
4941 r#"#!/usr/bin/env bash
4942set -euo pipefail
4943
4944SCRIPT_DIR=$(cd -- "$(dirname "${{BASH_SOURCE[0]}}")" && pwd)
4945ANDROID_HOME="${{ANDROID_HOME:-${{ANDROID_SDK_ROOT:-$HOME/Library/Android/sdk}}}}"
4946ADB="$ANDROID_HOME/platform-tools/adb"
4947EMULATOR_BIN="$ANDROID_HOME/emulator/emulator"
4948AVDMANAGER="${{ANDROID_AVDMANAGER:-$ANDROID_HOME/cmdline-tools/latest/bin/avdmanager}}"
4949
4950detect_latest_emulator_api() {{
4951 find "$ANDROID_HOME/system-images" -path '*/google_apis/arm64-v8a' -type d 2>/dev/null \
4952 | sed -n 's#.*system-images/android-\([0-9][0-9]*\)/google_apis/arm64-v8a#\1#p' \
4953 | sort -n \
4954 | tail -1
4955}}
4956
4957android_system_image_path() {{
4958 local image="$1"
4959 image="${{image#system-images;}}"
4960 printf '%s/system-images/%s\n' "$ANDROID_HOME" "${{image//;/\/}}"
4961}}
4962
4963wait_for_android_boot() {{
4964 "$ADB" wait-for-device
4965 until "$ADB" shell getprop sys.boot_completed 2>/dev/null | tr -d '\r' | grep -q '^1$'; do
4966 sleep 1
4967 done
4968 local deadline=$((SECONDS + 180))
4969 until "$ADB" shell cmd package list packages >/dev/null 2>&1; do
4970 if (( SECONDS > deadline )); then
4971 printf 'Android package manager did not become available. Restart the emulator with ANDROID_EMULATOR_RESTART=1 and try again.\n' >&2
4972 exit 1
4973 fi
4974 sleep 1
4975 done
4976}}
4977
4978ANDROID_EMULATOR_API_LEVEL="${{ANDROID_EMULATOR_API_LEVEL:-$(detect_latest_emulator_api)}}"
4979if [[ -z "$ANDROID_EMULATOR_API_LEVEL" ]]; then
4980 printf 'No Android arm64 google_apis emulator image found under %s/system-images.\nInstall one with sdkmanager "system-images;android-35;google_apis;arm64-v8a" or set ANDROID_SYSTEM_IMAGE.\n' "$ANDROID_HOME" >&2
4981 exit 1
4982fi
4983AVD_NAME="${{ANDROID_AVD_NAME:-FissionApi${{ANDROID_EMULATOR_API_LEVEL}}Arm64}}"
4984SYSTEM_IMAGE="${{ANDROID_SYSTEM_IMAGE:-system-images;android-${{ANDROID_EMULATOR_API_LEVEL}};google_apis;arm64-v8a}}"
4985DEVICE_PORT="${{ANDROID_TEST_CONTROL_DEVICE_PORT:-48761}}"
4986HOST_PORT="${{FISSION_TEST_CONTROL_PORT:-48761}}"
4987HEADLESS="${{ANDROID_EMULATOR_HEADLESS:-0}}"
4988RESTART_EMULATOR="${{ANDROID_EMULATOR_RESTART:-0}}"
4989
4990for tool in "$ADB" "$EMULATOR_BIN" "$AVDMANAGER"; do
4991 if [[ ! -x "$tool" ]]; then
4992 printf 'Required Android tool is missing or not executable: %s\nRun `fission doctor android --project-dir .` for setup help.\n' "$tool" >&2
4993 exit 1
4994 fi
4995done
4996
4997if ! "$AVDMANAGER" list avd | grep -q "Name: $AVD_NAME"; then
4998 if [[ ! -d "$(android_system_image_path "$SYSTEM_IMAGE")" ]]; then
4999 printf 'Android system image is not installed: %s\nInstall it with sdkmanager "%s" or set ANDROID_SYSTEM_IMAGE.\n' "$SYSTEM_IMAGE" "$SYSTEM_IMAGE" >&2
5000 exit 1
5001 fi
5002 echo "no" | "$AVDMANAGER" create avd -n "$AVD_NAME" -k "$SYSTEM_IMAGE" --abi "google_apis/arm64-v8a" --device "pixel_5"
5003fi
5004
5005RUNNING_EMULATOR=$("$ADB" devices | awk '/^emulator-.*device$/ {{ print $1; exit }}')
5006if [[ -n "$RUNNING_EMULATOR" && "$RESTART_EMULATOR" == "1" ]]; then
5007 "$ADB" -s "$RUNNING_EMULATOR" emu kill >/dev/null || true
5008 until ! "$ADB" devices | grep -q '^emulator-'; do
5009 sleep 1
5010 done
5011 RUNNING_EMULATOR=""
5012fi
5013
5014if [[ -z "$RUNNING_EMULATOR" ]]; then
5015 EMULATOR_ARGS=(-avd "$AVD_NAME" -gpu "${{ANDROID_EMULATOR_GPU:-swiftshader_indirect}}" -no-audio)
5016 if [[ "$HEADLESS" == "1" ]]; then
5017 EMULATOR_ARGS+=(-no-window)
5018 fi
5019 printf 'Launching emulator %s (%s)\n' "$AVD_NAME" "$([[ "$HEADLESS" == "1" ]] && echo headless || echo visible)"
5020 nohup "$EMULATOR_BIN" "${{EMULATOR_ARGS[@]}}" >/tmp/fission-android-emulator.log 2>&1 &
5021 disown || true
5022 wait_for_android_boot
5023else
5024 printf 'Using existing emulator %s\n' "$RUNNING_EMULATOR"
5025 wait_for_android_boot
5026 if [[ "$HEADLESS" != "1" ]]; then
5027 printf 'If the window is not visible, restart with ANDROID_EMULATOR_RESTART=1 to relaunch a visible emulator.\n'
5028 fi
5029fi
5030
5031APK=$("$SCRIPT_DIR/package-apk.sh")
5032read -r -a ADB_INSTALL_FLAGS <<< "${{ADB_INSTALL_FLAGS:---no-streaming -r}}"
5033"$ADB" install "${{ADB_INSTALL_FLAGS[@]}}" "$APK"
5034"$ADB" forward "tcp:$HOST_PORT" "tcp:$DEVICE_PORT"
5035"$ADB" shell am start -n {app_id}/rs.fission.runtime.FissionActivity >/dev/null
5036printf 'APK=%s\n' "$APK"
5037"#,
5038 app_id = project.app.app_id,
5039 )
5040}
5041
5042fn render_android_test_script() -> String {
5043 r#"#!/usr/bin/env bash
5044set -euo pipefail
5045
5046SCRIPT_DIR=$(cd -- "$(dirname "${BASH_SOURCE[0]}")" && pwd)
5047export FISSION_TEST_CONTROL_PORT="${FISSION_TEST_CONTROL_PORT:-48761}"
5048
5049"$SCRIPT_DIR/run-emulator.sh"
5050
5051python3 - <<'PY' "$FISSION_TEST_CONTROL_PORT"
5052import sys
5053import time
5054import urllib.request
5055
5056port = sys.argv[1]
5057url = f"http://127.0.0.1:{port}/health"
5058deadline = time.time() + 90
5059last_error = None
5060while time.time() < deadline:
5061 try:
5062 with urllib.request.urlopen(url, timeout=1) as response:
5063 body = response.read().decode("utf-8", "replace")
5064 if response.status == 200 and '"status":"ok"' in body:
5065 print(f"Android emulator test control is healthy on {url}")
5066 raise SystemExit(0)
5067 except Exception as error:
5068 last_error = error
5069 time.sleep(1)
5070raise SystemExit(f"Android emulator test control did not become healthy on {url}: {last_error}")
5071PY
5072"#
5073 .to_string()
5074}
5075
5076fn render_web_index(project: &FissionProject) -> String {
5077 let title = ios_bundle_name(project);
5078 format!(
5079 r#"<!doctype html>
5080<html lang="en">
5081 <head>
5082 <meta charset="utf-8" />
5083 <meta name="viewport" content="width=device-width, initial-scale=1" />
5084 <title>{title}</title>
5085 <link rel="icon" type="image/png" href="../../assets/app-icon.png" />
5086 <style>
5087 :root {{
5088 color-scheme: dark;
5089 background: #14171f;
5090 }}
5091 html, body {{
5092 margin: 0;
5093 width: 100%;
5094 height: 100%;
5095 overflow: hidden;
5096 overscroll-behavior: none;
5097 background: #14171f;
5098 }}
5099 body, #fission-web-mount {{
5100 width: 100vw;
5101 height: 100vh;
5102 }}
5103 canvas {{
5104 display: block;
5105 width: 100vw;
5106 height: 100vh;
5107 border: 0;
5108 outline: none;
5109 user-select: none;
5110 -webkit-user-drag: none;
5111 touch-action: none;
5112 -webkit-tap-highlight-color: transparent;
5113 }}
5114 canvas:focus, canvas:focus-visible {{
5115 outline: none;
5116 }}
5117 </style>
5118 </head>
5119 <body>
5120 <main id="fission-web-mount" aria-label="{title}"></main>
5121 <script type="module" src="./bootstrap.mjs"></script>
5122 </body>
5123</html>
5124"#,
5125 title = title,
5126 )
5127}
5128
5129fn render_web_bootstrap(project: &FissionProject) -> String {
5130 let module_name = project.app.name.replace('-', "_");
5131 format!(
5132 "import init from \"./pkg/{}.js\";\n\nawait init();\n",
5133 module_name
5134 )
5135}
5136
5137fn render_web_build_script() -> String {
5138 r#"#!/usr/bin/env bash
5139set -euo pipefail
5140
5141SCRIPT_DIR=$(cd -- "$(dirname "${BASH_SOURCE[0]}")" && pwd)
5142PROJECT_DIR=$(cd -- "$SCRIPT_DIR/../.." && pwd)
5143PROFILE="${FISSION_WEB_PROFILE:-dev}"
5144BUILD_ARGS=(build "$PROJECT_DIR" --target web --out-dir "$SCRIPT_DIR/pkg")
5145
5146if [[ "$PROFILE" == "release" ]]; then
5147 BUILD_ARGS+=(--release)
5148else
5149 BUILD_ARGS+=(--dev)
5150fi
5151
5152wasm-pack "${BUILD_ARGS[@]}"
5153"#
5154 .to_string()
5155}
5156
5157fn render_web_run_script(_project: &FissionProject) -> String {
5158 format!(
5159 r#"#!/usr/bin/env bash
5160set -euo pipefail
5161
5162SCRIPT_DIR=$(cd -- "$(dirname "${{BASH_SOURCE[0]}}")" && pwd)
5163PROJECT_DIR=$(cd -- "$SCRIPT_DIR/../.." && pwd)
5164HOST="${{FISSION_WEB_HOST:-127.0.0.1}}"
5165REQUESTED_PORT="${{FISSION_WEB_PORT:-8123}}"
5166PORT="$REQUESTED_PORT"
5167if [[ -z "${{FISSION_WEB_PORT:-}}" ]]; then
5168 PORT=$(python3 - "$HOST" "$REQUESTED_PORT" <<'PY'
5169import socket
5170import sys
5171
5172host = sys.argv[1]
5173start = int(sys.argv[2])
5174for port in range(start, start + 51):
5175 with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe:
5176 probe.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
5177 try:
5178 probe.bind((host, port))
5179 except OSError:
5180 continue
5181 print(port)
5182 raise SystemExit(0)
5183raise SystemExit(f"no free web port found from {{host}}:{{start}}")
5184PY
5185)
5186 if [[ "$PORT" != "$REQUESTED_PORT" ]]; then
5187 printf 'Port %s:%s is already in use; using %s:%s.\n' "$HOST" "$REQUESTED_PORT" "$HOST" "$PORT"
5188 fi
5189fi
5190URL="http://${{HOST}}:${{PORT}}/platforms/web/"
5191
5192"$SCRIPT_DIR/build-wasm.sh"
5193
5194printf 'Serving %s\n' "$URL"
5195printf 'Press Ctrl+C to stop the local server.\n'
5196if [[ "${{FISSION_WEB_OPEN:-0}}" == "1" ]]; then
5197 if command -v open >/dev/null 2>&1; then
5198 open "$URL"
5199 elif command -v xdg-open >/dev/null 2>&1; then
5200 xdg-open "$URL"
5201 elif command -v cmd.exe >/dev/null 2>&1; then
5202 cmd.exe /C start "$URL"
5203 else
5204 printf 'No browser opener found. Open %s manually.\n' "$URL"
5205 fi
5206fi
5207
5208cd "$PROJECT_DIR"
5209python3 -m http.server "$PORT" --bind "$HOST"
5210"#
5211 )
5212}
5213
5214fn render_web_test_script(_project: &FissionProject) -> String {
5215 r#"#!/usr/bin/env bash
5216set -euo pipefail
5217
5218SCRIPT_DIR=$(cd -- "$(dirname "${BASH_SOURCE[0]}")" && pwd)
5219PROJECT_DIR=$(cd -- "$SCRIPT_DIR/../.." && pwd)
5220HOST="${FISSION_WEB_HOST:-127.0.0.1}"
5221REQUESTED_PORT="${FISSION_WEB_PORT:-8123}"
5222PORT="$REQUESTED_PORT"
5223if [[ -z "${FISSION_WEB_PORT:-}" ]]; then
5224 PORT=$(python3 - "$HOST" "$REQUESTED_PORT" <<'PY'
5225import socket
5226import sys
5227
5228host = sys.argv[1]
5229start = int(sys.argv[2])
5230for port in range(start, start + 51):
5231 with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe:
5232 probe.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
5233 try:
5234 probe.bind((host, port))
5235 except OSError:
5236 continue
5237 print(port)
5238 raise SystemExit(0)
5239raise SystemExit(f"no free web port found from {host}:{start}")
5240PY
5241)
5242 if [[ "$PORT" != "$REQUESTED_PORT" ]]; then
5243 printf 'Port %s:%s is already in use; using %s:%s.\n' "$HOST" "$REQUESTED_PORT" "$HOST" "$PORT"
5244 fi
5245fi
5246REQUESTED_CDP_PORT="${FISSION_WEB_CDP_PORT:-9222}"
5247CDP_PORT="$REQUESTED_CDP_PORT"
5248if [[ -z "${FISSION_WEB_CDP_PORT:-}" ]]; then
5249 CDP_PORT=$(python3 - "127.0.0.1" "$REQUESTED_CDP_PORT" <<'PY'
5250import socket
5251import sys
5252
5253host = sys.argv[1]
5254start = int(sys.argv[2])
5255for port in range(start, start + 51):
5256 with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe:
5257 probe.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
5258 try:
5259 probe.bind((host, port))
5260 except OSError:
5261 continue
5262 print(port)
5263 raise SystemExit(0)
5264raise SystemExit(f"no free CDP port found from {host}:{start}")
5265PY
5266)
5267 if [[ "$CDP_PORT" != "$REQUESTED_CDP_PORT" ]]; then
5268 printf 'CDP port 127.0.0.1:%s is already in use; using 127.0.0.1:%s.\n' "$REQUESTED_CDP_PORT" "$CDP_PORT"
5269 fi
5270fi
5271URL="http://${HOST}:${PORT}/platforms/web/"
5272PROFILE_DIR="$SCRIPT_DIR/build/chrome-profile"
5273
5274require_node_websocket() {
5275 if ! command -v node >/dev/null 2>&1; then
5276 printf 'Node.js was not found. Install Node 22+ so the generated browser smoke test can inspect Chrome CDP console/runtime errors.\n' >&2
5277 exit 1
5278 fi
5279 if ! node -e 'process.exit(typeof WebSocket === "function" ? 0 : 1)' >/dev/null 2>&1; then
5280 printf 'Node.js is available but does not expose the built-in WebSocket client. Install Node 22+ for Chrome CDP smoke tests.\n' >&2
5281 exit 1
5282 fi
5283}
5284
5285detect_chrome() {
5286 if [[ -n "${FISSION_CHROME:-}" && -x "$FISSION_CHROME" ]]; then
5287 printf '%s\n' "$FISSION_CHROME"
5288 return
5289 fi
5290 local candidate
5291 for candidate in \
5292 "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" \
5293 "/Applications/Chromium.app/Contents/MacOS/Chromium" \
5294 "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge"; do
5295 if [[ -x "$candidate" ]]; then
5296 printf '%s\n' "$candidate"
5297 return
5298 fi
5299 done
5300 for candidate in google-chrome chromium chromium-browser chrome; do
5301 if command -v "$candidate" >/dev/null 2>&1; then
5302 command -v "$candidate"
5303 return
5304 fi
5305 done
5306 return 1
5307}
5308
5309require_node_websocket
5310"$SCRIPT_DIR/build-wasm.sh"
5311
5312mkdir -p "$SCRIPT_DIR/build"
5313cd "$PROJECT_DIR"
5314python3 -m http.server "$PORT" --bind "$HOST" >"$SCRIPT_DIR/build/web-server.log" 2>&1 &
5315SERVER_PID=$!
5316
5317cleanup() {
5318 if [[ -n "${CHROME_PID:-}" ]]; then
5319 kill "$CHROME_PID" >/dev/null 2>&1 || true
5320 fi
5321 kill "$SERVER_PID" >/dev/null 2>&1 || true
5322}
5323trap cleanup EXIT
5324
5325printf 'Running transient web smoke test at %s\n' "$URL"
5326printf 'The local server is stopped automatically when this script exits.\n'
5327
5328python3 - <<'PY' "$URL"
5329import sys
5330import time
5331import urllib.request
5332
5333url = sys.argv[1]
5334deadline = time.time() + 30
5335last_error = None
5336while time.time() < deadline:
5337 try:
5338 with urllib.request.urlopen(url, timeout=1) as response:
5339 if response.status == 200:
5340 raise SystemExit(0)
5341 except Exception as error:
5342 last_error = error
5343 time.sleep(0.5)
5344raise SystemExit(f"web server did not serve {url}: {last_error}")
5345PY
5346
5347CHROME=$(detect_chrome) || {
5348 printf 'Chrome/Chromium was not found. Set FISSION_CHROME=/path/to/chrome or run `fission doctor web --project-dir .`.\n' >&2
5349 exit 1
5350}
5351
5352rm -rf "$PROFILE_DIR"
5353"$CHROME" \
5354 --headless=new \
5355 --enable-unsafe-webgpu \
5356 --no-first-run \
5357 --no-default-browser-check \
5358 --remote-debugging-port="$CDP_PORT" \
5359 --user-data-dir="$PROFILE_DIR" \
5360 "$URL" >"$SCRIPT_DIR/build/chrome.log" 2>&1 &
5361CHROME_PID=$!
5362
5363CDP_PORT="$CDP_PORT" FISSION_WEB_URL="$URL" node <<'NODE'
5364const cdpPort = process.env.CDP_PORT;
5365const expectedUrl = process.env.FISSION_WEB_URL;
5366const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
5367
5368async function waitForTarget() {
5369 const deadline = Date.now() + 60_000;
5370 let lastError = null;
5371 while (Date.now() < deadline) {
5372 try {
5373 const response = await fetch(`http://127.0.0.1:${cdpPort}/json/list`);
5374 const targets = await response.json();
5375 const target = targets.find((entry) => entry.type === 'page' && entry.url.startsWith(expectedUrl));
5376 if (target?.webSocketDebuggerUrl) {
5377 return target.webSocketDebuggerUrl;
5378 }
5379 } catch (error) {
5380 lastError = error;
5381 }
5382 await sleep(250);
5383 }
5384 throw new Error(`Chrome CDP target did not become ready for ${expectedUrl}: ${lastError?.message ?? lastError}`);
5385}
5386
5387class CdpClient {
5388 constructor(url) {
5389 this.url = url;
5390 this.ws = null;
5391 this.nextId = 1;
5392 this.pending = new Map();
5393 this.errors = [];
5394 }
5395
5396 async open() {
5397 await new Promise((resolve, reject) => {
5398 const ws = new WebSocket(this.url);
5399 this.ws = ws;
5400 ws.addEventListener('open', resolve, { once: true });
5401 ws.addEventListener('error', (event) => reject(new Error(`CDP websocket error: ${event.message ?? 'unknown error'}`)), { once: true });
5402 ws.addEventListener('message', (event) => this.onMessage(event.data));
5403 ws.addEventListener('close', () => {
5404 for (const { reject: rejectPending } of this.pending.values()) {
5405 rejectPending(new Error('CDP websocket closed'));
5406 }
5407 this.pending.clear();
5408 });
5409 });
5410 }
5411
5412 send(method, params = {}) {
5413 const id = this.nextId++;
5414 const message = { id, method, params };
5415 return new Promise((resolve, reject) => {
5416 const timeout = setTimeout(() => {
5417 this.pending.delete(id);
5418 reject(new Error(`CDP command timed out: ${method}`));
5419 }, 10_000);
5420 this.pending.set(id, { resolve, reject, timeout, method });
5421 this.ws.send(JSON.stringify(message));
5422 });
5423 }
5424
5425 onMessage(raw) {
5426 const message = JSON.parse(raw);
5427 if (message.id) {
5428 const pending = this.pending.get(message.id);
5429 if (!pending) return;
5430 clearTimeout(pending.timeout);
5431 this.pending.delete(message.id);
5432 if (message.error) {
5433 pending.reject(new Error(`${pending.method}: ${message.error.message}`));
5434 } else {
5435 pending.resolve(message.result ?? {});
5436 }
5437 return;
5438 }
5439
5440 if (message.method === 'Runtime.exceptionThrown') {
5441 this.errors.push(formatException(message.params?.exceptionDetails));
5442 } else if (message.method === 'Runtime.consoleAPICalled') {
5443 const type = message.params?.type;
5444 if (type === 'error' || type === 'assert') {
5445 this.errors.push(`console.${type}: ${(message.params?.args ?? []).map(formatRemoteObject).join(' ')}`);
5446 }
5447 } else if (message.method === 'Log.entryAdded') {
5448 const entry = message.params?.entry;
5449 if (entry?.level === 'error') {
5450 if ((entry.url ?? '').endsWith('/__fission/renderer')) {
5451 return;
5452 }
5453 this.errors.push(`browser log error: ${entry.text}${entry.url ? ` (${entry.url}:${entry.lineNumber ?? 0})` : ''}`);
5454 }
5455 }
5456 }
5457
5458 close() {
5459 this.ws?.close();
5460 }
5461}
5462
5463function formatRemoteObject(value) {
5464 if (!value) return '<missing>';
5465 if (Object.prototype.hasOwnProperty.call(value, 'value')) return JSON.stringify(value.value);
5466 return value.description ?? value.unserializableValue ?? value.type ?? '<unknown>';
5467}
5468
5469function formatException(details) {
5470 if (!details) return 'runtime exception: <missing details>';
5471 const exception = details.exception?.description ?? details.exception?.value ?? details.text ?? 'unknown exception';
5472 const location = details.url ? ` at ${details.url}:${details.lineNumber ?? 0}:${details.columnNumber ?? 0}` : '';
5473 return `runtime exception: ${exception}${location}`;
5474}
5475
5476function errorBlock(errors) {
5477 return errors.slice(0, 10).map((error, index) => `${index + 1}. ${error}`).join('\n');
5478}
5479
5480async function readRuntimeStatus(client) {
5481 const expression = `(() => {
5482 const canvas = document.querySelector('canvas');
5483 if (!canvas) return { ready: false, reason: 'no canvas element' };
5484 const rect = canvas.getBoundingClientRect();
5485 const perf = globalThis.__FISSION_PERF ?? { frames: [], inputLatencies: [] };
5486 return {
5487 ready: rect.width > 0 && rect.height > 0,
5488 width: Math.round(rect.width),
5489 height: Math.round(rect.height),
5490 gpu: typeof navigator.gpu !== 'undefined',
5491 renderer: globalThis.__FISSION_RENDERER_INFO ?? null,
5492 frames: Array.isArray(perf.frames) ? perf.frames.slice(-120) : [],
5493 inputLatencies: Array.isArray(perf.inputLatencies) ? perf.inputLatencies.slice(-30) : [],
5494 title: document.title,
5495 };
5496 })()`;
5497 const result = await client.send('Runtime.evaluate', { expression, returnByValue: true });
5498 if (result.exceptionDetails) {
5499 throw new Error(formatException(result.exceptionDetails));
5500 }
5501 return result.result?.value ?? { ready: false, reason: 'evaluation returned no value' };
5502}
5503
5504function average(values) {
5505 if (!values.length) return 0;
5506 return values.reduce((sum, value) => sum + value, 0) / values.length;
5507}
5508
5509async function clickCanvasCenter(client, status) {
5510 const x = Math.max(1, Math.floor(status.width / 2));
5511 const y = Math.max(1, Math.floor(status.height / 2));
5512 await client.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x, y, button: 'none' });
5513 await client.send('Input.dispatchMouseEvent', { type: 'mousePressed', x, y, button: 'left', clickCount: 1 });
5514 await client.send('Input.dispatchMouseEvent', { type: 'mouseReleased', x, y, button: 'left', clickCount: 1 });
5515}
5516
5517async function main() {
5518 const wsUrl = await waitForTarget();
5519 const client = new CdpClient(wsUrl);
5520 await client.open();
5521 try {
5522 await Promise.all([
5523 client.send('Runtime.enable'),
5524 client.send('Log.enable'),
5525 client.send('Page.enable'),
5526 ]);
5527
5528 const deadline = Date.now() + 60_000;
5529 let readySince = null;
5530 let lastStatus = null;
5531 while (Date.now() < deadline) {
5532 if (client.errors.length > 0) {
5533 throw new Error(`browser reported runtime/console errors:\n${errorBlock(client.errors)}`);
5534 }
5535 lastStatus = await readRuntimeStatus(client);
5536 if (lastStatus.ready && lastStatus.renderer) {
5537 readySince ??= Date.now();
5538 if (Date.now() - readySince >= 1_500) {
5539 const renderer = lastStatus.renderer.active;
5540 if (lastStatus.gpu && renderer === 'canvas2d-software' && !lastStatus.renderer.fallback_reason && process.env.FISSION_ALLOW_WEBGPU_FALLBACK !== '1') {
5541 throw new Error(`WebGPU is exposed but Fission used canvas2d-software without a fallback reason: ${JSON.stringify(lastStatus.renderer)}`);
5542 }
5543 await clickCanvasCenter(client, lastStatus);
5544 const inputDeadline = Date.now() + 10_000;
5545 while (Date.now() < inputDeadline) {
5546 lastStatus = await readRuntimeStatus(client);
5547 if ((lastStatus.inputLatencies ?? []).length > 0) break;
5548 await sleep(100);
5549 }
5550 const frames = lastStatus.frames ?? [];
5551 const latencies = lastStatus.inputLatencies ?? [];
5552 if (frames.length < 2) {
5553 throw new Error(`web perf smoke did not capture enough frame samples: ${JSON.stringify(lastStatus)}`);
5554 }
5555 if (latencies.length < 1) {
5556 throw new Error(`web perf smoke did not capture input latency samples: ${JSON.stringify(lastStatus)}`);
5557 }
5558 const avgFrame = average(frames.slice(-30));
5559 const avgLatency = average(latencies.slice(-10));
5560 if (avgFrame > Number(process.env.FISSION_WEB_MAX_AVG_FRAME_MS ?? 80)) {
5561 throw new Error(`web average frame time ${avgFrame.toFixed(2)}ms exceeded smoke threshold`);
5562 }
5563 if (avgLatency > Number(process.env.FISSION_WEB_MAX_INPUT_LATENCY_MS ?? 180)) {
5564 throw new Error(`web input latency ${avgLatency.toFixed(2)}ms exceeded smoke threshold`);
5565 }
5566 console.log(`Web app renderer ${renderer}; canvas ${lastStatus.width}x${lastStatus.height}; avg frame ${avgFrame.toFixed(2)}ms; avg input latency ${avgLatency.toFixed(2)}ms.`);
5567 return;
5568 }
5569 } else {
5570 readySince = null;
5571 }
5572 await sleep(250);
5573 }
5574 throw new Error(`web app did not render a non-empty canvas with renderer diagnostics. Last state: ${JSON.stringify(lastStatus)}`);
5575 } finally {
5576 client.close();
5577 }
5578}
5579
5580main().catch((error) => {
5581 console.error(error.stack ?? error.message ?? String(error));
5582 process.exit(1);
5583});
5584NODE
5585"#
5586 .to_string()
5587}
5588fn render_app_main(package_name: &str) -> String {
5589 let lib_name = package_name.replace('-', "_");
5590 format!(
5591 r#"#[cfg(target_os = "android")]
5592fn main() {{}}
5593
5594#[cfg(target_arch = "wasm32")]
5595fn main() {{}}
5596
5597#[cfg(target_os = "ios")]
5598fn main() -> anyhow::Result<()> {{
5599 {lib_name}::run_mobile()
5600}}
5601
5602#[cfg(not(any(target_arch = "wasm32", target_os = "ios", target_os = "android")))]
5603fn main() -> anyhow::Result<()> {{
5604 {lib_name}::run_desktop()
5605}}
5606"#
5607 )
5608}
5609
5610const APP_LIB: &str = r#"pub mod app;
5611
5612use crate::app::CounterApp;
5613use fission::prelude::*;
5614
5615#[cfg(target_os = "android")]
5616const ANDROID_TEST_CONTROL_PORT: u16 = 48761;
5617
5618#[cfg(any(target_os = "android", target_os = "ios"))]
5619fn mobile_app() -> MobileApp<crate::app::CounterState, CounterApp> {
5620 let app = MobileApp::<crate::app::CounterState, _>::new(CounterApp).with_title("Fission App");
5621 #[cfg(target_os = "android")]
5622 let app = app.with_test_control_port(ANDROID_TEST_CONTROL_PORT);
5623 app
5624}
5625
5626#[cfg(target_arch = "wasm32")]
5627fn web_app() -> WebApp<crate::app::CounterState, CounterApp> {
5628 WebApp::<crate::app::CounterState, _>::new(CounterApp).with_title("Fission App")
5629}
5630
5631#[cfg(not(any(target_arch = "wasm32", target_os = "android", target_os = "ios")))]
5632pub fn run_desktop() -> anyhow::Result<()> {
5633 DesktopApp::<crate::app::CounterState, _>::new(CounterApp).run()
5634}
5635
5636#[cfg(any(target_os = "android", target_os = "ios"))]
5637pub fn run_mobile() -> anyhow::Result<()> {
5638 mobile_app().run()
5639}
5640
5641#[cfg(target_os = "android")]
5642#[no_mangle]
5643fn android_main(app_handle: AndroidApp) {
5644 let _ = mobile_app().run_with_android_app(app_handle);
5645}
5646
5647#[cfg(target_arch = "wasm32")]
5648#[wasm_bindgen::prelude::wasm_bindgen(start)]
5649pub fn run_web() -> Result<(), wasm_bindgen::JsValue> {
5650 console_error_panic_hook::set_once();
5651 web_app()
5652 .run()
5653 .map_err(|error| wasm_bindgen::JsValue::from_str(&error.to_string()))
5654}
5655"#;
5656
5657const APP_RS: &str = r#"use fission::prelude::*;
5658
5659#[derive(Default, Debug, Clone, PartialEq)]
5660pub struct CounterState {
5661 pub count: i32,
5662}
5663
5664impl GlobalState for CounterState {}
5665
5666#[fission_reducer(Increment)]
5667fn on_increment(state: &mut CounterState) {
5668 state.count += 1;
5669}
5670
5671#[derive(Clone)]
5672pub struct CounterApp;
5673
5674impl From<CounterApp> for Widget {
5675 fn from(component: CounterApp) -> Self {
5676 let (ctx, view) = fission::build::current::<CounterState>();
5677 let increment = with_reducer!(ctx, Increment, on_increment);
5678
5679 Column {
5680 gap: Some(16.0),
5681 children: vec![
5682 Text::new(format!("Count: {}", view.state().count)).size(28.0).into(),
5683 Button {
5684 on_press: Some(increment),
5685 child: Some(Text::new("Increment").into()),
5686 ..Default::default()
5687 }
5688 .into(),
5689 ],
5690 ..Default::default()
5691 }
5692 .into()
5693
5694 }
5695}
5696"#;
5697
5698#[cfg(test)]
5699mod tests {
5700 use super::*;
5701
5702 fn unique_dir(name: &str) -> PathBuf {
5703 let dir = std::env::temp_dir().join(format!(
5704 "fission-command-core-{name}-{}",
5705 std::process::id()
5706 ));
5707 fs::remove_dir_all(&dir).ok();
5708 fs::create_dir_all(&dir).unwrap();
5709 dir
5710 }
5711
5712 #[test]
5713 fn project_assets_stage_nested_resources_and_replace_stale_output() {
5714 let dir = unique_dir("stage-project-assets");
5715 let project = dir.join("project");
5716 let destination = dir.join("destination");
5717 fs::create_dir_all(project.join("assets/intelligence")).unwrap();
5718 fs::create_dir_all(destination.join("assets/stale")).unwrap();
5719 fs::write(
5720 project.join("assets/intelligence/base.pdb.zst"),
5721 b"signed base",
5722 )
5723 .unwrap();
5724 fs::write(destination.join("assets/stale/old"), b"stale").unwrap();
5725
5726 let staged = stage_project_assets(&project, &destination)
5727 .unwrap()
5728 .expect("assets directory should be staged");
5729
5730 assert_eq!(staged, destination.join("assets"));
5731 assert_eq!(
5732 fs::read(staged.join("intelligence/base.pdb.zst")).unwrap(),
5733 b"signed base"
5734 );
5735 assert!(!staged.join("stale/old").exists());
5736 fs::remove_dir_all(dir).unwrap();
5737 }
5738
5739 #[test]
5740 fn project_assets_are_optional_but_must_be_a_directory_when_present() {
5741 let dir = unique_dir("stage-project-assets-validation");
5742 let project = dir.join("project");
5743 let destination = dir.join("destination");
5744 fs::create_dir_all(&project).unwrap();
5745
5746 assert_eq!(stage_project_assets(&project, &destination).unwrap(), None);
5747
5748 fs::write(project.join("assets"), b"not a directory").unwrap();
5749 let error = stage_project_assets(&project, &destination).unwrap_err();
5750 assert!(error.to_string().contains("project assets path"));
5751 fs::remove_dir_all(dir).unwrap();
5752 }
5753
5754 #[test]
5755 fn windows_release_sync_updates_appx_identity() {
5756 let dir = unique_dir("windows-release-sync");
5757 let windows_dir = dir.join("platforms/windows");
5758 fs::create_dir_all(&windows_dir).unwrap();
5759 fs::write(
5760 dir.join("fission.toml"),
5761 r#"[package.windows]
5762identity_name = "Example.App"
5763publisher = "CN=Example & Co"
5764"#,
5765 )
5766 .unwrap();
5767 let manifest = windows_dir.join("Package.appxmanifest");
5768 fs::write(
5769 &manifest,
5770 r#"<Package>
5771 <Identity Name="Old.App" Publisher="CN=Old" Version="0.0.0.0" />
5772</Package>
5773"#,
5774 )
5775 .unwrap();
5776
5777 sync_release_platform_config(
5778 &dir,
5779 Target::Windows,
5780 &ReleaseVersionConfig {
5781 version: Some("1.2.3".to_string()),
5782 build: Some(42),
5783 },
5784 )
5785 .unwrap();
5786
5787 let updated = fs::read_to_string(&manifest).unwrap();
5788 assert!(updated.contains(r#"Name="Example.App""#));
5789 assert!(updated.contains(r#"Publisher="CN=Example & Co""#));
5790 assert!(updated.contains(r#"Version="1.2.3.42""#));
5791 }
5792
5793 #[test]
5794 fn windows_release_sync_rejects_invalid_version() {
5795 let dir = unique_dir("windows-release-invalid-version");
5796 let windows_dir = dir.join("platforms/windows");
5797 fs::create_dir_all(&windows_dir).unwrap();
5798 fs::write(
5799 windows_dir.join("Package.appxmanifest"),
5800 r#"<Package><Identity Version="0.0.0.0" /></Package>"#,
5801 )
5802 .unwrap();
5803
5804 let error = sync_release_platform_config(
5805 &dir,
5806 Target::Windows,
5807 &ReleaseVersionConfig {
5808 version: Some("1.2.beta".to_string()),
5809 build: Some(1),
5810 },
5811 )
5812 .unwrap_err();
5813
5814 assert!(error
5815 .to_string()
5816 .contains("Windows package version `1.2.beta` must be numeric"));
5817 }
5818
5819 #[test]
5820 fn macos_release_sync_updates_info_plist_version() {
5821 let dir = unique_dir("macos-release-sync");
5822 let macos_dir = dir.join("platforms/macos");
5823 fs::create_dir_all(&macos_dir).unwrap();
5824 let plist = macos_dir.join("Info.plist");
5825 fs::write(
5826 &plist,
5827 r#"<?xml version="1.0" encoding="UTF-8"?>
5828<plist version="1.0">
5829<dict>
5830 <key>CFBundleShortVersionString</key>
5831 <string>0.0.1</string>
5832 <key>CFBundleVersion</key>
5833 <string>1</string>
5834</dict>
5835</plist>
5836"#,
5837 )
5838 .unwrap();
5839
5840 sync_release_platform_config(
5841 &dir,
5842 Target::Macos,
5843 &ReleaseVersionConfig {
5844 version: Some("1.2.3".to_string()),
5845 build: Some(42),
5846 },
5847 )
5848 .unwrap();
5849
5850 let updated = fs::read_to_string(&plist).unwrap();
5851 assert!(updated.contains("<string>1.2.3</string>"));
5852 assert!(updated.contains("<string>42</string>"));
5853 }
5854
5855 #[test]
5856 fn project_config_includes_release_package_defaults() {
5857 let dir = unique_dir("release-package-defaults");
5858 let project = FissionProject {
5859 app: AppConfig {
5860 name: "release-demo".to_string(),
5861 app_id: "com.example.release_demo".to_string(),
5862 splash: None,
5863 },
5864 targets: BTreeSet::from([Target::Android, Target::Ios, Target::Macos, Target::Windows]),
5865 capabilities: BTreeSet::new(),
5866 native: NativeConfig::default(),
5867 };
5868
5869 write_project_config(&dir, &project).unwrap();
5870
5871 let text = fs::read_to_string(dir.join("fission.toml")).unwrap();
5872 assert!(text.contains("version = \"0.1.0\""));
5873 assert!(text.contains("build = 1"));
5874 assert!(text.contains("[package.android]"));
5875 assert!(text.contains("package_name = \"com.example.release_demo\""));
5876 assert!(text.contains("keystore_env = \"ANDROID_KEYSTORE\""));
5877 assert!(text.contains("[package.ios]"));
5878 assert!(text.contains("bundle_id = \"com.example.release_demo\""));
5879 assert!(text.contains("[package.macos]"));
5880 assert!(text.contains("marketing_version = \"0.1.0\""));
5881 assert!(text.contains("build_number = \"1\""));
5882 assert!(text.contains("[package.windows]"));
5883 assert!(text.contains("identity_name = \"com.example.release.demo\""));
5884 assert!(text.contains("certificate_base64_env = \"WINDOWS_CERTIFICATE_BASE64\""));
5885 assert!(text.contains("[distribution.play_store]"));
5886 assert!(text.contains(
5887 "service_account_json_base64_env = \"PLAY_STORE_SERVICE_ACCOUNT_JSON_BASE64\""
5888 ));
5889 assert!(text.contains("[distribution.app_store]"));
5890 assert!(text.contains("api_key_base64_env = \"APP_STORE_CONNECT_API_KEY_BASE64\""));
5891 assert!(text.contains("[distribution.microsoft_store]"));
5892 assert!(text.contains("client_secret_env = \"MICROSOFT_STORE_CLIENT_SECRET\""));
5893 }
5894
5895 #[test]
5896 fn target_aliases_parse_legacy_names_and_write_canonical_names() {
5897 assert_eq!(
5898 <Target as clap::ValueEnum>::from_str("site", true).unwrap(),
5899 Target::Site
5900 );
5901 assert_eq!(
5902 <Target as clap::ValueEnum>::from_str("server", true).unwrap(),
5903 Target::Server
5904 );
5905
5906 let dir = unique_dir("target-aliases");
5907 fs::write(
5908 dir.join("fission.toml"),
5909 r#"targets = ["site", "server"]
5910
5911[app]
5912name = "Alias Demo"
5913app_id = "com.example.alias"
5914"#,
5915 )
5916 .unwrap();
5917
5918 let project = read_project_config(&dir).unwrap();
5919 assert!(project.targets.contains(&Target::Site));
5920 assert!(project.targets.contains(&Target::Server));
5921
5922 write_project_config(&dir, &project).unwrap();
5923 let updated = fs::read_to_string(dir.join("fission.toml")).unwrap();
5924 assert!(updated.contains("\"static-site\""));
5925 assert!(updated.contains("\"ssr\""));
5926 assert!(!updated.contains("\"site\""));
5927 assert!(!updated.contains("\"server\""));
5928 }
5929
5930 #[test]
5931 fn static_site_uses_the_scaffold_path_created_by_add_target() {
5932 assert_eq!(
5933 Target::Site.scaffold_relative_path(),
5934 "platforms/site/README.md"
5935 );
5936 }
5937
5938 #[test]
5939 fn app_id_accepts_short_id_alias() {
5940 let dir = unique_dir("app-id-alias");
5941 fs::write(
5942 dir.join("fission.toml"),
5943 r#"targets = ["android"]
5944
5945[app]
5946name = "Alias Demo"
5947id = "com.example.alias"
5948"#,
5949 )
5950 .unwrap();
5951
5952 let project = read_project_config(&dir).unwrap();
5953 assert_eq!(project.app.app_id, "com.example.alias");
5954 }
5955}