1use std::fmt::Write as _;
29use std::fs;
30use std::path::Path;
31
32use crate::codec;
33use crate::contract::target::Level;
34use crate::project::{AppPart, FILE};
35
36const PART: &str = "app";
38
39pub const PLACEHOLDER: &str = "com.example.";
41
42pub const ICON_SIDE: u32 = 512;
45
46pub const WINDOW_ICON_SIDES: [u32; 3] = [16, 32, 64];
49
50#[derive(Debug, Clone, PartialEq, Eq)]
52pub struct AppProblem {
53 pub level: Level,
55 pub setting: String,
57 pub message: String,
59}
60
61impl AppProblem {
62 fn new(level: Level, field: &str, message: impl Into<String>) -> Self {
63 Self {
64 level,
65 setting: format!("{PART}.{field}"),
66 message: message.into(),
67 }
68 }
69
70 fn error(field: &str, message: impl Into<String>) -> Self {
71 Self::new(Level::Error, field, message)
72 }
73}
74
75pub fn problems(part: &AppPart, root: &Path, command: &str) -> Vec<AppProblem> {
79 let mut out = checked(part, root);
80 for path in stale(part, root, command) {
81 let field = if is_png(&path) {
82 "window_icons"
83 } else {
84 "code"
85 };
86 out.push(AppProblem::error(
87 field,
88 format!("{path} is out of date: run `{command} app-info`"),
89 ));
90 }
91 out
92}
93
94fn fields(part: &AppPart) -> Vec<AppProblem> {
96 let mut out = Vec::new();
97 if let Err(e) = check_name(&part.name) {
98 out.push(AppProblem::error("name", e));
99 }
100 match check_id(&part.id) {
101 Err(e) => out.push(AppProblem::error("id", e)),
102 Ok(()) if part.id.starts_with(PLACEHOLDER) => out.push(AppProblem::new(
103 Level::Warning,
104 "id",
105 format!(
106 "\"{}\" is a placeholder: two games with this id share their saves on one \
107 computer and on one web origin; set your own before you hand out a build, and \
108 before the first release, since the save folder follows it",
109 part.id
110 ),
111 )),
112 Ok(()) => {}
113 }
114 if let Some(version) = &part.version
115 && let Err(e) = check_version(version)
116 {
117 out.push(AppProblem::error("version", e));
118 }
119 if part.build == 0 {
120 out.push(AppProblem::error("build", "the build is 0: it starts at 1"));
121 }
122 let field = if part.storage.is_some() {
123 "storage"
124 } else {
125 "id"
126 };
127 if let Err(e) = check_storage(part.storage()) {
128 out.push(AppProblem::error(field, e));
129 }
130 if part.icon.is_some() && part.code.is_some() && part.window_icons.is_none() {
131 out.push(AppProblem::error(
132 "window_icons",
133 "the icon and `code` are set, so `window_icons` must be too: the folder where \
134 `app-info` writes the window's icons, which `code` reads",
135 ));
136 }
137 if !part.icon_sizes.is_empty() && (part.icon.is_none() || part.window_icons.is_none()) {
138 out.push(AppProblem::error(
139 "icon_sizes",
140 "pictures drawn per size need `icon`, which makes the sizes they don't draw, and \
141 `window_icons`, the folder `app-info` writes them to",
142 ));
143 }
144 for side in part.icon_sizes.keys() {
145 if !WINDOW_ICON_SIDES.contains(side) {
146 out.push(AppProblem::error(
147 "icon_sizes",
148 format!("{side} isn't a size of the window's icons: {WINDOW_ICON_SIDES:?}"),
149 ));
150 }
151 }
152 out
153}
154
155fn checked(part: &AppPart, root: &Path) -> Vec<AppProblem> {
157 let mut out = fields(part);
158 if let Err(e) = icon(part, root) {
159 out.push(AppProblem::error("icon", e));
160 }
161 let launcher = [
162 ("playdate_icon", &part.playdate_icon, (32, 32)),
163 ("playdate_card", &part.playdate_card, (350, 155)),
164 ];
165 for (field, path, size) in launcher {
166 if let Some(path) = path
167 && let Err(e) = sized_png(root, path, size)
168 {
169 out.push(AppProblem::error(field, e));
170 }
171 }
172 for (&side, path) in &part.icon_sizes {
173 if let Err(e) = sized_png(root, path, (side, side)) {
174 out.push(AppProblem::error("icon_sizes", e));
175 }
176 }
177 out
178}
179
180fn sized_png(root: &Path, path: &str, size: (u32, u32)) -> Result<Vec<u8>, String> {
183 let bytes = fs::read(root.join(path)).map_err(|e| format!("{path} can't be read: {e}"))?;
184 let (width, height, rgba) =
185 codec::decode_png(&bytes).map_err(|e| format!("{path} isn't a PNG file: {e}"))?;
186 if (width, height) == size {
187 Ok(rgba)
188 } else {
189 Err(format!(
190 "{path} is {width} × {height} pixels: it must be {} × {}",
191 size.0, size.1
192 ))
193 }
194}
195
196#[derive(Debug, Clone, PartialEq, Eq)]
198pub struct Icon {
199 pub side: u32,
201 pub rgba: Vec<u8>,
203}
204
205pub fn icon(part: &AppPart, root: &Path) -> Result<Option<Icon>, String> {
209 let Some(path) = &part.icon else {
210 return Ok(None);
211 };
212 let bytes = fs::read(root.join(path)).map_err(|e| format!("{path} can't be read: {e}"))?;
213 let (width, height, rgba) =
214 codec::decode_png(&bytes).map_err(|e| format!("{path} isn't a PNG file: {e}"))?;
215 if width != height {
216 return Err(format!(
217 "{path} is {width} × {height} pixels: an icon is square"
218 ));
219 }
220 if width < ICON_SIDE {
221 return Err(format!(
222 "{path} is {width} pixels a side: an icon is {ICON_SIDE} or more, so every size a \
223 target asks is made from it by downscaling"
224 ));
225 }
226 Ok(Some(Icon { side: width, rgba }))
227}
228
229pub fn downscale(width: u32, height: u32, rgba: &[u8], size: u32) -> Vec<u8> {
236 let bounds = |at: u32, whole: u32| {
237 let from = u64::from(at) * u64::from(whole) / u64::from(size);
238 let to = (u64::from(at) + 1) * u64::from(whole) / u64::from(size);
239 (from, to.max(from + 1))
240 };
241 let mut out = Vec::with_capacity(4 * size as usize * size as usize);
242 for y in 0..size {
243 let (top, bottom) = bounds(y, height);
244 for x in 0..size {
245 let (left, right) = bounds(x, width);
246 let (mut alpha, mut colour) = (0_u64, [0_u64; 3]);
248 for row in top..bottom {
249 for column in left..right {
250 let at = usize::try_from(4 * (row * u64::from(width) + column))
251 .unwrap_or(usize::MAX);
252 let Some(pixel) = rgba.get(at..at + 4) else {
253 continue;
254 };
255 let a = u64::from(pixel[3]);
256 alpha += a;
257 for (sum, &c) in colour.iter_mut().zip(pixel) {
258 *sum += u64::from(c) * a;
259 }
260 }
261 }
262 let count = (bottom - top) * (right - left);
263 for sum in colour {
264 out.push(if alpha == 0 { 0 } else { half_up(sum, alpha) });
265 }
266 out.push(half_up(alpha, count));
267 }
268 }
269 out
270}
271
272fn half_up(sum: u64, count: u64) -> u8 {
274 u8::try_from((2 * sum + count) / (2 * count)).unwrap_or(u8::MAX)
275}
276
277fn check_name(name: &str) -> Result<(), String> {
280 if name.trim().is_empty() {
281 return Err("the name is empty".to_owned());
282 }
283 match name.chars().find(|c| c.is_control()) {
284 Some(c) => Err(format!("the name may not hold {c:?}")),
285 None => Ok(()),
286 }
287}
288
289pub fn check_id(id: &str) -> Result<(), String> {
294 let parts: Vec<&str> = id.split('.').collect();
295 if parts.len() < 2 {
296 return Err(format!(
297 "\"{id}\" isn't reverse-DNS: it needs two or more parts between dots, as \
298 com.example.moon-garden"
299 ));
300 }
301 for part in parts {
302 if !part.starts_with(|c: char| c.is_ascii_alphabetic()) {
303 return Err(format!(
304 "\"{id}\": each part between dots starts with a letter, and \"{part}\" doesn't"
305 ));
306 }
307 if let Some(c) = part
308 .chars()
309 .find(|&c| !(c.is_ascii_alphanumeric() || c == '-'))
310 {
311 return Err(format!(
312 "\"{id}\" may hold only ASCII letters, digits, `-` and dots, not {c:?}"
313 ));
314 }
315 }
316 Ok(())
317}
318
319pub fn check_version(version: &str) -> Result<(), String> {
322 semver::Version::parse(version).map(|_| ()).map_err(|e| {
323 format!("\"{version}\" isn't semver (MAJOR.MINOR.PATCH, as 1.2.0 or 1.2.0-beta.1): {e}")
324 })
325}
326
327pub fn check_storage(storage: &str) -> Result<(), String> {
330 if storage.is_empty() {
331 return Err("the storage's name is empty".to_owned());
332 }
333 if let Some(c) = storage
334 .chars()
335 .find(|&c| !(c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_' | ' ')))
336 {
337 return Err(format!(
338 "the storage's name \"{storage}\" names a folder: ASCII letters, digits, `.`, `-`, \
339 `_` and spaces only, not {c:?}"
340 ));
341 }
342 let ends = [storage.chars().next(), storage.chars().last()];
343 if ends.iter().flatten().any(|&c| matches!(c, '.' | ' ')) {
344 return Err(format!(
345 "the storage's name \"{storage}\" names a folder: no `.` or space first or last"
346 ));
347 }
348 Ok(())
349}
350
351fn quoted(text: &str) -> String {
354 let mut out = String::from("\"");
355 for c in text.chars() {
356 match c {
357 '"' => out.push_str("\\\""),
358 '\\' => out.push_str("\\\\"),
359 c if c.is_control() => {
361 let _ = write!(out, "\\u{{{:x}}}", u32::from(c));
362 }
363 c => out.push(c),
364 }
365 }
366 out.push('"');
367 out
368}
369
370pub fn rust(part: &AppPart, command: &str) -> String {
374 let version = part
375 .version
376 .as_deref()
377 .map_or_else(|| "env!(\"CARGO_PKG_VERSION\")".to_owned(), quoted);
378 let mut app = format!(
379 "AppInfo::new({}, {}, {version})",
380 quoted(&part.name),
381 quoted(&part.id)
382 );
383 if let Some(storage) = &part.storage {
384 let _ = write!(app, "\n .with_storage({})", quoted(storage));
385 }
386 if part.build != 1 {
387 let _ = write!(app, "\n .with_build({})", part.build);
388 }
389 if !part.publisher.is_empty() {
390 let _ = write!(app, "\n .with_publisher({})", quoted(&part.publisher));
391 }
392 let mut uses = "AppInfo";
393 if let Some(paths) = window_icon_paths(part) {
394 uses = "{AppInfo, WindowIcon}";
395 let code = part.code.as_deref().unwrap_or_default();
396 app.push_str("\n .with_window_icon(Some(WindowIcon::new(");
397 for (_, path) in paths {
398 let _ = write!(
399 app,
400 "\n include_bytes!({}),",
401 quoted(&relative(code, &path))
402 );
403 }
404 app.push_str("\n )))");
405 }
406 format!(
407 "// Generated by `{command} {COMMAND}` from {FILE}'s `{PART}`. Do not edit.\n\
408 //! Who the game is, as `{FILE}`'s `{PART}` says: its name, id and version, and the\n\
409 //! name its saves are kept under.\n\
410 \n\
411 use shuyi::platform::{uses};\n\
412 \n\
413 /// Who the game is: the window's title, the loading card and the storage read it.\n\
414 // check-text: not player text, who the game is (`{FILE}`'s `{PART}`)\n\
415 #[rustfmt::skip]\n\
416 pub const APP: AppInfo = {app};\n"
417 )
418}
419
420pub const COMMAND: &str = "app-info";
422
423fn window_icon_paths(part: &AppPart) -> Option<[(u32, String); 3]> {
427 part.icon.as_ref()?;
428 let folder = part.window_icons.as_deref()?.trim_end_matches(['/', '\\']);
429 Some(WINDOW_ICON_SIDES.map(|side| (side, format!("{folder}/{side}.png"))))
430}
431
432fn is_png(path: &str) -> bool {
435 Path::new(path)
436 .extension()
437 .is_some_and(|extension| extension.eq_ignore_ascii_case("png"))
438}
439
440fn relative(from: &str, to: &str) -> String {
444 let parts = |path: &str| -> Vec<String> {
445 path.split(['/', '\\'])
446 .filter(|part| !part.is_empty() && *part != ".")
447 .map(str::to_owned)
448 .collect()
449 };
450 let mut folder = parts(from);
451 folder.pop();
452 let to = parts(to);
453 let shared = folder.iter().zip(&to).take_while(|(a, b)| a == b).count();
454 let mut out = vec!["..".to_owned(); folder.len() - shared];
455 out.extend_from_slice(&to[shared..]);
456 out.join("/")
457}
458
459fn rust_file(part: &AppPart, command: &str) -> Option<(String, Vec<u8>)> {
461 let path = part.code.clone()?;
462 Some((path, rust(part, command).into_bytes()))
463}
464
465pub fn files(part: &AppPart, root: &Path, command: &str) -> Result<Vec<(String, Vec<u8>)>, String> {
470 let mut out: Vec<_> = rust_file(part, command).into_iter().collect();
471 if let Some(paths) = window_icon_paths(part)
472 && let Some(icon) = icon(part, root)?
473 {
474 for (side, path) in paths {
475 let pixels = match part.icon_sizes.get(&side) {
476 Some(drawn) => sized_png(root, drawn, (side, side))?,
477 None => downscale(icon.side, icon.side, &icon.rgba, side),
478 };
479 out.push((path, codec::encode_png(side, side, &pixels)?));
480 }
481 }
482 Ok(out)
483}
484
485pub fn stale(part: &AppPart, root: &Path, command: &str) -> Vec<String> {
490 let files = files(part, root, command)
491 .unwrap_or_else(|_| rust_file(part, command).into_iter().collect());
492 files
493 .into_iter()
494 .filter(|(path, bytes)| {
495 fs::read(root.join(path)).map_or(true, |disk| {
496 if is_png(path) {
497 return disk != *bytes;
498 }
499 let lf: Vec<u8> = disk.into_iter().filter(|&b| b != b'\r').collect();
500 lf != *bytes
501 })
502 })
503 .map(|(path, _)| path)
504 .collect()
505}
506
507pub fn run(part: &AppPart, root: &Path, command: &str, check: bool) -> Result<String, String> {
511 let errors: Vec<String> = checked(part, root)
512 .into_iter()
513 .filter(|p| p.level == Level::Error)
514 .map(|p| format!("{}: {}", p.setting, p.message))
515 .collect();
516 if !errors.is_empty() {
517 return Err(format!(
518 "{FILE}'s `{PART}` has {} error(s):\n {}",
519 errors.len(),
520 errors.join("\n ")
521 ));
522 }
523 let files = files(part, root, command)?;
524 if files.is_empty() {
525 return Ok(format!(
526 "{COMMAND}: {FILE}'s `{PART}` names no `code`: no file to write"
527 ));
528 }
529 if check {
530 let stale = stale(part, root, command);
531 if stale.is_empty() {
532 return Ok(format!("{COMMAND}: up to date"));
533 }
534 return Err(format!(
535 "{} file(s) out of date: {}: run `{command} {COMMAND}`",
536 stale.len(),
537 stale.join(", ")
538 ));
539 }
540 let mut wrote = Vec::new();
541 for (path, bytes) in files {
542 let full = root.join(&path);
543 if let Some(parent) = full.parent() {
544 fs::create_dir_all(parent).map_err(|e| format!("create {}: {e}", parent.display()))?;
545 }
546 fs::write(&full, bytes).map_err(|e| format!("write {path}: {e}"))?;
547 wrote.push(path);
548 }
549 Ok(format!("{COMMAND}: wrote {}", wrote.join(", ")))
550}
551
552pub fn parse_args(args: &[String]) -> Result<bool, String> {
554 match args {
555 [] => Ok(false),
556 [flag] if flag == "--check" => Ok(true),
557 _ => Err("no arguments, or --check".to_owned()),
558 }
559}
560
561#[cfg(test)]
562mod tests;