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。
This commit is contained in:
@@ -0,0 +1,257 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user