用户 Windows 实测:日志"托盘已就绪"无报错、不在折叠区,但任务栏无图标。 源码级定位:tray_manager 0.5.3 Windows 原生端用 LoadImage(IMAGE_ICON, LR_LOADFROMFILE) 加载图标文件——该 Win32 API 只认 .ico,传入 png 返回 NULL → Shell_NotifyIcon 添加成功但图标为空 → 任务栏 不显示且全程无任何报错(mac 走 base64+NSImage 另一条路径所以正常)。 修复:Windows 托盘改用 assets/tray_icon_win.ico(16/24/32 三尺寸 PNG-in-ICO, gen_app_icons.dart 生成,python struct 校验目录项 offset 严格对齐;ico 随 flutter_assets 打包,Debug/Release/Inno 全形态生效);删除无效的 tray_icon_win.png;AppTray 按平台选路径。
176 lines
6.5 KiB
Dart
176 lines
6.5 KiB
Dart
// 生成全部应用图标(源:资安信 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<String> 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 = <String, int>{
|
||
'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<int> sizes) {
|
||
final pngs = <int, Uint8List>{};
|
||
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<int> sizes) {
|
||
final pngs = <int, Uint8List>{
|
||
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');
|
||
}
|