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