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