Skip to main content

fission_command_core/
splash.rs

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('&', "&amp;")
407        .replace('"', "&quot;")
408        .replace('<', "&lt;")
409        .replace('>', "&gt;")
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}