// 生成全部应用图标(源:资安信 logo 1024x1024 深蓝盾牌+白色人形,透明底) // dart run tool/gen_app_icons.dart <源图路径> // 产物: // macos/Runner/Assets.xcassets/AppIcon.appiconset/*.png (mac App 图标,白底圆角托+盾牌) // windows/runner/resources/app_icon.ico (16~256 多尺寸 PNG-in-ICO) // assets/tray_icon_mac.png / assets/tray_icon_win.png (托盘:白底圆盘+盾牌,深浅菜单栏均可见) // assets/logo.png (原样拷贝,侧栏显示用) // ignore_for_file: avoid_print import 'dart:io'; import 'dart:typed_data'; import 'package:image/image.dart'; /// 是否在圆角矩形外(>0 外) bool _cornerOutside(int x, int y, int x0, int y0, int x1, int y1, int r) { final cx = x < x0 + r ? x0 + r : (x > x1 - r ? x1 - r : x); final cy = y < y0 + r ? y0 + r : (y > y1 - r ? y1 - r : y); final dx = x - cx, dy = y - cy; if (dx > 0 || dy > 0) { return dx * dx + dy * dy > r * r; } return false; } void main(List args) { final src = args.isNotEmpty ? args.first : 'logo-1024.png'; final logo = decodeImage(File(src).readAsBytesSync())!; print('源图 ${logo.width}x${logo.height}'); // ---------- mac App 图标:白底圆角方托 + 盾牌居中 72% ---------- Image macIcon(int size) { final img = Image(width: size, height: size, numChannels: 4); final r = (size * 0.18).round(); // macOS squircle 风格圆角 final inset = (size * 0.14).round(); final inner = copyResize(logo, width: size - inset * 2); for (var y = 0; y < size; y++) { for (var x = 0; x < size; x++) { if (!_cornerOutside(x, y, 0, 0, size - 1, size - 1, r)) { final hasPixel = x >= inset && x < inset + inner.width && y >= inset && y < inset + inner.height; final p = hasPixel ? inner.getPixel(x - inset, y - inset) : null; if (p != null && p.a.toInt() > 0) { img.setPixel(x, y, p); } else { img.setPixel(x, y, ColorRgba8(255, 255, 255, 255)); } } } } return img; } final appIconDir = Directory('macos/Runner/Assets.xcassets/AppIcon.appiconset'); const macSizes = { 'app_icon_16.png': 16, 'app_icon_32.png': 32, 'app_icon_64.png': 64, 'app_icon_128.png': 128, 'app_icon_256.png': 256, 'app_icon_512.png': 512, 'app_icon_1024.png': 1024, }; for (final e in macSizes.entries) { final f = File('${appIconDir.path}/${e.key}'); if (!f.existsSync()) { print('跳过(Contents.json 未声明):${e.key}'); continue; } f.writeAsBytesSync(encodePng(macIcon(e.value))); print('written ${f.path} (${e.value}px)'); } // ---------- Windows ICO(PNG-in-ICO) ---------- Uint8List buildIco(List sizes) { final pngs = {}; for (final s in sizes) { pngs[s] = Uint8List.fromList(encodePng(macIcon(s))); } final b = BytesBuilder(); b.addByte(0); b.addByte(0); // reserved b.addByte(1); b.addByte(0); // type = icon b.addByte(sizes.length); b.addByte(0); // count var offset = 6 + 16 * sizes.length; for (final s in sizes) { final png = pngs[s]!; b.addByte(s >= 256 ? 0 : s); // width b.addByte(s >= 256 ? 0 : s); // height b.addByte(0); // colors b.addByte(0); // reserved b.addByte(1); b.addByte(0); // planes b.addByte(32); b.addByte(0); // bitcount for (var i = 0; i < 4; i++) { b.addByte((png.length >> (8 * i)) & 0xff); } for (var i = 0; i < 4; i++) { b.addByte((offset >> (8 * i)) & 0xff); } offset += png.length; } for (final s in sizes) { b.add(pngs[s]!); } return b.toBytes(); } final ico = buildIco([16, 24, 32, 48, 64, 128, 256]); File('windows/runner/resources/app_icon.ico').writeAsBytesSync(ico); print('written windows/runner/resources/app_icon.ico (${ico.length} bytes)'); // ---------- 托盘:白底圆盘 + 盾牌居中 ---------- Image trayIcon(int size) { final img = Image(width: size, height: size, numChannels: 4); final c = (size - 1) / 2; final rOuter = c; final inset = (size * 0.13).round(); final inner = copyResize(logo, width: size - inset * 2); for (var y = 0; y < size; y++) { for (var x = 0; x < size; x++) { final d = ((x - c) * (x - c) + (y - c) * (y - c)); if (d > rOuter * rOuter) continue; // 圆外透明 final hasPixel = x >= inset && x < inset + inner.width && y >= inset && y < inset + inner.height; final p = hasPixel ? inner.getPixel(x - inset, y - inset) : null; if (p != null && p.a.toInt() > 0) { img.setPixel(x, y, p); } else { img.setPixel(x, y, ColorRgba8(255, 255, 255, 255)); } } } return img; } Directory('assets').createSync(recursive: true); File('assets/tray_icon_mac.png').writeAsBytesSync(encodePng(trayIcon(64))); print('written assets/tray_icon_mac.png'); // Windows 托盘必须用 .ico:tray_manager 的原生端 LoadImage(IMAGE_ICON) 只认 // ico 文件(传 png 返回 NULL → 图标空占位、任务栏不显示且无任何报错) Uint8List buildIcoFrom(Image Function(int) generator, List sizes) { final pngs = { for (final s in sizes) s: Uint8List.fromList(encodePng(generator(s))), }; final b = BytesBuilder(); // ICONDIR:reserved=0, type=icon, count b.addByte(0); b.addByte(0); b.addByte(1); b.addByte(0); b.addByte(sizes.length); b.addByte(0); var offset = 6 + 16 * sizes.length; // 目录项全部在前 for (final s in sizes) { final png = pngs[s]!; b.addByte(s); b.addByte(s); b.addByte(0); b.addByte(0); b.addByte(1); b.addByte(0); b.addByte(32); b.addByte(0); for (var i = 0; i < 4; i++) { b.addByte((png.length >> (8 * i)) & 0xff); } for (var i = 0; i < 4; i++) { b.addByte((offset >> (8 * i)) & 0xff); } offset += png.length; } // PNG 数据全部在后 for (final s in sizes) { b.add(pngs[s]!); } return b.toBytes(); } final trayIco = buildIcoFrom(trayIcon, [16, 24, 32]); File('assets/tray_icon_win.ico').writeAsBytesSync(trayIco); print('written assets/tray_icon_win.ico (${trayIco.length} bytes)'); // ---------- 原样 logo ---------- File('assets/logo.png').writeAsBytesSync(encodePng(logo)); print('written assets/logo.png'); }