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