rosace-cli 0.1.0

rsc: the ROSACE CLI for scaffolding, running, building, and analyzing apps
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
//! App icon generation for `rsc new` (D104/D106 scaffolding).
//!
//! The framework's default app icon (the aurora brand mark,
//! `assets/rosace/rosace-mark-aurora.svg`) is bundled into the `rsc` binary
//! at compile time and rasterized into every platform's required icon format
//! at `rsc new` time — no manual asset work, no filesystem lookup outside the
//! generated project.
//!
//! `.ico` (Windows) and `.icns` (macOS) are simple tag/length/data table
//! formats — hand-written here rather than pulling in extra crates for
//! them; `resvg`/`tiny-skia` (already a dependency for SVG→PNG) are the
//! only image tooling needed.

use std::collections::HashMap;
use std::fs;
use std::path::Path;

use resvg::{tiny_skia, usvg};

use super::new::Platform;

/// The framework's default app icon: the rosace brand MARK (aurora variant),
/// embedded at compile time. This SVG is a transparent-background mark (the
/// diamond rosette, no backdrop) — a copy of the workspace's shared
/// `assets/rosace/rosace-mark-aurora.svg` lives inside this crate's own
/// `assets/` (`cargo publish` packages each crate in isolation, so a path
/// reaching outside the crate directory resolves to nothing once published
/// — found live publishing rosace-cli, 2026-08-05). Flat icon formats
/// rasterize it over `ICON_BACKGROUND_COLOR`; the Android adaptive-icon
/// foreground layer uses it as-is (transparent), which is exactly what that
/// layer wants (the system composites its own background under it and
/// independently masks/zooms/parallaxes the foreground).
const MARK_SVG: &[u8] =
    include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/assets/rosace-mark-aurora.svg"));

/// The app-icon background. The aurora mark is designed on white (see the
/// reference render `assets/rosace/rosace-icon-aurora-1024.png`), so flat
/// icons get an opaque white backdrop — iOS and both app stores reject icons
/// with transparency. Reused as the Android adaptive-icon background color so
/// the flat and adaptive icons stay visually identical.
const ICON_BACKGROUND_COLOR: &str = "#FFFFFF";

/// `ICON_BACKGROUND_COLOR` parsed into a tiny-skia color, so the rasterized
/// backdrop and the Android `ic_launcher_background` XML never drift.
fn icon_bg_color() -> tiny_skia::Color {
    let hex = ICON_BACKGROUND_COLOR.trim_start_matches('#');
    let byte = |i: usize| u8::from_str_radix(&hex[i..i + 2], 16).unwrap_or(255);
    tiny_skia::Color::from_rgba8(byte(0), byte(2), byte(4), 255)
}

/// Rasterizes `svg` to a square PNG of `size` x `size` pixels.
fn render_svg_png(svg: &[u8], size: u32) -> Result<Vec<u8>, String> {
    let opt = usvg::Options::default();
    let tree = usvg::Tree::from_data(svg, &opt)
        .map_err(|e| format!("failed to parse bundled icon SVG: {e}"))?;
    let src = tree.size();
    let mut pixmap = tiny_skia::Pixmap::new(size, size)
        .ok_or_else(|| format!("invalid icon size {size}"))?;
    let transform = tiny_skia::Transform::from_scale(
        size as f32 / src.width(),
        size as f32 / src.height(),
    );
    resvg::render(&tree, transform, &mut pixmap.as_mut());
    pixmap.encode_png().map_err(|e| format!("failed to encode {size}x{size} PNG: {e}"))
}

/// Rasterizes the flat app icon (mark composited over the opaque
/// `ICON_BACKGROUND_COLOR` backdrop) to a square PNG — every platform except
/// the Android adaptive-icon foreground uses this. The backdrop is filled
/// first because flat icons must not be transparent (iOS / App Store /
/// Play Store all reject alpha).
fn render_png(size: u32) -> Result<Vec<u8>, String> {
    let opt = usvg::Options::default();
    let tree = usvg::Tree::from_data(MARK_SVG, &opt)
        .map_err(|e| format!("failed to parse bundled mark SVG: {e}"))?;
    let src = tree.size();
    let mut pixmap = tiny_skia::Pixmap::new(size, size)
        .ok_or_else(|| format!("invalid icon size {size}"))?;
    pixmap.fill(icon_bg_color());
    let transform = tiny_skia::Transform::from_scale(
        size as f32 / src.width(),
        size as f32 / src.height(),
    );
    resvg::render(&tree, transform, &mut pixmap.as_mut());
    pixmap.encode_png().map_err(|e| format!("failed to encode {size}x{size} PNG: {e}"))
}

