1use anyhow::{bail, Context, Result};
2use clap::ValueEnum;
3use serde::{Deserialize, Serialize};
4use std::collections::BTreeSet;
5use std::fs;
6use std::path::{Path, PathBuf};
7
8const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION");
9const DEFAULT_APP_ICON_PNG: &[u8] = include_bytes!("../assets/fission_logo.png");
10
11#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, ValueEnum, Serialize, Deserialize)]
12#[serde(rename_all = "kebab-case")]
13pub enum Target {
14 Android,
15 Ios,
16 Linux,
17 Macos,
18 Site,
19 Web,
20 Windows,
21}
22
23#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, ValueEnum, Serialize, Deserialize)]
24#[serde(rename_all = "kebab-case")]
25pub enum PlatformCapability {
26 BarcodeScanner,
27 Biometric,
28 Bluetooth,
29 Camera,
30 Geolocation,
31 Haptics,
32 Microphone,
33 Nfc,
34 Passkeys,
35 VolumeControl,
36 Wifi,
37}
38
39impl PlatformCapability {
40 pub fn as_str(self) -> &'static str {
41 match self {
42 Self::BarcodeScanner => "barcode-scanner",
43 Self::Biometric => "biometric",
44 Self::Bluetooth => "bluetooth",
45 Self::Camera => "camera",
46 Self::Geolocation => "geolocation",
47 Self::Haptics => "haptics",
48 Self::Microphone => "microphone",
49 Self::Nfc => "nfc",
50 Self::Passkeys => "passkeys",
51 Self::VolumeControl => "volume-control",
52 Self::Wifi => "wifi",
53 }
54 }
55}
56
57impl Target {
58 pub fn as_str(self) -> &'static str {
59 match self {
60 Self::Android => "android",
61 Self::Ios => "ios",
62 Self::Linux => "linux",
63 Self::Macos => "macos",
64 Self::Site => "site",
65 Self::Web => "web",
66 Self::Windows => "windows",
67 }
68 }
69
70 pub fn scaffold_relative_path(self) -> &'static str {
71 match self {
72 Self::Android => "platforms/android/README.md",
73 Self::Ios => "platforms/ios/README.md",
74 Self::Linux => "platforms/linux/README.md",
75 Self::Macos => "platforms/macos/README.md",
76 Self::Site => "platforms/site/README.md",
77 Self::Web => "platforms/web/README.md",
78 Self::Windows => "platforms/windows/README.md",
79 }
80 }
81}
82
83#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
84pub enum DistributionProvider {
85 #[value(name = "app-store")]
86 AppStore,
87 #[value(name = "github-pages")]
88 GithubPages,
89 #[value(name = "github-releases")]
90 GithubReleases,
91 #[value(name = "cloudflare-pages")]
92 CloudflarePages,
93 Dropbox,
94 #[value(name = "google-drive")]
95 GoogleDrive,
96 #[value(name = "microsoft-store")]
97 MicrosoftStore,
98 Netlify,
99 #[value(name = "onedrive")]
100 OneDrive,
101 #[value(name = "play-store")]
102 PlayStore,
103 S3,
104}
105
106impl DistributionProvider {
107 pub fn as_str(self) -> &'static str {
108 match self {
109 Self::AppStore => "app-store",
110 Self::GithubPages => "github-pages",
111 Self::GithubReleases => "github-releases",
112 Self::CloudflarePages => "cloudflare-pages",
113 Self::Dropbox => "dropbox",
114 Self::GoogleDrive => "google-drive",
115 Self::MicrosoftStore => "microsoft-store",
116 Self::Netlify => "netlify",
117 Self::OneDrive => "onedrive",
118 Self::PlayStore => "play-store",
119 Self::S3 => "s3",
120 }
121 }
122}
123
124#[derive(Debug, Serialize, Deserialize)]
125pub struct FissionProject {
126 pub app: AppConfig,
127 pub targets: BTreeSet<Target>,
128 #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
129 pub capabilities: BTreeSet<PlatformCapability>,
130}
131
132#[derive(Debug, Serialize, Deserialize)]
133pub struct AppConfig {
134 pub name: String,
135 pub app_id: String,
136}
137
138#[derive(Debug, Deserialize)]
139struct CargoManifest {
140 package: Option<CargoPackage>,
141}
142
143#[derive(Debug, Deserialize)]
144struct CargoPackage {
145 pub name: String,
146}
147
148#[derive(Clone, Copy, Debug, Eq, PartialEq)]
149enum WritePolicy {
150 Overwrite,
151 PreserveExisting,
152}
153
154pub fn init_project(
155 root: &Path,
156 name: Option<String>,
157 app_id: Option<String>,
158 local_path: Option<PathBuf>,
159) -> Result<()> {
160 let existing_project = root.exists() && root.read_dir()?.next().is_some();
161 fs::create_dir_all(root.join("src"))?;
162
163 let write_policy = if existing_project {
164 WritePolicy::PreserveExisting
165 } else {
166 WritePolicy::Overwrite
167 };
168 let project = initial_project_config(root, name, app_id)?;
169
170 write_file_with_policy(
171 &root.join("Cargo.toml"),
172 &render_cargo_toml(&project, local_path.as_deref()),
173 write_policy,
174 )?;
175 write_file_with_policy(
176 &root.join("src/main.rs"),
177 &render_app_main(project.app.name.as_str()),
178 write_policy,
179 )?;
180 write_file_with_policy(&root.join("src/lib.rs"), APP_LIB, write_policy)?;
181 write_file_with_policy(&root.join("src/app.rs"), APP_RS, write_policy)?;
182 write_binary_file_with_policy(
183 &root.join("assets/app-icon.png"),
184 DEFAULT_APP_ICON_PNG,
185 write_policy,
186 )?;
187 write_file_with_policy(
188 &root.join("README.md"),
189 &render_project_readme(&project),
190 write_policy,
191 )?;
192 write_file_with_policy(
193 &root.join(".gitignore"),
194 "target/\nplatforms/*/build/\n",
195 write_policy,
196 )?;
197 write_project_config(root, &project)?;
198
199 let targets = project.targets.iter().copied().collect::<Vec<_>>();
200 for target in targets {
201 scaffold_target_with_policy(root, &project, target, write_policy)?;
202 }
203 apply_platform_capability_config(root, &project)?;
204
205 Ok(())
206}
207
208fn initial_project_config(
209 root: &Path,
210 name: Option<String>,
211 app_id: Option<String>,
212) -> Result<FissionProject> {
213 let existing = if root.join("fission.toml").exists() {
214 Some(read_project_config(root)?)
215 } else {
216 None
217 };
218 let cargo_name = cargo_package_name(root);
219 if let (Some(requested), Some(cargo_name)) = (&name, &cargo_name) {
220 let requested = normalize_crate_name(requested);
221 let cargo_name = normalize_crate_name(cargo_name);
222 if requested != cargo_name {
223 bail!(
224 "refusing to set app name `{requested}` for existing Cargo package `{cargo_name}`; rename the package in Cargo.toml first or omit --name"
225 );
226 }
227 }
228 let project_name = cargo_name
229 .or(name)
230 .or_else(|| existing.as_ref().map(|project| project.app.name.clone()))
231 .unwrap_or_else(|| {
232 root.file_name()
233 .and_then(|value| value.to_str())
234 .unwrap_or("fission-app")
235 .to_string()
236 });
237 let normalized_name = normalize_crate_name(&project_name);
238
239 let mut targets = existing
240 .as_ref()
241 .map(|project| project.targets.clone())
242 .unwrap_or_default();
243 targets.extend(detect_project_targets(root));
244 if targets.is_empty() {
245 targets.extend([Target::Windows, Target::Macos, Target::Linux]);
246 }
247
248 Ok(FissionProject {
249 app: AppConfig {
250 name: normalized_name.clone(),
251 app_id: app_id
252 .or_else(|| existing.as_ref().map(|project| project.app.app_id.clone()))
253 .unwrap_or_else(|| format!("com.example.{}", normalized_name.replace('-', "_"))),
254 },
255 targets,
256 capabilities: existing
257 .as_ref()
258 .map(|project| project.capabilities.clone())
259 .unwrap_or_default(),
260 })
261}
262
263pub fn cargo_package_name(root: &Path) -> Option<String> {
264 let manifest = fs::read_to_string(root.join("Cargo.toml")).ok()?;
265 let manifest: CargoManifest = toml::from_str(&manifest).ok()?;
266 manifest.package.map(|package| package.name)
267}
268
269fn detect_project_targets(root: &Path) -> BTreeSet<Target> {
270 let mut targets = BTreeSet::new();
271 if root.join("src/main.rs").exists() || root.join("src/lib.rs").exists() {
272 targets.extend([Target::Windows, Target::Macos, Target::Linux]);
273 }
274 for (target, relative) in [
275 (Target::Android, "platforms/android"),
276 (Target::Ios, "platforms/ios"),
277 (Target::Linux, "platforms/linux"),
278 (Target::Macos, "platforms/macos"),
279 (Target::Site, "content"),
280 (Target::Web, "platforms/web"),
281 (Target::Windows, "platforms/windows"),
282 ] {
283 if root.join(relative).exists() {
284 targets.insert(target);
285 }
286 }
287 targets
288}
289
290pub fn add_targets(project_dir: &Path, targets: &[Target]) -> Result<()> {
291 if targets.is_empty() {
292 bail!("no targets provided");
293 }
294 let mut project = read_project_config(project_dir)?;
295 for target in targets {
296 let target_exists =
297 project.targets.contains(target) || target_scaffold_dir_exists(project_dir, *target);
298 project.targets.insert(*target);
299 let write_policy = if target_exists {
300 WritePolicy::PreserveExisting
301 } else {
302 WritePolicy::Overwrite
303 };
304 scaffold_target_with_policy(project_dir, &project, *target, write_policy)?;
305 }
306 apply_platform_capability_config(project_dir, &project)?;
307 write_project_config(project_dir, &project)?;
308 update_cargo_fission_features(project_dir, &project)?;
309 write_file_with_policy(
310 &project_dir.join("README.md"),
311 &render_project_readme(&project),
312 WritePolicy::PreserveExisting,
313 )?;
314 Ok(())
315}
316
317pub fn add_capabilities(project_dir: &Path, capabilities: &[PlatformCapability]) -> Result<()> {
318 if capabilities.is_empty() {
319 bail!("no capabilities provided");
320 }
321 let mut project = read_project_config(project_dir)?;
322 for capability in capabilities {
323 project.capabilities.insert(*capability);
324 }
325 write_project_config(project_dir, &project)?;
326 apply_platform_capability_config(project_dir, &project)?;
327 Ok(())
328}
329
330fn apply_platform_capability_config(root: &Path, project: &FissionProject) -> Result<()> {
331 if project.capabilities.is_empty() {
332 return Ok(());
333 }
334 if project.targets.contains(&Target::Android) {
335 apply_android_capability_config(root, project)?;
336 }
337 if project.targets.contains(&Target::Ios) {
338 apply_ios_capability_config(root, project)?;
339 }
340 Ok(())
341}
342
343fn apply_android_capability_config(root: &Path, project: &FissionProject) -> Result<()> {
344 let path = root.join("platforms/android/AndroidManifest.xml");
345 if !path.exists() {
346 return Ok(());
347 }
348 let existing =
349 fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))?;
350 let mut capabilities = String::new();
351 if project.capabilities.contains(&PlatformCapability::Nfc)
352 && !existing.contains("android.permission.NFC")
353 {
354 capabilities.push_str(&render_android_nfc_manifest_entries());
355 }
356 if project
357 .capabilities
358 .contains(&PlatformCapability::Biometric)
359 && !existing.contains("android.permission.USE_BIOMETRIC")
360 {
361 capabilities.push_str(&render_android_biometric_manifest_entries());
362 }
363 if project
364 .capabilities
365 .contains(&PlatformCapability::Bluetooth)
366 {
367 capabilities.push_str(&render_missing_android_bluetooth_manifest_entries(
368 &existing,
369 ));
370 }
371 if project
372 .capabilities
373 .contains(&PlatformCapability::BarcodeScanner)
374 && !project.capabilities.contains(&PlatformCapability::Camera)
375 && !existing.contains("android.permission.CAMERA")
376 {
377 capabilities.push_str(&render_android_barcode_camera_manifest_entries());
378 }
379 if project.capabilities.contains(&PlatformCapability::Camera) {
380 capabilities.push_str(&render_missing_android_camera_manifest_entries(&existing));
381 }
382 if project
383 .capabilities
384 .contains(&PlatformCapability::Geolocation)
385 && !existing.contains("android.permission.ACCESS_FINE_LOCATION")
386 {
387 capabilities.push_str(&render_android_geolocation_manifest_entries());
388 }
389 if project.capabilities.contains(&PlatformCapability::Haptics)
390 && !existing.contains("android.permission.VIBRATE")
391 {
392 capabilities.push_str(&render_android_haptics_manifest_entries());
393 }
394 if project
395 .capabilities
396 .contains(&PlatformCapability::Microphone)
397 && !existing.contains("android.permission.RECORD_AUDIO")
398 {
399 capabilities.push_str(&render_android_microphone_manifest_entries());
400 }
401 if project.capabilities.contains(&PlatformCapability::Wifi) {
402 capabilities.push_str(&render_missing_android_wifi_manifest_entries(&existing));
403 }
404 if project
405 .capabilities
406 .contains(&PlatformCapability::VolumeControl)
407 && !existing.contains("android.permission.MODIFY_AUDIO_SETTINGS")
408 {
409 capabilities.push_str(&render_android_volume_manifest_entries());
410 }
411 if capabilities.is_empty() {
412 return Ok(());
413 }
414 let marker = r#" <uses-permission android:name="android.permission.INTERNET" />"#;
415 let updated = if existing.contains(marker) {
416 existing.replacen(marker, &format!("{marker}\n{capabilities}"), 1)
417 } else {
418 existing.replacen("<uses-sdk", &format!("{capabilities}\n <uses-sdk"), 1)
419 };
420 fs::write(&path, updated).with_context(|| format!("failed to write {}", path.display()))
421}
422
423fn apply_ios_capability_config(root: &Path, project: &FissionProject) -> Result<()> {
424 let info_path = root.join("platforms/ios/Info.plist");
425 if info_path.exists() {
426 let existing = fs::read_to_string(&info_path)
427 .with_context(|| format!("failed to read {}", info_path.display()))?;
428 if project.capabilities.contains(&PlatformCapability::Nfc)
429 && !existing.contains("NFCReaderUsageDescription")
430 {
431 let entry = " <key>NFCReaderUsageDescription</key>\n <string>This app uses NFC to scan nearby tags when you request it.</string>\n";
432 let updated = existing.replacen("</dict>", &format!("{entry}</dict>"), 1);
433 fs::write(&info_path, updated)
434 .with_context(|| format!("failed to write {}", info_path.display()))?;
435 }
436 }
437
438 if project.capabilities.contains(&PlatformCapability::Nfc) {
439 let entitlements_path = root.join("platforms/ios/Entitlements.plist");
440 if entitlements_path.exists() {
441 let existing = fs::read_to_string(&entitlements_path)
442 .with_context(|| format!("failed to read {}", entitlements_path.display()))?;
443 if !existing.contains("com.apple.developer.nfc.readersession.formats") {
444 let entry = " <key>com.apple.developer.nfc.readersession.formats</key>\n <array>\n <string>NDEF</string>\n </array>\n";
445 let updated = existing.replacen("</dict>", &format!("{entry}</dict>"), 1);
446 fs::write(&entitlements_path, updated)
447 .with_context(|| format!("failed to write {}", entitlements_path.display()))?;
448 }
449 } else {
450 write_file_with_policy(
451 &entitlements_path,
452 IOS_NFC_ENTITLEMENTS_PLIST,
453 WritePolicy::PreserveExisting,
454 )?;
455 }
456 }
457 if project
458 .capabilities
459 .contains(&PlatformCapability::Biometric)
460 && info_path.exists()
461 {
462 let existing = fs::read_to_string(&info_path)
463 .with_context(|| format!("failed to read {}", info_path.display()))?;
464 if !existing.contains("NSFaceIDUsageDescription") {
465 let entry = " <key>NSFaceIDUsageDescription</key>\n <string>This app uses biometrics to authenticate you when you request it.</string>\n";
466 let updated = existing.replacen("</dict>", &format!("{entry}</dict>"), 1);
467 fs::write(&info_path, updated)
468 .with_context(|| format!("failed to write {}", info_path.display()))?;
469 }
470 }
471 if project
472 .capabilities
473 .contains(&PlatformCapability::Bluetooth)
474 && info_path.exists()
475 {
476 let existing = fs::read_to_string(&info_path)
477 .with_context(|| format!("failed to read {}", info_path.display()))?;
478 if !existing.contains("NSBluetoothAlwaysUsageDescription") {
479 let entry = " <key>NSBluetoothAlwaysUsageDescription</key>\n <string>This app uses Bluetooth when you request nearby-device features.</string>\n";
480 let updated = existing.replacen("</dict>", &format!("{entry}</dict>"), 1);
481 fs::write(&info_path, updated)
482 .with_context(|| format!("failed to write {}", info_path.display()))?;
483 }
484 }
485 if project
486 .capabilities
487 .contains(&PlatformCapability::BarcodeScanner)
488 && info_path.exists()
489 {
490 let existing = fs::read_to_string(&info_path)
491 .with_context(|| format!("failed to read {}", info_path.display()))?;
492 if !existing.contains("NSCameraUsageDescription") {
493 let entry = " <key>NSCameraUsageDescription</key>\n <string>This app uses the camera to scan barcodes when you request it.</string>\n";
494 let updated = existing.replacen("</dict>", &format!("{entry}</dict>"), 1);
495 fs::write(&info_path, updated)
496 .with_context(|| format!("failed to write {}", info_path.display()))?;
497 }
498 }
499 if project.capabilities.contains(&PlatformCapability::Camera) && info_path.exists() {
500 let existing = fs::read_to_string(&info_path)
501 .with_context(|| format!("failed to read {}", info_path.display()))?;
502 if !existing.contains("NSCameraUsageDescription") {
503 let entry = " <key>NSCameraUsageDescription</key>\n <string>This app uses the camera when you request camera features.</string>\n";
504 let updated = existing.replacen("</dict>", &format!("{entry}</dict>"), 1);
505 fs::write(&info_path, updated)
506 .with_context(|| format!("failed to write {}", info_path.display()))?;
507 }
508 }
509 if project
510 .capabilities
511 .contains(&PlatformCapability::Geolocation)
512 && info_path.exists()
513 {
514 let existing = fs::read_to_string(&info_path)
515 .with_context(|| format!("failed to read {}", info_path.display()))?;
516 if !existing.contains("NSLocationWhenInUseUsageDescription") {
517 let entry = " <key>NSLocationWhenInUseUsageDescription</key>\n <string>This app uses your location when you request location-aware features.</string>\n";
518 let updated = existing.replacen("</dict>", &format!("{entry}</dict>"), 1);
519 fs::write(&info_path, updated)
520 .with_context(|| format!("failed to write {}", info_path.display()))?;
521 }
522 }
523 if project
524 .capabilities
525 .contains(&PlatformCapability::Microphone)
526 && info_path.exists()
527 {
528 let existing = fs::read_to_string(&info_path)
529 .with_context(|| format!("failed to read {}", info_path.display()))?;
530 if !existing.contains("NSMicrophoneUsageDescription") {
531 let entry = " <key>NSMicrophoneUsageDescription</key>\n <string>This app uses the microphone when you request audio capture.</string>\n";
532 let updated = existing.replacen("</dict>", &format!("{entry}</dict>"), 1);
533 fs::write(&info_path, updated)
534 .with_context(|| format!("failed to write {}", info_path.display()))?;
535 }
536 }
537 if project.capabilities.contains(&PlatformCapability::Wifi) && info_path.exists() {
538 let existing = fs::read_to_string(&info_path)
539 .with_context(|| format!("failed to read {}", info_path.display()))?;
540 if !existing.contains("NSLocationWhenInUseUsageDescription") {
541 let entry = " <key>NSLocationWhenInUseUsageDescription</key>\n <string>This app uses location permission where the platform requires it for Wi-Fi information.</string>\n";
542 let updated = existing.replacen("</dict>", &format!("{entry}</dict>"), 1);
543 fs::write(&info_path, updated)
544 .with_context(|| format!("failed to write {}", info_path.display()))?;
545 }
546 }
547 if project.capabilities.contains(&PlatformCapability::Wifi) {
548 let entitlements_path = root.join("platforms/ios/Entitlements.plist");
549 apply_ios_wifi_entitlements(&entitlements_path)?;
550 }
551 Ok(())
552}
553
554fn apply_ios_wifi_entitlements(path: &Path) -> Result<()> {
555 if path.exists() {
556 let existing = fs::read_to_string(path)
557 .with_context(|| format!("failed to read {}", path.display()))?;
558 let mut entry = String::new();
559 if !existing.contains("com.apple.developer.networking.wifi-info") {
560 entry.push_str(" <key>com.apple.developer.networking.wifi-info</key>\n <true/>\n");
561 }
562 if !existing.contains("com.apple.developer.networking.HotspotConfiguration") {
563 entry.push_str(
564 " <key>com.apple.developer.networking.HotspotConfiguration</key>\n <true/>\n",
565 );
566 }
567 if entry.is_empty() {
568 return Ok(());
569 }
570 let updated = existing.replacen("</dict>", &format!("{entry}</dict>"), 1);
571 fs::write(path, updated).with_context(|| format!("failed to write {}", path.display()))?;
572 return Ok(());
573 }
574 write_file_with_policy(
575 path,
576 IOS_WIFI_ENTITLEMENTS_PLIST,
577 WritePolicy::PreserveExisting,
578 )
579}
580
581fn target_scaffold_dir_exists(project_dir: &Path, target: Target) -> bool {
582 Path::new(target.scaffold_relative_path())
583 .parent()
584 .is_some_and(|relative| project_dir.join(relative).exists())
585}
586
587fn write_project_config(root: &Path, project: &FissionProject) -> Result<()> {
588 let data = toml::to_string_pretty(project)?;
589 write_file(&root.join("fission.toml"), &(data + "\n"))
590}
591
592pub fn read_project_config(root: &Path) -> Result<FissionProject> {
593 let path = root.join("fission.toml");
594 let data = fs::read_to_string(&path).with_context(|| {
595 format!(
596 "failed to read {}; run `fission init {}` to register this project without overwriting existing files",
597 path.display(),
598 root.display()
599 )
600 })?;
601 toml::from_str(&data).with_context(|| format!("failed to parse {}", path.display()))
602}
603
604fn update_cargo_fission_features(root: &Path, project: &FissionProject) -> Result<()> {
605 let path = root.join("Cargo.toml");
606 let Ok(text) = fs::read_to_string(&path) else {
607 return Ok(());
608 };
609 let feature_list = render_fission_feature_list(&project.targets);
610 let mut changed = false;
611 let mut out = Vec::new();
612 for line in text.lines() {
613 if let Some(updated) = update_inline_fission_dependency(line, &feature_list) {
614 changed |= updated != line;
615 out.push(updated);
616 } else {
617 out.push(line.to_string());
618 }
619 }
620 if changed {
621 fs::write(&path, out.join("\n") + "\n")
622 .with_context(|| format!("failed to update {}", path.display()))?;
623 }
624 Ok(())
625}
626
627fn update_inline_fission_dependency(line: &str, feature_list: &str) -> Option<String> {
628 let trimmed = line.trim_start();
629 if !trimmed.starts_with("fission =") {
630 return None;
631 }
632 let indent = &line[..line.len() - trimmed.len()];
633 let value = trimmed.strip_prefix("fission =")?.trim();
634 if value.starts_with('"') {
635 return Some(format!(
636 "{indent}fission = {{ version = {value}, default-features = false, features = [{feature_list}] }}"
637 ));
638 }
639 if !(value.starts_with('{') && value.ends_with('}')) {
640 return None;
641 }
642 let inner = value
643 .strip_prefix('{')
644 .and_then(|value| value.strip_suffix('}'))?
645 .trim();
646 let mut fields = split_top_level_fields(inner)
647 .into_iter()
648 .filter(|field| {
649 let key = field
650 .split_once('=')
651 .map(|(key, _)| key.trim())
652 .unwrap_or_default();
653 key != "default-features" && key != "features"
654 })
655 .collect::<Vec<_>>();
656 fields.push("default-features = false".to_string());
657 fields.push(format!("features = [{feature_list}]"));
658 Some(format!("{indent}fission = {{ {} }}", fields.join(", ")))
659}
660
661fn split_top_level_fields(input: &str) -> Vec<String> {
662 let mut fields = Vec::new();
663 let mut start = 0;
664 let mut bracket_depth = 0usize;
665 let mut in_string = false;
666 let mut escaped = false;
667 for (index, ch) in input.char_indices() {
668 if in_string {
669 if escaped {
670 escaped = false;
671 } else if ch == '\\' {
672 escaped = true;
673 } else if ch == '"' {
674 in_string = false;
675 }
676 continue;
677 }
678 match ch {
679 '"' => in_string = true,
680 '[' => bracket_depth += 1,
681 ']' => bracket_depth = bracket_depth.saturating_sub(1),
682 ',' if bracket_depth == 0 => {
683 let field = input[start..index].trim();
684 if !field.is_empty() {
685 fields.push(field.to_string());
686 }
687 start = index + ch.len_utf8();
688 }
689 _ => {}
690 }
691 }
692 let field = input[start..].trim();
693 if !field.is_empty() {
694 fields.push(field.to_string());
695 }
696 fields
697}
698
699fn scaffold_target_with_policy(
700 root: &Path,
701 project: &FissionProject,
702 target: Target,
703 write_policy: WritePolicy,
704) -> Result<()> {
705 let relative = Path::new(target.scaffold_relative_path());
706 let text = match target {
707 Target::Android => {
708 scaffold_android_bundle(root, project, write_policy)?;
709 platform_readme(
710 "Android",
711 "Runnable emulator target. The CLI generates a NativeActivity manifest plus shell scripts that build, install, and launch the Fission app on an Android emulator.",
712 &[
713 "Install the Rust target: `rustup target add aarch64-linux-android`.",
714 "Run `fission doctor android --project-dir .` to check SDK, NDK, emulator, and Rust target setup.",
715 "Run `fission devices --project-dir .` to list connected Android devices and configured emulators.",
716 "Run `fission run --target android --project-dir .` to build, install, launch, and attach to logs.",
717 "Run `fission run --target android --device <adb-serial> --project-dir .` to launch on a specific device.",
718 "Run `fission test --target android --project-dir .` for an emulator launch plus test-control health check.",
719 "Run `./platforms/android/run-emulator.sh` from the project root to build, package, install, and launch the app on the configured emulator.",
720 "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.",
721 "Set `ANDROID_EMULATOR_HEADLESS=1` for background/CI runs, or `ANDROID_EMULATOR_RESTART=1` to relaunch a hidden emulator visibly.",
722 "The generated package uses `assets/app-icon.png` as its default launcher icon.",
723 "Run `fission add-capability nfc --project-dir .` to add NFC manifest permission and feature declarations.",
724 "Run `fission add-capability biometric --project-dir .` to add biometric manifest permissions.",
725 "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.",
726 "Run `fission add-capability bluetooth --project-dir .` to add Bluetooth permissions and optional hardware feature declarations.",
727 "Run `fission add-capability barcode-scanner --project-dir .` to add camera permission for barcode scanning.",
728 "Run `fission add-capability camera --project-dir .` to add camera permission and optional camera/flash hardware feature declarations.",
729 "Run `fission add-capability geolocation --project-dir .` to add location permissions.",
730 "Run `fission add-capability haptics --project-dir .` to add the vibration permission.",
731 "Run `fission add-capability microphone --project-dir .` to add audio recording permission.",
732 "Run `fission add-capability volume-control --project-dir .` to add Android audio settings permission.",
733 "Run `fission add-capability wifi --project-dir .` to add Wi-Fi permissions and optional hardware feature declarations.",
734 "Set `FISSION_TEST_CONTROL_PORT=<host-port>` before `run-emulator.sh`; the script forwards it to the fixed in-app device port.",
735 ],
736 )
737 }
738 Target::Ios => {
739 scaffold_ios_bundle(root, project, write_policy)?;
740 platform_readme(
741 "iOS",
742 "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`.",
743 &[
744 "Install the Rust targets: `rustup target add aarch64-apple-ios aarch64-apple-ios-sim`.",
745 "Run `fission doctor ios --project-dir .` to check Xcode, simulator, and Rust target setup.",
746 "Confirm the simulator SDK path with `xcrun --sdk iphonesimulator --show-sdk-path`.",
747 "Run `fission devices --project-dir .` to list available iOS simulators.",
748 "Run `fission run --target ios --project-dir .` to build, install, launch, and attach to simulator logs.",
749 "Run `fission run --target ios --device <simulator-udid> --project-dir .` to launch on a specific simulator.",
750 "Run `fission test --target ios --project-dir .` for a simulator launch plus test-control health check.",
751 "Run `./platforms/ios/run-sim.sh` from the project root to build, install, and launch the app on the first available iPhone simulator.",
752 "The generated bundle uses `assets/app-icon.png` as its default app icon.",
753 "Run `fission add-capability nfc --project-dir .` to add the NFC usage description and entitlements file.",
754 "Run `fission add-capability biometric --project-dir .` to add the Face ID usage description.",
755 "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.",
756 "Run `fission add-capability bluetooth --project-dir .` to add the Bluetooth usage description.",
757 "Run `fission add-capability barcode-scanner --project-dir .` to add the camera usage description for barcode scanning.",
758 "Run `fission add-capability camera --project-dir .` to add the camera usage description.",
759 "Run `fission add-capability geolocation --project-dir .` to add the location usage description.",
760 "Run `fission add-capability microphone --project-dir .` to add the microphone usage description.",
761 "Run `fission add-capability wifi --project-dir .` to add Wi-Fi entitlements and the location usage description required by current-network information APIs.",
762 "Volume control does not require an iOS Info.plist key in the generated scaffold.",
763 "Haptics do not require an iOS Info.plist key in the generated scaffold.",
764 "Set `FISSION_TEST_CONTROL_PORT=<port>` before `run-sim.sh` to expose the in-app test control server on the host.",
765 "Set `IOS_SIM_DEVICE_ID=<udid>` if you want a specific simulator device.",
766 "Set `IOS_SIM_HEADLESS=1` for CI or background-only simulator runs; otherwise the script opens Simulator visibly.",
767 ],
768 )
769 }
770 Target::Web => {
771 scaffold_web_bundle(root, project, write_policy)?;
772 platform_readme(
773 "Web",
774 "Runnable browser target. The CLI generates a WASM host page plus helper scripts that build the app with `wasm-pack` and serve it locally.",
775 &[
776 "Install the Rust target: `rustup target add wasm32-unknown-unknown`.",
777 "Install `wasm-pack` once: `cargo install wasm-pack`.",
778 "Install Node.js 22+ so the smoke test can inspect Chrome/Chromium CDP runtime and console output.",
779 "Run `fission doctor web --project-dir .` to check wasm-pack, Node.js, Chrome/Chromium, and Rust target setup.",
780 "Run `fission devices --project-dir .` to confirm Chrome/Chromium detection.",
781 "Run `fission run --target web --project-dir .` to build, serve, open, and attach to the local server.",
782 "Run `fission run --target web --detach --project-dir .` to keep the local server running in the background.",
783 "Run `fission test --target web --project-dir .` for a headless Chrome/Chromium CDP smoke test.",
784 "Run `./platforms/web/run-browser.sh` from the project root to build the wasm package and serve the app locally.",
785 "Set `FISSION_WEB_PORT=<port>` or `FISSION_WEB_HOST=<host>` if the default `127.0.0.1:8123` does not suit your machine.",
786 "Set `FISSION_WEB_OPEN=1` if you want the helper script to open a browser tab automatically.",
787 "The generated page uses `assets/app-icon.png` as its default favicon/app icon seed.",
788 ],
789 )
790 }
791 Target::Site => {
792 write_file_with_policy(
793 &root.join("content/getting-started.md"),
794 "---\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",
795 write_policy,
796 )?;
797 platform_readme(
798 "Static site",
799 "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.",
800 &[
801 "Add Markdown or MDX content under `content/`.",
802 "Run `fission site routes --project-dir .` to list generated routes.",
803 "Run `fission site build --project-dir .` to render HTML into `target/fission/site`.",
804 "Run `fission site serve --project-dir .` to build and serve the generated site locally.",
805 "Unsupported interactive widgets fail during the static render instead of silently falling back to JavaScript.",
806 ],
807 )
808 }
809 Target::Linux | Target::Macos | Target::Windows => platform_readme(
810 match target {
811 Target::Linux => "Linux",
812 Target::Macos => "macOS",
813 Target::Windows => "Windows",
814 _ => unreachable!(),
815 },
816 "Runnable target. Desktop platforms share the default `src/main.rs` entrypoint through `DesktopApp`.",
817 &[
818 "Run `fission run --project-dir .` from the project root to launch the desktop app and attach output.",
819 "Run `fission build --project-dir . --release` for a release desktop build.",
820 "Run `fission test --project-dir .` for the app crate's Rust tests.",
821 "This target uses the default Vello desktop shell path.",
822 ],
823 ),
824 };
825 write_file_with_policy(&root.join(relative), &text, write_policy)
826}
827
828fn scaffold_ios_bundle(
829 root: &Path,
830 project: &FissionProject,
831 write_policy: WritePolicy,
832) -> Result<()> {
833 let executable = ios_executable_name(project);
834 let bundle_name = ios_bundle_name(project);
835 let plist = render_ios_plist(project, &executable);
836 let package_script = render_ios_package_script(project, &bundle_name, &executable);
837 let run_script = render_ios_run_script(project);
838 let test_script = render_ios_test_script();
839
840 write_file_with_policy(&root.join("platforms/ios/Info.plist"), &plist, write_policy)?;
841 if project.capabilities.contains(&PlatformCapability::Nfc)
842 || project.capabilities.contains(&PlatformCapability::Wifi)
843 {
844 write_file_with_policy(
845 &root.join("platforms/ios/Entitlements.plist"),
846 &render_ios_entitlements_plist(project),
847 write_policy,
848 )?;
849 }
850 write_file_with_policy(
851 &root.join("platforms/ios/package-sim.sh"),
852 &package_script,
853 write_policy,
854 )?;
855 write_file_with_policy(
856 &root.join("platforms/ios/run-sim.sh"),
857 &run_script,
858 write_policy,
859 )?;
860 write_file_with_policy(
861 &root.join("platforms/ios/test-sim.sh"),
862 &test_script,
863 write_policy,
864 )?;
865 #[cfg(unix)]
866 {
867 use std::os::unix::fs::PermissionsExt;
868 for relative in [
869 "platforms/ios/package-sim.sh",
870 "platforms/ios/run-sim.sh",
871 "platforms/ios/test-sim.sh",
872 ] {
873 let path = root.join(relative);
874 if path.exists() {
875 fs::set_permissions(path, fs::Permissions::from_mode(0o755))?;
876 }
877 }
878 }
879 Ok(())
880}
881
882fn scaffold_android_bundle(
883 root: &Path,
884 project: &FissionProject,
885 write_policy: WritePolicy,
886) -> Result<()> {
887 let manifest = render_android_manifest(project);
888 let package_script = render_android_package_script(project);
889 let run_script = render_android_run_script(project);
890 let test_script = render_android_test_script();
891
892 write_file_with_policy(
893 &root.join("platforms/android/AndroidManifest.xml"),
894 &manifest,
895 write_policy,
896 )?;
897 write_file_with_policy(
898 &root.join("platforms/android/package-apk.sh"),
899 &package_script,
900 write_policy,
901 )?;
902 write_file_with_policy(
903 &root.join("platforms/android/run-emulator.sh"),
904 &run_script,
905 write_policy,
906 )?;
907 write_file_with_policy(
908 &root.join("platforms/android/test-emulator.sh"),
909 &test_script,
910 write_policy,
911 )?;
912 #[cfg(unix)]
913 {
914 use std::os::unix::fs::PermissionsExt;
915 for relative in [
916 "platforms/android/package-apk.sh",
917 "platforms/android/run-emulator.sh",
918 "platforms/android/test-emulator.sh",
919 ] {
920 let path = root.join(relative);
921 if path.exists() {
922 fs::set_permissions(path, fs::Permissions::from_mode(0o755))?;
923 }
924 }
925 }
926 Ok(())
927}
928
929fn scaffold_web_bundle(
930 root: &Path,
931 project: &FissionProject,
932 write_policy: WritePolicy,
933) -> Result<()> {
934 let index_html = render_web_index(project);
935 let bootstrap = render_web_bootstrap(project);
936 let build_script = render_web_build_script();
937 let run_script = render_web_run_script(project);
938 let test_script = render_web_test_script(project);
939
940 write_file_with_policy(
941 &root.join("platforms/web/index.html"),
942 &index_html,
943 write_policy,
944 )?;
945 write_file_with_policy(
946 &root.join("platforms/web/bootstrap.mjs"),
947 &bootstrap,
948 write_policy,
949 )?;
950 write_file_with_policy(
951 &root.join("platforms/web/build-wasm.sh"),
952 &build_script,
953 write_policy,
954 )?;
955 write_file_with_policy(
956 &root.join("platforms/web/run-browser.sh"),
957 &run_script,
958 write_policy,
959 )?;
960 write_file_with_policy(
961 &root.join("platforms/web/test-browser.sh"),
962 &test_script,
963 write_policy,
964 )?;
965
966 #[cfg(unix)]
967 {
968 use std::os::unix::fs::PermissionsExt;
969 for relative in [
970 "platforms/web/build-wasm.sh",
971 "platforms/web/run-browser.sh",
972 "platforms/web/test-browser.sh",
973 ] {
974 let path = root.join(relative);
975 if path.exists() {
976 let mut perms = fs::metadata(&path)?.permissions();
977 perms.set_mode(0o755);
978 fs::set_permissions(path, perms)?;
979 }
980 }
981 }
982
983 Ok(())
984}
985
986fn write_file(path: &Path, contents: &str) -> Result<()> {
987 write_file_with_policy(path, contents, WritePolicy::Overwrite)
988}
989
990fn write_file_with_policy(path: &Path, contents: &str, write_policy: WritePolicy) -> Result<()> {
991 if write_policy == WritePolicy::PreserveExisting && path.exists() {
992 return Ok(());
993 }
994 if let Some(parent) = path.parent() {
995 fs::create_dir_all(parent)?;
996 }
997 fs::write(path, contents).with_context(|| format!("failed to write {}", path.display()))
998}
999
1000fn write_binary_file_with_policy(
1001 path: &Path,
1002 contents: &[u8],
1003 write_policy: WritePolicy,
1004) -> Result<()> {
1005 if write_policy == WritePolicy::PreserveExisting && path.exists() {
1006 return Ok(());
1007 }
1008 if let Some(parent) = path.parent() {
1009 fs::create_dir_all(parent)?;
1010 }
1011 fs::write(path, contents).with_context(|| format!("failed to write {}", path.display()))
1012}
1013
1014fn render_cargo_toml(project: &FissionProject, local_path: Option<&Path>) -> String {
1015 let feature_list = render_fission_feature_list(&project.targets);
1016 let deps = if let Some(root) = local_path {
1017 let fission_path = root.join("crates/authoring/fission");
1018 format!(
1019 "fission = {{ path = {:?}, default-features = false, features = [{}] }}\n",
1020 fission_path.to_string_lossy().to_string(),
1021 feature_list
1022 )
1023 } else {
1024 format!(
1025 "fission = {{ version = \"{}\", default-features = false, features = [{}] }}\n",
1026 CURRENT_VERSION, feature_list
1027 )
1028 };
1029 let lib_name = project.app.name.replace('-', "_");
1030
1031 format!(
1032 "[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",
1033 project.app.name, lib_name, deps
1034 )
1035}
1036
1037fn render_fission_feature_list(targets: &BTreeSet<Target>) -> String {
1038 fission_features_for_targets(targets)
1039 .into_iter()
1040 .map(|feature| format!("\"{feature}\""))
1041 .collect::<Vec<_>>()
1042 .join(", ")
1043}
1044
1045fn fission_features_for_targets(targets: &BTreeSet<Target>) -> Vec<&'static str> {
1046 let mut features = Vec::new();
1047 if targets
1048 .iter()
1049 .any(|target| matches!(target, Target::Linux | Target::Macos | Target::Windows))
1050 {
1051 features.push("desktop");
1052 }
1053 if targets.contains(&Target::Web) {
1054 features.push("web");
1055 }
1056 if targets.contains(&Target::Android) {
1057 features.push("android");
1058 }
1059 if targets.contains(&Target::Ios) {
1060 features.push("ios");
1061 }
1062 if targets.contains(&Target::Site) {
1063 features.push("site");
1064 }
1065 features
1066}
1067
1068fn render_project_readme(project: &FissionProject) -> String {
1069 let mut targets = String::new();
1070 for target in &project.targets {
1071 targets.push_str(&format!("- `{}`\n", target.as_str()));
1072 }
1073 format!(
1074 "# {}\n\nGenerated by `fission init`.\n\n## Targets\n\n{}\n## Commands\n\n- `fission doctor --project-dir .` -- check local SDKs, browsers, emulators, and Rust targets\n- `fission devices --project-dir .` -- list runnable desktop, browser, simulator, emulator, and device targets\n- `fission run --project-dir .` -- launch the desktop app and attach to output\n- `fission run --target web --project-dir .` -- launch the web app and attach to the local server\n- `fission run --target ios --project-dir .` -- build, install, launch, and attach to simulator logs\n- `fission run --target android --project-dir .` -- build, install, launch, and attach to Android logs\n- `fission run --target <target> --device <id> --detach --project-dir .` -- launch without attaching\n- `fission logs --target <target> --device <id> --project-dir . --follow` -- attach later where supported\n- `fission build --target <target> --project-dir . --release` -- build a target without launching it\n- `fission test --target <target> --project-dir .` -- run the generated platform smoke test\n- `fission add-target web ios android --project-dir .` -- scaffold more targets\n- `fission add-capability nfc 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",
1075 project.app.name, targets
1076 )
1077}
1078
1079fn platform_readme(title: &str, summary: &str, bullets: &[&str]) -> String {
1080 let mut out = format!("# {} target\n\n{}\n", title, summary);
1081 for bullet in bullets {
1082 out.push_str(&format!("\n- {}", bullet));
1083 }
1084 out.push('\n');
1085 out
1086}
1087
1088fn normalize_crate_name(name: &str) -> String {
1089 name.chars()
1090 .map(|ch| match ch {
1091 'A'..='Z' => ch.to_ascii_lowercase(),
1092 'a'..='z' | '0'..='9' => ch,
1093 _ => '-',
1094 })
1095 .collect::<String>()
1096 .trim_matches('-')
1097 .to_string()
1098}
1099
1100pub fn ios_executable_name(project: &FissionProject) -> String {
1101 project.app.name.replace('-', "_")
1102}
1103
1104fn ios_bundle_name(project: &FissionProject) -> String {
1105 let mut out = String::new();
1106 let mut uppercase_next = true;
1107 for ch in project.app.name.chars() {
1108 match ch {
1109 '-' | '_' | ' ' => uppercase_next = true,
1110 _ if uppercase_next => {
1111 out.extend(ch.to_uppercase());
1112 uppercase_next = false;
1113 }
1114 _ => out.push(ch),
1115 }
1116 }
1117 if out.is_empty() {
1118 "FissionApp".to_string()
1119 } else {
1120 out
1121 }
1122}
1123
1124fn android_library_name(project: &FissionProject) -> String {
1125 project.app.name.replace('-', "_")
1126}
1127
1128fn render_ios_plist(project: &FissionProject, executable: &str) -> String {
1129 let capability_entries = render_ios_info_plist_capability_entries(project);
1130 format!(
1131 r#"<?xml version="1.0" encoding="UTF-8"?>
1132<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
1133<plist version="1.0">
1134<dict>
1135 <key>CFBundleDevelopmentRegion</key>
1136 <string>en</string>
1137 <key>CFBundleDisplayName</key>
1138 <string>{display_name}</string>
1139 <key>CFBundleExecutable</key>
1140 <string>{executable}</string>
1141 <key>CFBundleIdentifier</key>
1142 <string>{bundle_id}</string>
1143 <key>CFBundleInfoDictionaryVersion</key>
1144 <string>6.0</string>
1145 <key>CFBundleName</key>
1146 <string>{display_name}</string>
1147 <key>CFBundlePackageType</key>
1148 <string>APPL</string>
1149 <key>CFBundleShortVersionString</key>
1150 <string>0.1.0</string>
1151 <key>CFBundleVersion</key>
1152 <string>1</string>
1153 <key>CFBundleIconFile</key>
1154 <string>AppIcon</string>
1155 <key>LSRequiresIPhoneOS</key>
1156 <true/>
1157 <key>MinimumOSVersion</key>
1158 <string>18.0</string>
1159{capability_entries}
1160 <key>UIDeviceFamily</key>
1161 <array>
1162 <integer>1</integer>
1163 <integer>2</integer>
1164 </array>
1165</dict>
1166</plist>
1167"#,
1168 display_name = ios_bundle_name(project),
1169 executable = executable,
1170 bundle_id = project.app.app_id,
1171 capability_entries = capability_entries,
1172 )
1173}
1174
1175fn render_ios_info_plist_capability_entries(project: &FissionProject) -> String {
1176 let mut out = String::new();
1177 if project.capabilities.contains(&PlatformCapability::Nfc) {
1178 out.push_str(" <key>NFCReaderUsageDescription</key>\n <string>This app uses NFC to scan nearby tags when you request it.</string>\n");
1179 }
1180 if project
1181 .capabilities
1182 .contains(&PlatformCapability::Biometric)
1183 {
1184 out.push_str(" <key>NSFaceIDUsageDescription</key>\n <string>This app uses biometrics to authenticate you when you request it.</string>\n");
1185 }
1186 if project
1187 .capabilities
1188 .contains(&PlatformCapability::Bluetooth)
1189 {
1190 out.push_str(" <key>NSBluetoothAlwaysUsageDescription</key>\n <string>This app uses Bluetooth when you request nearby-device features.</string>\n");
1191 }
1192 if project
1193 .capabilities
1194 .contains(&PlatformCapability::BarcodeScanner)
1195 {
1196 out.push_str(" <key>NSCameraUsageDescription</key>\n <string>This app uses the camera to scan barcodes when you request it.</string>\n");
1197 }
1198 if project.capabilities.contains(&PlatformCapability::Camera)
1199 && !project
1200 .capabilities
1201 .contains(&PlatformCapability::BarcodeScanner)
1202 {
1203 out.push_str(" <key>NSCameraUsageDescription</key>\n <string>This app uses the camera when you request camera features.</string>\n");
1204 }
1205 if project
1206 .capabilities
1207 .contains(&PlatformCapability::Geolocation)
1208 {
1209 out.push_str(" <key>NSLocationWhenInUseUsageDescription</key>\n <string>This app uses your location when you request location-aware features.</string>\n");
1210 }
1211 if project
1212 .capabilities
1213 .contains(&PlatformCapability::Microphone)
1214 {
1215 out.push_str(" <key>NSMicrophoneUsageDescription</key>\n <string>This app uses the microphone when you request audio capture.</string>\n");
1216 }
1217 if project.capabilities.contains(&PlatformCapability::Wifi)
1218 && !project
1219 .capabilities
1220 .contains(&PlatformCapability::Geolocation)
1221 {
1222 out.push_str(" <key>NSLocationWhenInUseUsageDescription</key>\n <string>This app uses location permission where the platform requires it for Wi-Fi information.</string>\n");
1223 }
1224 out
1225}
1226
1227fn render_ios_package_script(
1228 project: &FissionProject,
1229 bundle_name: &str,
1230 executable: &str,
1231) -> String {
1232 format!(
1233 r#"#!/usr/bin/env bash
1234set -euo pipefail
1235
1236SCRIPT_DIR=$(cd -- "$(dirname "${{BASH_SOURCE[0]}}")" && pwd)
1237PROJECT_DIR=$(cd -- "$SCRIPT_DIR/../.." && pwd)
1238TARGET="${{IOS_SIM_TARGET:-aarch64-apple-ios-sim}}"
1239PROFILE="${{IOS_SIM_PROFILE:-debug}}"
1240PACKAGE_NAME="{package_name}"
1241BUNDLE_ID="${{IOS_BUNDLE_ID:-{bundle_id}}}"
1242DISPLAY_NAME="${{IOS_DISPLAY_NAME:-{bundle_name}}}"
1243EXECUTABLE_NAME="${{IOS_EXECUTABLE_NAME:-{executable}}}"
1244BUNDLE_NAME="${{IOS_BUNDLE_NAME:-$DISPLAY_NAME.app}}"
1245BUILD_DIR="$SCRIPT_DIR/build/$PROFILE"
1246BUNDLE_DIR="$BUILD_DIR/$BUNDLE_NAME"
1247
1248BUILD_ARGS=(build --manifest-path "$PROJECT_DIR/Cargo.toml" --target "$TARGET" --package "$PACKAGE_NAME")
1249ARTIFACT_DIR=debug
1250if [[ "$PROFILE" == "release" ]]; then
1251 BUILD_ARGS+=(--release)
1252 ARTIFACT_DIR=release
1253fi
1254
1255cargo "${{BUILD_ARGS[@]}}"
1256TARGET_DIR=$(python3 - <<'PY' "$PROJECT_DIR/Cargo.toml"
1257import json
1258import subprocess
1259import sys
1260
1261manifest = sys.argv[1]
1262metadata = json.loads(
1263 subprocess.check_output(
1264 ["cargo", "metadata", "--manifest-path", manifest, "--format-version", "1", "--no-deps"]
1265 )
1266)
1267print(metadata["target_directory"])
1268PY
1269)
1270
1271rm -rf "$BUNDLE_DIR"
1272mkdir -p "$BUNDLE_DIR"
1273cp "$TARGET_DIR/$TARGET/$ARTIFACT_DIR/$PACKAGE_NAME" "$BUNDLE_DIR/$EXECUTABLE_NAME"
1274chmod +x "$BUNDLE_DIR/$EXECUTABLE_NAME"
1275python3 - <<'PY' "$SCRIPT_DIR/Info.plist" "$BUNDLE_DIR/Info.plist" "$BUNDLE_ID" "$DISPLAY_NAME" "$EXECUTABLE_NAME"
1276import plistlib
1277import sys
1278
1279source, dest, bundle_id, display_name, executable_name = sys.argv[1:]
1280with open(source, "rb") as handle:
1281 plist = plistlib.load(handle)
1282plist["CFBundleIdentifier"] = bundle_id
1283plist["CFBundleDisplayName"] = display_name
1284plist["CFBundleName"] = display_name
1285plist["CFBundleExecutable"] = executable_name
1286with open(dest, "wb") as handle:
1287 plistlib.dump(plist, handle, sort_keys=False)
1288PY
1289cp "$PROJECT_DIR/assets/app-icon.png" "$BUNDLE_DIR/AppIcon.png"
1290printf 'APPL????' > "$BUNDLE_DIR/PkgInfo"
1291printf '%s\n' "$BUNDLE_DIR"
1292"#,
1293 package_name = project.app.name,
1294 bundle_id = project.app.app_id,
1295 bundle_name = bundle_name,
1296 executable = executable,
1297 )
1298}
1299
1300fn render_ios_run_script(project: &FissionProject) -> String {
1301 format!(
1302 r#"#!/usr/bin/env bash
1303set -euo pipefail
1304
1305SCRIPT_DIR=$(cd -- "$(dirname "${{BASH_SOURCE[0]}}")" && pwd)
1306BUNDLE_DIR=$("$SCRIPT_DIR/package-sim.sh")
1307BUNDLE_ID="${{IOS_BUNDLE_ID:-{bundle_id}}}"
1308DEVICE_ID="${{IOS_SIM_DEVICE_ID:-}}"
1309
1310if [[ -z "$DEVICE_ID" ]]; then
1311 DEVICE_ID=$(python3 - <<'PY'
1312import json
1313import subprocess
1314payload = json.loads(subprocess.check_output(["xcrun", "simctl", "list", "devices", "available", "-j"]))
1315for runtime, devices in payload["devices"].items():
1316 if not runtime.startswith("com.apple.CoreSimulator.SimRuntime.iOS-"):
1317 continue
1318 for device in devices:
1319 if device.get("isAvailable") and "iPhone" in device["name"]:
1320 print(device["udid"])
1321 raise SystemExit(0)
1322raise SystemExit("no available iPhone simulator found")
1323PY
1324)
1325fi
1326
1327if [[ "${{IOS_SIM_HEADLESS:-0}}" != "1" ]] && command -v open >/dev/null 2>&1; then
1328 open -a Simulator --args -CurrentDeviceUDID "$DEVICE_ID" >/dev/null 2>&1 \
1329 || open -a Simulator >/dev/null 2>&1 \
1330 || true
1331fi
1332
1333xcrun simctl boot "$DEVICE_ID" >/dev/null 2>&1 || true
1334xcrun simctl bootstatus "$DEVICE_ID" -b
1335xcrun simctl install "$DEVICE_ID" "$BUNDLE_DIR"
1336
1337if [[ -n "${{FISSION_TEST_CONTROL_PORT:-}}" ]]; then
1338 SIMCTL_CHILD_FISSION_TEST_CONTROL_PORT="${{FISSION_TEST_CONTROL_PORT}}" \
1339 xcrun simctl launch --terminate-running-process "$DEVICE_ID" "$BUNDLE_ID"
1340else
1341 xcrun simctl launch --terminate-running-process "$DEVICE_ID" "$BUNDLE_ID"
1342fi
1343"#,
1344 bundle_id = project.app.app_id,
1345 )
1346}
1347
1348fn render_ios_test_script() -> String {
1349 r#"#!/usr/bin/env bash
1350set -euo pipefail
1351
1352SCRIPT_DIR=$(cd -- "$(dirname "${BASH_SOURCE[0]}")" && pwd)
1353export FISSION_TEST_CONTROL_PORT="${FISSION_TEST_CONTROL_PORT:-48711}"
1354
1355"$SCRIPT_DIR/run-sim.sh"
1356
1357python3 - <<'PY' "$FISSION_TEST_CONTROL_PORT"
1358import sys
1359import time
1360import urllib.request
1361
1362port = sys.argv[1]
1363url = f"http://127.0.0.1:{port}/health"
1364deadline = time.time() + 90
1365last_error = None
1366while time.time() < deadline:
1367 try:
1368 with urllib.request.urlopen(url, timeout=1) as response:
1369 body = response.read().decode("utf-8", "replace")
1370 if response.status == 200 and '"status":"ok"' in body:
1371 print(f"iOS simulator test control is healthy on {url}")
1372 raise SystemExit(0)
1373 except Exception as error:
1374 last_error = error
1375 time.sleep(1)
1376raise SystemExit(f"iOS simulator test control did not become healthy on {url}: {last_error}")
1377PY
1378"#
1379 .to_string()
1380}
1381
1382fn render_android_manifest(project: &FissionProject) -> String {
1383 let capability_entries = render_android_capability_manifest_entries(project);
1384 format!(
1385 r#"<?xml version="1.0" encoding="utf-8"?>
1386<manifest xmlns:android="http://schemas.android.com/apk/res/android"
1387 package="{app_id}">
1388
1389 <uses-permission android:name="android.permission.INTERNET" />
1390{capability_entries}
1391
1392 <uses-sdk
1393 android:minSdkVersion="24"
1394 android:targetSdkVersion="35" />
1395
1396 <application
1397 android:debuggable="true"
1398 android:extractNativeLibs="true"
1399 android:hasCode="false"
1400 android:icon="@drawable/app_icon"
1401 android:label="{label}">
1402 <activity
1403 android:name="android.app.NativeActivity"
1404 android:configChanges="orientation|keyboardHidden|screenSize|screenLayout|smallestScreenSize|uiMode|density"
1405 android:exported="true"
1406 android:launchMode="singleTask">
1407 <meta-data
1408 android:name="android.app.lib_name"
1409 android:value="{lib_name}" />
1410 <intent-filter>
1411 <action android:name="android.intent.action.MAIN" />
1412 <category android:name="android.intent.category.LAUNCHER" />
1413 </intent-filter>
1414 </activity>
1415 </application>
1416
1417</manifest>
1418"#,
1419 app_id = project.app.app_id,
1420 label = ios_bundle_name(project),
1421 lib_name = android_library_name(project),
1422 capability_entries = capability_entries,
1423 )
1424}
1425
1426fn render_android_capability_manifest_entries(project: &FissionProject) -> String {
1427 let mut out = String::new();
1428 if project.capabilities.contains(&PlatformCapability::Nfc) {
1429 out.push_str(&render_android_nfc_manifest_entries());
1430 }
1431 if project
1432 .capabilities
1433 .contains(&PlatformCapability::Biometric)
1434 {
1435 out.push_str(&render_android_biometric_manifest_entries());
1436 }
1437 if project
1438 .capabilities
1439 .contains(&PlatformCapability::Bluetooth)
1440 {
1441 out.push_str(&render_android_bluetooth_manifest_entries());
1442 }
1443 if project.capabilities.contains(&PlatformCapability::Camera) {
1444 out.push_str(&render_android_camera_manifest_entries());
1445 } else if project
1446 .capabilities
1447 .contains(&PlatformCapability::BarcodeScanner)
1448 {
1449 out.push_str(&render_android_barcode_camera_manifest_entries());
1450 }
1451 if project
1452 .capabilities
1453 .contains(&PlatformCapability::Geolocation)
1454 {
1455 out.push_str(&render_android_geolocation_manifest_entries());
1456 }
1457 if project.capabilities.contains(&PlatformCapability::Haptics) {
1458 out.push_str(&render_android_haptics_manifest_entries());
1459 }
1460 if project
1461 .capabilities
1462 .contains(&PlatformCapability::Microphone)
1463 {
1464 out.push_str(&render_android_microphone_manifest_entries());
1465 }
1466 if project
1467 .capabilities
1468 .contains(&PlatformCapability::VolumeControl)
1469 {
1470 out.push_str(&render_android_volume_manifest_entries());
1471 }
1472 if project.capabilities.contains(&PlatformCapability::Wifi) {
1473 out.push_str(&render_android_wifi_manifest_entries());
1474 }
1475 out
1476}
1477
1478fn render_android_nfc_manifest_entries() -> String {
1479 let mut out = String::new();
1480 out.push_str(" <uses-permission android:name=\"android.permission.NFC\" />\n");
1481 out.push_str(
1482 " <uses-feature android:name=\"android.hardware.nfc\" android:required=\"false\" />\n",
1483 );
1484 out
1485}
1486
1487fn render_android_biometric_manifest_entries() -> String {
1488 let mut out = String::new();
1489 out.push_str(" <uses-permission android:name=\"android.permission.USE_BIOMETRIC\" />\n");
1490 out.push_str(" <uses-permission android:name=\"android.permission.USE_FINGERPRINT\" android:maxSdkVersion=\"28\" />\n");
1491 out
1492}
1493
1494fn render_android_bluetooth_manifest_entries() -> String {
1495 let mut out = String::new();
1496 out.push_str(" <uses-permission android:name=\"android.permission.BLUETOOTH\" android:maxSdkVersion=\"30\" />\n");
1497 out.push_str(" <uses-permission android:name=\"android.permission.BLUETOOTH_ADMIN\" android:maxSdkVersion=\"30\" />\n");
1498 out.push_str(" <uses-permission android:name=\"android.permission.BLUETOOTH_SCAN\" android:usesPermissionFlags=\"neverForLocation\" />\n");
1499 out.push_str(" <uses-permission android:name=\"android.permission.BLUETOOTH_CONNECT\" />\n");
1500 out.push_str(
1501 " <uses-permission android:name=\"android.permission.BLUETOOTH_ADVERTISE\" />\n",
1502 );
1503 out.push_str(
1504 " <uses-feature android:name=\"android.hardware.bluetooth\" android:required=\"false\" />\n",
1505 );
1506 out.push_str(
1507 " <uses-feature android:name=\"android.hardware.bluetooth_le\" android:required=\"false\" />\n",
1508 );
1509 out
1510}
1511
1512fn render_missing_android_bluetooth_manifest_entries(existing: &str) -> String {
1513 let mut out = String::new();
1514 if !existing.contains("android.permission.BLUETOOTH\"") {
1515 out.push_str(" <uses-permission android:name=\"android.permission.BLUETOOTH\" android:maxSdkVersion=\"30\" />\n");
1516 }
1517 if !existing.contains("android.permission.BLUETOOTH_ADMIN") {
1518 out.push_str(" <uses-permission android:name=\"android.permission.BLUETOOTH_ADMIN\" android:maxSdkVersion=\"30\" />\n");
1519 }
1520 if !existing.contains("android.permission.BLUETOOTH_SCAN") {
1521 out.push_str(" <uses-permission android:name=\"android.permission.BLUETOOTH_SCAN\" android:usesPermissionFlags=\"neverForLocation\" />\n");
1522 }
1523 if !existing.contains("android.permission.BLUETOOTH_CONNECT") {
1524 out.push_str(
1525 " <uses-permission android:name=\"android.permission.BLUETOOTH_CONNECT\" />\n",
1526 );
1527 }
1528 if !existing.contains("android.permission.BLUETOOTH_ADVERTISE") {
1529 out.push_str(
1530 " <uses-permission android:name=\"android.permission.BLUETOOTH_ADVERTISE\" />\n",
1531 );
1532 }
1533 if !existing.contains("android.hardware.bluetooth\"") {
1534 out.push_str(
1535 " <uses-feature android:name=\"android.hardware.bluetooth\" android:required=\"false\" />\n",
1536 );
1537 }
1538 if !existing.contains("android.hardware.bluetooth_le") {
1539 out.push_str(
1540 " <uses-feature android:name=\"android.hardware.bluetooth_le\" android:required=\"false\" />\n",
1541 );
1542 }
1543 out
1544}
1545
1546fn render_android_barcode_camera_manifest_entries() -> String {
1547 let mut out = String::new();
1548 out.push_str(" <uses-permission android:name=\"android.permission.CAMERA\" />\n");
1549 out.push_str(
1550 " <uses-feature android:name=\"android.hardware.camera.any\" android:required=\"false\" />\n",
1551 );
1552 out
1553}
1554
1555fn render_android_camera_manifest_entries() -> String {
1556 let mut out = String::new();
1557 out.push_str(" <uses-permission android:name=\"android.permission.CAMERA\" />\n");
1558 out.push_str(
1559 " <uses-feature android:name=\"android.hardware.camera.any\" android:required=\"false\" />\n",
1560 );
1561 out.push_str(
1562 " <uses-feature android:name=\"android.hardware.camera\" android:required=\"false\" />\n",
1563 );
1564 out.push_str(
1565 " <uses-feature android:name=\"android.hardware.camera.front\" android:required=\"false\" />\n",
1566 );
1567 out.push_str(
1568 " <uses-feature android:name=\"android.hardware.camera.flash\" android:required=\"false\" />\n",
1569 );
1570 out
1571}
1572
1573fn render_missing_android_camera_manifest_entries(existing: &str) -> String {
1574 let mut out = String::new();
1575 if !existing.contains("android.permission.CAMERA") {
1576 out.push_str(" <uses-permission android:name=\"android.permission.CAMERA\" />\n");
1577 }
1578 if !existing.contains("android.hardware.camera.any") {
1579 out.push_str(
1580 " <uses-feature android:name=\"android.hardware.camera.any\" android:required=\"false\" />\n",
1581 );
1582 }
1583 if !existing.contains("android.hardware.camera\"") {
1584 out.push_str(
1585 " <uses-feature android:name=\"android.hardware.camera\" android:required=\"false\" />\n",
1586 );
1587 }
1588 if !existing.contains("android.hardware.camera.front") {
1589 out.push_str(
1590 " <uses-feature android:name=\"android.hardware.camera.front\" android:required=\"false\" />\n",
1591 );
1592 }
1593 if !existing.contains("android.hardware.camera.flash") {
1594 out.push_str(
1595 " <uses-feature android:name=\"android.hardware.camera.flash\" android:required=\"false\" />\n",
1596 );
1597 }
1598 out
1599}
1600
1601fn render_android_geolocation_manifest_entries() -> String {
1602 let mut out = String::new();
1603 out.push_str(
1604 " <uses-permission android:name=\"android.permission.ACCESS_COARSE_LOCATION\" />\n",
1605 );
1606 out.push_str(
1607 " <uses-permission android:name=\"android.permission.ACCESS_FINE_LOCATION\" />\n",
1608 );
1609 out
1610}
1611
1612fn render_android_haptics_manifest_entries() -> String {
1613 " <uses-permission android:name=\"android.permission.VIBRATE\" />\n".to_string()
1614}
1615
1616fn render_android_microphone_manifest_entries() -> String {
1617 " <uses-permission android:name=\"android.permission.RECORD_AUDIO\" />\n".to_string()
1618}
1619
1620fn render_android_volume_manifest_entries() -> String {
1621 " <uses-permission android:name=\"android.permission.MODIFY_AUDIO_SETTINGS\" />\n"
1622 .to_string()
1623}
1624
1625fn render_android_wifi_manifest_entries() -> String {
1626 let mut out = String::new();
1627 out.push_str(" <uses-permission android:name=\"android.permission.ACCESS_WIFI_STATE\" />\n");
1628 out.push_str(" <uses-permission android:name=\"android.permission.CHANGE_WIFI_STATE\" />\n");
1629 out.push_str(
1630 " <uses-permission android:name=\"android.permission.ACCESS_NETWORK_STATE\" />\n",
1631 );
1632 out.push_str(
1633 " <uses-permission android:name=\"android.permission.CHANGE_NETWORK_STATE\" />\n",
1634 );
1635 out.push_str(" <uses-permission android:name=\"android.permission.NEARBY_WIFI_DEVICES\" android:usesPermissionFlags=\"neverForLocation\" />\n");
1636 out.push_str(" <uses-permission android:name=\"android.permission.ACCESS_FINE_LOCATION\" android:maxSdkVersion=\"32\" />\n");
1637 out.push_str(
1638 " <uses-feature android:name=\"android.hardware.wifi\" android:required=\"false\" />\n",
1639 );
1640 out.push_str(
1641 " <uses-feature android:name=\"android.hardware.wifi.direct\" android:required=\"false\" />\n",
1642 );
1643 out
1644}
1645
1646fn render_missing_android_wifi_manifest_entries(existing: &str) -> String {
1647 let mut out = String::new();
1648 if !existing.contains("android.permission.ACCESS_WIFI_STATE") {
1649 out.push_str(
1650 " <uses-permission android:name=\"android.permission.ACCESS_WIFI_STATE\" />\n",
1651 );
1652 }
1653 if !existing.contains("android.permission.CHANGE_WIFI_STATE") {
1654 out.push_str(
1655 " <uses-permission android:name=\"android.permission.CHANGE_WIFI_STATE\" />\n",
1656 );
1657 }
1658 if !existing.contains("android.permission.ACCESS_NETWORK_STATE") {
1659 out.push_str(
1660 " <uses-permission android:name=\"android.permission.ACCESS_NETWORK_STATE\" />\n",
1661 );
1662 }
1663 if !existing.contains("android.permission.CHANGE_NETWORK_STATE") {
1664 out.push_str(
1665 " <uses-permission android:name=\"android.permission.CHANGE_NETWORK_STATE\" />\n",
1666 );
1667 }
1668 if !existing.contains("android.permission.NEARBY_WIFI_DEVICES") {
1669 out.push_str(" <uses-permission android:name=\"android.permission.NEARBY_WIFI_DEVICES\" android:usesPermissionFlags=\"neverForLocation\" />\n");
1670 }
1671 if !existing.contains("android.permission.ACCESS_FINE_LOCATION") {
1672 out.push_str(" <uses-permission android:name=\"android.permission.ACCESS_FINE_LOCATION\" android:maxSdkVersion=\"32\" />\n");
1673 }
1674 if !existing.contains("android.hardware.wifi\"") {
1675 out.push_str(
1676 " <uses-feature android:name=\"android.hardware.wifi\" android:required=\"false\" />\n",
1677 );
1678 }
1679 if !existing.contains("android.hardware.wifi.direct") {
1680 out.push_str(
1681 " <uses-feature android:name=\"android.hardware.wifi.direct\" android:required=\"false\" />\n",
1682 );
1683 }
1684 out
1685}
1686
1687fn render_ios_entitlements_plist(project: &FissionProject) -> String {
1688 let mut entries = String::new();
1689 if project.capabilities.contains(&PlatformCapability::Nfc) {
1690 entries.push_str(" <key>com.apple.developer.nfc.readersession.formats</key>\n <array>\n <string>NDEF</string>\n </array>\n");
1691 }
1692 if project.capabilities.contains(&PlatformCapability::Wifi) {
1693 entries.push_str(" <key>com.apple.developer.networking.wifi-info</key>\n <true/>\n");
1694 entries.push_str(
1695 " <key>com.apple.developer.networking.HotspotConfiguration</key>\n <true/>\n",
1696 );
1697 }
1698 format!(
1699 "<?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"
1700 )
1701}
1702
1703const IOS_NFC_ENTITLEMENTS_PLIST: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
1704<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
1705<plist version="1.0">
1706<dict>
1707 <key>com.apple.developer.nfc.readersession.formats</key>
1708 <array>
1709 <string>NDEF</string>
1710 </array>
1711</dict>
1712</plist>
1713"#;
1714
1715const IOS_WIFI_ENTITLEMENTS_PLIST: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
1716<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
1717<plist version="1.0">
1718<dict>
1719 <key>com.apple.developer.networking.wifi-info</key>
1720 <true/>
1721 <key>com.apple.developer.networking.HotspotConfiguration</key>
1722 <true/>
1723</dict>
1724</plist>
1725"#;
1726
1727fn render_android_package_script(project: &FissionProject) -> String {
1728 let lib_name = android_library_name(project);
1729 format!(
1730 r#"#!/usr/bin/env bash
1731set -euo pipefail
1732
1733SCRIPT_DIR=$(cd -- "$(dirname "${{BASH_SOURCE[0]}}")" && pwd)
1734PROJECT_DIR=$(cd -- "$SCRIPT_DIR/../.." && pwd)
1735TARGET="${{ANDROID_TARGET_TRIPLE:-aarch64-linux-android}}"
1736PACKAGE_NAME="{package_name}"
1737LIB_NAME="{lib_name}"
1738PROFILE="${{ANDROID_PROFILE:-debug}}"
1739ANDROID_HOME="${{ANDROID_HOME:-${{ANDROID_SDK_ROOT:-$HOME/Library/Android/sdk}}}}"
1740ANDROID_MIN_API_LEVEL="${{ANDROID_MIN_API_LEVEL:-${{ANDROID_API_LEVEL:-24}}}}"
1741
1742find_android_ndk() {{
1743 if [[ -n "${{ANDROID_NDK:-}}" ]]; then
1744 printf '%s\n' "$ANDROID_NDK"
1745 return
1746 fi
1747 local ndk_root="$ANDROID_HOME/ndk"
1748 if [[ ! -d "$ndk_root" ]]; then
1749 printf 'Android NDK not found. Set ANDROID_NDK or install one under %s.\n' "$ndk_root" >&2
1750 return 1
1751 fi
1752 local ndk
1753 ndk=$(find "$ndk_root" -maxdepth 1 -mindepth 1 -type d | sort -V | tail -1)
1754 if [[ -z "$ndk" ]]; then
1755 printf 'Android NDK not found. Set ANDROID_NDK or install one under %s.\n' "$ndk_root" >&2
1756 return 1
1757 fi
1758 printf '%s\n' "$ndk"
1759}}
1760
1761detect_android_toolchain() {{
1762 local prebuilt_root="$ANDROID_NDK/toolchains/llvm/prebuilt"
1763 local host
1764 for host in darwin-aarch64 darwin-x86_64 linux-x86_64 windows-x86_64; do
1765 if [[ -d "$prebuilt_root/$host/bin" ]]; then
1766 printf '%s\n' "$prebuilt_root/$host/bin"
1767 return
1768 fi
1769 done
1770 local fallback
1771 fallback=$(find "$prebuilt_root" -maxdepth 1 -mindepth 1 -type d 2>/dev/null | sort | head -1 || true)
1772 if [[ -n "$fallback" && -d "$fallback/bin" ]]; then
1773 printf '%s\n' "$fallback/bin"
1774 return
1775 fi
1776 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
1777 return 1
1778}}
1779
1780detect_latest_android_api() {{
1781 find "$ANDROID_HOME/platforms" -maxdepth 1 -type d -name 'android-*' 2>/dev/null \
1782 | sed 's#.*android-##' \
1783 | sort -n \
1784 | tail -1
1785}}
1786
1787detect_build_tools_dir() {{
1788 if [[ -n "${{ANDROID_BUILD_TOOLS:-}}" ]]; then
1789 if [[ -d "$ANDROID_BUILD_TOOLS" ]]; then
1790 printf '%s\n' "$ANDROID_BUILD_TOOLS"
1791 return
1792 fi
1793 if [[ -d "$ANDROID_HOME/build-tools/$ANDROID_BUILD_TOOLS" ]]; then
1794 printf '%s\n' "$ANDROID_HOME/build-tools/$ANDROID_BUILD_TOOLS"
1795 return
1796 fi
1797 fi
1798 find "$ANDROID_HOME/build-tools" -maxdepth 1 -mindepth 1 -type d 2>/dev/null | sort -V | tail -1
1799}}
1800
1801ANDROID_TARGET_API_LEVEL="${{ANDROID_TARGET_API_LEVEL:-$(detect_latest_android_api)}}"
1802if [[ -z "$ANDROID_TARGET_API_LEVEL" ]]; then
1803 printf 'No Android platform found under %s/platforms. Install one with sdkmanager "platforms;android-35" or newer.\n' "$ANDROID_HOME" >&2
1804 exit 1
1805fi
1806
1807ANDROID_NDK=$(find_android_ndk)
1808ANDROID_TOOLCHAIN="${{ANDROID_TOOLCHAIN:-$(detect_android_toolchain)}}"
1809CC_aarch64_linux_android="${{CC_aarch64_linux_android:-$ANDROID_TOOLCHAIN/aarch64-linux-android${{ANDROID_MIN_API_LEVEL}}-clang}}"
1810AR_aarch64_linux_android="${{AR_aarch64_linux_android:-$ANDROID_TOOLCHAIN/llvm-ar}}"
1811CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER="${{CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER:-$CC_aarch64_linux_android}}"
1812CARGO_TARGET_AARCH64_LINUX_ANDROID_AR="${{CARGO_TARGET_AARCH64_LINUX_ANDROID_AR:-$AR_aarch64_linux_android}}"
1813export ANDROID_HOME ANDROID_NDK ANDROID_MIN_API_LEVEL ANDROID_TARGET_API_LEVEL ANDROID_TOOLCHAIN CC_aarch64_linux_android AR_aarch64_linux_android
1814export CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER CARGO_TARGET_AARCH64_LINUX_ANDROID_AR
1815
1816BUILD_TOOLS=$(detect_build_tools_dir)
1817if [[ -z "$BUILD_TOOLS" || ! -d "$BUILD_TOOLS" ]]; then
1818 printf 'Android build-tools not found. Install them with sdkmanager "build-tools;35.0.0" or set ANDROID_BUILD_TOOLS.\n' >&2
1819 exit 1
1820fi
1821ANDROID_JAR="$ANDROID_HOME/platforms/android-$ANDROID_TARGET_API_LEVEL/android.jar"
1822if [[ ! -f "$ANDROID_JAR" ]]; then
1823 printf 'Android platform android-%s not found. Install it with sdkmanager "platforms;android-%s" or set ANDROID_TARGET_API_LEVEL.\n' "$ANDROID_TARGET_API_LEVEL" "$ANDROID_TARGET_API_LEVEL" >&2
1824 exit 1
1825fi
1826AAPT="$BUILD_TOOLS/aapt"
1827ZIPALIGN="$BUILD_TOOLS/zipalign"
1828APKSIGNER="$BUILD_TOOLS/apksigner"
1829for tool in "$AAPT" "$ZIPALIGN" "$APKSIGNER"; do
1830 if [[ ! -x "$tool" ]]; then
1831 printf 'Required Android build tool is missing or not executable: %s\n' "$tool" >&2
1832 exit 1
1833 fi
1834done
1835
1836BUILD_ARGS=(build --manifest-path "$PROJECT_DIR/Cargo.toml" --lib --target "$TARGET" --package "$PACKAGE_NAME")
1837ARTIFACT_DIR=debug
1838if [[ "$PROFILE" == "release" ]]; then
1839 BUILD_ARGS+=(--release)
1840 ARTIFACT_DIR=release
1841fi
1842
1843cargo "${{BUILD_ARGS[@]}}"
1844TARGET_DIR=$(python3 - <<'PY' "$PROJECT_DIR/Cargo.toml"
1845import json
1846import subprocess
1847import sys
1848
1849manifest = sys.argv[1]
1850metadata = json.loads(
1851 subprocess.check_output(
1852 ["cargo", "metadata", "--manifest-path", manifest, "--format-version", "1", "--no-deps"]
1853 )
1854)
1855print(metadata["target_directory"])
1856PY
1857)
1858
1859SO_PATH="$TARGET_DIR/$TARGET/$ARTIFACT_DIR/lib$LIB_NAME.so"
1860BUILD_DIR="$SCRIPT_DIR/build/$PROFILE"
1861APK_ROOT="$BUILD_DIR/apk-root"
1862UNALIGNED_APK="$BUILD_DIR/$PACKAGE_NAME-unaligned.apk"
1863ALIGNED_APK="$BUILD_DIR/$PACKAGE_NAME-aligned.apk"
1864SIGNED_APK="$BUILD_DIR/$PACKAGE_NAME.apk"
1865KEYSTORE="${{ANDROID_DEBUG_KEYSTORE:-$HOME/.android/debug.keystore}}"
1866
1867rm -rf "$APK_ROOT"
1868mkdir -p "$APK_ROOT/lib/arm64-v8a" "$APK_ROOT/res/drawable-nodpi" "$BUILD_DIR"
1869cp "$SO_PATH" "$APK_ROOT/lib/arm64-v8a/lib$LIB_NAME.so"
1870cp "$PROJECT_DIR/assets/app-icon.png" "$APK_ROOT/res/drawable-nodpi/app_icon.png"
1871
1872BUILD_MANIFEST="$BUILD_DIR/AndroidManifest.xml"
1873python3 - <<'PY' "$SCRIPT_DIR/AndroidManifest.xml" "$BUILD_MANIFEST" "$ANDROID_MIN_API_LEVEL" "$ANDROID_TARGET_API_LEVEL"
1874import re
1875import sys
1876
1877source, dest, min_api, target_api = sys.argv[1:]
1878manifest = open(source, encoding="utf-8").read()
1879manifest = re.sub(r'android:minSdkVersion="\d+"', f'android:minSdkVersion="{{min_api}}"', manifest)
1880manifest = re.sub(r'android:targetSdkVersion="\d+"', f'android:targetSdkVersion="{{target_api}}"', manifest)
1881open(dest, "w", encoding="utf-8").write(manifest)
1882PY
1883
1884"$AAPT" package -f -F "$UNALIGNED_APK" -M "$BUILD_MANIFEST" -S "$APK_ROOT/res" -I "$ANDROID_JAR"
1885(cd "$APK_ROOT" && zip -qr "$UNALIGNED_APK" lib)
1886"$ZIPALIGN" -f 4 "$UNALIGNED_APK" "$ALIGNED_APK"
1887
1888if [[ ! -f "$KEYSTORE" ]]; then
1889 mkdir -p "$(dirname "$KEYSTORE")"
1890 keytool -genkeypair -v \
1891 -keystore "$KEYSTORE" \
1892 -storepass android \
1893 -alias androiddebugkey \
1894 -keypass android \
1895 -dname "CN=Android Debug,O=Android,C=US" \
1896 -keyalg RSA \
1897 -keysize 2048 \
1898 -validity 10000 >/dev/null 2>&1
1899fi
1900
1901"$APKSIGNER" sign \
1902 --ks "$KEYSTORE" \
1903 --ks-pass pass:android \
1904 --key-pass pass:android \
1905 --out "$SIGNED_APK" \
1906 "$ALIGNED_APK"
1907
1908printf '%s\n' "$SIGNED_APK"
1909"#,
1910 package_name = project.app.name,
1911 lib_name = lib_name,
1912 )
1913}
1914
1915fn render_android_run_script(project: &FissionProject) -> String {
1916 format!(
1917 r#"#!/usr/bin/env bash
1918set -euo pipefail
1919
1920SCRIPT_DIR=$(cd -- "$(dirname "${{BASH_SOURCE[0]}}")" && pwd)
1921ANDROID_HOME="${{ANDROID_HOME:-${{ANDROID_SDK_ROOT:-$HOME/Library/Android/sdk}}}}"
1922ADB="$ANDROID_HOME/platform-tools/adb"
1923EMULATOR_BIN="$ANDROID_HOME/emulator/emulator"
1924AVDMANAGER="${{ANDROID_AVDMANAGER:-$ANDROID_HOME/cmdline-tools/latest/bin/avdmanager}}"
1925
1926detect_latest_emulator_api() {{
1927 find "$ANDROID_HOME/system-images" -path '*/google_apis/arm64-v8a' -type d 2>/dev/null \
1928 | sed -n 's#.*system-images/android-\([0-9][0-9]*\)/google_apis/arm64-v8a#\1#p' \
1929 | sort -n \
1930 | tail -1
1931}}
1932
1933android_system_image_path() {{
1934 local image="$1"
1935 image="${{image#system-images;}}"
1936 printf '%s/system-images/%s\n' "$ANDROID_HOME" "${{image//;/\/}}"
1937}}
1938
1939ANDROID_EMULATOR_API_LEVEL="${{ANDROID_EMULATOR_API_LEVEL:-$(detect_latest_emulator_api)}}"
1940if [[ -z "$ANDROID_EMULATOR_API_LEVEL" ]]; then
1941 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
1942 exit 1
1943fi
1944AVD_NAME="${{ANDROID_AVD_NAME:-FissionApi${{ANDROID_EMULATOR_API_LEVEL}}Arm64}}"
1945SYSTEM_IMAGE="${{ANDROID_SYSTEM_IMAGE:-system-images;android-${{ANDROID_EMULATOR_API_LEVEL}};google_apis;arm64-v8a}}"
1946DEVICE_PORT="${{ANDROID_TEST_CONTROL_DEVICE_PORT:-48761}}"
1947HOST_PORT="${{FISSION_TEST_CONTROL_PORT:-48761}}"
1948HEADLESS="${{ANDROID_EMULATOR_HEADLESS:-0}}"
1949RESTART_EMULATOR="${{ANDROID_EMULATOR_RESTART:-0}}"
1950
1951for tool in "$ADB" "$EMULATOR_BIN" "$AVDMANAGER"; do
1952 if [[ ! -x "$tool" ]]; then
1953 printf 'Required Android tool is missing or not executable: %s\nRun `fission doctor android --project-dir .` for setup help.\n' "$tool" >&2
1954 exit 1
1955 fi
1956done
1957
1958if ! "$AVDMANAGER" list avd | grep -q "Name: $AVD_NAME"; then
1959 if [[ ! -d "$(android_system_image_path "$SYSTEM_IMAGE")" ]]; then
1960 printf 'Android system image is not installed: %s\nInstall it with sdkmanager "%s" or set ANDROID_SYSTEM_IMAGE.\n' "$SYSTEM_IMAGE" "$SYSTEM_IMAGE" >&2
1961 exit 1
1962 fi
1963 echo "no" | "$AVDMANAGER" create avd -n "$AVD_NAME" -k "$SYSTEM_IMAGE" --abi "google_apis/arm64-v8a" --device "pixel_5"
1964fi
1965
1966RUNNING_EMULATOR=$("$ADB" devices | awk '/^emulator-.*device$/ {{ print $1; exit }}')
1967if [[ -n "$RUNNING_EMULATOR" && "$RESTART_EMULATOR" == "1" ]]; then
1968 "$ADB" -s "$RUNNING_EMULATOR" emu kill >/dev/null || true
1969 until ! "$ADB" devices | grep -q '^emulator-'; do
1970 sleep 1
1971 done
1972 RUNNING_EMULATOR=""
1973fi
1974
1975if [[ -z "$RUNNING_EMULATOR" ]]; then
1976 EMULATOR_ARGS=(-avd "$AVD_NAME" -gpu "${{ANDROID_EMULATOR_GPU:-swiftshader_indirect}}" -no-audio)
1977 if [[ "$HEADLESS" == "1" ]]; then
1978 EMULATOR_ARGS+=(-no-window)
1979 fi
1980 printf 'Launching emulator %s (%s)\n' "$AVD_NAME" "$([[ "$HEADLESS" == "1" ]] && echo headless || echo visible)"
1981 "$EMULATOR_BIN" "${{EMULATOR_ARGS[@]}}" >/tmp/fission-android-emulator.log 2>&1 &
1982 "$ADB" wait-for-device
1983 until "$ADB" shell getprop sys.boot_completed 2>/dev/null | tr -d '\r' | grep -q '^1$'; do
1984 sleep 1
1985 done
1986else
1987 printf 'Using existing emulator %s\n' "$RUNNING_EMULATOR"
1988 if [[ "$HEADLESS" != "1" ]]; then
1989 printf 'If the window is not visible, restart with ANDROID_EMULATOR_RESTART=1 to relaunch a visible emulator.\n'
1990 fi
1991fi
1992
1993APK=$("$SCRIPT_DIR/package-apk.sh")
1994"$ADB" install -r "$APK"
1995"$ADB" forward "tcp:$HOST_PORT" "tcp:$DEVICE_PORT"
1996"$ADB" shell am start -n {app_id}/android.app.NativeActivity >/dev/null
1997printf 'APK=%s\n' "$APK"
1998"#,
1999 app_id = project.app.app_id,
2000 )
2001}
2002
2003fn render_android_test_script() -> String {
2004 r#"#!/usr/bin/env bash
2005set -euo pipefail
2006
2007SCRIPT_DIR=$(cd -- "$(dirname "${BASH_SOURCE[0]}")" && pwd)
2008export FISSION_TEST_CONTROL_PORT="${FISSION_TEST_CONTROL_PORT:-48761}"
2009
2010"$SCRIPT_DIR/run-emulator.sh"
2011
2012python3 - <<'PY' "$FISSION_TEST_CONTROL_PORT"
2013import sys
2014import time
2015import urllib.request
2016
2017port = sys.argv[1]
2018url = f"http://127.0.0.1:{port}/health"
2019deadline = time.time() + 90
2020last_error = None
2021while time.time() < deadline:
2022 try:
2023 with urllib.request.urlopen(url, timeout=1) as response:
2024 body = response.read().decode("utf-8", "replace")
2025 if response.status == 200 and '"status":"ok"' in body:
2026 print(f"Android emulator test control is healthy on {url}")
2027 raise SystemExit(0)
2028 except Exception as error:
2029 last_error = error
2030 time.sleep(1)
2031raise SystemExit(f"Android emulator test control did not become healthy on {url}: {last_error}")
2032PY
2033"#
2034 .to_string()
2035}
2036
2037fn render_web_index(project: &FissionProject) -> String {
2038 let title = ios_bundle_name(project);
2039 format!(
2040 r#"<!doctype html>
2041<html lang="en">
2042 <head>
2043 <meta charset="utf-8" />
2044 <meta name="viewport" content="width=device-width, initial-scale=1" />
2045 <title>{title}</title>
2046 <link rel="icon" type="image/png" href="../../assets/app-icon.png" />
2047 <style>
2048 :root {{
2049 color-scheme: dark;
2050 background: #14171f;
2051 }}
2052 html, body {{
2053 margin: 0;
2054 width: 100%;
2055 height: 100%;
2056 overflow: hidden;
2057 overscroll-behavior: none;
2058 background: #14171f;
2059 }}
2060 body, #fission-web-mount {{
2061 width: 100vw;
2062 height: 100vh;
2063 }}
2064 canvas {{
2065 display: block;
2066 width: 100vw;
2067 height: 100vh;
2068 border: 0;
2069 outline: none;
2070 user-select: none;
2071 -webkit-user-drag: none;
2072 touch-action: none;
2073 -webkit-tap-highlight-color: transparent;
2074 }}
2075 canvas:focus, canvas:focus-visible {{
2076 outline: none;
2077 }}
2078 </style>
2079 </head>
2080 <body>
2081 <main id="fission-web-mount" aria-label="{title}"></main>
2082 <script type="module" src="./bootstrap.mjs"></script>
2083 </body>
2084</html>
2085"#,
2086 title = title,
2087 )
2088}
2089
2090fn render_web_bootstrap(project: &FissionProject) -> String {
2091 let module_name = project.app.name.replace('-', "_");
2092 format!(
2093 "import init from \"./pkg/{}.js\";\n\nawait init();\n",
2094 module_name
2095 )
2096}
2097
2098fn render_web_build_script() -> String {
2099 r#"#!/usr/bin/env bash
2100set -euo pipefail
2101
2102SCRIPT_DIR=$(cd -- "$(dirname "${BASH_SOURCE[0]}")" && pwd)
2103PROJECT_DIR=$(cd -- "$SCRIPT_DIR/../.." && pwd)
2104PROFILE="${FISSION_WEB_PROFILE:-dev}"
2105BUILD_ARGS=(build "$PROJECT_DIR" --target web --out-dir "$SCRIPT_DIR/pkg")
2106
2107if [[ "$PROFILE" == "release" ]]; then
2108 BUILD_ARGS+=(--release)
2109else
2110 BUILD_ARGS+=(--dev)
2111fi
2112
2113wasm-pack "${BUILD_ARGS[@]}"
2114"#
2115 .to_string()
2116}
2117
2118fn render_web_run_script(_project: &FissionProject) -> String {
2119 format!(
2120 r#"#!/usr/bin/env bash
2121set -euo pipefail
2122
2123SCRIPT_DIR=$(cd -- "$(dirname "${{BASH_SOURCE[0]}}")" && pwd)
2124PROJECT_DIR=$(cd -- "$SCRIPT_DIR/../.." && pwd)
2125HOST="${{FISSION_WEB_HOST:-127.0.0.1}}"
2126PORT="${{FISSION_WEB_PORT:-8123}}"
2127URL="http://${{HOST}}:${{PORT}}/platforms/web/"
2128
2129"$SCRIPT_DIR/build-wasm.sh"
2130
2131printf 'Serving %s\n' "$URL"
2132printf 'Press Ctrl+C to stop the local server.\n'
2133if [[ "${{FISSION_WEB_OPEN:-0}}" == "1" ]]; then
2134 if command -v open >/dev/null 2>&1; then
2135 open "$URL"
2136 elif command -v xdg-open >/dev/null 2>&1; then
2137 xdg-open "$URL"
2138 elif command -v cmd.exe >/dev/null 2>&1; then
2139 cmd.exe /C start "$URL"
2140 else
2141 printf 'No browser opener found. Open %s manually.\n' "$URL"
2142 fi
2143fi
2144
2145cd "$PROJECT_DIR"
2146python3 -m http.server "$PORT" --bind "$HOST"
2147"#
2148 )
2149}
2150
2151fn render_web_test_script(_project: &FissionProject) -> String {
2152 r#"#!/usr/bin/env bash
2153set -euo pipefail
2154
2155SCRIPT_DIR=$(cd -- "$(dirname "${BASH_SOURCE[0]}")" && pwd)
2156PROJECT_DIR=$(cd -- "$SCRIPT_DIR/../.." && pwd)
2157HOST="${FISSION_WEB_HOST:-127.0.0.1}"
2158PORT="${FISSION_WEB_PORT:-8123}"
2159CDP_PORT="${FISSION_WEB_CDP_PORT:-9222}"
2160URL="http://${HOST}:${PORT}/platforms/web/"
2161PROFILE_DIR="$SCRIPT_DIR/build/chrome-profile"
2162
2163require_node_websocket() {
2164 if ! command -v node >/dev/null 2>&1; then
2165 printf 'Node.js was not found. Install Node 22+ so the generated browser smoke test can inspect Chrome CDP console/runtime errors.\n' >&2
2166 exit 1
2167 fi
2168 if ! node -e 'process.exit(typeof WebSocket === "function" ? 0 : 1)' >/dev/null 2>&1; then
2169 printf 'Node.js is available but does not expose the built-in WebSocket client. Install Node 22+ for Chrome CDP smoke tests.\n' >&2
2170 exit 1
2171 fi
2172}
2173
2174detect_chrome() {
2175 if [[ -n "${FISSION_CHROME:-}" && -x "$FISSION_CHROME" ]]; then
2176 printf '%s\n' "$FISSION_CHROME"
2177 return
2178 fi
2179 local candidate
2180 for candidate in \
2181 "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" \
2182 "/Applications/Chromium.app/Contents/MacOS/Chromium" \
2183 "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge"; do
2184 if [[ -x "$candidate" ]]; then
2185 printf '%s\n' "$candidate"
2186 return
2187 fi
2188 done
2189 for candidate in google-chrome chromium chromium-browser chrome; do
2190 if command -v "$candidate" >/dev/null 2>&1; then
2191 command -v "$candidate"
2192 return
2193 fi
2194 done
2195 return 1
2196}
2197
2198require_node_websocket
2199"$SCRIPT_DIR/build-wasm.sh"
2200
2201mkdir -p "$SCRIPT_DIR/build"
2202cd "$PROJECT_DIR"
2203python3 -m http.server "$PORT" --bind "$HOST" >"$SCRIPT_DIR/build/web-server.log" 2>&1 &
2204SERVER_PID=$!
2205
2206cleanup() {
2207 if [[ -n "${CHROME_PID:-}" ]]; then
2208 kill "$CHROME_PID" >/dev/null 2>&1 || true
2209 fi
2210 kill "$SERVER_PID" >/dev/null 2>&1 || true
2211}
2212trap cleanup EXIT
2213
2214printf 'Running transient web smoke test at %s\n' "$URL"
2215printf 'The local server is stopped automatically when this script exits.\n'
2216
2217python3 - <<'PY' "$URL"
2218import sys
2219import time
2220import urllib.request
2221
2222url = sys.argv[1]
2223deadline = time.time() + 30
2224last_error = None
2225while time.time() < deadline:
2226 try:
2227 with urllib.request.urlopen(url, timeout=1) as response:
2228 if response.status == 200:
2229 raise SystemExit(0)
2230 except Exception as error:
2231 last_error = error
2232 time.sleep(0.5)
2233raise SystemExit(f"web server did not serve {url}: {last_error}")
2234PY
2235
2236CHROME=$(detect_chrome) || {
2237 printf 'Chrome/Chromium was not found. Set FISSION_CHROME=/path/to/chrome or run `fission doctor web --project-dir .`.\n' >&2
2238 exit 1
2239}
2240
2241rm -rf "$PROFILE_DIR"
2242"$CHROME" \
2243 --headless=new \
2244 --no-first-run \
2245 --no-default-browser-check \
2246 --remote-debugging-port="$CDP_PORT" \
2247 --user-data-dir="$PROFILE_DIR" \
2248 "$URL" >"$SCRIPT_DIR/build/chrome.log" 2>&1 &
2249CHROME_PID=$!
2250
2251CDP_PORT="$CDP_PORT" FISSION_WEB_URL="$URL" node <<'NODE'
2252const cdpPort = process.env.CDP_PORT;
2253const expectedUrl = process.env.FISSION_WEB_URL;
2254const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
2255
2256async function waitForTarget() {
2257 const deadline = Date.now() + 60_000;
2258 let lastError = null;
2259 while (Date.now() < deadline) {
2260 try {
2261 const response = await fetch(`http://127.0.0.1:${cdpPort}/json/list`);
2262 const targets = await response.json();
2263 const target = targets.find((entry) => entry.type === 'page' && entry.url.startsWith(expectedUrl));
2264 if (target?.webSocketDebuggerUrl) {
2265 return target.webSocketDebuggerUrl;
2266 }
2267 } catch (error) {
2268 lastError = error;
2269 }
2270 await sleep(250);
2271 }
2272 throw new Error(`Chrome CDP target did not become ready for ${expectedUrl}: ${lastError?.message ?? lastError}`);
2273}
2274
2275class CdpClient {
2276 constructor(url) {
2277 this.url = url;
2278 this.ws = null;
2279 this.nextId = 1;
2280 this.pending = new Map();
2281 this.errors = [];
2282 }
2283
2284 async open() {
2285 await new Promise((resolve, reject) => {
2286 const ws = new WebSocket(this.url);
2287 this.ws = ws;
2288 ws.addEventListener('open', resolve, { once: true });
2289 ws.addEventListener('error', (event) => reject(new Error(`CDP websocket error: ${event.message ?? 'unknown error'}`)), { once: true });
2290 ws.addEventListener('message', (event) => this.onMessage(event.data));
2291 ws.addEventListener('close', () => {
2292 for (const { reject: rejectPending } of this.pending.values()) {
2293 rejectPending(new Error('CDP websocket closed'));
2294 }
2295 this.pending.clear();
2296 });
2297 });
2298 }
2299
2300 send(method, params = {}) {
2301 const id = this.nextId++;
2302 const message = { id, method, params };
2303 return new Promise((resolve, reject) => {
2304 const timeout = setTimeout(() => {
2305 this.pending.delete(id);
2306 reject(new Error(`CDP command timed out: ${method}`));
2307 }, 10_000);
2308 this.pending.set(id, { resolve, reject, timeout, method });
2309 this.ws.send(JSON.stringify(message));
2310 });
2311 }
2312
2313 onMessage(raw) {
2314 const message = JSON.parse(raw);
2315 if (message.id) {
2316 const pending = this.pending.get(message.id);
2317 if (!pending) return;
2318 clearTimeout(pending.timeout);
2319 this.pending.delete(message.id);
2320 if (message.error) {
2321 pending.reject(new Error(`${pending.method}: ${message.error.message}`));
2322 } else {
2323 pending.resolve(message.result ?? {});
2324 }
2325 return;
2326 }
2327
2328 if (message.method === 'Runtime.exceptionThrown') {
2329 this.errors.push(formatException(message.params?.exceptionDetails));
2330 } else if (message.method === 'Runtime.consoleAPICalled') {
2331 const type = message.params?.type;
2332 if (type === 'error' || type === 'assert') {
2333 this.errors.push(`console.${type}: ${(message.params?.args ?? []).map(formatRemoteObject).join(' ')}`);
2334 }
2335 } else if (message.method === 'Log.entryAdded') {
2336 const entry = message.params?.entry;
2337 if (entry?.level === 'error') {
2338 this.errors.push(`browser log error: ${entry.text}${entry.url ? ` (${entry.url}:${entry.lineNumber ?? 0})` : ''}`);
2339 }
2340 }
2341 }
2342
2343 close() {
2344 this.ws?.close();
2345 }
2346}
2347
2348function formatRemoteObject(value) {
2349 if (!value) return '<missing>';
2350 if (Object.prototype.hasOwnProperty.call(value, 'value')) return JSON.stringify(value.value);
2351 return value.description ?? value.unserializableValue ?? value.type ?? '<unknown>';
2352}
2353
2354function formatException(details) {
2355 if (!details) return 'runtime exception: <missing details>';
2356 const exception = details.exception?.description ?? details.exception?.value ?? details.text ?? 'unknown exception';
2357 const location = details.url ? ` at ${details.url}:${details.lineNumber ?? 0}:${details.columnNumber ?? 0}` : '';
2358 return `runtime exception: ${exception}${location}`;
2359}
2360
2361function errorBlock(errors) {
2362 return errors.slice(0, 10).map((error, index) => `${index + 1}. ${error}`).join('\n');
2363}
2364
2365async function readCanvas(client) {
2366 const expression = `(() => {
2367 const canvas = document.querySelector('canvas');
2368 if (!canvas) return { ready: false, reason: 'no canvas element' };
2369 const rect = canvas.getBoundingClientRect();
2370 return {
2371 ready: rect.width > 0 && rect.height > 0,
2372 width: Math.round(rect.width),
2373 height: Math.round(rect.height),
2374 gpu: typeof navigator.gpu !== 'undefined',
2375 title: document.title,
2376 };
2377 })()`;
2378 const result = await client.send('Runtime.evaluate', { expression, returnByValue: true });
2379 if (result.exceptionDetails) {
2380 throw new Error(formatException(result.exceptionDetails));
2381 }
2382 return result.result?.value ?? { ready: false, reason: 'evaluation returned no value' };
2383}
2384
2385async function main() {
2386 const wsUrl = await waitForTarget();
2387 const client = new CdpClient(wsUrl);
2388 await client.open();
2389 try {
2390 await Promise.all([
2391 client.send('Runtime.enable'),
2392 client.send('Log.enable'),
2393 client.send('Page.enable'),
2394 ]);
2395
2396 const deadline = Date.now() + 60_000;
2397 let readySince = null;
2398 let lastCanvas = null;
2399 while (Date.now() < deadline) {
2400 if (client.errors.length > 0) {
2401 throw new Error(`browser reported runtime/console errors:\n${errorBlock(client.errors)}`);
2402 }
2403 lastCanvas = await readCanvas(client);
2404 if (lastCanvas.ready) {
2405 readySince ??= Date.now();
2406 if (Date.now() - readySince >= 1_500) {
2407 console.log(`Web app rendered canvas ${lastCanvas.width}x${lastCanvas.height}; no runtime console errors observed.`);
2408 return;
2409 }
2410 } else {
2411 readySince = null;
2412 }
2413 await sleep(250);
2414 }
2415 throw new Error(`web app did not render a non-empty canvas. Last canvas state: ${JSON.stringify(lastCanvas)}`);
2416 } finally {
2417 client.close();
2418 }
2419}
2420
2421main().catch((error) => {
2422 console.error(error.stack ?? error.message ?? String(error));
2423 process.exit(1);
2424});
2425NODE
2426"#
2427 .to_string()
2428}
2429fn render_app_main(package_name: &str) -> String {
2430 let lib_name = package_name.replace('-', "_");
2431 format!(
2432 r#"#[cfg(target_os = "android")]
2433fn main() {{}}
2434
2435#[cfg(target_arch = "wasm32")]
2436fn main() {{}}
2437
2438#[cfg(target_os = "ios")]
2439fn main() -> anyhow::Result<()> {{
2440 {lib_name}::run_mobile()
2441}}
2442
2443#[cfg(not(any(target_arch = "wasm32", target_os = "ios", target_os = "android")))]
2444fn main() -> anyhow::Result<()> {{
2445 {lib_name}::run_desktop()
2446}}
2447"#
2448 )
2449}
2450
2451const APP_LIB: &str = r#"pub mod app;
2452
2453use crate::app::CounterApp;
2454use fission::prelude::*;
2455
2456#[cfg(target_os = "android")]
2457const ANDROID_TEST_CONTROL_PORT: u16 = 48761;
2458
2459#[cfg(any(target_os = "android", target_os = "ios"))]
2460fn mobile_app() -> MobileApp<crate::app::CounterState, CounterApp> {
2461 let app = MobileApp::new(CounterApp).with_title("Fission App");
2462 #[cfg(target_os = "android")]
2463 let app = app.with_test_control_port(ANDROID_TEST_CONTROL_PORT);
2464 app
2465}
2466
2467#[cfg(target_arch = "wasm32")]
2468fn web_app() -> WebApp<crate::app::CounterState, CounterApp> {
2469 WebApp::new(CounterApp).with_title("Fission App")
2470}
2471
2472#[cfg(not(any(target_arch = "wasm32", target_os = "android", target_os = "ios")))]
2473pub fn run_desktop() -> anyhow::Result<()> {
2474 DesktopApp::new(CounterApp).run()
2475}
2476
2477#[cfg(any(target_os = "android", target_os = "ios"))]
2478pub fn run_mobile() -> anyhow::Result<()> {
2479 mobile_app().run()
2480}
2481
2482#[cfg(target_os = "android")]
2483#[no_mangle]
2484fn android_main(app_handle: AndroidApp) {
2485 let _ = mobile_app().run_with_android_app(app_handle);
2486}
2487
2488#[cfg(target_arch = "wasm32")]
2489#[wasm_bindgen::prelude::wasm_bindgen(start)]
2490pub fn run_web() -> Result<(), wasm_bindgen::JsValue> {
2491 console_error_panic_hook::set_once();
2492 web_app()
2493 .run()
2494 .map_err(|error| wasm_bindgen::JsValue::from_str(&error.to_string()))
2495}
2496"#;
2497
2498const APP_RS: &str = r#"use fission::prelude::*;
2499
2500#[derive(Default, Debug, Clone, PartialEq)]
2501pub struct CounterState {
2502 pub count: i32,
2503}
2504
2505impl AppState for CounterState {}
2506
2507#[fission_reducer(Increment)]
2508fn on_increment(state: &mut CounterState) {
2509 state.count += 1;
2510}
2511
2512pub struct CounterApp;
2513
2514impl Widget<CounterState> for CounterApp {
2515 fn build(&self, ctx: &mut BuildCtx<CounterState>, view: &View<CounterState>) -> Node {
2516 let increment = with_reducer!(ctx, Increment, on_increment);
2517
2518 Column {
2519 gap: Some(16.0),
2520 children: vec![
2521 Text::new(format!("Count: {}", view.state.count)).size(28.0).into_node(),
2522 Button {
2523 on_press: Some(increment),
2524 child: Some(Box::new(Text::new("Increment").into_node())),
2525 ..Default::default()
2526 }
2527 .into_node(),
2528 ],
2529 ..Default::default()
2530 }
2531 .into_node()
2532 }
2533}
2534"#;