Files
ledgerly/tools/IconGen.java
T
rosercode ce71fd9b7a feat: 桌面启动器、可执行 jar 瘦身与数据库配置实时进度
本次提交为「汇账」补充桌面 GUI 启动器与 jpackage 免安装打包,并对可执行 jar 做两项依赖瘦身(移除 OpenAI SDK、裁剪 Knife4j/Swagger UI),修复 Windows 构建兼容问题,最后将数据库「测试连接 / 保存并应用」改为 SSE 实时进度并新增进度弹框。

变更内容:
- 桌面启动器:新增 cn.rosercode.ledgerly.launcher(启停服务、日志实时查看、系统托盘、单实例锁、端口配置),普通 jar 经 exclude 排除该包以保持无 JavaFX 依赖。
- 打包:bootJar/bootGuiJar 文件名携带目标 JDK 版本;新增 jpackageAppImage(聚合)与 jpackageServerAppImage/jpackageGuiAppImage,基于 jpackage --type app-image 产出免安装应用,GUI 内嵌 win/linux/mac/mac-aarch64 四平台 JavaFX;应用图标不入库,打包时从 OSS 地址(launcher/icon.url)下载源图并由 tools/IconGen.java 生成平台图标(ico/icns/png)。
- 启动器图标:图标二进制不随源码分发,地址记录在 launcher/icon.url(阿里云 OSS 长期签名地址);运行时由 LauncherIconProvider 下载并缓存到 <基准目录>/cache/launcher-icon.png,缓存缺失时先直接用远端地址,下载失败静默降级为无图标。
- 运行目录:AppHome 新增 configDir/logsDir/setHome,config/logs/data 统一以基准目录(ledgerly.home 系统属性 / LEDGERLY_HOME 环境变量)解析,logback 同步支持。
- 依赖瘦身:移除 com.openai:openai-java,DeepSeekClient 改用 Spring RestClient 直连 Chat Completions(对外签名与 DeepSeekResponse 不变);Knife4j/Swagger UI 改为 developmentOnly,生产仅保留 /v3/api-docs JSON,Knife4jConfig 增加 @ConditionalOnClass 兜底。
- Windows 兼容:npm 解析兼容 npm.cmd;Spotless 统一 LF 并新增 .gitattributes。
- 数据库配置:新增 DatabaseConfigStreamService 与 POST /api/database/{test,config}/stream,SchemaInitializer/SchemaMigrator/DatabaseService 增加进度回调,前端弹框实时展示日志;数据库接口超时放宽为测试 60s、保存 120s。
- 缺陷修复:LauncherHome 改用 java.class.path 定位 jar,避免 Spring Boot 可执行 jar 下嵌套路径导致启动器崩溃;JwtInterceptor 跳过 SSE 完成后的 ASYNC 分派并加固响应已提交判断,避免首次无 Token 配置数据库时重复鉴权写入 401 触发 getOutputStream() 异常。
- 文档:README 精简为功能特性与快速开始/构建打包,细节说明迁移至本地 AGENTS.md。
2026-09-24 14:43:01 +08:00

