附件保存在应用基准目录下的 {@code data/email-bill/{configId}/{uid}/{index}_{name}},
* 数据库仅记录相对路径;导入或忽略后不自动删除,便于追溯与重新解析。
*
* @date 2026-09-19 16:43:56
@@ -22,8 +23,8 @@ import cn.rosercode.ledgerly.common.BusinessException;
@Slf4j
public class EmailBillStorage {
- /** 附件存储根目录(相对工作目录) */
- private static final Path ROOT = Paths.get("data", "email-bill");
+ /** 附件存储根目录(相对应用基准目录) */
+ private static final Path ROOT = AppHome.resolve("data", "email-bill");
/**
* 保存附件到本地,返回相对存储根目录的路径。
diff --git a/src/main/java/cn/rosercode/ledgerly/interceptor/JwtInterceptor.java b/src/main/java/cn/rosercode/ledgerly/interceptor/JwtInterceptor.java
index e90a781..b92e2a8 100644
--- a/src/main/java/cn/rosercode/ledgerly/interceptor/JwtInterceptor.java
+++ b/src/main/java/cn/rosercode/ledgerly/interceptor/JwtInterceptor.java
@@ -4,6 +4,7 @@ import java.util.HashMap;
import java.util.Map;
import com.fasterxml.jackson.databind.ObjectMapper;
+import jakarta.servlet.DispatcherType;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.stereotype.Component;
@@ -51,6 +52,12 @@ public class JwtInterceptor implements HandlerInterceptor {
return true;
}
+ // SSE 等异步请求完成后的 ASYNC 分派会再次进入拦截器,此时首次分派已完成鉴权,
+ // 且响应可能已提交(如 SSE 已写出),直接放行,避免重复鉴权写入 401 导致 IllegalStateException
+ if (request.getDispatcherType() == DispatcherType.ASYNC) {
+ return true;
+ }
+
// 数据库未配置时放行数据库配置接口,否则用户无法登录、陷入死锁;
// 配置完成后这些接口重新纳入 JWT 认证保护
String uri = request.getRequestURI();
@@ -72,6 +79,10 @@ public class JwtInterceptor implements HandlerInterceptor {
// Token 为空或验证不通过时,返回 401 未授权响应
if (token == null || !jwtUtil.validateToken(token)) {
+ // 响应已提交时无法再写入,避免二次写出触发 IllegalStateException
+ if (response.isCommitted()) {
+ return false;
+ }
response.setContentType("application/json;charset=UTF-8");
response.setStatus(401);
Map result = new HashMap<>();
diff --git a/src/main/java/cn/rosercode/ledgerly/launcher/LauncherApp.java b/src/main/java/cn/rosercode/ledgerly/launcher/LauncherApp.java
new file mode 100644
index 0000000..acb4ae1
--- /dev/null
+++ b/src/main/java/cn/rosercode/ledgerly/launcher/LauncherApp.java
@@ -0,0 +1,102 @@
+package cn.rosercode.ledgerly.launcher;
+
+import java.io.IOException;
+import java.net.URL;
+import java.nio.file.Path;
+
+import javafx.application.Application;
+import javafx.application.Platform;
+import javafx.scene.control.Alert;
+import javafx.stage.Stage;
+
+/**
+ * 桌面启动器的 JavaFX 应用。
+ *
+ * 负责初始化单实例锁、主窗口、日志读取与系统托盘,并在退出时统一释放资源。
+ *
+ * @date 2026-09-20 02:47:21
+ */
+public class LauncherApp extends Application {
+
+ private SingleInstanceGuard guard;
+
+ private LauncherConfigStore configStore;
+
+ private LauncherConfig config;
+
+ private ServerController serverController;
+
+ private LogTailer logTailer;
+
+ private TrayManager trayManager;
+
+ private LauncherFrame frame;
+
+ private Path home;
+
+ @Override
+ public void start(Stage stage) {
+ home = LauncherHome.resolve(getParameters().getNamed().get("home"));
+ LauncherHome.apply(home);
+
+ try {
+ guard = SingleInstanceGuard.acquire(home);
+ } catch (IOException e) {
+ showFatal("无法创建单实例锁:" + e.getMessage());
+ return;
+ }
+ if (guard == null) {
+ showFatal("程序已在运行,请勿重复启动。");
+ return;
+ }
+
+ configStore = new LauncherConfigStore(home);
+ config = configStore.load();
+ serverController = new ServerController();
+ URL iconUrl = LauncherIconProvider.resolve(home);
+ frame = new LauncherFrame(stage, serverController, config, configStore, home, iconUrl);
+ serverController.setStatusListener(status -> Platform.runLater(() -> frame.updateStatus(status)));
+ serverController.setErrorListener(error -> frame.showError("服务启动失败:" + rootMessage(error)));
+ frame.show();
+
+ logTailer = new LogTailer(home.resolve("logs").resolve("ledgerly.log"), frame::appendLog);
+ logTailer.start();
+
+ trayManager = new TrayManager(frame);
+ trayManager.init();
+ frame.setTray(trayManager);
+ }
+
+ @Override
+ public void stop() {
+ if (serverController != null) {
+ serverController.stopNow();
+ }
+ if (logTailer != null) {
+ logTailer.stop();
+ }
+ if (trayManager != null) {
+ trayManager.shutdown();
+ }
+ if (guard != null) {
+ guard.close();
+ }
+ }
+
+ private void showFatal(String message) {
+ Alert alert = new Alert(Alert.AlertType.WARNING);
+ alert.setTitle("提示");
+ alert.setHeaderText(null);
+ alert.setContentText(message);
+ alert.showAndWait();
+ Platform.exit();
+ }
+
+ private String rootMessage(Throwable error) {
+ Throwable cause = error;
+ while (cause.getCause() != null && cause.getCause() != cause) {
+ cause = cause.getCause();
+ }
+ return cause.getMessage() != null ? cause.getMessage() : cause.getClass().getSimpleName();
+ }
+}
diff --git a/src/main/java/cn/rosercode/ledgerly/launcher/LauncherConfig.java b/src/main/java/cn/rosercode/ledgerly/launcher/LauncherConfig.java
new file mode 100644
index 0000000..059e8f8
--- /dev/null
+++ b/src/main/java/cn/rosercode/ledgerly/launcher/LauncherConfig.java
@@ -0,0 +1,55 @@
+package cn.rosercode.ledgerly.launcher;
+
+/**
+ * 桌面启动器的本地配置。
+ *
+ * 持久化在 {@code config/launcher.json},包含服务端口与若干交互开关。
+ *
+ * @date 2026-09-20 02:47:21
+ */
+public class LauncherConfig {
+
+ /** 服务端口 */
+ private int serverPort = 8080;
+
+ /** 服务启动成功后是否自动打开浏览器 */
+ private boolean autoOpenBrowser = true;
+
+ /** 关闭窗口时是否最小化到系统托盘 */
+ private boolean closeToTray = true;
+
+ /** 日志窗口是否自动滚动到底部 */
+ private boolean logAutoScroll = true;
+
+ public int getServerPort() {
+ return serverPort;
+ }
+
+ public void setServerPort(int serverPort) {
+ this.serverPort = serverPort;
+ }
+
+ public boolean isAutoOpenBrowser() {
+ return autoOpenBrowser;
+ }
+
+ public void setAutoOpenBrowser(boolean autoOpenBrowser) {
+ this.autoOpenBrowser = autoOpenBrowser;
+ }
+
+ public boolean isCloseToTray() {
+ return closeToTray;
+ }
+
+ public void setCloseToTray(boolean closeToTray) {
+ this.closeToTray = closeToTray;
+ }
+
+ public boolean isLogAutoScroll() {
+ return logAutoScroll;
+ }
+
+ public void setLogAutoScroll(boolean logAutoScroll) {
+ this.logAutoScroll = logAutoScroll;
+ }
+}
diff --git a/src/main/java/cn/rosercode/ledgerly/launcher/LauncherConfigStore.java b/src/main/java/cn/rosercode/ledgerly/launcher/LauncherConfigStore.java
new file mode 100644
index 0000000..3926d79
--- /dev/null
+++ b/src/main/java/cn/rosercode/ledgerly/launcher/LauncherConfigStore.java
@@ -0,0 +1,74 @@
+package cn.rosercode.ledgerly.launcher;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+/**
+ * 桌面启动器配置的本地文件存储。
+ *
+ * 配置以 JSON 形式保存在 {@code /config/launcher.json};文件不存在或解析失败时
+ * 返回默认配置,保证首次运行可用。
+ *
+ * @date 2026-09-20 02:47:21
+ */
+public class LauncherConfigStore {
+
+ /** 配置文件名 */
+ private static final String FILE_NAME = "launcher.json";
+
+ private final ObjectMapper objectMapper = new ObjectMapper();
+
+ private final Path file;
+
+ /**
+ * 构造配置存储。
+ *
+ * @param home 应用基准目录
+ */
+ public LauncherConfigStore(Path home) {
+ this.file = home.resolve("config").resolve(FILE_NAME);
+ }
+
+ /**
+ * 获取配置文件路径。
+ *
+ * @return 配置文件路径
+ */
+ public Path getFile() {
+ return file;
+ }
+
+ /**
+ * 读取配置,文件不存在或解析失败时返回默认配置。
+ *
+ * @return 启动器配置
+ */
+ public LauncherConfig load() {
+ if (!Files.exists(file)) {
+ return new LauncherConfig();
+ }
+ try {
+ LauncherConfig config = objectMapper.readValue(file.toFile(), LauncherConfig.class);
+ return config != null ? config : new LauncherConfig();
+ } catch (IOException e) {
+ return new LauncherConfig();
+ }
+ }
+
+ /**
+ * 保存配置,自动创建父目录。
+ *
+ * @param config 启动器配置
+ */
+ public void save(LauncherConfig config) {
+ try {
+ Files.createDirectories(file.getParent());
+ objectMapper.writerWithDefaultPrettyPrinter().writeValue(file.toFile(), config);
+ } catch (IOException e) {
+ // 配置保存失败不影响主流程,仅忽略
+ }
+ }
+}
diff --git a/src/main/java/cn/rosercode/ledgerly/launcher/LauncherFrame.java b/src/main/java/cn/rosercode/ledgerly/launcher/LauncherFrame.java
new file mode 100644
index 0000000..1ae35e2
--- /dev/null
+++ b/src/main/java/cn/rosercode/ledgerly/launcher/LauncherFrame.java
@@ -0,0 +1,393 @@
+package cn.rosercode.ledgerly.launcher;
+
+import java.awt.Desktop;
+import java.io.IOException;
+import java.net.URI;
+import java.net.URL;
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+import javafx.application.Platform;
+import javafx.geometry.Insets;
+import javafx.geometry.Pos;
+import javafx.scene.Scene;
+import javafx.scene.control.Alert;
+import javafx.scene.control.Button;
+import javafx.scene.control.CheckBox;
+import javafx.scene.control.Label;
+import javafx.scene.control.TextArea;
+import javafx.scene.control.TextField;
+import javafx.scene.control.TextFormatter;
+import javafx.scene.image.Image;
+import javafx.scene.layout.BorderPane;
+import javafx.scene.layout.HBox;
+import javafx.scene.layout.Priority;
+import javafx.scene.layout.Region;
+import javafx.scene.layout.VBox;
+import javafx.scene.paint.Color;
+import javafx.scene.shape.Circle;
+import javafx.stage.Stage;
+
+/**
+ * 桌面启动器主窗口。
+ *
+ * 提供服务启停、端口设置、打开浏览器、查看运行日志等操作入口,并负责根据
+ * 服务状态刷新界面与系统托盘提示。
+ *
+ * @date 2026-09-20 02:47:21
+ */
+public class LauncherFrame {
+
+ /** 日志窗口保留的最大行数 */
+ private static final int MAX_LOG_LINES = 3000;
+
+ private final Stage stage;
+
+ private final ServerController serverController;
+
+ private final LauncherConfig config;
+
+ private final LauncherConfigStore configStore;
+
+ private final Path home;
+
+ private final URL iconUrl;
+
+ private final Label statusLabel = new Label();
+
+ private final Circle statusDot = new Circle(6);
+
+ private final TextField portField = new TextField();
+
+ private final CheckBox autoOpenBrowserBox = new CheckBox("启动后打开浏览器");
+
+ private final CheckBox autoScrollBox = new CheckBox("自动滚动");
+
+ private final TextArea logArea = new TextArea();
+
+ private final Button startButton = new Button("启动服务");
+
+ private final Button stopButton = new Button("停止服务");
+
+ private TrayManager tray;
+
+ private int logLineCount;
+
+ private boolean browserOpenedForRun;
+
+ /**
+ * 构造主窗口。
+ *
+ * @param stage 主舞台
+ * @param serverController 服务控制器
+ * @param config 启动器配置
+ * @param configStore 配置存储
+ * @param home 应用基准目录
+ * @param iconUrl 启动器图标地址(本地缓存或远端),可为 null
+ */
+ public LauncherFrame(Stage stage, ServerController serverController,
+ LauncherConfig config, LauncherConfigStore configStore, Path home, URL iconUrl) {
+ this.stage = stage;
+ this.serverController = serverController;
+ this.config = config;
+ this.configStore = configStore;
+ this.home = home;
+ this.iconUrl = iconUrl;
+ build();
+ }
+
+ private void build() {
+ BorderPane root = new BorderPane();
+ root.setPadding(new Insets(12));
+ root.setTop(buildHeader());
+ root.setCenter(buildLogArea());
+ root.setBottom(buildActions());
+
+ Scene scene = new Scene(root, 720, 480);
+ stage.setScene(scene);
+ stage.setTitle("汇账");
+ if (iconUrl != null) {
+ stage.getIcons().add(new Image(iconUrl.toExternalForm()));
+ }
+ stage.setOnCloseRequest(event -> {
+ if (config.isCloseToTray() && tray != null && tray.isAvailable()) {
+ event.consume();
+ stage.hide();
+ } else {
+ exitApp();
+ }
+ });
+ applyStatus(ServerController.Status.STOPPED);
+ }
+
+ private VBox buildHeader() {
+ statusLabel.setStyle("-fx-font-size: 14px; -fx-font-weight: bold;");
+ HBox statusRow = new HBox(8, statusDot, statusLabel);
+ statusRow.setAlignment(Pos.CENTER_LEFT);
+
+ Region spacer = new Region();
+ HBox.setHgrow(spacer, Priority.ALWAYS);
+
+ Label portLabel = new Label("端口");
+ portField.setPrefColumnCount(6);
+ portField.setText(String.valueOf(config.getServerPort()));
+ portField.setTextFormatter(new TextFormatter<>(change ->
+ change.getControlNewText().matches("\\d{0,5}") ? change : null));
+
+ autoOpenBrowserBox.setSelected(config.isAutoOpenBrowser());
+
+ HBox controlRow = new HBox(8, spacer, portLabel, portField, autoOpenBrowserBox);
+ controlRow.setAlignment(Pos.CENTER_LEFT);
+ controlRow.setPadding(new Insets(8, 0, 8, 0));
+
+ return new VBox(statusRow, controlRow);
+ }
+
+ private TextArea buildLogArea() {
+ logArea.setEditable(false);
+ logArea.setWrapText(false);
+ logArea.setStyle("-fx-font-family: 'Consolas', 'Menlo', monospace; -fx-font-size: 12px;");
+ VBox.setVgrow(logArea, Priority.ALWAYS);
+ return logArea;
+ }
+
+ private HBox buildActions() {
+ startButton.setOnAction(e -> startServer());
+ stopButton.setOnAction(e -> stopServer());
+ Button browserButton = new Button("打开浏览器");
+ browserButton.setOnAction(e -> openBrowser());
+ Button logDirButton = new Button("打开日志目录");
+ logDirButton.setOnAction(e -> openLogDir());
+ Button exitButton = new Button("退出");
+ exitButton.setOnAction(e -> exitApp());
+
+ autoScrollBox.setSelected(config.isLogAutoScroll());
+
+ Region spacer = new Region();
+ HBox.setHgrow(spacer, Priority.ALWAYS);
+
+ HBox actions = new HBox(8, startButton, stopButton, browserButton, logDirButton, spacer,
+ autoScrollBox, exitButton);
+ actions.setAlignment(Pos.CENTER_LEFT);
+ actions.setPadding(new Insets(8, 0, 0, 0));
+ return actions;
+ }
+
+ /**
+ * 显示窗口。
+ */
+ public void show() {
+ stage.show();
+ }
+
+ /**
+ * 显示并置顶窗口。
+ */
+ public void showWindow() {
+ stage.show();
+ stage.toFront();
+ stage.requestFocus();
+ }
+
+ /**
+ * 设置托盘管理器。
+ *
+ * @param tray 托盘管理器
+ */
+ public void setTray(TrayManager tray) {
+ this.tray = tray;
+ }
+
+ /**
+ * 获取启动器图标地址。
+ *
+ * @return 图标地址,可为 null
+ */
+ public URL getIconUrl() {
+ return iconUrl;
+ }
+
+ /**
+ * 启动服务。
+ */
+ public void startServer() {
+ Integer port = parsePort();
+ if (port == null) {
+ showError("端口必须是 1-65535 之间的整数");
+ return;
+ }
+ config.setServerPort(port);
+ config.setAutoOpenBrowser(autoOpenBrowserBox.isSelected());
+ config.setLogAutoScroll(autoScrollBox.isSelected());
+ configStore.save(config);
+ browserOpenedForRun = false;
+ serverController.start(port);
+ }
+
+ /**
+ * 停止服务。
+ */
+ public void stopServer() {
+ serverController.stop();
+ }
+
+ /**
+ * 在浏览器中打开服务地址。
+ */
+ public void openBrowser() {
+ int port = serverController.isRunning() ? serverController.getPort() : parsePortOr(config.getServerPort());
+ openUri("http://localhost:" + port);
+ }
+
+ /**
+ * 打开日志目录。
+ */
+ public void openLogDir() {
+ Path dir = home.resolve("logs");
+ try {
+ Files.createDirectories(dir);
+ } catch (IOException e) {
+ // 目录创建失败时仍尝试打开
+ }
+ try {
+ if (Desktop.isDesktopSupported() && Desktop.getDesktop().isSupported(Desktop.Action.OPEN)) {
+ Desktop.getDesktop().open(dir.toFile());
+ return;
+ }
+ } catch (Exception e) {
+ // 降级为提示
+ }
+ showInfo("日志目录:" + dir);
+ }
+
+ /**
+ * 退出应用。
+ */
+ public void exitApp() {
+ serverController.stopNow();
+ Platform.exit();
+ }
+
+ /**
+ * 追加一行日志。
+ *
+ * @param line 日志行
+ */
+ public void appendLog(String line) {
+ Platform.runLater(() -> {
+ logArea.appendText(line + "\n");
+ logLineCount++;
+ if (logLineCount > MAX_LOG_LINES) {
+ int index = logArea.getText().indexOf('\n');
+ if (index >= 0) {
+ logArea.deleteText(0, index + 1);
+ logLineCount--;
+ }
+ }
+ if (autoScrollBox.isSelected()) {
+ logArea.positionCaret(logArea.getLength());
+ logArea.setScrollTop(Double.MAX_VALUE);
+ }
+ });
+ }
+
+ /**
+ * 根据服务状态刷新界面。
+ *
+ * @param status 服务状态
+ */
+ public void updateStatus(ServerController.Status status) {
+ applyStatus(status);
+ if (tray != null) {
+ tray.setStatus(statusText(status));
+ }
+ if (status == ServerController.Status.RUNNING
+ && autoOpenBrowserBox.isSelected() && !browserOpenedForRun) {
+ browserOpenedForRun = true;
+ openBrowser();
+ }
+ }
+
+ /**
+ * 弹出错误提示。
+ *
+ * @param message 错误信息
+ */
+ public void showError(String message) {
+ Platform.runLater(() -> {
+ Alert alert = new Alert(Alert.AlertType.ERROR);
+ alert.setTitle("错误");
+ alert.setHeaderText(null);
+ alert.setContentText(message);
+ alert.initOwner(stage);
+ alert.showAndWait();
+ });
+ }
+
+ private void applyStatus(ServerController.Status status) {
+ statusLabel.setText(statusText(status));
+ statusDot.setFill(statusColor(status));
+ startButton.setDisable(status == ServerController.Status.STARTING
+ || status == ServerController.Status.RUNNING
+ || status == ServerController.Status.STOPPING);
+ stopButton.setDisable(status != ServerController.Status.RUNNING
+ && status != ServerController.Status.STARTING);
+ portField.setDisable(status == ServerController.Status.STARTING
+ || status == ServerController.Status.RUNNING
+ || status == ServerController.Status.STOPPING);
+ }
+
+ private String statusText(ServerController.Status status) {
+ return switch (status) {
+ case STOPPED -> "已停止";
+ case STARTING -> "启动中…";
+ case RUNNING -> "运行中";
+ case STOPPING -> "停止中…";
+ case FAILED -> "启动失败";
+ };
+ }
+
+ private Color statusColor(ServerController.Status status) {
+ return switch (status) {
+ case STOPPED -> Color.web("#9e9e9e");
+ case STARTING, STOPPING -> Color.web("#fb8c00");
+ case RUNNING -> Color.web("#43a047");
+ case FAILED -> Color.web("#e53935");
+ };
+ }
+
+ private Integer parsePort() {
+ try {
+ int port = Integer.parseInt(portField.getText().trim());
+ return port >= 1 && port <= 65535 ? port : null;
+ } catch (NumberFormatException e) {
+ return null;
+ }
+ }
+
+ private int parsePortOr(int fallback) {
+ Integer port = parsePort();
+ return port != null ? port : fallback;
+ }
+
+ private void openUri(String uri) {
+ try {
+ if (Desktop.isDesktopSupported() && Desktop.getDesktop().isSupported(Desktop.Action.BROWSE)) {
+ Desktop.getDesktop().browse(URI.create(uri));
+ return;
+ }
+ } catch (Exception e) {
+ // 降级为提示
+ }
+ showInfo("请在浏览器中访问:" + uri);
+ }
+
+ private void showInfo(String message) {
+ Alert alert = new Alert(Alert.AlertType.INFORMATION);
+ alert.setTitle("提示");
+ alert.setHeaderText(null);
+ alert.setContentText(message);
+ alert.initOwner(stage);
+ alert.showAndWait();
+ }
+}
diff --git a/src/main/java/cn/rosercode/ledgerly/launcher/LauncherHome.java b/src/main/java/cn/rosercode/ledgerly/launcher/LauncherHome.java
new file mode 100644
index 0000000..f063692
--- /dev/null
+++ b/src/main/java/cn/rosercode/ledgerly/launcher/LauncherHome.java
@@ -0,0 +1,150 @@
+package cn.rosercode.ledgerly.launcher;
+
+import java.io.File;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+
+import cn.rosercode.ledgerly.util.AppHome;
+
+/**
+ * 桌面启动器的基准目录解析。
+ *
+ * 解析优先级:命令行 {@code --home} 参数 → 环境变量 {@code LEDGERLY_HOME}
+ * → jar 所在目录 → 当前工作目录。解析结果会写入系统属性,供后端
+ * {@link AppHome} 与 logback 使用,保证双击启动时配置、日志、数据落点稳定。
+ *
+ * @date 2026-09-20 02:47:21
+ */
+public final class LauncherHome {
+
+ /** 环境变量名,与 logback 使用的属性名保持一致 */
+ private static final String ENV_HOME = "LEDGERLY_HOME";
+
+ private LauncherHome() {
+ }
+
+ /**
+ * 解析应用基准目录。
+ *
+ * @param explicitHome 命令行指定的目录,可为 null
+ * @return 规范化后的基准目录绝对路径
+ */
+ public static Path resolve(String explicitHome) {
+ if (explicitHome != null && !explicitHome.isBlank()) {
+ return Paths.get(explicitHome).toAbsolutePath().normalize();
+ }
+ String envHome = System.getenv(ENV_HOME);
+ if (envHome != null && !envHome.isBlank()) {
+ return Paths.get(envHome).toAbsolutePath().normalize();
+ }
+ Path jarDir = detectJarDir();
+ if (jarDir != null) {
+ return jarDir;
+ }
+ return Paths.get("").toAbsolutePath().normalize();
+ }
+
+ /**
+ * 将基准目录写入系统属性,供后端与 logback 读取。
+ *
+ * @param home 基准目录
+ */
+ public static void apply(Path home) {
+ AppHome.setHome(home);
+ }
+
+ /**
+ * 探测应用 jar 所在目录。
+ *
+ * 不直接使用 {@code getProtectionDomain().getCodeSource()}:在 Spring Boot 可执行 jar
+ * 中它返回的是 jar 内部的嵌套虚拟路径({@code nested:}),对其创建文件会抛
+ * {@code Unable to resolve nested path}。这里优先从 {@code java.class.path} 取真实 jar,
+ * 其次兼容 jpackage app-image,最后才回退 code source 且仅接受本地 {@code file:} 路径。
+ *
+ * @return jar 所在目录;无法探测时返回 null
+ */
+ private static Path detectJarDir() {
+ Path fromClasspath = detectFromClasspath();
+ if (fromClasspath != null) {
+ return fromClasspath;
+ }
+ String appPath = System.getProperty("jpackage.app-path");
+ if (appPath != null && !appPath.isBlank()) {
+ Path binDir = Paths.get(appPath).getParent();
+ if (binDir != null && binDir.getParent() != null) {
+ return binDir.getParent();
+ }
+ }
+ return detectFromCodeSource();
+ }
+
+ /**
+ * 从 {@code java.class.path} 中探测真实 jar 所在目录。
+ *
+ * @return jar 所在目录;未找到返回 null
+ */
+ private static Path detectFromClasspath() {
+ String classPath = System.getProperty("java.class.path");
+ if (classPath == null || classPath.isBlank()) {
+ return null;
+ }
+ for (String entry : classPath.split(File.pathSeparator)) {
+ if (entry.isBlank()) {
+ continue;
+ }
+ Path path = Paths.get(entry);
+ if (isLocalJar(path)) {
+ return path.toAbsolutePath().normalize().getParent();
+ }
+ }
+ return null;
+ }
+
+ /**
+ * 从 code source 探测,且仅接受本地文件系统路径。
+ *
+ * @return jar 所在目录;无法探测或为嵌套路径时返回 null
+ */
+ private static Path detectFromCodeSource() {
+ try {
+ var source = LauncherHome.class.getProtectionDomain().getCodeSource();
+ if (source == null || source.getLocation() == null) {
+ return null;
+ }
+ Path path = Paths.get(source.getLocation().toURI());
+ if (!isLocalFile(path)) {
+ return null;
+ }
+ return Files.isRegularFile(path) ? path.getParent() : path;
+ } catch (Exception e) {
+ return null;
+ }
+ }
+
+ /**
+ * 判断路径是否为本地文件系统上的真实文件,排除 jar 内部虚拟路径。
+ *
+ * @param path 待判断路径
+ * @return 本地且存在返回 true
+ */
+ private static boolean isLocalFile(Path path) {
+ try {
+ // 先判断协议,避免对 nested:/jar: 路径执行文件操作
+ return "file".equalsIgnoreCase(path.getFileSystem().provider().getScheme())
+ && Files.exists(path);
+ } catch (Exception e) {
+ return false;
+ }
+ }
+
+ /**
+ * 判断路径是否为本地文件系统上的真实 jar 文件。
+ *
+ * @param path 待判断路径
+ * @return 真实 jar 返回 true
+ */
+ private static boolean isLocalJar(Path path) {
+ return path.toString().endsWith(".jar") && isLocalFile(path) && Files.isRegularFile(path);
+ }
+}
diff --git a/src/main/java/cn/rosercode/ledgerly/launcher/LauncherIconProvider.java b/src/main/java/cn/rosercode/ledgerly/launcher/LauncherIconProvider.java
new file mode 100644
index 0000000..13ba3ea
--- /dev/null
+++ b/src/main/java/cn/rosercode/ledgerly/launcher/LauncherIconProvider.java
@@ -0,0 +1,156 @@
+package cn.rosercode.ledgerly.launcher;
+
+import java.io.InputStream;
+import java.net.URI;
+import java.net.URL;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.StandardCopyOption;
+import java.time.Duration;
+
+import lombok.extern.slf4j.Slf4j;
+
+/**
+ * 桌面启动器图标提供者。
+ *
+ * 图标资源不随源码/jar 分发,而是托管在远端(阿里云 OSS)。启动时优先使用本地缓存
+ * {@code /cache/launcher-icon.png};缓存缺失时直接返回远端地址供窗口/托盘加载,
+ * 同时在后台下载并写入缓存,便于后续离线使用。下载失败时静默降级(不显示图标,不影响启动)。
+ *
+ * @date 2026-09-24 14:36:55
+ */
+@Slf4j
+public final class LauncherIconProvider {
+
+ /** 图标远端地址所在资源(classpath) */
+ private static final String URL_RESOURCE = "/launcher/icon.url";
+
+ /** 覆盖图标远端地址的系统属性 */
+ private static final String URL_PROPERTY = "ledgerly.icon.url";
+
+ /** 缓存目录名(相对应用基准目录) */
+ private static final String CACHE_DIR = "cache";
+
+ /** 缓存文件名 */
+ private static final String CACHE_FILE = "launcher-icon.png";
+
+ /** 连接超时 */
+ private static final Duration CONNECT_TIMEOUT = Duration.ofSeconds(10);
+
+ /** 读取超时 */
+ private static final Duration READ_TIMEOUT = Duration.ofSeconds(20);
+
+ private LauncherIconProvider() {
+ }
+
+ /**
+ * 解析启动器图标地址:优先本地缓存,其次远端地址(并触发后台缓存下载)。
+ *
+ * @param home 应用基准目录
+ * @return 可用的图标地址;无法获得时返回 null
+ */
+ public static URL resolve(Path home) {
+ Path cached = cacheFile(home);
+ if (Files.exists(cached)) {
+ URL url = toUrl(cached.toUri().toString());
+ if (url != null) {
+ return url;
+ }
+ }
+ String remote = iconUrl();
+ if (remote == null) {
+ return null;
+ }
+ prefetchAsync(cached, remote);
+ return toUrl(remote);
+ }
+
+ /**
+ * 读取图标远端地址(系统属性优先,其次 classpath 资源)。
+ *
+ * @return 图标远端地址;未配置时返回 null
+ */
+ public static String iconUrl() {
+ String override = System.getProperty(URL_PROPERTY);
+ if (override != null && !override.isBlank()) {
+ return override.trim();
+ }
+ try (InputStream in = LauncherIconProvider.class.getResourceAsStream(URL_RESOURCE)) {
+ if (in == null) {
+ return null;
+ }
+ String text = new String(in.readAllBytes(), StandardCharsets.UTF_8).trim();
+ return text.isEmpty() ? null : text;
+ } catch (Exception e) {
+ return null;
+ }
+ }
+
+ /**
+ * 获取缓存文件路径。
+ *
+ * @param home 应用基准目录
+ * @return 缓存文件路径
+ */
+ private static Path cacheFile(Path home) {
+ return home.resolve(CACHE_DIR).resolve(CACHE_FILE);
+ }
+
+ /**
+ * 后台下载图标到缓存文件。
+ *
+ * @param cacheFile 缓存文件路径
+ * @param url 图标远端地址
+ */
+ private static void prefetchAsync(Path cacheFile, String url) {
+ Thread thread = new Thread(() -> download(cacheFile, url), "ledgerly-icon-download");
+ thread.setDaemon(true);
+ thread.start();
+ }
+
+ /**
+ * 下载图标并写入缓存,失败时记录日志并忽略。
+ *
+ * @param cacheFile 缓存文件路径
+ * @param url 图标远端地址
+ */
+ private static void download(Path cacheFile, String url) {
+ try {
+ HttpClient client = HttpClient.newBuilder().connectTimeout(CONNECT_TIMEOUT).build();
+ HttpRequest request = HttpRequest.newBuilder(URI.create(url))
+ .timeout(READ_TIMEOUT)
+ .GET()
+ .build();
+ HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofByteArray());
+ if (response.statusCode() != 200 || response.body().length == 0) {
+ log.warn("下载启动器图标失败:HTTP {}", response.statusCode());
+ return;
+ }
+ Files.createDirectories(cacheFile.getParent());
+ Path temp = cacheFile.resolveSibling(CACHE_FILE + ".tmp");
+ Files.write(temp, response.body());
+ Files.move(temp, cacheFile, StandardCopyOption.REPLACE_EXISTING);
+ log.info("启动器图标已缓存:{}", cacheFile);
+ } catch (Exception e) {
+ log.warn("下载启动器图标失败:{}: {}", e.getClass().getSimpleName(), e.getMessage());
+ }
+ }
+
+ /**
+ * 字符串转 URL,失败返回 null。
+ *
+ * @param value 地址字符串
+ * @return URL;非法时返回 null
+ */
+ private static URL toUrl(String value) {
+ try {
+ return URI.create(value).toURL();
+ } catch (Exception e) {
+ return null;
+ }
+ }
+}
diff --git a/src/main/java/cn/rosercode/ledgerly/launcher/LauncherMain.java b/src/main/java/cn/rosercode/ledgerly/launcher/LauncherMain.java
new file mode 100644
index 0000000..b7cc436
--- /dev/null
+++ b/src/main/java/cn/rosercode/ledgerly/launcher/LauncherMain.java
@@ -0,0 +1,28 @@
+package cn.rosercode.ledgerly.launcher;
+
+import javafx.application.Application;
+
+/**
+ * 带 GUI 的 jar 的主入口。
+ *
+ * 与纯命令行启动({@code java -jar ledgerly-*.jar} 直接启动 Spring Boot)并存:
+ * 本入口先启动 JavaFX 桌面启动器,由用户在界面中按需启动 / 停止后端服务。
+ *
+ * @date 2026-09-20 02:47:21
+ */
+public final class LauncherMain {
+
+ private LauncherMain() {
+ }
+
+ /**
+ * 程序入口。
+ *
+ * @param args 命令行参数,支持 {@code --home=<目录>} 指定基准目录
+ */
+ public static void main(String[] args) {
+ // 关闭 AWT 无头模式,保证系统托盘可用
+ System.setProperty("java.awt.headless", "false");
+ Application.launch(LauncherApp.class, args);
+ }
+}
diff --git a/src/main/java/cn/rosercode/ledgerly/launcher/LogTailer.java b/src/main/java/cn/rosercode/ledgerly/launcher/LogTailer.java
new file mode 100644
index 0000000..888bb20
--- /dev/null
+++ b/src/main/java/cn/rosercode/ledgerly/launcher/LogTailer.java
@@ -0,0 +1,181 @@
+package cn.rosercode.ledgerly.launcher;
+
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.nio.channels.FileChannel;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.StandardOpenOption;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.TimeUnit;
+import java.util.function.Consumer;
+
+/**
+ * 日志文件增量读取器。
+ *
+ * 定时读取日志文件的新增内容并按行回调,供桌面启动器展示后端运行日志。
+ * 启动时先回放文件末尾若干行;当检测到文件被截断(如按天滚动)时自动从头读取。
+ *
+ * @date 2026-09-20 02:47:21
+ */
+public class LogTailer {
+
+ /** 启动时回放的最大行数 */
+ private static final int TAIL_LINES = 300;
+
+ /** 单次回放读取的最大字节数 */
+ private static final int TAIL_BYTES = 256 * 1024;
+
+ /** 轮询间隔(秒) */
+ private static final long POLL_INTERVAL_SECONDS = 1;
+
+ private final Path file;
+
+ private final Consumer lineConsumer;
+
+ private final ScheduledExecutorService executor = Executors.newSingleThreadScheduledExecutor(r -> {
+ Thread thread = new Thread(r, "ledgerly-log-tailer");
+ thread.setDaemon(true);
+ return thread;
+ });
+
+ private long position;
+
+ private byte[] leftover = new byte[0];
+
+ /**
+ * 构造日志读取器。
+ *
+ * @param file 日志文件路径
+ * @param lineConsumer 行回调(在读取线程调用,调用方需自行切换到界面线程)
+ */
+ public LogTailer(Path file, Consumer lineConsumer) {
+ this.file = file;
+ this.lineConsumer = lineConsumer;
+ }
+
+ /**
+ * 启动读取。
+ */
+ public void start() {
+ long initialSize = sizeOf();
+ readTail().forEach(lineConsumer);
+ position = Math.max(sizeOf(), initialSize);
+ leftover = new byte[0];
+ executor.scheduleWithFixedDelay(this::safePoll,
+ POLL_INTERVAL_SECONDS, POLL_INTERVAL_SECONDS, TimeUnit.SECONDS);
+ }
+
+ /**
+ * 停止读取。
+ */
+ public void stop() {
+ executor.shutdownNow();
+ }
+
+ private void safePoll() {
+ try {
+ poll();
+ } catch (Exception e) {
+ // 读取失败时等待下一轮重试
+ }
+ }
+
+ private void poll() {
+ if (!Files.exists(file)) {
+ return;
+ }
+ try (FileChannel channel = FileChannel.open(file, StandardOpenOption.READ)) {
+ long size = channel.size();
+ if (size < position) {
+ position = 0;
+ leftover = new byte[0];
+ }
+ if (size == position) {
+ return;
+ }
+ int length = (int) (size - position);
+ ByteBuffer buffer = ByteBuffer.allocate(length);
+ channel.position(position);
+ while (buffer.hasRemaining() && channel.read(buffer) >= 0) {
+ // 读满缓冲区
+ }
+ position = size;
+
+ byte[] data = new byte[leftover.length + length];
+ System.arraycopy(leftover, 0, data, 0, leftover.length);
+ System.arraycopy(buffer.array(), 0, data, leftover.length, length);
+
+ int lastNewline = lastNewline(data);
+ if (lastNewline < 0) {
+ leftover = data;
+ return;
+ }
+ emit(new String(data, 0, lastNewline, StandardCharsets.UTF_8));
+ leftover = Arrays.copyOfRange(data, lastNewline + 1, data.length);
+ } catch (IOException e) {
+ // 文件暂不可读时等待下一轮
+ }
+ }
+
+ private void emit(String text) {
+ for (String line : text.split("\n", -1)) {
+ if (line.endsWith("\r")) {
+ line = line.substring(0, line.length() - 1);
+ }
+ if (!line.isEmpty()) {
+ lineConsumer.accept(line);
+ }
+ }
+ }
+
+ private List readTail() {
+ if (!Files.exists(file)) {
+ return List.of();
+ }
+ try (FileChannel channel = FileChannel.open(file, StandardOpenOption.READ)) {
+ long size = channel.size();
+ long start = Math.max(0, size - TAIL_BYTES);
+ int length = (int) (size - start);
+ ByteBuffer buffer = ByteBuffer.allocate(length);
+ channel.position(start);
+ while (buffer.hasRemaining() && channel.read(buffer) >= 0) {
+ // 读满缓冲区
+ }
+ String[] lines = new String(buffer.array(), 0, length, StandardCharsets.UTF_8).split("\n");
+ List result = new ArrayList<>();
+ for (int i = Math.max(0, lines.length - TAIL_LINES); i < lines.length; i++) {
+ String line = lines[i].endsWith("\r")
+ ? lines[i].substring(0, lines[i].length() - 1) : lines[i];
+ if (!line.isEmpty()) {
+ result.add(line);
+ }
+ }
+ return result;
+ } catch (IOException e) {
+ return List.of();
+ }
+ }
+
+ private long sizeOf() {
+ try {
+ return Files.exists(file) ? Files.size(file) : 0;
+ } catch (IOException e) {
+ return 0;
+ }
+ }
+
+ private int lastNewline(byte[] data) {
+ for (int i = data.length - 1; i >= 0; i--) {
+ if (data[i] == '\n') {
+ return i;
+ }
+ }
+ return -1;
+ }
+}
diff --git a/src/main/java/cn/rosercode/ledgerly/launcher/ServerController.java b/src/main/java/cn/rosercode/ledgerly/launcher/ServerController.java
new file mode 100644
index 0000000..1a4dc21
--- /dev/null
+++ b/src/main/java/cn/rosercode/ledgerly/launcher/ServerController.java
@@ -0,0 +1,187 @@
+package cn.rosercode.ledgerly.launcher;
+
+import java.io.IOException;
+import java.net.InetSocketAddress;
+import java.net.ServerSocket;
+import java.util.function.Consumer;
+
+import org.springframework.boot.Banner;
+import org.springframework.boot.builder.SpringApplicationBuilder;
+import org.springframework.context.ConfigurableApplicationContext;
+
+import cn.rosercode.ledgerly.LedgerlyApplication;
+
+/**
+ * 后端服务的进程内控制器。
+ *
+ * 在桌面启动器所在的同一 JVM 中启动 / 关闭 Spring Boot 应用上下文。启动与关闭
+ * 均放在后台线程执行,避免阻塞 JavaFX 界面线程;状态变化通过监听器回调。
+ *
+ * @date 2026-09-20 02:47:21
+ */
+public class ServerController {
+
+ /** 服务状态 */
+ public enum Status {
+ /** 已停止 */
+ STOPPED,
+ /** 启动中 */
+ STARTING,
+ /** 运行中 */
+ RUNNING,
+ /** 停止中 */
+ STOPPING,
+ /** 启动失败 */
+ FAILED
+ }
+
+ private ConfigurableApplicationContext context;
+
+ private volatile Status status = Status.STOPPED;
+
+ private volatile int port = 8080;
+
+ private Consumer statusListener;
+
+ private Consumer errorListener;
+
+ /**
+ * 设置状态监听器(在后台线程回调,调用方需自行切换到界面线程)。
+ *
+ * @param statusListener 状态监听器
+ */
+ public void setStatusListener(Consumer statusListener) {
+ this.statusListener = statusListener;
+ }
+
+ /**
+ * 设置错误监听器。
+ *
+ * @param errorListener 错误监听器
+ */
+ public void setErrorListener(Consumer errorListener) {
+ this.errorListener = errorListener;
+ }
+
+ /**
+ * 获取当前状态。
+ *
+ * @return 服务状态
+ */
+ public Status getStatus() {
+ return status;
+ }
+
+ /**
+ * 获取当前端口。
+ *
+ * @return 服务端口
+ */
+ public int getPort() {
+ return port;
+ }
+
+ /**
+ * 判断服务是否运行中。
+ *
+ * @return 运行中返回 true
+ */
+ public boolean isRunning() {
+ return context != null && context.isActive();
+ }
+
+ /**
+ * 异步启动服务。
+ *
+ * @param port 服务端口
+ */
+ public synchronized void start(int port) {
+ if (isRunning() || status == Status.STARTING) {
+ return;
+ }
+ this.port = port;
+ updateStatus(Status.STARTING);
+ Thread thread = new Thread(() -> doStart(port), "ledgerly-server-start");
+ thread.setDaemon(true);
+ thread.start();
+ }
+
+ /**
+ * 异步停止服务。
+ */
+ public synchronized void stop() {
+ if (context == null) {
+ updateStatus(Status.STOPPED);
+ return;
+ }
+ updateStatus(Status.STOPPING);
+ Thread thread = new Thread(this::doStop, "ledgerly-server-stop");
+ thread.setDaemon(true);
+ thread.start();
+ }
+
+ /**
+ * 同步停止服务,用于进程退出前的清理。
+ */
+ public synchronized void stopNow() {
+ doStop();
+ }
+
+ private void doStart(int port) {
+ try {
+ if (!isPortFree(port)) {
+ throw new IllegalStateException("端口 " + port + " 已被占用,请更换端口后重试");
+ }
+ ConfigurableApplicationContext ctx = new SpringApplicationBuilder(LedgerlyApplication.class)
+ .headless(false)
+ .bannerMode(Banner.Mode.OFF)
+ .run("--server.port=" + port);
+ context = ctx;
+ updateStatus(Status.RUNNING);
+ } catch (Throwable e) {
+ context = null;
+ updateStatus(Status.FAILED);
+ if (errorListener != null) {
+ errorListener.accept(e);
+ }
+ }
+ }
+
+ private void doStop() {
+ ConfigurableApplicationContext ctx = context;
+ context = null;
+ if (ctx != null) {
+ try {
+ ctx.close();
+ } catch (Throwable e) {
+ if (errorListener != null) {
+ errorListener.accept(e);
+ }
+ }
+ }
+ updateStatus(Status.STOPPED);
+ }
+
+ private void updateStatus(Status newStatus) {
+ this.status = newStatus;
+ if (statusListener != null) {
+ statusListener.accept(newStatus);
+ }
+ }
+
+ /**
+ * 检测端口是否可用。
+ *
+ * @param port 待检测端口
+ * @return 可用返回 true
+ */
+ private boolean isPortFree(int port) {
+ try (ServerSocket socket = new ServerSocket()) {
+ socket.setReuseAddress(true);
+ socket.bind(new InetSocketAddress("0.0.0.0", port));
+ return true;
+ } catch (IOException e) {
+ return false;
+ }
+ }
+}
diff --git a/src/main/java/cn/rosercode/ledgerly/launcher/SingleInstanceGuard.java b/src/main/java/cn/rosercode/ledgerly/launcher/SingleInstanceGuard.java
new file mode 100644
index 0000000..0b01ba0
--- /dev/null
+++ b/src/main/java/cn/rosercode/ledgerly/launcher/SingleInstanceGuard.java
@@ -0,0 +1,67 @@
+package cn.rosercode.ledgerly.launcher;
+
+import java.io.IOException;
+import java.nio.channels.FileChannel;
+import java.nio.channels.FileLock;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.StandardOpenOption;
+
+/**
+ * 桌面启动器单实例守卫。
+ *
+ * 基于 {@code /config/.launcher.lock} 的文件锁实现:同一时间只允许一个
+ * 启动器实例持有锁,避免重复启动造成端口与配置冲突。
+ *
+ * @date 2026-09-20 02:47:21
+ */
+public class SingleInstanceGuard implements AutoCloseable {
+
+ /** 锁文件名 */
+ private static final String LOCK_FILE = ".launcher.lock";
+
+ private final FileChannel channel;
+
+ private final FileLock lock;
+
+ private SingleInstanceGuard(FileChannel channel, FileLock lock) {
+ this.channel = channel;
+ this.lock = lock;
+ }
+
+ /**
+ * 尝试获取单实例锁。
+ *
+ * @param home 应用基准目录
+ * @return 获取成功返回守卫对象;已被占用返回 null
+ * @throws IOException 创建锁文件失败
+ */
+ public static SingleInstanceGuard acquire(Path home) throws IOException {
+ Path lockFile = home.resolve("config").resolve(LOCK_FILE);
+ Files.createDirectories(lockFile.getParent());
+ FileChannel channel = FileChannel.open(lockFile,
+ StandardOpenOption.CREATE, StandardOpenOption.WRITE);
+ FileLock lock = channel.tryLock();
+ if (lock == null) {
+ channel.close();
+ return null;
+ }
+ return new SingleInstanceGuard(channel, lock);
+ }
+
+ @Override
+ public void close() {
+ try {
+ if (lock != null && lock.isValid()) {
+ lock.release();
+ }
+ } catch (IOException e) {
+ // 释放失败无补救手段,忽略
+ }
+ try {
+ channel.close();
+ } catch (IOException e) {
+ // 关闭失败无补救手段,忽略
+ }
+ }
+}
diff --git a/src/main/java/cn/rosercode/ledgerly/launcher/TrayManager.java b/src/main/java/cn/rosercode/ledgerly/launcher/TrayManager.java
new file mode 100644
index 0000000..6e9e2e2
--- /dev/null
+++ b/src/main/java/cn/rosercode/ledgerly/launcher/TrayManager.java
@@ -0,0 +1,105 @@
+package cn.rosercode.ledgerly.launcher;
+
+import java.net.URL;
+import javafx.application.Platform;
+
+import dorkbox.systemTray.Menu;
+import dorkbox.systemTray.MenuItem;
+import dorkbox.systemTray.Separator;
+import dorkbox.systemTray.SystemTray;
+
+/**
+ * 桌面启动器的系统托盘管理。
+ *
+ * 基于 dorkbox SystemTray 实现跨平台托盘图标与右键菜单;不支持托盘的环境下
+ * 自动降级为仅窗口模式。
+ *
+ * @date 2026-09-20 02:47:21
+ */
+public class TrayManager {
+
+ private final LauncherFrame frame;
+
+ private SystemTray systemTray;
+
+ /**
+ * 构造托盘管理器。
+ *
+ * @param frame 主窗口,用于承载菜单动作
+ */
+ public TrayManager(LauncherFrame frame) {
+ this.frame = frame;
+ }
+
+ /**
+ * 初始化系统托盘。
+ */
+ public void init() {
+ try {
+ systemTray = SystemTray.get();
+ } catch (Throwable e) {
+ systemTray = null;
+ return;
+ }
+ if (systemTray == null) {
+ return;
+ }
+ try {
+ URL icon = frame.getIconUrl();
+ if (icon != null) {
+ systemTray.setImage(icon);
+ }
+ systemTray.setTooltip("汇账");
+ systemTray.setStatus("已停止");
+ Menu menu = systemTray.getMenu();
+ menu.add(new MenuItem("显示窗口", e -> Platform.runLater(frame::showWindow)));
+ menu.add(new MenuItem("启动服务", e -> Platform.runLater(frame::startServer)));
+ menu.add(new MenuItem("停止服务", e -> Platform.runLater(frame::stopServer)));
+ menu.add(new MenuItem("打开浏览器", e -> Platform.runLater(frame::openBrowser)));
+ menu.add(new Separator());
+ menu.add(new MenuItem("退出", e -> Platform.runLater(frame::exitApp)));
+ } catch (Throwable e) {
+ systemTray = null;
+ }
+ }
+
+ /**
+ * 判断托盘是否可用。
+ *
+ * @return 可用返回 true
+ */
+ public boolean isAvailable() {
+ return systemTray != null;
+ }
+
+ /**
+ * 更新托盘状态文本。
+ *
+ * @param text 状态文本
+ */
+ public void setStatus(String text) {
+ if (systemTray == null) {
+ return;
+ }
+ try {
+ systemTray.setStatus(text);
+ } catch (Throwable e) {
+ // 托盘状态更新失败可忽略
+ }
+ }
+
+ /**
+ * 关闭托盘。
+ */
+ public void shutdown() {
+ if (systemTray == null) {
+ return;
+ }
+ try {
+ systemTray.shutdown();
+ } catch (Throwable e) {
+ // 托盘关闭失败可忽略
+ }
+ systemTray = null;
+ }
+}
diff --git a/src/main/java/cn/rosercode/ledgerly/service/DatabaseConfigStreamService.java b/src/main/java/cn/rosercode/ledgerly/service/DatabaseConfigStreamService.java
new file mode 100644
index 0000000..b8cc6f9
--- /dev/null
+++ b/src/main/java/cn/rosercode/ledgerly/service/DatabaseConfigStreamService.java
@@ -0,0 +1,160 @@
+package cn.rosercode.ledgerly.service;
+
+import java.io.IOException;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.function.Consumer;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import jakarta.annotation.PreDestroy;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
+
+import cn.rosercode.ledgerly.dto.DatabaseConfigRequest;
+import cn.rosercode.ledgerly.vo.DatabaseStatusVO;
+
+/**
+ * 数据库配置操作的实时进度推送服务。
+ *
+ * 基于 SSE 将“测试连接”“保存并应用”的执行进度实时推送给前端:处理过程中逐条推送
+ * {@code progress} 事件(面向用户的中文进度文本),正常结束时推送 {@code done}
+ * (保存操作携带切换后的 {@link DatabaseStatusVO} JSON),异常时推送 {@code failed}
+ * (含失败原因)。实际处理在后台线程执行,避免阻塞请求线程。
+ *
+ * @date 2026-09-24 13:53:41
+ */
+@Slf4j
+@Service
+public class DatabaseConfigStreamService {
+
+ private final DatabaseService databaseService;
+ private final ObjectMapper objectMapper;
+
+ /** 执行数据库配置任务的守护线程池 */
+ private final ExecutorService executor = Executors.newCachedThreadPool(runnable -> {
+ Thread thread = new Thread(runnable, "database-config-stream");
+ thread.setDaemon(true);
+ return thread;
+ });
+
+ public DatabaseConfigStreamService(DatabaseService databaseService, ObjectMapper objectMapper) {
+ this.databaseService = databaseService;
+ this.objectMapper = objectMapper;
+ }
+
+ /**
+ * 建立“测试连接”的进度流。
+ *
+ * @param request 数据库配置请求
+ * @return SSE 发射器
+ */
+ public SseEmitter streamTest(DatabaseConfigRequest request) {
+ SseEmitter emitter = new SseEmitter(0L);
+ executor.submit(() -> run(emitter, () -> {
+ Consumer progress = line -> trySend(emitter, "progress", line);
+ databaseService.testConnection(request, progress);
+ trySend(emitter, "done", "{}");
+ }));
+ return emitter;
+ }
+
+ /**
+ * 建立“保存并应用”的进度流。
+ *
+ * @param request 数据库配置请求
+ * @return SSE 发射器
+ */
+ public SseEmitter streamSave(DatabaseConfigRequest request) {
+ SseEmitter emitter = new SseEmitter(0L);
+ executor.submit(() -> run(emitter, () -> {
+ Consumer progress = line -> trySend(emitter, "progress", line);
+ DatabaseStatusVO status = databaseService.save(request, progress);
+ trySend(emitter, "done", objectMapper.writeValueAsString(status));
+ }));
+ return emitter;
+ }
+
+ /**
+ * 应用关闭时停止线程池。
+ */
+ @PreDestroy
+ public void shutdown() {
+ executor.shutdownNow();
+ }
+
+ /**
+ * 执行一次数据库配置任务并把结果推送到发射器。
+ *
+ * @param emitter SSE 发射器
+ * @param task 实际任务(负责推送进度与 done)
+ */
+ private void run(SseEmitter emitter, StreamTask task) {
+ try {
+ task.execute();
+ } catch (RuntimeException | JsonProcessingException e) {
+ log.warn("数据库配置流式处理失败: error={}", e.getMessage());
+ trySend(emitter, "failed", "{\"message\":\"" + escape(e.getMessage()) + "\"}");
+ } finally {
+ complete(emitter);
+ }
+ }
+
+ /**
+ * 推送一个 SSE 事件,客户端已断开时静默失败。
+ *
+ * @param emitter SSE 发射器
+ * @param event 事件名
+ * @param data 事件数据
+ */
+ private void trySend(SseEmitter emitter, String event, String data) {
+ try {
+ emitter.send(SseEmitter.event().name(event).data(data));
+ } catch (IOException | IllegalStateException e) {
+ // 连接已断开或发射器已结束,忽略
+ }
+ }
+
+ /**
+ * 结束 SSE 连接,忽略已完成或已断开的异常。
+ *
+ * @param emitter SSE 发射器
+ */
+ private void complete(SseEmitter emitter) {
+ try {
+ emitter.complete();
+ } catch (Exception ignored) {
+ // 连接可能已断开
+ }
+ }
+
+ /**
+ * 转义 JSON 字符串中的特殊字符。
+ *
+ * @param value 原始字符串
+ * @return 转义后的字符串
+ */
+ private String escape(String value) {
+ if (value == null) {
+ return "";
+ }
+ return value.replace("\\", "\\\\")
+ .replace("\"", "\\\"")
+ .replace("\n", " ")
+ .replace("\r", " ");
+ }
+
+ /**
+ * 可抛出异常、并声明受检 JsonProcessingException 的流式任务。
+ */
+ @FunctionalInterface
+ private interface StreamTask {
+ /**
+ * 执行任务。
+ *
+ * @throws JsonProcessingException 结果序列化失败
+ */
+ void execute() throws JsonProcessingException;
+ }
+}
diff --git a/src/main/java/cn/rosercode/ledgerly/service/DatabaseService.java b/src/main/java/cn/rosercode/ledgerly/service/DatabaseService.java
index 47e92ee..6a79a72 100644
--- a/src/main/java/cn/rosercode/ledgerly/service/DatabaseService.java
+++ b/src/main/java/cn/rosercode/ledgerly/service/DatabaseService.java
@@ -1,5 +1,7 @@
package cn.rosercode.ledgerly.service;
+import java.util.function.Consumer;
+
import cn.rosercode.ledgerly.dto.DatabaseConfigRequest;
import cn.rosercode.ledgerly.vo.DatabaseStatusVO;
@@ -22,7 +24,17 @@ public interface DatabaseService {
*
* @param request 数据库配置请求
*/
- void testConnection(DatabaseConfigRequest request);
+ default void testConnection(DatabaseConfigRequest request) {
+ testConnection(request, null);
+ }
+
+ /**
+ * 测试数据库连接是否可用,并通过回调推送进度。
+ *
+ * @param request 数据库配置请求
+ * @param progress 进度回调(面向用户的进度文本),可为空
+ */
+ void testConnection(DatabaseConfigRequest request, Consumer progress);
/**
* 保存配置并切换到新的数据源,同时自动初始化表结构。
@@ -30,7 +42,18 @@ public interface DatabaseService {
* @param request 数据库配置请求
* @return 切换后的数据库状态
*/
- DatabaseStatusVO save(DatabaseConfigRequest request);
+ default DatabaseStatusVO save(DatabaseConfigRequest request) {
+ return save(request, null);
+ }
+
+ /**
+ * 保存配置并切换到新的数据源,并通过回调推送进度。
+ *
+ * @param request 数据库配置请求
+ * @param progress 进度回调(面向用户的进度文本),可为空
+ * @return 切换后的数据库状态
+ */
+ DatabaseStatusVO save(DatabaseConfigRequest request, Consumer progress);
/**
* 应用启动时引导数据库:优先读取本地配置,其次读取环境变量。
diff --git a/src/main/java/cn/rosercode/ledgerly/service/impl/DatabaseServiceImpl.java b/src/main/java/cn/rosercode/ledgerly/service/impl/DatabaseServiceImpl.java
index 7e9f72d..7dfa11f 100644
--- a/src/main/java/cn/rosercode/ledgerly/service/impl/DatabaseServiceImpl.java
+++ b/src/main/java/cn/rosercode/ledgerly/service/impl/DatabaseServiceImpl.java
@@ -6,6 +6,7 @@ import java.nio.file.Paths;
import java.sql.Connection;
import java.sql.SQLException;
import java.util.Optional;
+import java.util.function.Consumer;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import javax.sql.DataSource;
@@ -29,6 +30,7 @@ import cn.rosercode.ledgerly.config.datasource.SchemaInitializer;
import cn.rosercode.ledgerly.config.datasource.SchemaMigrator;
import cn.rosercode.ledgerly.dto.DatabaseConfigRequest;
import cn.rosercode.ledgerly.service.DatabaseService;
+import cn.rosercode.ledgerly.util.AppHome;
import cn.rosercode.ledgerly.vo.DatabaseStatusVO;
/**
@@ -83,16 +85,21 @@ public class DatabaseServiceImpl implements DatabaseService {
* 使用临时连接池验证连通性,验证完成后立即关闭,不影响当前数据源。
*/
@Override
- public void testConnection(DatabaseConfigRequest request) {
+ public void testConnection(DatabaseConfigRequest request, Consumer progress) {
DatabaseConfig config = toConfig(request, currentConfig);
validate(config);
normalizeSqlitePath(config);
+ notifyProgress(progress, "正在测试数据库连接:" + config.maskedSummary());
try (HikariDataSource dataSource = createDataSource(config)) {
assertConnectable(dataSource);
+ notifyProgress(progress, "数据库连接成功");
+ log.info("数据库连接测试成功:{}", config.maskedSummary());
} catch (BusinessException e) {
+ log.warn("数据库连接测试失败:{}", e.getMessage());
throw e;
} catch (Exception e) {
+ log.warn("数据库连接测试失败:{}", rootMessage(e));
throw new BusinessException("数据库连接失败:" + rootMessage(e));
}
}
@@ -100,33 +107,47 @@ public class DatabaseServiceImpl implements DatabaseService {
/**
* {@inheritDoc}
*
- * 执行顺序:创建并测试新数据源 -> 建表 -> 持久化配置 -> 切换数据源。
+ * 执行顺序:创建并测试新数据源 -> 建表 -> 迁移 -> 持久化配置 -> 切换数据源。
* 任一步骤失败都会关闭新数据源并保持原有配置不变。
*/
@Override
- public synchronized DatabaseStatusVO save(DatabaseConfigRequest request) {
+ public synchronized DatabaseStatusVO save(DatabaseConfigRequest request, Consumer progress) {
+ long start = System.currentTimeMillis();
+ notifyProgress(progress, "开始应用数据库配置");
DatabaseConfig config = toConfig(request, currentConfig);
validate(config);
normalizeSqlitePath(config);
+ notifyProgress(progress, "配置校验通过,目标:" + config.maskedSummary());
+ log.info("开始应用数据库配置:{}", config.maskedSummary());
+ notifyProgress(progress, "正在创建连接池…");
HikariDataSource newDataSource = createDataSource(config);
boolean switched = false;
try {
+ notifyProgress(progress, "正在测试数据库连接…");
assertConnectable(newDataSource);
- schemaInitializer.initialize(newDataSource, config.getType());
- schemaMigrator.migrate(newDataSource, config.getType());
+ notifyProgress(progress, "数据库连接成功");
+ schemaInitializer.initialize(newDataSource, config.getType(), progress);
+ schemaMigrator.migrate(newDataSource, config.getType(), progress);
// 先落盘再切换,避免切换成功但配置未保存导致重启后状态不一致
+ notifyProgress(progress, "正在保存配置文件…");
configStore.save(config);
+ notifyProgress(progress, "正在切换数据源…");
dynamicDataSource.addAndSwitch(config.getType(), newDataSource);
switched = true;
currentConfig = config;
// 切换数据源后清空偏好缓存,避免读到旧库的数据
evictUserPreferenceCache();
- log.info("数据库配置已更新并切换到 {}:{}", config.getType().getDisplayName(), config.maskedSummary());
+ long cost = System.currentTimeMillis() - start;
+ notifyProgress(progress, "数据库切换完成,耗时 " + cost + "ms");
+ log.info("数据库配置已更新并切换到 {}:{},耗时 {}ms",
+ config.getType().getDisplayName(), config.maskedSummary(), cost);
return buildStatus(config);
} catch (BusinessException e) {
+ log.warn("数据库配置失败:{}", e.getMessage());
throw e;
} catch (Exception e) {
+ log.warn("数据库配置失败:{}", rootMessage(e));
throw new BusinessException("数据库配置失败:" + rootMessage(e));
} finally {
if (!switched) {
@@ -265,7 +286,9 @@ public class DatabaseServiceImpl implements DatabaseService {
if (config.getType() != DataSourceType.SQLITE) {
return;
}
- Path path = Paths.get(config.getSqlitePath()).toAbsolutePath().normalize();
+ // 相对路径以应用基准目录(默认工作目录,GUI 下为 jar 所在目录)为基准
+ Path rawPath = Paths.get(config.getSqlitePath());
+ Path path = (rawPath.isAbsolute() ? rawPath : AppHome.getHome().resolve(rawPath)).normalize();
try {
if (path.getParent() != null) {
Files.createDirectories(path.getParent());
@@ -391,6 +414,18 @@ public class DatabaseServiceImpl implements DatabaseService {
return vo;
}
+ /**
+ * 推送进度,回调为空时忽略。
+ *
+ * @param progress 进度回调
+ * @param message 进度文本
+ */
+ private void notifyProgress(Consumer progress, String message) {
+ if (progress != null) {
+ progress.accept(message);
+ }
+ }
+
/**
* 获取异常链中最底层异常的消息。
*
diff --git a/src/main/java/cn/rosercode/ledgerly/util/AppHome.java b/src/main/java/cn/rosercode/ledgerly/util/AppHome.java
index 069cc57..b716fd4 100644
--- a/src/main/java/cn/rosercode/ledgerly/util/AppHome.java
+++ b/src/main/java/cn/rosercode/ledgerly/util/AppHome.java
@@ -38,6 +38,21 @@ public final class AppHome {
return Paths.get(home);
}
+ /**
+ * 显式设置运行基准目录。
+ *
+ * 同时写入系统属性 {@code ledgerly.home} 与环境变量名 {@code LEDGERLY_HOME},
+ * 前者供本工具类解析、后者供 logback 的 {@code ${LEDGERLY_HOME}} 占位符解析,
+ * 由桌面启动器在进程启动早期调用。
+ *
+ * @param home 基准目录
+ */
+ public static void setHome(Path home) {
+ String value = home.toAbsolutePath().normalize().toString();
+ System.setProperty(HOME_PROPERTY, value);
+ System.setProperty(HOME_ENV, value);
+ }
+
/**
* 基于基准目录解析路径。
*
@@ -48,4 +63,22 @@ public final class AppHome {
public static Path resolve(String first, String... more) {
return getHome().resolve(Paths.get(first, more));
}
+
+ /**
+ * 获取配置目录。
+ *
+ * @return 配置目录路径
+ */
+ public static Path configDir() {
+ return resolve("config");
+ }
+
+ /**
+ * 获取日志目录。
+ *
+ * @return 日志目录路径
+ */
+ public static Path logsDir() {
+ return resolve("logs");
+ }
}
diff --git a/src/main/java/cn/rosercode/ledgerly/util/AppLogUtils.java b/src/main/java/cn/rosercode/ledgerly/util/AppLogUtils.java
index a142d8c..1a33f5d 100644
--- a/src/main/java/cn/rosercode/ledgerly/util/AppLogUtils.java
+++ b/src/main/java/cn/rosercode/ledgerly/util/AppLogUtils.java
@@ -1,7 +1,6 @@
package cn.rosercode.ledgerly.util;
import java.nio.file.Path;
-import java.nio.file.Paths;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
@@ -48,7 +47,7 @@ public final class AppLogUtils {
* @return 规范化后的日志目录路径
*/
public static Path logDir() {
- return Paths.get(LOG_DIR).toAbsolutePath().normalize();
+ return AppHome.logsDir();
}
/**
diff --git a/src/main/resources/launcher/icon.url b/src/main/resources/launcher/icon.url
new file mode 100644
index 0000000..f0c6d52
--- /dev/null
+++ b/src/main/resources/launcher/icon.url
@@ -0,0 +1 @@
+https://adccrac.oss-cn-beijing.aliyuncs.com/ledgerly%2Fassets%2Flauncher%2Ficon.png?OSSAccessKeyId=LTAI5tA21KsXJTQoSmt4m7YJ&Expires=2105591774&Signature=8%2BRYLWQb4vx7Js2eepd4a0Ut4Uc%3D
diff --git a/src/main/resources/logback-spring.xml b/src/main/resources/logback-spring.xml
index 8bef9be..ef6edca 100644
--- a/src/main/resources/logback-spring.xml
+++ b/src/main/resources/logback-spring.xml
@@ -8,11 +8,11 @@
-
+
- logs/ledgerly.log
+ ${LEDGERLY_HOME:-.}/logs/ledgerly.log
- logs/ledgerly.%d{yyyy-MM-dd}.log
+ ${LEDGERLY_HOME:-.}/logs/ledgerly.%d{yyyy-MM-dd}.log
30
diff --git a/tools/IconGen.java b/tools/IconGen.java
new file mode 100644
index 0000000..e369249
--- /dev/null
+++ b/tools/IconGen.java
@@ -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 各平台所需的应用图标。
+ *
+ * jpackage 对各平台图标格式要求不同:Windows 用 {@code .ico}、macOS 用
+ * {@code .icns}、Linux 用 {@code .png}。本工具基于纯 JDK(ImageIO + AWT)实现转换,
+ * 不依赖 ImageMagick/Pillow 等外部工具,可在 Windows/macOS/Linux 的构建机上直接运行。
+ *
+ * 用法:{@code java IconGen.java },输出
+ * {@code icon.png}、{@code icon.ico}、{@code icon.icns}。
+ */
+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 ");
+ 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);
+ }
+}