fn write_png(path: impl AsRef<Path>, size: u32) -> Result<(), String> {
    let bytes = render_png(size)?;
    fs::write(&path, &bytes)
        .map_err(|e| format!("failed to write {}: {}", path.as_ref().display(), e))
}

fn write_svg_png(path: impl AsRef<Path>, svg: &[u8], size: u32) -> Result<(), String> {
    let bytes = render_svg_png(svg, size)?;
    fs::write(&path, &bytes)
        .map_err(|e| format!("failed to write {}: {}", path.as_ref().display(), e))
}

/// Packs PNG-compressed images into a Windows `.ico` container. Modern
/// (Vista+) readers accept PNG payloads at any size — no BMP fallback
/// needed for a tool this new.
fn write_ico(path: impl AsRef<Path>, sizes: &[u32]) -> Result<(), String> {
    let mut pngs = Vec::with_capacity(sizes.len());
    for &size in sizes {
        pngs.push((size, render_png(size)?));
    }

    let mut buf = Vec::new();
    buf.extend_from_slice(&0u16.to_le_bytes()); // reserved
    buf.extend_from_slice(&1u16.to_le_bytes()); // type = icon
    buf.extend_from_slice(&(pngs.len() as u16).to_le_bytes());

    let mut offset = (6 + 16 * pngs.len()) as u32; // header + directory entries
    for (size, data) in &pngs {
        // 0 encodes "256" in the ICO directory entry format.
        let dim = if *size >= 256 { 0u8 } else { *size as u8 };
        buf.push(dim); // width
        buf.push(dim); // height
        buf.push(0);   // color count (0 = no palette)
        buf.push(0);   // reserved
        buf.extend_from_slice(&1u16.to_le_bytes());  // color planes
        buf.extend_from_slice(&32u16.to_le_bytes()); // bits per pixel
        buf.extend_from_slice(&(data.len() as u32).to_le_bytes());
        buf.extend_from_slice(&offset.to_le_bytes());
        offset += data.len() as u32;
    }
    for (_, data) in &pngs {
        buf.extend_from_slice(data);
    }

    fs::write(&path, &buf)
        .map_err(|e| format!("failed to write {}: {}", path.as_ref().display(), e))
}

/// Packs PNG images into a macOS `.icns` container — the exact tag set
/// `iconutil -c icns` produces from a standard 10-image `.iconset` folder
/// (traditional + `@2x` retina variants sharing pixel sizes where the
/// dimensions coincide, e.g. `icp5`/`ic11` are both 32x32).
fn write_icns(path: impl AsRef<Path>) -> Result<(), String> {
    const ENTRIES: &[(&[u8; 4], u32)] = &[
        (b"icp4", 16), (b"ic11", 32),
        (b"icp5", 32), (b"ic12", 64),
        (b"ic07", 128), (b"ic13", 256),
        (b"ic08", 256), (b"ic14", 512),
        (b"ic09", 512), (b"ic10", 1024),
    ];

    let mut cache: HashMap<u32, Vec<u8>> = HashMap::new();
    let mut body = Vec::new();
    for (tag, size) in ENTRIES {
        let data = match cache.get(size) {
            Some(d) => d.clone(),
            None => {
                let d = render_png(*size)?;
                cache.insert(*size, d.clone());
                d
            }
        };
        body.extend_from_slice(*tag);
        body.extend_from_slice(&((data.len() + 8) as u32).to_be_bytes());
        body.extend_from_slice(&data);
    }

    let mut buf = Vec::new();
    buf.extend_from_slice(b"icns");
    buf.extend_from_slice(&((body.len() + 8) as u32).to_be_bytes());
    buf.extend_from_slice(&body);

    fs::write(&path, &buf)
        .map_err(|e| format!("failed to write {}: {}", path.as_ref().display(), e))
}

