use crate::{FissionProject, Target};
use anyhow::{bail, Context, Result};
use serde::Deserialize;
use std::fs;
use std::path::{Path, PathBuf};
const DEFAULT_APP_ICON: &str = "assets/app-icon.png";
#[derive(Clone, Debug)]
pub struct ResolvedIcon {
pub path: PathBuf,
pub configured: bool,
}
#[derive(Debug, Deserialize, Default)]
struct IconManifest {
package: Option<PackageManifest>,
}
#[derive(Debug, Deserialize, Default)]
struct PackageManifest {
icon: Option<String>,
icons: Option<PackageIcons>,
}
#[derive(Debug, Deserialize, Default)]
struct PackageIcons {
mode: Option<IconMode>,
source: Option<String>,
monochrome: Option<String>,
background_color: Option<String>,
safe_zone: Option<IconSafeZone>,
allow_upscale: Option<bool>,
android: Option<AndroidIcons>,
ios: Option<AppleIcons>,
macos: Option<AppleIcons>,
windows: Option<WindowsIcons>,
linux: Option<LinuxIcons>,
web: Option<WebIcons>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "kebab-case")]
enum IconMode {
Generate,
Provided,
Mixed,
}
#[derive(Debug, Deserialize)]
#[serde(untagged)]
enum IconSafeZone {
Named(String),
Fraction(f32),
}
#[derive(Debug, Deserialize, Default)]
struct AndroidIcons {
source: Option<String>,
foreground: Option<String>,
background: Option<String>,
monochrome: Option<String>,
}
#[derive(Debug, Deserialize, Default)]
struct AppleIcons {
source: Option<String>,
dark: Option<String>,
tinted: Option<String>,
}
#[derive(Debug, Deserialize, Default)]
struct WindowsIcons {
source: Option<String>,
light: Option<String>,
dark: Option<String>,
unplated: Option<String>,
}
#[derive(Debug, Deserialize, Default)]
struct LinuxIcons {
source: Option<String>,
}
#[derive(Debug, Deserialize, Default)]
struct WebIcons {
source: Option<String>,
favicon: Option<String>,
maskable: Option<String>,
}
pub fn resolve_app_icon(root: &Path, target: Target) -> Result<Option<ResolvedIcon>> {
if target == Target::Server {
return Ok(None);
}
let manifest = read_icon_manifest(root)?;
if let Some(configured) = configured_icon_path(&manifest, target) {
let path = root.join(configured);
validate_icon_file(&path, target)?;
return Ok(Some(ResolvedIcon {
path,
configured: true,
}));
}
for relative in fallback_icon_paths(target) {
let path = root.join(relative);
if path.is_file() {
return Ok(Some(ResolvedIcon {
path,
configured: false,
}));
}
}
Ok(None)
}
pub(crate) fn apply_platform_icon_config(root: &Path, project: &FissionProject) -> Result<()> {
if project.targets.contains(&Target::Android) {
apply_android_icon_config(root)?;
}
if project.targets.contains(&Target::Ios) {
apply_ios_icon_config(root)?;
}
Ok(())
}
fn read_icon_manifest(root: &Path) -> Result<IconManifest> {
let path = root.join("fission.toml");
let data =
fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))?;
let manifest: IconManifest = toml::from_str(&data)
.with_context(|| format!("failed to parse icon config in {}", path.display()))?;
validate_icon_metadata(&manifest)?;
Ok(manifest)
}
fn configured_icon_path(manifest: &IconManifest, target: Target) -> Option<&str> {
if target == Target::Server {
return None;
}
let package = manifest.package.as_ref()?;
let icons = package.icons.as_ref();
let platform = match target {
Target::Android => icons
.and_then(|icons| icons.android.as_ref())
.and_then(|icons| icons.source.as_deref().or(icons.foreground.as_deref())),
Target::Ios => icons
.and_then(|icons| icons.ios.as_ref())
.and_then(|icons| icons.source.as_deref()),
Target::Macos => icons
.and_then(|icons| icons.macos.as_ref())
.and_then(|icons| icons.source.as_deref()),
Target::Windows => icons
.and_then(|icons| icons.windows.as_ref())
.and_then(|icons| icons.source.as_deref()),
Target::Linux => icons
.and_then(|icons| icons.linux.as_ref())
.and_then(|icons| icons.source.as_deref()),
Target::Web | Target::Site => icons
.and_then(|icons| icons.web.as_ref())
.and_then(|icons| icons.source.as_deref().or(icons.favicon.as_deref())),
Target::Server => None,
};
platform
.or_else(|| icons.and_then(|icons| icons.source.as_deref()))
.or(package.icon.as_deref())
}
fn fallback_icon_paths(target: Target) -> &'static [&'static str] {
match target {
Target::Macos => &[
"assets/app-icon.icns",
"assets/AppIcon.icns",
"assets/app-icon.png",
"assets/icon.png",
],
Target::Windows => &[
"assets/app-icon.ico",
"assets/AppIcon.ico",
"assets/app-icon.png",
"assets/icon.png",
],
Target::Linux => &[
"assets/app-icon.svg",
"assets/app-icon.png",
"assets/icon.svg",
"assets/icon.png",
],
Target::Server => &[],
_ => &[DEFAULT_APP_ICON, "assets/icon.png"],
}
}
fn validate_icon_file(path: &Path, target: Target) -> Result<()> {
if !path.is_file() {
bail!("configured icon does not exist: {}", path.display());
}
let extension = normalized_extension(path).with_context(|| {
format!(
"configured icon must have a file extension: {}",
path.display()
)
})?;
let allowed = match target {
Target::Android => &["png", "jpg", "jpeg", "webp", "xml"][..],
Target::Ios => &["png", "jpg", "jpeg"][..],
Target::Macos => &["icns", "png", "jpg", "jpeg"][..],
Target::Windows => &["ico", "png", "jpg", "jpeg"][..],
Target::Linux => &["png", "svg"][..],
Target::Server => &[][..],
Target::Web | Target::Site => &["ico", "png", "jpg", "jpeg", "svg", "webp"][..],
};
if !allowed.contains(&extension.as_str()) {
bail!(
"configured {} icon must use one of: {}",
target.as_str(),
allowed.join(", ")
);
}
Ok(())
}
fn apply_android_icon_config(root: &Path) -> Result<()> {
let Some(icon) = resolve_app_icon(root, Target::Android)? else {
return Ok(());
};
if !icon.configured {
return Ok(());
}
let extension = normalized_extension(&icon.path)?;
let (destination, resource_ref) = if extension == "xml" {
(
root.join("platforms/android/res/drawable/app_icon.xml"),
"@drawable/app_icon",
)
} else {
(
root.join("platforms/android/res/drawable-nodpi/app_icon.")
.with_extension(&extension),
"@drawable/app_icon",
)
};
copy_required_asset(&icon.path, &destination)?;
ensure_android_manifest_icon_ref(root, resource_ref)?;
ensure_android_package_script_copies_icon_resources(root)
}
fn apply_ios_icon_config(root: &Path) -> Result<()> {
let Some(icon) = resolve_app_icon(root, Target::Ios)? else {
return Ok(());
};
if !icon.configured {
return Ok(());
}
let extension = normalized_extension(&icon.path)?;
let destination = root
.join("platforms/ios")
.join(format!("AppIcon.{extension}"));
copy_required_asset(&icon.path, &destination)?;
ensure_ios_package_script_copies_icon(root)
}
fn ensure_android_manifest_icon_ref(root: &Path, resource_ref: &str) -> Result<()> {
let path = root.join("platforms/android/AndroidManifest.xml");
if !path.exists() {
return Ok(());
}
let existing =
fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))?;
if existing.contains(&format!("android:icon=\"{resource_ref}\"")) {
return Ok(());
}
let updated = replace_android_icon_attr(&existing, resource_ref);
fs::write(&path, updated).with_context(|| format!("failed to write {}", path.display()))
}
fn replace_android_icon_attr(existing: &str, resource_ref: &str) -> String {
let Some(start) = existing.find("android:icon=\"") else {
return existing.replacen(
"android:label=",
&format!("android:icon=\"{resource_ref}\"\n android:label="),
1,
);
};
let value_start = start + "android:icon=\"".len();
let Some(relative_end) = existing[value_start..].find('"') else {
return existing.to_string();
};
let end = value_start + relative_end;
let mut updated = existing.to_string();
updated.replace_range(value_start..end, resource_ref);
updated
}
fn ensure_android_package_script_copies_icon_resources(root: &Path) -> Result<()> {
let path = root.join("platforms/android/package-apk.sh");
if !path.exists() {
return Ok(());
}
let existing =
fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))?;
if existing.contains("app_icon.png") && existing.contains("res/drawable-nodpi/app_icon.*") {
return Ok(());
}
let marker =
"cp \"$PROJECT_DIR/assets/app-icon.png\" \"$APK_ROOT/res/drawable-nodpi/app_icon.png\"\n";
let replacement = r#"shopt -s nullglob
APP_ICONS=("$SCRIPT_DIR"/res/drawable-nodpi/app_icon.* "$SCRIPT_DIR"/res/drawable/app_icon.*)
if (( ${#APP_ICONS[@]} == 0 )); then
cp "$PROJECT_DIR/assets/app-icon.png" "$APK_ROOT/res/drawable-nodpi/app_icon.png"
fi
shopt -u nullglob
"#;
let updated = if existing.contains(marker) {
existing.replacen(marker, replacement, 1)
} else {
existing
};
fs::write(&path, updated).with_context(|| format!("failed to write {}", path.display()))
}
fn ensure_ios_package_script_copies_icon(root: &Path) -> Result<()> {
let path = root.join("platforms/ios/package-sim.sh");
if !path.exists() {
return Ok(());
}
let existing =
fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))?;
if existing.contains("PLATFORM_APP_ICONS") {
return Ok(());
}
let marker = "cp \"$PROJECT_DIR/assets/app-icon.png\" \"$BUNDLE_DIR/AppIcon.png\"\n";
let replacement = r#"shopt -s nullglob
PLATFORM_APP_ICONS=("$SCRIPT_DIR"/AppIcon.*)
if (( ${#PLATFORM_APP_ICONS[@]} == 0 )); then
cp "$PROJECT_DIR/assets/app-icon.png" "$BUNDLE_DIR/AppIcon.png"
else
app_icon="${PLATFORM_APP_ICONS[0]}"
cp "$app_icon" "$BUNDLE_DIR/$(basename "$app_icon")"
fi
shopt -u nullglob
"#;
let updated = if existing.contains(marker) {
existing.replacen(marker, replacement, 1)
} else {
existing
};
fs::write(&path, updated).with_context(|| format!("failed to write {}", path.display()))
}
fn copy_required_asset(source: &Path, destination: &Path) -> Result<()> {
if !source.exists() {
bail!("configured icon asset does not exist: {}", source.display());
}
if let Some(parent) = destination.parent() {
fs::create_dir_all(parent)?;
}
fs::copy(source, destination).with_context(|| {
format!(
"failed to copy {} to {}",
source.display(),
destination.display()
)
})?;
Ok(())
}
pub fn copy_icon_for_bundle(
root: &Path,
target: Target,
destination: &Path,
) -> Result<Option<PathBuf>> {
let Some(icon) = resolve_app_icon(root, target)? else {
return Ok(None);
};
if let Some(parent) = destination.parent() {
fs::create_dir_all(parent)?;
}
fs::copy(&icon.path, destination).with_context(|| {
format!(
"failed to copy {} to {}",
icon.path.display(),
destination.display()
)
})?;
Ok(Some(icon.path))
}
pub fn normalized_extension(path: &Path) -> Result<String> {
path.extension()
.and_then(|value| value.to_str())
.map(|value| value.to_ascii_lowercase())
.with_context(|| format!("path has no extension: {}", path.display()))
}
fn validate_icon_metadata(manifest: &IconManifest) -> Result<()> {
if let Some(icons) = manifest
.package
.as_ref()
.and_then(|package| package.icons.as_ref())
{
let _ = (
&icons.mode,
&icons.monochrome,
&icons.background_color,
icons.allow_upscale,
);
if let Some(safe_zone) = &icons.safe_zone {
match safe_zone {
IconSafeZone::Named(value) if matches!(value.as_str(), "platform" | "none") => {}
IconSafeZone::Named(value) => bail!(
"package.icons.safe_zone must be `platform`, `none`, or a numeric fraction; got `{value}`"
),
IconSafeZone::Fraction(value) if (0.0..=1.0).contains(value) => {}
IconSafeZone::Fraction(value) => bail!(
"package.icons.safe_zone numeric fraction must be between 0.0 and 1.0; got {value}"
),
}
}
if let Some(android) = &icons.android {
let _ = (&android.background, &android.monochrome);
}
if let Some(ios) = &icons.ios {
let _ = (&ios.dark, &ios.tinted);
}
if let Some(macos) = &icons.macos {
let _ = (&macos.dark, &macos.tinted);
}
if let Some(windows) = &icons.windows {
let _ = (&windows.light, &windows.dark, &windows.unplated);
}
if let Some(web) = &icons.web {
let _ = &web.maskable;
}
}
Ok(())
}