1use crate::{write_file, FissionProject, Target};
2use anyhow::{bail, Context, Result};
3use serde::{Deserialize, Serialize};
4use std::fs;
5use std::path::Path;
6
7const DEFAULT_SPLASH_BACKGROUND: &str = "#F8FAFC";
8const DEFAULT_SPLASH_IMAGE: &str = "assets/app-icon.png";
9
10#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct SplashConfig {
12 #[serde(default, skip_serializing_if = "Option::is_none")]
13 pub background_color: Option<String>,
14 #[serde(default, skip_serializing_if = "Option::is_none")]
15 pub image: Option<String>,
16 #[serde(default, skip_serializing_if = "Option::is_none")]
17 pub resize_mode: Option<SplashResizeMode>,
18 #[serde(default, skip_serializing_if = "Option::is_none")]
19 pub android_animated_icon: Option<String>,
20 #[serde(default, skip_serializing_if = "Option::is_none")]
21 pub android_animation_duration_ms: Option<u16>,
22}
23
24#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
25#[serde(rename_all = "kebab-case")]
26pub enum SplashResizeMode {
27 Center,
28 Contain,
29 Cover,
30}
31
32pub(crate) fn apply_platform_splash_config(root: &Path, project: &FissionProject) -> Result<()> {
33 validate_splash_config(project)?;
34 if project.targets.contains(&Target::Android) {
35 apply_android_splash_config(root, project)?;
36 }
37 if project.targets.contains(&Target::Ios) {
38 apply_ios_splash_config(root, project)?;
39 }
40 Ok(())
41}
42
43fn validate_splash_config(project: &FissionProject) -> Result<()> {
44 let color = splash_background_color(project);
45 parse_hex_color(color)
46 .with_context(|| format!("invalid app.splash.background_color `{color}`"))?;
47 if let Some(config) = &project.app.splash {
48 if let Some(duration) = config.android_animation_duration_ms {
49 if duration == 0 {
50 bail!("app.splash.android_animation_duration_ms must be greater than zero");
51 }
52 }
53 if let Some(animated_icon) = &config.android_animated_icon {
54 if Path::new(animated_icon)
55 .extension()
56 .and_then(|value| value.to_str())
57 != Some("xml")
58 {
59 bail!(
60 "app.splash.android_animated_icon must point to an Android XML drawable resource"
61 );
62 }
63 }
64 }
65 Ok(())
66}
67
68fn apply_android_splash_config(root: &Path, project: &FissionProject) -> Result<()> {
69 let color = android_color_literal(splash_background_color(project))?;
70 let static_icon = copy_android_splash_image(root, project)?;
71 let animated_icon = copy_android_animated_splash_icon(root, project)?;
72 let window_icon = animated_icon.as_deref().unwrap_or(&static_icon);
73
74 write_file(
75 &root.join("platforms/android/res/values/colors.xml"),
76 &render_android_splash_colors(&color),
77 )?;
78 write_file(
79 &root.join("platforms/android/res/values/styles.xml"),
80 &render_android_splash_styles(window_icon, project),
81 )?;
82 write_file(
83 &root.join("platforms/android/res/drawable/fission_splash_background.xml"),
84 &render_android_splash_background(&static_icon),
85 )?;
86 ensure_android_manifest_uses_splash_theme(root)?;
87 ensure_android_package_script_copies_resources(root)
88}
89
90fn apply_ios_splash_config(root: &Path, project: &FissionProject) -> Result<()> {
91 let color = parse_hex_color(splash_background_color(project))?;
92 let image_name = copy_ios_splash_image(root, project)?;
93 write_file(
94 &root.join("platforms/ios/LaunchScreen.storyboard"),
95 &render_ios_launch_storyboard(&color, image_name.as_deref(), splash_resize_mode(project)),
96 )?;
97 ensure_ios_plist_launch_storyboard(root)?;
98 ensure_ios_package_script_copies_launch_screen(root)
99}
100
101fn splash_background_color(project: &FissionProject) -> &str {
102 project
103 .app
104 .splash
105 .as_ref()
106 .and_then(|config| config.background_color.as_deref())
107 .unwrap_or(DEFAULT_SPLASH_BACKGROUND)
108}
109
110fn splash_resize_mode(project: &FissionProject) -> SplashResizeMode {
111 project
112 .app
113 .splash
114 .as_ref()
115 .and_then(|config| config.resize_mode)
116 .unwrap_or(SplashResizeMode::Contain)
117}
118
119#[derive(Clone, Copy)]
120struct RgbaColor {
121 red: u8,
122 green: u8,
123 blue: u8,
124 alpha: u8,
125}
126
127fn parse_hex_color(value: &str) -> Result<RgbaColor> {
128 let hex = value
129 .strip_prefix('#')
130 .with_context(|| "expected #RRGGBB or #RRGGBBAA")?;
131 if hex.len() != 6 && hex.len() != 8 {
132 bail!("expected #RRGGBB or #RRGGBBAA");
133 }
134 let parse_byte = |range: std::ops::Range<usize>| -> Result<u8> {
135 u8::from_str_radix(&hex[range], 16).with_context(|| "expected hexadecimal color digits")
136 };
137 Ok(RgbaColor {
138 red: parse_byte(0..2)?,
139 green: parse_byte(2..4)?,
140 blue: parse_byte(4..6)?,
141 alpha: if hex.len() == 8 {
142 parse_byte(6..8)?
143 } else {
144 255
145 },
146 })
147}
148
149fn android_color_literal(value: &str) -> Result<String> {
150 let color = parse_hex_color(value)?;
151 if color.alpha == 255 {
152 Ok(format!(
153 "#{:02X}{:02X}{:02X}",
154 color.red, color.green, color.blue
155 ))
156 } else {
157 Ok(format!(
158 "#{:02X}{:02X}{:02X}{:02X}",
159 color.alpha, color.red, color.green, color.blue
160 ))
161 }
162}
163
164fn copy_android_splash_image(root: &Path, project: &FissionProject) -> Result<String> {
165 let resource_name = "fission_splash_image";
166 if let Some(source) = configured_splash_image(project) {
167 let source = root.join(source);
168 let extension = android_bitmap_extension(&source)?;
169 let destination = root
170 .join("platforms/android/res/drawable-nodpi")
171 .join(format!("{resource_name}.{extension}"));
172 copy_required_asset(&source, &destination)?;
173 } else {
174 let source = root.join(DEFAULT_SPLASH_IMAGE);
175 android_bitmap_extension(&source)?;
176 if !source.exists() {
177 bail!("default splash asset does not exist: {}", source.display());
178 }
179 }
180 Ok(format!("@drawable/{resource_name}"))
181}
182
183fn copy_android_animated_splash_icon(
184 root: &Path,
185 project: &FissionProject,
186) -> Result<Option<String>> {
187 let Some(source) = project
188 .app
189 .splash
190 .as_ref()
191 .and_then(|config| config.android_animated_icon.as_deref())
192 else {
193 return Ok(None);
194 };
195 let destination = root.join("platforms/android/res/drawable/fission_splash_animated_icon.xml");
196 copy_required_asset(&root.join(source), &destination)?;
197 Ok(Some("@drawable/fission_splash_animated_icon".to_string()))
198}
199
200fn copy_ios_splash_image(root: &Path, project: &FissionProject) -> Result<Option<String>> {
201 if let Some(source) = configured_splash_image(project) {
202 let source = root.join(source);
203 let extension = ios_image_extension(&source)?;
204 let destination = root
205 .join("platforms/ios")
206 .join(format!("SplashImage.{extension}"));
207 copy_required_asset(&source, &destination)?;
208 } else {
209 let source = root.join(DEFAULT_SPLASH_IMAGE);
210 ios_image_extension(&source)?;
211 if !source.exists() {
212 bail!("default splash asset does not exist: {}", source.display());
213 }
214 }
215 Ok(Some("SplashImage".to_string()))
216}
217
218fn configured_splash_image(project: &FissionProject) -> Option<&str> {
219 project
220 .app
221 .splash
222 .as_ref()
223 .and_then(|config| config.image.as_deref())
224}
225
226fn copy_required_asset(source: &Path, destination: &Path) -> Result<()> {
227 if !source.exists() {
228 bail!(
229 "configured splash asset does not exist: {}",
230 source.display()
231 );
232 }
233 if let Some(parent) = destination.parent() {
234 fs::create_dir_all(parent)?;
235 }
236 fs::copy(source, destination).with_context(|| {
237 format!(
238 "failed to copy {} to {}",
239 source.display(),
240 destination.display()
241 )
242 })?;
243 Ok(())
244}
245
246fn android_bitmap_extension(path: &Path) -> Result<String> {
247 let extension = path
248 .extension()
249 .and_then(|value| value.to_str())
250 .map(|value| value.to_ascii_lowercase())
251 .with_context(|| {
252 format!(
253 "splash image must have a file extension: {}",
254 path.display()
255 )
256 })?;
257 match extension.as_str() {
258 "png" | "jpg" | "jpeg" | "webp" => Ok(extension),
259 _ => bail!("Android splash image must be png, jpg, jpeg, or webp"),
260 }
261}
262
263fn ios_image_extension(path: &Path) -> Result<String> {
264 let extension = path
265 .extension()
266 .and_then(|value| value.to_str())
267 .map(|value| value.to_ascii_lowercase())
268 .with_context(|| {
269 format!(
270 "splash image must have a file extension: {}",
271 path.display()
272 )
273 })?;
274 match extension.as_str() {
275 "png" | "jpg" | "jpeg" => Ok(extension),
276 _ => bail!("iOS splash image must be png, jpg, or jpeg"),
277 }
278}
279
280fn render_android_splash_colors(color: &str) -> String {
281 format!(
282 r#"<?xml version="1.0" encoding="utf-8"?>
283<resources>
284 <color name="fission_splash_background">{color}</color>
285</resources>
286"#
287 )
288}
289
290fn render_android_splash_styles(window_icon: &str, project: &FissionProject) -> String {
291 let duration = project
292 .app
293 .splash
294 .as_ref()
295 .and_then(|config| config.android_animation_duration_ms)
296 .unwrap_or(800);
297 format!(
298 r#"<?xml version="1.0" encoding="utf-8"?>
299<resources>
300 <style name="FissionLaunchTheme" parent="@android:style/Theme.Material.NoActionBar">
301 <item name="android:windowNoTitle">true</item>
302 <item name="android:windowActionBar">false</item>
303 <item name="android:windowDisablePreview">false</item>
304 <item name="android:windowBackground">@drawable/fission_splash_background</item>
305 <item name="android:windowSplashScreenBackground">@color/fission_splash_background</item>
306 <item name="android:windowSplashScreenAnimatedIcon">{window_icon}</item>
307 <item name="android:windowSplashScreenAnimationDuration">{duration}</item>
308 </style>
309</resources>
310"#
311 )
312}
313
314fn render_android_splash_background(static_icon: &str) -> String {
315 format!(
316 r#"<?xml version="1.0" encoding="utf-8"?>
317<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
318 <item android:drawable="@color/fission_splash_background" />
319 <item android:gravity="center">
320 <bitmap
321 android:gravity="center"
322 android:src="{static_icon}" />
323 </item>
324</layer-list>
325"#
326 )
327}
328
329fn render_ios_launch_storyboard(
330 color: &RgbaColor,
331 image_name: Option<&str>,
332 resize_mode: SplashResizeMode,
333) -> String {
334 let content_mode = match resize_mode {
335 SplashResizeMode::Center => "center",
336 SplashResizeMode::Contain => "scaleAspectFit",
337 SplashResizeMode::Cover => "scaleAspectFill",
338 };
339 let image_view = image_name.map(|name| {
340 format!(
341 r#"
342 <imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="{content_mode}" image="{image}" translatesAutoresizingMaskIntoConstraints="NO" id="splash-image">
343 <rect key="frame" x="79" y="320" width="256" height="256"/>
344 </imageView>"#,
345 image = xml_escape_attr(name)
346 )
347 }).unwrap_or_default();
348 let image_constraints = if image_name.is_some() {
349 r#"
350 <constraint firstItem="splash-image" firstAttribute="centerX" secondItem="splash-root" secondAttribute="centerX" id="splash-center-x"/>
351 <constraint firstItem="splash-image" firstAttribute="centerY" secondItem="splash-root" secondAttribute="centerY" id="splash-center-y"/>
352 <constraint firstItem="splash-image" firstAttribute="width" constant="256" id="splash-width"/>
353 <constraint firstItem="splash-image" firstAttribute="height" constant="256" id="splash-height"/>"#
354 } else {
355 ""
356 };
357 let resources = image_name
358 .map(|name| {
359 format!(
360 r#"
361 <resources>
362 <image name="{image}" width="256" height="256"/>
363 </resources>"#,
364 image = xml_escape_attr(name)
365 )
366 })
367 .unwrap_or_default();
368
369 format!(
370 r#"<?xml version="1.0" encoding="UTF-8"?>
371<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="23504" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" useTraitCollections="YES" colorMatched="YES" initialViewController="splash-controller">
372 <dependencies>
373 <deployment identifier="iOS"/>
374 <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="23506"/>
375 <capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
376 </dependencies>
377 <scenes>
378 <scene sceneID="splash-scene">
379 <objects>
380 <viewController id="splash-controller" sceneMemberID="viewController">
381 <view key="view" contentMode="scaleToFill" id="splash-root">
382 <rect key="frame" x="0.0" y="0.0" width="414" height="896"/>
383 <autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
384 <subviews>{image_view}
385 </subviews>
386 <color key="backgroundColor" red="{red:.6}" green="{green:.6}" blue="{blue:.6}" alpha="{alpha:.6}" colorSpace="custom" customColorSpace="sRGB"/>
387 <constraints>{image_constraints}
388 </constraints>
389 </view>
390 </viewController>
391 <placeholder placeholderIdentifier="IBFirstResponder" id="splash-first-responder" userLabel="First Responder" sceneMemberID="firstResponder"/>
392 </objects>
393 </scene>
394 </scenes>{resources}
395</document>
396"#,
397 red = f32::from(color.red) / 255.0,
398 green = f32::from(color.green) / 255.0,
399 blue = f32::from(color.blue) / 255.0,
400 alpha = f32::from(color.alpha) / 255.0,
401 )
402}
403
404fn xml_escape_attr(value: &str) -> String {
405 value
406 .replace('&', "&")
407 .replace('"', """)
408 .replace('<', "<")
409 .replace('>', ">")
410}
411
412fn ensure_android_manifest_uses_splash_theme(root: &Path) -> Result<()> {
413 let path = root.join("platforms/android/AndroidManifest.xml");
414 if !path.exists() {
415 return Ok(());
416 }
417 let existing =
418 fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))?;
419 if existing.contains("android:theme=\"@style/FissionLaunchTheme\"") {
420 return Ok(());
421 }
422 let updated = if existing.contains("android:launchMode=\"singleTask\">") {
423 existing.replacen(
424 "android:launchMode=\"singleTask\">",
425 "android:launchMode=\"singleTask\"\n android:theme=\"@style/FissionLaunchTheme\">",
426 1,
427 )
428 } else {
429 existing.replacen(
430 "android:exported=\"true\"",
431 "android:exported=\"true\"\n android:theme=\"@style/FissionLaunchTheme\"",
432 1,
433 )
434 };
435 fs::write(&path, updated).with_context(|| format!("failed to write {}", path.display()))
436}
437
438fn ensure_android_package_script_copies_resources(root: &Path) -> Result<()> {
439 let path = root.join("platforms/android/package-apk.sh");
440 if !path.exists() {
441 return Ok(());
442 }
443 let existing =
444 fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))?;
445 if existing.contains("fission_splash_image.png")
446 && existing.contains("cp -R \"$SCRIPT_DIR/res/.\" \"$APK_ROOT/res/\"")
447 {
448 return Ok(());
449 }
450 let marker =
451 "cp \"$PROJECT_DIR/assets/app-icon.png\" \"$APK_ROOT/res/drawable-nodpi/app_icon.png\"\n";
452 let insertion = r#"shopt -s nullglob
453SPLASH_IMAGES=("$SCRIPT_DIR"/res/drawable-nodpi/fission_splash_image.*)
454if (( ${#SPLASH_IMAGES[@]} == 0 )); then
455 cp "$PROJECT_DIR/assets/app-icon.png" "$APK_ROOT/res/drawable-nodpi/fission_splash_image.png"
456fi
457shopt -u nullglob
458if [[ -d "$SCRIPT_DIR/res" ]]; then
459 mkdir -p "$APK_ROOT/res"
460 cp -R "$SCRIPT_DIR/res/." "$APK_ROOT/res/"
461fi
462"#;
463 let old_start = "if [[ -d \"$SCRIPT_DIR/res\" ]]; then\n";
464 let updated = if let Some(start) = existing.find(old_start) {
465 if let Some(relative_end) = existing[start..].find("fi\n") {
466 let end = start + relative_end + "fi\n".len();
467 let mut updated = existing.clone();
468 updated.replace_range(start..end, insertion);
469 updated
470 } else {
471 existing.replacen(marker, &(marker.to_string() + insertion), 1)
472 }
473 } else {
474 existing.replacen(marker, &(marker.to_string() + insertion), 1)
475 };
476 fs::write(&path, updated).with_context(|| format!("failed to write {}", path.display()))
477}
478
479fn ensure_ios_plist_launch_storyboard(root: &Path) -> Result<()> {
480 let path = root.join("platforms/ios/Info.plist");
481 if !path.exists() {
482 return Ok(());
483 }
484 let existing =
485 fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))?;
486 if existing.contains("UILaunchStoryboardName") {
487 return Ok(());
488 }
489 let entry = " <key>UILaunchStoryboardName</key>\n <string>LaunchScreen</string>\n";
490 let updated = existing.replacen(
491 " <key>MinimumOSVersion</key>",
492 &format!("{entry} <key>MinimumOSVersion</key>"),
493 1,
494 );
495 fs::write(&path, updated).with_context(|| format!("failed to write {}", path.display()))
496}
497
498fn ensure_ios_package_script_copies_launch_screen(root: &Path) -> Result<()> {
499 let path = root.join("platforms/ios/package-sim.sh");
500 if !path.exists() {
501 return Ok(());
502 }
503 let existing =
504 fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))?;
505 if existing.contains("ibtool")
506 && existing.contains("LaunchScreen.storyboardc")
507 && existing.contains("$BUNDLE_DIR/SplashImage.png")
508 {
509 return Ok(());
510 }
511 let marker = "cp \"$PROJECT_DIR/assets/app-icon.png\" \"$BUNDLE_DIR/AppIcon.png\"\n";
512 let insertion = r#"shopt -s nullglob
513SPLASH_IMAGES=("$SCRIPT_DIR"/SplashImage.*)
514if (( ${#SPLASH_IMAGES[@]} == 0 )); then
515 cp "$PROJECT_DIR/assets/app-icon.png" "$BUNDLE_DIR/SplashImage.png"
516else
517 for splash_image in "${SPLASH_IMAGES[@]}"; do
518 cp "$splash_image" "$BUNDLE_DIR/"
519 done
520fi
521shopt -u nullglob
522if [[ -f "$SCRIPT_DIR/LaunchScreen.storyboard" ]]; then
523 IBTOOL=$(xcrun --find ibtool 2>/dev/null || true)
524 if [[ -z "$IBTOOL" ]]; then
525 printf 'ibtool not found. Install Xcode command line tools to compile the iOS launch screen storyboard.\n' >&2
526 exit 1
527 fi
528 "$IBTOOL" \
529 --errors \
530 --warnings \
531 --notices \
532 --target-device iphone \
533 --target-device ipad \
534 --minimum-deployment-target 18.0 \
535 --output-format human-readable-text \
536 --compile "$BUNDLE_DIR/LaunchScreen.storyboardc" \
537 "$SCRIPT_DIR/LaunchScreen.storyboard"
538fi
539"#;
540 let old_start = "shopt -s nullglob\n";
541 let old_end = " \"$SCRIPT_DIR/LaunchScreen.storyboard\"\nfi\n";
542 let updated = if let Some(start) = existing.find(old_start) {
543 if existing[start..].contains("LaunchScreen.storyboard") {
544 if let Some(relative_end) = existing[start..].find(old_end) {
545 let end = start + relative_end + old_end.len();
546 let mut updated = existing.clone();
547 updated.replace_range(start..end, insertion);
548 updated
549 } else {
550 existing.replacen(marker, &(marker.to_string() + insertion), 1)
551 }
552 } else {
553 let mut updated = existing.clone();
554 updated.insert_str(start, insertion);
555 updated
556 }
557 } else {
558 existing.replacen(marker, &(marker.to_string() + insertion), 1)
559 };
560 fs::write(&path, updated).with_context(|| format!("failed to write {}", path.display()))
561}