/// iOS: a single 1024x1024 "universal" App Icon — the modern (Xcode 14+)
/// single-size `AppIcon.appiconset` format; Xcode derives every smaller
/// slot from it at build time. Lives under `ios/App/Assets.xcassets/`, the
/// real asset catalog the Phase 24 Step 2 Xcode project generator wires via
/// `ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon`.
fn write_ios_icon(app_dir: &Path) -> Result<(), String> {
    let xcassets = app_dir.join("ios").join("App").join("Assets.xcassets");
    fs::create_dir_all(&xcassets).map_err(|e| e.to_string())?;
    fs::write(
        xcassets.join("Contents.json"),
        "{\n  \"info\" : {\n    \"author\" : \"rsc\",\n    \"version\" : 1\n  }\n}\n",
    )
    .map_err(|e| e.to_string())?;

    let dir = xcassets.join("AppIcon.appiconset");
    fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
    write_png(dir.join("AppIcon-1024.png"), 1024)?;
    fs::write(
        dir.join("Contents.json"),
        r#"{
  "images" : [
    {
      "filename" : "AppIcon-1024.png",
      "idiom" : "universal",
      "platform" : "ios",
      "size" : "1024x1024"
    }
  ],
  "info" : {
    "author" : "rsc",
    "version" : 1
  }
}
"#,
    )
    .map_err(|e| e.to_string())
}

/// Android: legacy (pre-API-26) `mipmap-*` launcher icons at every standard
/// density — the OS applies its own mask/shape — PLUS real adaptive icons
/// (API 26+): a flat-color background (`ICON_BACKGROUND_COLOR`, the same
/// backdrop the flat icon is composited over) under a transparent-background
/// foreground layer (the bare `MARK_SVG`) that the system independently
/// masks, zooms, and parallaxes. Both are written so the app looks right on
/// every Android version, old and new.
fn write_android_icons(app_dir: &Path) -> Result<(), String> {
    // Legacy square launcher icon, one raster per density.
    const DENSITIES: &[(&str, u32)] = &[
        ("mipmap-mdpi", 48),
        ("mipmap-hdpi", 72),
        ("mipmap-xhdpi", 96),
        ("mipmap-xxhdpi", 144),
        ("mipmap-xxxhdpi", 192),
    ];
    // Adaptive icon foreground layer: standard 108/162/216/324/432dp sizes
    // (Android's adaptive-icon canvas is larger than the legacy launcher
    // icon to allow for system masking/zoom without clipping content).
    const ADAPTIVE_DENSITIES: &[(&str, u32)] = &[
        ("mipmap-mdpi", 108),
        ("mipmap-hdpi", 162),
        ("mipmap-xhdpi", 216),
        ("mipmap-xxhdpi", 324),
        ("mipmap-xxxhdpi", 432),
    ];

    let res_dir = app_dir.join("android").join("app").join("src").join("main").join("res");

    for (dir_name, size) in DENSITIES {
        let dir = res_dir.join(dir_name);
        fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
        write_png(dir.join("ic_launcher.png"), *size)?;
        write_png(dir.join("ic_launcher_round.png"), *size)?;
    }

    for (dir_name, size) in ADAPTIVE_DENSITIES {
        let dir = res_dir.join(dir_name);
        fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
        write_svg_png(dir.join("ic_launcher_foreground.png"), MARK_SVG, *size)?;
    }

    let values_dir = res_dir.join("values");
    fs::create_dir_all(&values_dir).map_err(|e| e.to_string())?;
    fs::write(
        values_dir.join("ic_launcher_background.xml"),
        format!(
            "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\
             <resources>\n    \
             <color name=\"ic_launcher_background\">{ICON_BACKGROUND_COLOR}</color>\n\
             </resources>\n"
        ),
    )
    .map_err(|e| e.to_string())?;

    let anydpi_dir = res_dir.join("mipmap-anydpi-v26");
    fs::create_dir_all(&anydpi_dir).map_err(|e| e.to_string())?;
    let adaptive_icon_xml = "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\
        <adaptive-icon xmlns:android=\"http://schemas.android.com/apk/res/android\">\n    \
        <background android:drawable=\"@color/ic_launcher_background\"/>\n    \
        <foreground android:drawable=\"@mipmap/ic_launcher_foreground\"/>\n\
        </adaptive-icon>\n";
    fs::write(anydpi_dir.join("ic_launcher.xml"), adaptive_icon_xml).map_err(|e| e.to_string())?;
    fs::write(anydpi_dir.join("ic_launcher_round.xml"), adaptive_icon_xml).map_err(|e| e.to_string())?;

    Ok(())
}

