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}