258 lines
8.7 KiB
Java
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import java.awt.Graphics2D;
import java.awt.RenderingHints;
import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardCopyOption;
import javax.imageio.ImageIO;
/**
* 由单张 PNG 源图生成 jpackage 各平台所需的应用图标。
*
* <p>jpackage 对各平台图标格式要求不同:Windows 用 {@code .ico}、macOS 用
* {@code .icns}、Linux 用 {@code .png}。本工具基于纯 JDK(ImageIO + AWT)实现转换,
* 不依赖 ImageMagick/Pillow 等外部工具,可在 Windows/macOS/Linux 的构建机上直接运行。</p>
*
* <p>用法:{@code java IconGen.java <sourcePng> <outputDir>},输出
* {@code icon.png}、{@code icon.ico}、{@code icon.icns}。</p>
*/
public final class IconGen {
/** ICO 中包含的图标尺寸 */
private static final int[] ICO_SIZES = {16, 24, 32, 48, 64, 128, 256};
/** 尺寸不大于该值时使用 BMP 条目,否则使用 PNG 条目(兼容性最佳) */
private static final int ICO_BMP_MAX_SIZE = 64;
/** ICNS 条目:类型(4 字节)与其像素尺寸 */
private static final Object[][] ICNS_ENTRIES = {
{"ic11", 32},
{"ic12", 64},
{"ic07", 128},
{"ic13", 256},
{"ic08", 256},
{"ic14", 512},
{"ic09", 512},
};
private IconGen() {
}
/**
* 程序入口。
*
* @param args 源 PNG 路径与输出目录
* @throws IOException 读写失败
*/
public static void main(String[] args) throws IOException {
if (args.length < 2) {
System.err.println("用法: java IconGen.java <sourcePng> <outputDir>");
System.exit(2);
}
Path source = Paths.get(args[0]);
Path outputDir = Paths.get(args[1]);
Files.createDirectories(outputDir);
BufferedImage sourceImage = ImageIO.read(source.toFile());
if (sourceImage == null) {
throw new IOException("无法读取源图标:" + source);
}
Files.copy(source, outputDir.resolve("icon.png"), StandardCopyOption.REPLACE_EXISTING);
writeIco(sourceImage, outputDir.resolve("icon.ico"));
writeIcns(sourceImage, outputDir.resolve("icon.icns"));
System.out.println("已生成平台图标到 " + outputDir.toAbsolutePath());
}
/**
* 生成 Windows ICO 文件。
*
* @param source 源图
* @param out 输出文件
* @throws IOException 写入失败
*/
private static void writeIco(BufferedImage source, Path out) throws IOException {
int count = ICO_SIZES.length;
byte[][] images = new byte[count][];
for (int i = 0; i < count; i++) {
BufferedImage scaled = resize(source, ICO_SIZES[i]);
images[i] = ICO_SIZES[i] <= ICO_BMP_MAX_SIZE ? toBmpEntry(scaled) : toPng(scaled);
}
try (OutputStream os = Files.newOutputStream(out)) {
writeShortLE(os, 0);
writeShortLE(os, 1);
writeShortLE(os, count);
// 图标数据区起始偏移:6 字节文件头 + 每项 16 字节目录项
int offset = 6 + count * 16;
for (int i = 0; i < count; i++) {
int size = ICO_SIZES[i];
os.write(size >= 256 ? 0 : size);
os.write(size >= 256 ? 0 : size);
os.write(0);
os.write(0);
writeShortLE(os, 1);
writeShortLE(os, 32);
writeIntLE(os, images[i].length);
writeIntLE(os, offset);
offset += images[i].length;
}
for (byte[] image : images) {
os.write(image);
}
}
}
/**
* 将图像编码为 ICO 中的 BMP 条目:BITMAPINFOHEADER + 32 位 BGRA 像素 + AND 掩码。
*
* @param image 图像
* @return BMP 条目字节
* @throws IOException 编码失败
*/
private static byte[] toBmpEntry(BufferedImage image) throws IOException {
int width = image.getWidth();
int height = image.getHeight();
ByteArrayOutputStream buffer = new ByteArrayOutputStream();
// BITMAPINFOHEADER(高度为两倍,包含 XOR 图与 AND 掩码)
writeIntLE(buffer, 40);
writeIntLE(buffer, width);
writeIntLE(buffer, height * 2);
writeShortLE(buffer, 1);
writeShortLE(buffer, 32);
writeIntLE(buffer, 0);
writeIntLE(buffer, 0);
writeIntLE(buffer, 0);
writeIntLE(buffer, 0);
writeIntLE(buffer, 0);
writeIntLE(buffer, 0);
// XOR 图:32 位 BGRA,自下而上
for (int y = height - 1; y >= 0; y--) {
for (int x = 0; x < width; x++) {
int argb = image.getRGB(x, y);
buffer.write((argb) & 0xFF);
buffer.write((argb >> 8) & 0xFF);
buffer.write((argb >> 16) & 0xFF);
buffer.write((argb >>> 24) & 0xFF);
}
}
// AND 掩码:1 位/像素,行按 4 字节对齐,全 0(透明由 alpha 通道处理)
int maskRowBytes = ((width + 31) / 32) * 4;
byte[] maskRow = new byte[maskRowBytes];
for (int y = 0; y < height; y++) {
buffer.write(maskRow);
}
return buffer.toByteArray();
}
/**
* 生成 macOS ICNS 文件(以 PNG 条目承载各尺寸)。
*
* @param source 源图
* @param out 输出文件
* @throws IOException 写入失败
*/
private static void writeIcns(BufferedImage source, Path out) throws IOException {
ByteArrayOutputStream body = new ByteArrayOutputStream();
for (Object[] entry : ICNS_ENTRIES) {
byte[] png = toPng(resize(source, (Integer) entry[1]));
body.write(((String) entry[0]).getBytes("US-ASCII"));
writeIntBE(body, png.length + 8);
body.write(png);
}
byte[] bodyBytes = body.toByteArray();
try (OutputStream os = Files.newOutputStream(out)) {
os.write("icns".getBytes("US-ASCII"));
writeIntBE(os, bodyBytes.length + 8);
os.write(bodyBytes);
}
}
/**
* 缩放图像。
*
* @param source 源图
* @param size 目标边长
* @return 缩放后的 ARGB 图像
*/
private static BufferedImage resize(BufferedImage source, int size) {
BufferedImage target = new BufferedImage(size, size, BufferedImage.TYPE_INT_ARGB);
Graphics2D graphics = target.createGraphics();
try {
graphics.setRenderingHint(RenderingHints.KEY_INTERPOLATION,
RenderingHints.VALUE_INTERPOLATION_BICUBIC);
graphics.setRenderingHint(RenderingHints.KEY_RENDERING,
RenderingHints.VALUE_RENDER_QUALITY);
graphics.setRenderingHint(RenderingHints.KEY_ANTIALIASING,
RenderingHints.VALUE_ANTIALIAS_ON);
graphics.drawImage(source, 0, 0, size, size, null);
} finally {
graphics.dispose();
}
return target;
}
/**
* 将图像编码为 PNG 字节。
*
* @param image 图像
* @return PNG 字节
* @throws IOException 编码失败
*/
private static byte[] toPng(BufferedImage image) throws IOException {
ByteArrayOutputStream buffer = new ByteArrayOutputStream();
ImageIO.write(image, "png", buffer);
return buffer.toByteArray();
}
/**
* 写出小端 2 字节整数。
*
* @param os 输出流
* @param value 值
* @throws IOException 写入失败
*/
private static void writeShortLE(OutputStream os, int value) throws IOException {
os.write(value & 0xFF);
os.write((value >> 8) & 0xFF);
}
/**
* 写出小端 4 字节整数。
*
* @param os 输出流
* @param value 值
* @throws IOException 写入失败
*/
private static void writeIntLE(OutputStream os, int value) throws IOException {
os.write(value & 0xFF);
os.write((value >> 8) & 0xFF);
os.write((value >> 16) & 0xFF);
os.write((value >>> 24) & 0xFF);
}
/**
* 写出大端 4 字节整数。
*
* @param os 输出流
* @param value 值
* @throws IOException 写入失败
*/
private static void writeIntBE(OutputStream os, int value) throws IOException {
os.write((value >>> 24) & 0xFF);
os.write((value >> 16) & 0xFF);
os.write((value >> 8) & 0xFF);
os.write(value & 0xFF);
}
}