/// macOS: `.icns` for the dock/Finder — `new.rs`'s `macos_info_plist`
/// references it via `CFBundleIconFile`, and `package.rs::bundle_macos`
/// copies it into the built `.app`.
fn write_macos_icon(app_dir: &Path) -> Result<(), String> {
    let dir = app_dir.join("macos");
    fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
    write_icns(dir.join("icon.icns"))
}

/// Windows: `.ico` for the taskbar/shortcuts — `package.rs::bundle_windows`
/// copies it alongside the built `.exe`.
fn write_windows_icon(app_dir: &Path) -> Result<(), String> {
    let dir = app_dir.join("windows");
    fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
    write_ico(dir.join("icon.ico"), &[16, 32, 48, 256])
}

/// Linux: a plain 256x256 PNG — the standard `hicolor` icon theme size
/// desktop environments expect (`usr/share/icons/hicolor/256x256/apps/`).
fn write_linux_icon(app_dir: &Path) -> Result<(), String> {
    let dir = app_dir.join("linux");
    fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
    write_png(dir.join("icon.png"), 256)
}

/// Web: favicon + PWA/manifest icons, plus a minimal `site.webmanifest`.
/// `new::web_index_html` links these from the generated `index.html`.
fn write_web_icons(app_dir: &Path) -> Result<(), String> {
    let dir = app_dir.join("web");
    fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
    write_ico(dir.join("favicon.ico"), &[16, 32, 48])?;
    write_png(dir.join("apple-touch-icon.png"), 180)?;
    write_png(dir.join("icon-192.png"), 192)?;
    write_png(dir.join("icon-512.png"), 512)?;
    fs::write(
        dir.join("site.webmanifest"),
        r#"{
  "icons": [
    { "src": "icon-192.png", "sizes": "192x192", "type": "image/png" },
    { "src": "icon-512.png", "sizes": "512x512", "type": "image/png" }
  ]
}
"#,
    )
    .map_err(|e| e.to_string())
}

