Skip to main content

fission_command_core/
icons.rs

1use crate::{FissionProject, Target};
2use anyhow::{bail, Context, Result};
3use serde::Deserialize;
4use std::fs;
5use std::path::{Path, PathBuf};
6
7const DEFAULT_APP_ICON: &str = "assets/app-icon.png";
8
9#[derive(Clone, Debug)]
10pub struct ResolvedIcon {
11    pub path: PathBuf,
12    pub configured: bool,
13}
14
15#[derive(Debug, Deserialize, Default)]
16struct IconManifest {
17    package: Option<PackageManifest>,
18}
19
20#[derive(Debug, Deserialize, Default)]
21struct PackageManifest {
22    icon: Option<String>,
23    icons: Option<PackageIcons>,
24}
25
26#[derive(Debug, Deserialize, Default)]
27struct PackageIcons {
28    mode: Option<IconMode>,
29    source: Option<String>,
30    monochrome: Option<String>,
31    background_color: Option<String>,
32    safe_zone: Option<IconSafeZone>,
33    allow_upscale: Option<bool>,
34    android: Option<AndroidIcons>,
35    ios: Option<AppleIcons>,
36    macos: Option<AppleIcons>,
37    windows: Option<WindowsIcons>,
38    linux: Option<LinuxIcons>,
39    web: Option<WebIcons>,
40}
41
42#[derive(Debug, Deserialize)]
43#[serde(rename_all = "kebab-case")]
44enum IconMode {
45    Generate,
46    Provided,
47    Mixed,
48}
49
50#[derive(Debug, Deserialize)]
51#[serde(untagged)]
52enum IconSafeZone {
53    Named(String),
54    Fraction(f32),
55}
56
57#[derive(Debug, Deserialize, Default)]
58struct AndroidIcons {
59    source: Option<String>,
60    foreground: Option<String>,
61    background: Option<String>,
62    monochrome: Option<String>,
63}
64
65#[derive(Debug, Deserialize, Default)]
66struct AppleIcons {
67    source: Option<String>,
68    dark: Option<String>,
69    tinted: Option<String>,
70}
71
72#[derive(Debug, Deserialize, Default)]
73struct WindowsIcons {
74    source: Option<String>,
75    light: Option<String>,
76    dark: Option<String>,
77    unplated: Option<String>,
78}
79
80#[derive(Debug, Deserialize, Default)]
81struct LinuxIcons {
82    source: Option<String>,
83}
84
85#[derive(Debug, Deserialize, Default)]
86struct WebIcons {
87    source: Option<String>,
88    favicon: Option<String>,
89    maskable: Option<String>,
90}
91
92pub fn resolve_app_icon(root: &Path, target: Target) -> Result<Option<ResolvedIcon>> {
93    if target == Target::Server {
94        return Ok(None);
95    }
96    let manifest = read_icon_manifest(root)?;
97    if let Some(configured) = configured_icon_path(&manifest, target) {
98        let path = root.join(configured);
99        validate_icon_file(&path, target)?;
100        return Ok(Some(ResolvedIcon {
101            path,
102            configured: true,
103        }));
104    }
105
106    for relative in fallback_icon_paths(target) {
107        let path = root.join(relative);
108        if path.is_file() {
109            return Ok(Some(ResolvedIcon {
110                path,
111                configured: false,
112            }));
113        }
114    }
115    Ok(None)
116}
117
118pub(crate) fn apply_platform_icon_config(root: &Path, project: &FissionProject) -> Result<()> {
119    if project.targets.contains(&Target::Android) {
120        apply_android_icon_config(root)?;
121    }
122    if project.targets.contains(&Target::Ios) {
123        apply_ios_icon_config(root)?;
124    }
125    Ok(())
126}
127
128fn read_icon_manifest(root: &Path) -> Result<IconManifest> {
129    let path = root.join("fission.toml");
130    let data =
131        fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))?;
132    let manifest: IconManifest = toml::from_str(&data)
133        .with_context(|| format!("failed to parse icon config in {}", path.display()))?;
134    validate_icon_metadata(&manifest)?;
135    Ok(manifest)
136}
137
138fn configured_icon_path(manifest: &IconManifest, target: Target) -> Option<&str> {
139    if target == Target::Server {
140        return None;
141    }
142    let package = manifest.package.as_ref()?;
143    let icons = package.icons.as_ref();
144    let platform = match target {
145        Target::Android => icons
146            .and_then(|icons| icons.android.as_ref())
147            .and_then(|icons| icons.source.as_deref().or(icons.foreground.as_deref())),
148        Target::Ios => icons
149            .and_then(|icons| icons.ios.as_ref())
150            .and_then(|icons| icons.source.as_deref()),
151        Target::Macos => icons
152            .and_then(|icons| icons.macos.as_ref())
153            .and_then(|icons| icons.source.as_deref()),
154        Target::Windows => icons
155            .and_then(|icons| icons.windows.as_ref())
156            .and_then(|icons| icons.source.as_deref()),
157        Target::Linux => icons
158            .and_then(|icons| icons.linux.as_ref())
159            .and_then(|icons| icons.source.as_deref()),
160        Target::Web | Target::Site => icons
161            .and_then(|icons| icons.web.as_ref())
162            .and_then(|icons| icons.source.as_deref().or(icons.favicon.as_deref())),
163        Target::Server => None,
164    };
165    platform
166        .or_else(|| icons.and_then(|icons| icons.source.as_deref()))
167        .or(package.icon.as_deref())
168}
169
170fn fallback_icon_paths(target: Target) -> &'static [&'static str] {
171    match target {
172        Target::Macos => &[
173            "assets/app-icon.icns",
174            "assets/AppIcon.icns",
175            "assets/app-icon.png",
176            "assets/icon.png",
177        ],
178        Target::Windows => &[
179            "assets/app-icon.ico",
180            "assets/AppIcon.ico",
181            "assets/app-icon.png",
182            "assets/icon.png",
183        ],
184        Target::Linux => &[
185            "assets/app-icon.svg",
186            "assets/app-icon.png",
187            "assets/icon.svg",
188            "assets/icon.png",
189        ],
190        Target::Server => &[],
191        _ => &[DEFAULT_APP_ICON, "assets/icon.png"],
192    }
193}
194
195fn validate_icon_file(path: &Path, target: Target) -> Result<()> {
196    if !path.is_file() {
197        bail!("configured icon does not exist: {}", path.display());
198    }
199    let extension = normalized_extension(path).with_context(|| {
200        format!(
201            "configured icon must have a file extension: {}",
202            path.display()
203        )
204    })?;
205    let allowed = match target {
206        Target::Android => &["png", "jpg", "jpeg", "webp", "xml"][..],
207        Target::Ios => &["png", "jpg", "jpeg"][..],
208        Target::Macos => &["icns", "png", "jpg", "jpeg"][..],
209        Target::Windows => &["ico", "png", "jpg", "jpeg"][..],
210        Target::Linux => &["png", "svg"][..],
211        Target::Server => &[][..],
212        Target::Web | Target::Site => &["ico", "png", "jpg", "jpeg", "svg", "webp"][..],
213    };
214    if !allowed.contains(&extension.as_str()) {
215        bail!(
216            "configured {} icon must use one of: {}",
217            target.as_str(),
218            allowed.join(", ")
219        );
220    }
221    Ok(())
222}
223
224fn apply_android_icon_config(root: &Path) -> Result<()> {
225    let Some(icon) = resolve_app_icon(root, Target::Android)? else {
226        return Ok(());
227    };
228    if !icon.configured {
229        return Ok(());
230    }
231    let extension = normalized_extension(&icon.path)?;
232    let (destination, resource_ref) = if extension == "xml" {
233        (
234            root.join("platforms/android/res/drawable/app_icon.xml"),
235            "@drawable/app_icon",
236        )
237    } else {
238        (
239            root.join("platforms/android/res/drawable-nodpi/app_icon.")
240                .with_extension(&extension),
241            "@drawable/app_icon",
242        )
243    };
244    copy_required_asset(&icon.path, &destination)?;
245    ensure_android_manifest_icon_ref(root, resource_ref)?;
246    ensure_android_package_script_copies_icon_resources(root)
247}
248
249fn apply_ios_icon_config(root: &Path) -> Result<()> {
250    let Some(icon) = resolve_app_icon(root, Target::Ios)? else {
251        return Ok(());
252    };
253    if !icon.configured {
254        return Ok(());
255    }
256    let extension = normalized_extension(&icon.path)?;
257    let destination = root
258        .join("platforms/ios")
259        .join(format!("AppIcon.{extension}"));
260    copy_required_asset(&icon.path, &destination)?;
261    ensure_ios_package_script_copies_icon(root)
262}
263
264fn ensure_android_manifest_icon_ref(root: &Path, resource_ref: &str) -> Result<()> {
265    let path = root.join("platforms/android/AndroidManifest.xml");
266    if !path.exists() {
267        return Ok(());
268    }
269    let existing =
270        fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))?;
271    if existing.contains(&format!("android:icon=\"{resource_ref}\"")) {
272        return Ok(());
273    }
274    let updated = replace_android_icon_attr(&existing, resource_ref);
275    fs::write(&path, updated).with_context(|| format!("failed to write {}", path.display()))
276}
277
278fn replace_android_icon_attr(existing: &str, resource_ref: &str) -> String {
279    let Some(start) = existing.find("android:icon=\"") else {
280        return existing.replacen(
281            "android:label=",
282            &format!("android:icon=\"{resource_ref}\"\n        android:label="),
283            1,
284        );
285    };
286    let value_start = start + "android:icon=\"".len();
287    let Some(relative_end) = existing[value_start..].find('"') else {
288        return existing.to_string();
289    };
290    let end = value_start + relative_end;
291    let mut updated = existing.to_string();
292    updated.replace_range(value_start..end, resource_ref);
293    updated
294}
295
296fn ensure_android_package_script_copies_icon_resources(root: &Path) -> Result<()> {
297    let path = root.join("platforms/android/package-apk.sh");
298    if !path.exists() {
299        return Ok(());
300    }
301    let existing =
302        fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))?;
303    if existing.contains("app_icon.png") && existing.contains("res/drawable-nodpi/app_icon.*") {
304        return Ok(());
305    }
306    let marker =
307        "cp \"$PROJECT_DIR/assets/app-icon.png\" \"$APK_ROOT/res/drawable-nodpi/app_icon.png\"\n";
308    let replacement = r#"shopt -s nullglob
309APP_ICONS=("$SCRIPT_DIR"/res/drawable-nodpi/app_icon.* "$SCRIPT_DIR"/res/drawable/app_icon.*)
310if (( ${#APP_ICONS[@]} == 0 )); then
311  cp "$PROJECT_DIR/assets/app-icon.png" "$APK_ROOT/res/drawable-nodpi/app_icon.png"
312fi
313shopt -u nullglob
314"#;
315    let updated = if existing.contains(marker) {
316        existing.replacen(marker, replacement, 1)
317    } else {
318        existing
319    };
320    fs::write(&path, updated).with_context(|| format!("failed to write {}", path.display()))
321}
322
323fn ensure_ios_package_script_copies_icon(root: &Path) -> Result<()> {
324    let path = root.join("platforms/ios/package-sim.sh");
325    if !path.exists() {
326        return Ok(());
327    }
328    let existing =
329        fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))?;
330    if existing.contains("PLATFORM_APP_ICONS") {
331        return Ok(());
332    }
333    let marker = "cp \"$PROJECT_DIR/assets/app-icon.png\" \"$BUNDLE_DIR/AppIcon.png\"\n";
334    let replacement = r#"shopt -s nullglob
335PLATFORM_APP_ICONS=("$SCRIPT_DIR"/AppIcon.*)
336if (( ${#PLATFORM_APP_ICONS[@]} == 0 )); then
337  cp "$PROJECT_DIR/assets/app-icon.png" "$BUNDLE_DIR/AppIcon.png"
338else
339  app_icon="${PLATFORM_APP_ICONS[0]}"
340  cp "$app_icon" "$BUNDLE_DIR/$(basename "$app_icon")"
341fi
342shopt -u nullglob
343"#;
344    let updated = if existing.contains(marker) {
345        existing.replacen(marker, replacement, 1)
346    } else {
347        existing
348    };
349    fs::write(&path, updated).with_context(|| format!("failed to write {}", path.display()))
350}
351
352fn copy_required_asset(source: &Path, destination: &Path) -> Result<()> {
353    if !source.exists() {
354        bail!("configured icon asset does not exist: {}", source.display());
355    }
356    if let Some(parent) = destination.parent() {
357        fs::create_dir_all(parent)?;
358    }
359    fs::copy(source, destination).with_context(|| {
360        format!(
361            "failed to copy {} to {}",
362            source.display(),
363            destination.display()
364        )
365    })?;
366    Ok(())
367}
368
369pub fn copy_icon_for_bundle(
370    root: &Path,
371    target: Target,
372    destination: &Path,
373) -> Result<Option<PathBuf>> {
374    let Some(icon) = resolve_app_icon(root, target)? else {
375        return Ok(None);
376    };
377    if let Some(parent) = destination.parent() {
378        fs::create_dir_all(parent)?;
379    }
380    fs::copy(&icon.path, destination).with_context(|| {
381        format!(
382            "failed to copy {} to {}",
383            icon.path.display(),
384            destination.display()
385        )
386    })?;
387    Ok(Some(icon.path))
388}
389
390pub fn normalized_extension(path: &Path) -> Result<String> {
391    path.extension()
392        .and_then(|value| value.to_str())
393        .map(|value| value.to_ascii_lowercase())
394        .with_context(|| format!("path has no extension: {}", path.display()))
395}
396
397fn validate_icon_metadata(manifest: &IconManifest) -> Result<()> {
398    if let Some(icons) = manifest
399        .package
400        .as_ref()
401        .and_then(|package| package.icons.as_ref())
402    {
403        let _ = (
404            &icons.mode,
405            &icons.monochrome,
406            &icons.background_color,
407            icons.allow_upscale,
408        );
409        if let Some(safe_zone) = &icons.safe_zone {
410            match safe_zone {
411                IconSafeZone::Named(value) if matches!(value.as_str(), "platform" | "none") => {}
412                IconSafeZone::Named(value) => bail!(
413                    "package.icons.safe_zone must be `platform`, `none`, or a numeric fraction; got `{value}`"
414                ),
415                IconSafeZone::Fraction(value) if (0.0..=1.0).contains(value) => {}
416                IconSafeZone::Fraction(value) => bail!(
417                    "package.icons.safe_zone numeric fraction must be between 0.0 and 1.0; got {value}"
418                ),
419            }
420        }
421        if let Some(android) = &icons.android {
422            let _ = (&android.background, &android.monochrome);
423        }
424        if let Some(ios) = &icons.ios {
425            let _ = (&ios.dark, &ios.tinted);
426        }
427        if let Some(macos) = &icons.macos {
428            let _ = (&macos.dark, &macos.tinted);
429        }
430        if let Some(windows) = &icons.windows {
431            let _ = (&windows.light, &windows.dark, &windows.unplated);
432        }
433        if let Some(web) = &icons.web {
434            let _ = &web.maskable;
435        }
436    }
437    Ok(())
438}