/// Generates every icon format the selected `platforms` need, rooted at
/// the scaffolded project directory `app_dir`. Called once from
/// `new::run`, after the rest of the project tree is written.
pub fn generate(app_dir: &Path, platforms: &[Platform]) -> Result<(), String> {
    if platforms.contains(&Platform::Ios) {
        write_ios_icon(app_dir)?;
    }
    if platforms.contains(&Platform::Android) {
        write_android_icons(app_dir)?;
    }
    if platforms.contains(&Platform::MacOs) {
        write_macos_icon(app_dir)?;
    }
    if platforms.contains(&Platform::Windows) {
        write_windows_icon(app_dir)?;
    }
    if platforms.contains(&Platform::Linux) {
        write_linux_icon(app_dir)?;
    }
    if platforms.contains(&Platform::Web) {
        write_web_icons(app_dir)?;
    }
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn render_png_produces_a_valid_png_of_the_requested_size() {
        let bytes = render_png(64).expect("render should succeed");
        // PNG magic bytes.
        assert_eq!(&bytes[0..8], &[0x89, b'P', b'N', b'G', b'\r', b'\n', 0x1A, b'\n']);
        let img = tiny_skia::Pixmap::decode_png(&bytes).expect("should decode back");
        assert_eq!((img.width(), img.height()), (64, 64));
    }

    #[test]
    fn write_ico_produces_a_well_formed_directory() {
        let dir = std::env::temp_dir().join(format!("rsc_icons_test_{}", std::process::id()));
        fs::create_dir_all(&dir).unwrap();
        let path = dir.join("test.ico");
        write_ico(&path, &[16, 32]).expect("ico write should succeed");
        let bytes = fs::read(&path).unwrap();
        assert_eq!(u16::from_le_bytes([bytes[0], bytes[1]]), 0); // reserved
        assert_eq!(u16::from_le_bytes([bytes[2], bytes[3]]), 1); // type = icon
        assert_eq!(u16::from_le_bytes([bytes[4], bytes[5]]), 2); // 2 images
        fs::remove_dir_all(&dir).ok();
    }

    #[test]
    fn write_icns_starts_with_magic_and_reports_consistent_length() {
        let dir = std::env::temp_dir().join(format!("rsc_icns_test_{}", std::process::id()));
        fs::create_dir_all(&dir).unwrap();
        let path = dir.join("test.icns");
        write_icns(&path).expect("icns write should succeed");
        let bytes = fs::read(&path).unwrap();
        assert_eq!(&bytes[0..4], b"icns");
        let declared_len = u32::from_be_bytes([bytes[4], bytes[5], bytes[6], bytes[7]]) as usize;
        assert_eq!(declared_len, bytes.len());
        fs::remove_dir_all(&dir).ok();
    }

    #[test]
    fn generate_writes_expected_files_for_all_platforms() {
        let dir = std::env::temp_dir().join(format!("rsc_gen_icons_test_{}", std::process::id()));
        fs::create_dir_all(&dir).unwrap();
        let platforms = [
            Platform::MacOs, Platform::Windows, Platform::Linux,
            Platform::Web, Platform::Ios, Platform::Android,
        ];
        generate(&dir, &platforms).expect("generate should succeed");

        assert!(dir.join("ios/App/Assets.xcassets/Contents.json").exists());
        assert!(dir.join("ios/App/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png").exists());
        assert!(dir.join("ios/App/Assets.xcassets/AppIcon.appiconset/Contents.json").exists());
        assert!(dir.join("android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png").exists());
        assert!(dir.join("macos/icon.icns").exists());
        assert!(dir.join("windows/icon.ico").exists());
        assert!(dir.join("linux/icon.png").exists());
        assert!(dir.join("web/favicon.ico").exists());
        assert!(dir.join("web/site.webmanifest").exists());

        fs::remove_dir_all(&dir).ok();
    }

    #[test]
    fn generate_only_writes_folders_for_selected_platforms() {
        let dir = std::env::temp_dir().join(format!("rsc_gen_icons_partial_{}", std::process::id()));
        fs::create_dir_all(&dir).unwrap();
        generate(&dir, &[Platform::MacOs]).expect("generate should succeed");

        assert!(dir.join("macos/icon.icns").exists());
        assert!(!dir.join("windows").exists());
        assert!(!dir.join("linux").exists());
        assert!(!dir.join("ios").exists());
        assert!(!dir.join("android").exists());
        assert!(!dir.join("web").exists());

        fs::remove_dir_all(&dir).ok();
    }

    #[test]
    fn android_adaptive_icon_layers_are_generated() {
        let dir = std::env::temp_dir().join(format!("rsc_gen_adaptive_test_{}", std::process::id()));
        fs::create_dir_all(&dir).unwrap();
        write_android_icons(&dir).expect("android icon generation should succeed");

        let res = dir.join("android/app/src/main/res");
        assert!(res.join("mipmap-anydpi-v26/ic_launcher.xml").exists());
        assert!(res.join("mipmap-anydpi-v26/ic_launcher_round.xml").exists());
        assert!(res.join("values/ic_launcher_background.xml").exists());
        assert!(res.join("mipmap-xxxhdpi/ic_launcher_foreground.png").exists());

        // Adaptive foreground layer must be transparent where the mark
        // doesn't cover — that's the whole point of splitting it from the
        // background (the system composites its own background under it).
        // The corner is well outside the centered rosette, so it's transparent.
        let fg_bytes = fs::read(res.join("mipmap-mdpi/ic_launcher_foreground.png")).unwrap();
        let fg = tiny_skia::Pixmap::decode_png(&fg_bytes).unwrap();
        let corner = fg.pixel(0, 0).unwrap();
        assert_eq!(corner.alpha(), 0, "foreground corner should be transparent");

        // Legacy ic_launcher.png is the flat icon: the mark composited over an
        // opaque full-bleed backdrop, so EVERY pixel is opaque — including the
        // corner, unlike the transparent adaptive foreground above.
        let legacy_bytes = fs::read(res.join("mipmap-mdpi/ic_launcher.png")).unwrap();
        let legacy = tiny_skia::Pixmap::decode_png(&legacy_bytes).unwrap();
        let corner = legacy.pixel(0, 0).unwrap();
        assert_eq!(corner.alpha(), 255, "legacy icon corner should be opaque (full-bleed backdrop)");

        let bg_xml = fs::read_to_string(res.join("values/ic_launcher_background.xml")).unwrap();
        assert!(bg_xml.contains(ICON_BACKGROUND_COLOR));

        fs::remove_dir_all(&dir).ok();
    }
}