diff --git a/.gitignore b/.gitignore index b073885..bff243e 100644 --- a/.gitignore +++ b/.gitignore @@ -405,4 +405,9 @@ openspec/changes/archive temp .agents skills-lock.json -.worktrees \ No newline at end of file +.worktrees +!scripts/build/ + +# Embedfs generated +embedfs/assets/ +embedfs/frontend-dist/ \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..0044793 --- /dev/null +++ b/Makefile @@ -0,0 +1,115 @@ +.PHONY: all clean \ + backend-build backend-run backend-test backend-test-unit backend-test-integration backend-test-coverage \ + backend-lint backend-deps backend-generate \ + backend-migrate-up backend-migrate-down backend-migrate-status backend-migrate-create \ + frontend-build frontend-dev frontend-test frontend-test-watch frontend-test-coverage frontend-test-e2e frontend-lint \ + desktop desktop-darwin desktop-windows desktop-linux package-macos + +# ============================================ +# 后端 +# ============================================ + +all: backend-build + +backend-build: + cd backend && go build -o bin/server ./cmd/server + +backend-run: + cd backend && go run ./cmd/server + +backend-test: + cd backend && go test ./... -v + +backend-test-unit: + cd backend && go test ./internal/... ./pkg/... -v + +backend-test-integration: + cd backend && go test ./tests/... -v + +backend-test-coverage: + cd backend && go test ./... -coverprofile=coverage.out + cd backend && go tool cover -html=coverage.out -o coverage.html + @echo "Coverage report generated: backend/coverage.html" + +backend-lint: + cd backend && go tool golangci-lint run ./... + +backend-deps: + cd backend && go mod tidy + +backend-generate: + cd backend && go generate ./... + +backend-migrate-up: + cd backend && goose -dir migrations sqlite3 $(DB_PATH) up + +backend-migrate-down: + cd backend && goose -dir migrations sqlite3 $(DB_PATH) down + +backend-migrate-status: + cd backend && goose -dir migrations sqlite3 $(DB_PATH) status + +backend-migrate-create: + @read -p "Migration name: " name; \ + cd backend && goose -dir migrations create $$name sql + +# ============================================ +# 前端 +# ============================================ + +frontend-build: + cd frontend && bun install && bun run build + +frontend-dev: + cd frontend && bun dev + +frontend-test: + cd frontend && bun run test + +frontend-test-watch: + cd frontend && bun run test:watch + +frontend-test-coverage: + cd frontend && bun run test:coverage + +frontend-test-e2e: + cd frontend && bun run test:e2e + +frontend-lint: + cd frontend && bun run lint + +# ============================================ +# 桌面应用 +# ============================================ + +desktop: frontend-build-desktop embedfs-prepare + cd backend && CGO_ENABLED=1 go build -o ../build/nex ./cmd/desktop + +frontend-build-desktop: + cd frontend && cp .env.desktop .env.production.local && bun install && bun run build && rm -f .env.production.local + +embedfs-prepare: + rm -rf embedfs/assets embedfs/frontend-dist + cp -r assets embedfs/assets + cp -r frontend/dist embedfs/frontend-dist + +desktop-darwin: frontend-build-desktop embedfs-prepare + cd backend && CGO_ENABLED=1 GOOS=darwin GOARCH=arm64 go build -o ../build/nex-darwin-arm64 ./cmd/desktop + cd backend && CGO_ENABLED=1 GOOS=darwin GOARCH=amd64 go build -o ../build/nex-darwin-amd64 ./cmd/desktop + +desktop-windows: frontend-build-desktop embedfs-prepare + cd backend && CGO_ENABLED=1 GOOS=windows GOARCH=amd64 go build -ldflags "-H=windowsgui" -o ../build/nex-windows-amd64.exe ./cmd/desktop + +desktop-linux: frontend-build-desktop embedfs-prepare + cd backend && CGO_ENABLED=1 GOOS=linux GOARCH=amd64 go build -o ../build/nex-linux-amd64 ./cmd/desktop + +package-macos: + ./scripts/build/package-macos.sh + +# ============================================ +# 清理 +# ============================================ + +clean: + rm -rf backend/bin/ backend/coverage.out backend/coverage.html + rm -rf build/ diff --git a/README.md b/README.md index 946e6e2..bdedc85 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,9 @@ ``` nex/ ├── backend/ # Go 后端服务(分层架构) -│ ├── cmd/server/ # 主程序入口 +│ ├── cmd/ +│ │ ├── server/ # CLI 主程序入口 +│ │ └── desktop/ # 桌面应用入口 │ ├── internal/ │ │ ├── handler/ # HTTP 处理器 + 中间件 │ │ ├── service/ # 业务逻辑层 @@ -32,6 +34,15 @@ nex/ │ ├── e2e/ # Playwright E2E 测试 │ └── package.json │ +├── assets/ # 应用资源 +│ ├── icon.png # 托盘图标 +│ ├── AppIcon.icns # macOS 应用图标 +│ └── icon.ico # Windows 应用图标 +│ +├── scripts/ # 构建脚本 +│ └── build/ +│ └── package-macos.sh # macOS .app 打包脚本 +│ └── README.md # 本文件 ``` @@ -72,7 +83,46 @@ nex/ ## 快速开始 -### 后端 +### 桌面应用(推荐) + +**构建桌面应用**: + +```bash +# 当前平台 +make desktop + +# macOS (arm64 + amd64) +make desktop-darwin +make package-macos # 打包为 .app + +# Windows +make desktop-windows + +# Linux +make desktop-linux +``` + +**使用桌面应用**: +- 双击启动应用(macOS: Nex.app,Windows: nex.exe,Linux: nex) +- 系统托盘图标出现,浏览器自动打开管理界面 +- 点击托盘图标显示菜单,可打开管理界面或退出 +- 关闭浏览器后服务继续运行,可通过托盘重新打开 + +**注意事项**: +- 桌面应用需要 CGO 支持 +- macOS: 自带 Xcode Command Line Tools +- Linux: 自带 gcc,部分桌面环境需要 `libappindicator3-dev` +- Windows: 需要 MinGW-w64 或在 Windows 环境构建 + +**Linux 桌面环境兼容性**: +- GNOME: 需要 AppIndicator 扩展 +- KDE Plasma: 原生支持 +- Xfce: 需要 libappindicator +- 其他支持 StatusNotifierItem 规范的环境 + +### CLI 模式 + +#### 后端 ```bash cd backend @@ -161,41 +211,24 @@ log: ## 测试 -### 后端测试 - ```bash -cd backend -make test # 运行所有测试 -make test-coverage # 生成覆盖率报告 -``` - -### 前端测试 - -```bash -cd frontend -bun run test # 单元测试 + 组件测试 -bun run test:watch # 监听模式 -bun run test:coverage # 生成覆盖率报告 -bun run test:e2e # E2E 测试 +make backend-test # 后端测试 +make backend-test-coverage # 后端覆盖率 +make frontend-test # 前端测试 +make frontend-test-e2e # 前端 E2E 测试 ``` ## 开发 -### 后端开发 - ```bash -cd backend -make build # 构建 -make lint # 代码检查 -make migrate-up # 数据库迁移 -``` +make backend-build # 构建后端 +make backend-run # 运行后端 +make backend-lint # 后端代码检查 +make backend-migrate-up # 数据库迁移 -### 前端开发 - -```bash -cd frontend -bun run build # 构建生产版本 -bun run lint # 代码检查 +make frontend-build # 构建前端 +make frontend-dev # 前端开发模式 +make frontend-lint # 前端代码检查 ``` ## 开发规范 diff --git a/assets/AppIcon.icns b/assets/AppIcon.icns new file mode 100644 index 0000000..47645c0 Binary files /dev/null and b/assets/AppIcon.icns differ diff --git a/assets/README.md b/assets/README.md new file mode 100644 index 0000000..102c2c8 --- /dev/null +++ b/assets/README.md @@ -0,0 +1,64 @@ +# Assets + +应用资源文件目录。 + +## 文件说明 + +| 文件 | 用途 | 尺寸 | 格式 | +|------|------|------|------| +| `icon.svg` | 源图标 | 64x64 | SVG | +| `icon.png` | 托盘图标 | 64x64 | PNG | +| `AppIcon.icns` | macOS 应用图标 | 多尺寸 | ICNS | +| `icon.ico` | Windows 应用图标 | 256x256 | ICO | + +## 替换图标 + +### 1. 准备图标 + +推荐使用 SVG 格式的源图标,尺寸至少 256x256。 + +### 2. 生成各平台图标 + +**托盘图标 (PNG)**: +```bash +magick your-icon.svg -resize 64x64 icon.png +``` + +**macOS 应用图标 (ICNS)**: +```bash +mkdir icon.iconset +magick your-icon.svg -resize 16x16 icon.iconset/icon_16x16.png +magick your-icon.svg -resize 32x32 icon.iconset/icon_16x16@2x.png +magick your-icon.svg -resize 32x32 icon.iconset/icon_32x32.png +magick your-icon.svg -resize 64x64 icon.iconset/icon_32x32@2x.png +magick your-icon.svg -resize 128x128 icon.iconset/icon_128x128.png +magick your-icon.svg -resize 256x256 icon.iconset/icon_128x128@2x.png +iconutil -c icns icon.iconset -o AppIcon.icns +rm -rf icon.iconset +``` + +**Windows 应用图标 (ICO)**: +```bash +magick your-icon.svg -resize 256x256 icon.ico +``` + +### 3. 替换文件 + +将生成的文件放入此目录,然后重新构建桌面应用: +```bash +./scripts/build/build-darwin-arm64.sh +``` + +## macOS Template 图标 + +macOS 支持 Template 图标,自动适配深浅色模式: +- 使用黑色 + 透明设计 +- 文件名以 `Template` 结尾(如 `iconTemplate.png`) +- 黑色在深色模式下自动变为白色 + +## 设计建议 + +- 托盘图标应简洁,在小尺寸下清晰可辨 +- 避免过多细节和文字 +- 使用高对比度颜色 +- macOS 建议使用 Template 图标风格 diff --git a/assets/icon.ico b/assets/icon.ico new file mode 100644 index 0000000..7bc6e0f Binary files /dev/null and b/assets/icon.ico differ diff --git a/assets/icon.png b/assets/icon.png new file mode 100644 index 0000000..bd18837 Binary files /dev/null and b/assets/icon.png differ diff --git a/assets/icon.svg b/assets/icon.svg new file mode 100644 index 0000000..a85dcff --- /dev/null +++ b/assets/icon.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/backend/Makefile b/backend/Makefile deleted file mode 100644 index 0003a85..0000000 --- a/backend/Makefile +++ /dev/null @@ -1,57 +0,0 @@ -.PHONY: build run test test-unit test-integration test-coverage clean migrate-up migrate-down migrate-status migrate-create lint generate deps - -# 构建 -build: - go build -o bin/server ./cmd/server - -# 运行 -run: - go run ./cmd/server - -# 测试 -test: - go test ./... -v - -# 单元测试 -test-unit: - go test ./internal/... ./pkg/... -v - -# 集成测试 -test-integration: - go test ./tests/... -v - -# 测试覆盖率 -test-coverage: - go test ./... -coverprofile=coverage.out - go tool cover -html=coverage.out -o coverage.html - @echo "Coverage report generated: coverage.html" - -# 清理 -clean: - rm -rf bin/ coverage.out coverage.html - -# 数据库迁移 -migrate-up: - goose -dir migrations sqlite3 $(DB_PATH) up - -migrate-down: - goose -dir migrations sqlite3 $(DB_PATH) down - -migrate-status: - goose -dir migrations sqlite3 $(DB_PATH) status - -migrate-create: - @read -p "Migration name: " name; \ - goose -dir migrations create $$name sql - -# 代码检查 -lint: - go tool golangci-lint run ./... - -# 安装依赖 -deps: - go mod tidy - -# 生成代码(mock 等) -generate: - go generate ./... diff --git a/backend/cmd/desktop/main.go b/backend/cmd/desktop/main.go new file mode 100644 index 0000000..6721bb8 --- /dev/null +++ b/backend/cmd/desktop/main.go @@ -0,0 +1,456 @@ +package main + +import ( + "context" + "fmt" + "io/fs" + "log" + "net" + "net/http" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "syscall" + "time" + + "github.com/gin-gonic/gin" + "github.com/getlantern/systray" + "github.com/pressly/goose/v3" + "go.uber.org/zap" + "gorm.io/driver/sqlite" + "gorm.io/gorm" + "gorm.io/gorm/logger" + + "nex/backend/internal/config" + "nex/backend/internal/conversion" + "nex/backend/internal/conversion/anthropic" + "nex/backend/internal/conversion/openai" + "nex/backend/internal/handler" + "nex/backend/internal/handler/middleware" + "nex/backend/internal/provider" + "nex/backend/internal/repository" + "nex/backend/internal/service" + pkgLogger "nex/backend/pkg/logger" + + "nex/embedfs" +) + +var ( + server *http.Server + zapLogger *zap.Logger + shutdownCtx context.Context + shutdownCancel context.CancelFunc +) + +func main() { + port := 9826 + + if err := acquireSingleInstance(); err != nil { + showError("Nex Gateway", "已有 Nex 实例运行") + os.Exit(1) + } + defer releaseSingleInstance() + + if err := checkPortAvailable(port); err != nil { + showError("Nex Gateway", err.Error()) + os.Exit(1) + } + + cfg, err := config.LoadConfig() + if err != nil { + showError("Nex Gateway", fmt.Sprintf("加载配置失败: %v", err)) + os.Exit(1) + } + + zapLogger, err = pkgLogger.New(pkgLogger.Config{ + Level: cfg.Log.Level, + Path: cfg.Log.Path, + MaxSize: cfg.Log.MaxSize, + MaxBackups: cfg.Log.MaxBackups, + MaxAge: cfg.Log.MaxAge, + Compress: cfg.Log.Compress, + }) + if err != nil { + showError("Nex Gateway", fmt.Sprintf("初始化日志失败: %v", err)) + os.Exit(1) + } + defer zapLogger.Sync() + + db, err := initDatabase(cfg) + if err != nil { + showError("Nex Gateway", fmt.Sprintf("初始化数据库失败: %v", err)) + os.Exit(1) + } + defer closeDB(db) + + providerRepo := repository.NewProviderRepository(db) + modelRepo := repository.NewModelRepository(db) + statsRepo := repository.NewStatsRepository(db) + + providerService := service.NewProviderService(providerRepo, modelRepo) + modelService := service.NewModelService(modelRepo, providerRepo) + routingService := service.NewRoutingService(modelRepo, providerRepo) + statsService := service.NewStatsService(statsRepo) + + registry := conversion.NewMemoryRegistry() + if err := registry.Register(openai.NewAdapter()); err != nil { + zapLogger.Fatal("注册 OpenAI 适配器失败", zap.String("error", err.Error())) + } + if err := registry.Register(anthropic.NewAdapter()); err != nil { + zapLogger.Fatal("注册 Anthropic 适配器失败", zap.String("error", err.Error())) + } + engine := conversion.NewConversionEngine(registry, zapLogger) + + providerClient := provider.NewClient() + + proxyHandler := handler.NewProxyHandler(engine, providerClient, routingService, providerService, statsService) + providerHandler := handler.NewProviderHandler(providerService) + modelHandler := handler.NewModelHandler(modelService) + statsHandler := handler.NewStatsHandler(statsService) + + gin.SetMode(gin.ReleaseMode) + r := gin.New() + + r.Use(middleware.RequestID()) + r.Use(middleware.Recovery(zapLogger)) + r.Use(middleware.Logging(zapLogger)) + r.Use(middleware.CORS()) + + setupRoutes(r, proxyHandler, providerHandler, modelHandler, statsHandler) + setupStaticFiles(r) + + server = &http.Server{ + Addr: fmt.Sprintf(":%d", port), + Handler: r, + ReadTimeout: cfg.Server.ReadTimeout, + WriteTimeout: cfg.Server.WriteTimeout, + } + + shutdownCtx, shutdownCancel = context.WithCancel(context.Background()) + + go func() { + zapLogger.Info("AI Gateway 启动", zap.String("addr", server.Addr)) + if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { + zapLogger.Fatal("服务器启动失败", zap.String("error", err.Error())) + } + }() + + go func() { + time.Sleep(500 * time.Millisecond) + if err := openBrowser(fmt.Sprintf("http://localhost:%d", port)); err != nil { + zapLogger.Warn("无法打开浏览器", zap.String("error", err.Error())) + } + }() + + setupSystray(port) +} + +func initDatabase(cfg *config.Config) (*gorm.DB, error) { + dbDir := filepath.Dir(cfg.Database.Path) + if err := os.MkdirAll(dbDir, 0755); err != nil { + return nil, fmt.Errorf("创建数据库目录失败: %w", err) + } + + db, err := gorm.Open(sqlite.Open(cfg.Database.Path), &gorm.Config{ + Logger: logger.Default.LogMode(logger.Info), + }) + if err != nil { + return nil, err + } + + if err := runMigrations(db); err != nil { + return nil, fmt.Errorf("数据库迁移失败: %w", err) + } + + if err := db.Exec("PRAGMA journal_mode=WAL").Error; err != nil { + log.Printf("警告: 启用 WAL 模式失败: %v", err) + } + + sqlDB, err := db.DB() + if err != nil { + return nil, err + } + sqlDB.SetMaxIdleConns(cfg.Database.MaxIdleConns) + sqlDB.SetMaxOpenConns(cfg.Database.MaxOpenConns) + sqlDB.SetConnMaxLifetime(cfg.Database.ConnMaxLifetime) + + return db, nil +} + +func runMigrations(db *gorm.DB) error { + sqlDB, err := db.DB() + if err != nil { + return err + } + + migrationsDir := getMigrationsDir() + if _, err := os.Stat(migrationsDir); os.IsNotExist(err) { + return fmt.Errorf("迁移目录不存在: %s", migrationsDir) + } + + goose.SetDialect("sqlite3") + if err := goose.Up(sqlDB, migrationsDir); err != nil { + return err + } + + return nil +} + +func getMigrationsDir() string { + _, filename, _, ok := runtime.Caller(0) + if ok { + dir := filepath.Join(filepath.Dir(filename), "..", "..", "migrations") + if abs, err := filepath.Abs(dir); err == nil { + return abs + } + } + return "./migrations" +} + +func closeDB(db *gorm.DB) { + sqlDB, err := db.DB() + if err != nil { + return + } + sqlDB.Close() +} + +func setupRoutes(r *gin.Engine, proxyHandler *handler.ProxyHandler, providerHandler *handler.ProviderHandler, modelHandler *handler.ModelHandler, statsHandler *handler.StatsHandler) { + r.Any("/v1/*path", proxyHandler.HandleProxy) + + providers := r.Group("/api/providers") + { + providers.GET("", providerHandler.ListProviders) + providers.POST("", providerHandler.CreateProvider) + providers.GET("/:id", providerHandler.GetProvider) + providers.PUT("/:id", providerHandler.UpdateProvider) + providers.DELETE("/:id", providerHandler.DeleteProvider) + } + + models := r.Group("/api/models") + { + models.GET("", modelHandler.ListModels) + models.POST("", modelHandler.CreateModel) + models.GET("/:id", modelHandler.GetModel) + models.PUT("/:id", modelHandler.UpdateModel) + models.DELETE("/:id", modelHandler.DeleteModel) + } + + stats := r.Group("/api/stats") + { + stats.GET("", statsHandler.GetStats) + stats.GET("/aggregate", statsHandler.AggregateStats) + } + + r.GET("/health", func(c *gin.Context) { + c.JSON(200, gin.H{"status": "ok"}) + }) +} + +func setupStaticFiles(r *gin.Engine) { + distFS, err := fs.Sub(embedfs.FrontendDist, "frontend-dist") + if err != nil { + zapLogger.Fatal("无法加载前端资源", zap.String("error", err.Error())) + } + + getContentType := func(path string) string { + if strings.HasSuffix(path, ".js") { + return "application/javascript" + } + if strings.HasSuffix(path, ".css") { + return "text/css" + } + if strings.HasSuffix(path, ".svg") { + return "image/svg+xml" + } + if strings.HasSuffix(path, ".png") { + return "image/png" + } + if strings.HasSuffix(path, ".ico") { + return "image/x-icon" + } + if strings.HasSuffix(path, ".woff") || strings.HasSuffix(path, ".woff2") { + return "font/woff2" + } + return "application/octet-stream" + } + + r.GET("/assets/*filepath", func(c *gin.Context) { + filepath := c.Param("filepath") + data, err := fs.ReadFile(distFS, "assets"+filepath) + if err != nil { + c.Status(404) + return + } + c.Data(200, getContentType(filepath), data) + }) + + r.GET("/favicon.svg", func(c *gin.Context) { + data, err := fs.ReadFile(distFS, "favicon.svg") + if err != nil { + c.Status(404) + return + } + c.Data(200, "image/svg+xml", data) + }) + + r.NoRoute(func(c *gin.Context) { + path := c.Request.URL.Path + + if strings.HasPrefix(path, "/api/") || + strings.HasPrefix(path, "/v1/") || + strings.HasPrefix(path, "/health") { + c.JSON(404, gin.H{"error": "not found"}) + return + } + + data, err := fs.ReadFile(distFS, "index.html") + if err != nil { + c.Status(500) + return + } + c.Data(200, "text/html; charset=utf-8", data) + }) +} + +func setupSystray(port int) { + systray.Run(func() { + icon, err := embedfs.Assets.ReadFile("assets/icon.png") + if err != nil { + zapLogger.Error("无法加载托盘图标", zap.String("error", err.Error())) + } + systray.SetIcon(icon) + systray.SetTitle("Nex Gateway") + systray.SetTooltip("AI Gateway") + + mOpen := systray.AddMenuItem("打开管理界面", "在浏览器中打开") + systray.AddSeparator() + mStatus := systray.AddMenuItem("状态: 运行中", "") + mStatus.Disable() + mPort := systray.AddMenuItem(fmt.Sprintf("端口: %d", port), "") + mPort.Disable() + systray.AddSeparator() + mAbout := systray.AddMenuItem("关于", "") + systray.AddSeparator() + mQuit := systray.AddMenuItem("退出", "停止服务并退出") + + go func() { + for { + select { + case <-mOpen.ClickedCh: + openBrowser(fmt.Sprintf("http://localhost:%d", port)) + case <-mAbout.ClickedCh: + showAbout() + case <-mQuit.ClickedCh: + doShutdown() + systray.Quit() + return + } + } + }() + }, nil) +} + +func doShutdown() { + if zapLogger != nil { + zapLogger.Info("正在关闭服务器...") + } + + if server != nil { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + server.Shutdown(ctx) + } + + if shutdownCancel != nil { + shutdownCancel() + } +} + +func checkPortAvailable(port int) error { + ln, err := net.Listen("tcp", fmt.Sprintf(":%d", port)) + if err != nil { + return fmt.Errorf("端口 %d 已被占用\n\n可能原因:\n- 已有 Nex 实例运行\n- 其他程序占用了该端口\n\n请检查并关闭占用端口的程序", port) + } + ln.Close() + return nil +} + +var lockFile *os.File + +func acquireSingleInstance() error { + lockPath := filepath.Join(os.TempDir(), "nex-gateway.lock") + + f, err := os.OpenFile(lockPath, os.O_CREATE|os.O_RDWR, 0666) + if err != nil { + return err + } + + err = syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB) + if err != nil { + f.Close() + return fmt.Errorf("已有实例运行") + } + + lockFile = f + return nil +} + +func releaseSingleInstance() { + if lockFile != nil { + syscall.Flock(int(lockFile.Fd()), syscall.LOCK_UN) + lockFile.Close() + } +} + +func openBrowser(url string) error { + var cmd *exec.Cmd + + switch runtime.GOOS { + case "darwin": + cmd = exec.Command("open", url) + case "windows": + cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", url) + case "linux": + browsers := []string{"xdg-open", "google-chrome", "firefox"} + for _, browser := range browsers { + if _, err := exec.LookPath(browser); err == nil { + cmd = exec.Command(browser, url) + break + } + } + } + + if cmd == nil { + return fmt.Errorf("无法打开浏览器") + } + + return cmd.Start() +} + +func showError(title, message string) { + switch runtime.GOOS { + case "darwin": + script := fmt.Sprintf(`display dialog "%s" buttons {"OK"} default button "OK" with title "%s"`, message, title) + exec.Command("osascript", "-e", script).Run() + case "windows": + exec.Command("msg", "*", message).Run() + case "linux": + exec.Command("zenity", "--error", fmt.Sprintf("--title=%s", title), fmt.Sprintf("--text=%s", message)).Run() + } +} + +func showAbout() { + message := "Nex Gateway\n\nAI Gateway - 统一的大模型 API 网关\n\nhttps://github.com/nex/gateway" + switch runtime.GOOS { + case "darwin": + script := fmt.Sprintf(`display dialog "%s" buttons {"OK"} default button "OK" with title "关于 Nex Gateway"`, message) + exec.Command("osascript", "-e", script).Run() + case "windows": + exec.Command("msg", "*", message).Run() + case "linux": + exec.Command("zenity", "--info", "--title=关于 Nex Gateway", fmt.Sprintf("--text=%s", message)).Run() + } +} diff --git a/backend/cmd/desktop/port_test.go b/backend/cmd/desktop/port_test.go new file mode 100644 index 0000000..cd810d3 --- /dev/null +++ b/backend/cmd/desktop/port_test.go @@ -0,0 +1,69 @@ +package main + +import ( + "net" + "net/http" + "testing" + "time" +) + +func TestCheckPortAvailable(t *testing.T) { + port := 19826 + + err := checkPortAvailable(port) + if err != nil { + t.Fatalf("端口 %d 应该可用: %v", port, err) + } + + t.Log("端口可用测试通过") +} + +func TestCheckPortOccupied(t *testing.T) { + port := 19827 + + listener, err := net.Listen("tcp", ":19827") + if err != nil { + t.Fatalf("无法启动测试服务器: %v", err) + } + defer listener.Close() + + go func() { + conn, err := listener.Accept() + if err == nil { + conn.Close() + } + }() + + time.Sleep(100 * time.Millisecond) + + err = checkPortAvailable(port) + if err == nil { + t.Fatal("端口被占用时应该返回错误") + } + + t.Log("端口占用检测测试通过") +} + +func TestCheckPortAvailableAfterClose(t *testing.T) { + port := 19828 + + listener, err := net.Listen("tcp", ":19828") + if err != nil { + t.Fatalf("无法启动测试服务器: %v", err) + } + + server := &http.Server{} + go server.Serve(listener) + + time.Sleep(100 * time.Millisecond) + + listener.Close() + time.Sleep(100 * time.Millisecond) + + err = checkPortAvailable(port) + if err != nil { + t.Fatalf("端口关闭后应该可用: %v", err) + } + + t.Log("端口关闭后可用测试通过") +} diff --git a/backend/cmd/desktop/singleton_test.go b/backend/cmd/desktop/singleton_test.go new file mode 100644 index 0000000..03031cf --- /dev/null +++ b/backend/cmd/desktop/singleton_test.go @@ -0,0 +1,39 @@ +package main + +import ( + "os" + "path/filepath" + "syscall" + "testing" +) + +func TestAcquireSingleInstance(t *testing.T) { + lockPath := filepath.Join(os.TempDir(), "nex-gateway-test.lock") + + origLockFile := lockFile + lockFile = nil + defer func() { lockFile = origLockFile }() + + f, err := os.OpenFile(lockPath, os.O_CREATE|os.O_RDWR, 0666) + if err != nil { + t.Fatalf("无法创建锁文件: %v", err) + } + defer f.Close() + defer os.Remove(lockPath) + + err = syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB) + if err != nil { + t.Fatalf("无法获取文件锁: %v", err) + } + defer syscall.Flock(int(f.Fd()), syscall.LOCK_UN) + + t.Log("单实例锁测试通过") +} + +func TestReleaseSingleInstance(t *testing.T) { + lockFile = nil + + releaseSingleInstance() + + t.Log("释放空锁测试通过") +} diff --git a/backend/cmd/desktop/static_test.go b/backend/cmd/desktop/static_test.go new file mode 100644 index 0000000..f3bac4b --- /dev/null +++ b/backend/cmd/desktop/static_test.go @@ -0,0 +1,123 @@ +package main + +import ( + "io/fs" + "net/http/httptest" + "strings" + "testing" + + "github.com/gin-gonic/gin" + + "nex/embedfs" +) + +func TestSetupStaticFiles(t *testing.T) { + gin.SetMode(gin.TestMode) + + distFS, err := fs.Sub(embedfs.FrontendDist, "frontend-dist") + if err != nil { + t.Skipf("跳过测试: 前端资源未构建: %v", err) + return + } + + getContentType := func(path string) string { + if strings.HasSuffix(path, ".js") { + return "application/javascript" + } + if strings.HasSuffix(path, ".css") { + return "text/css" + } + if strings.HasSuffix(path, ".svg") { + return "image/svg+xml" + } + return "application/octet-stream" + } + + r := gin.New() + r.GET("/assets/*filepath", func(c *gin.Context) { + filepath := c.Param("filepath") + data, err := fs.ReadFile(distFS, "assets"+filepath) + if err != nil { + c.Status(404) + return + } + c.Data(200, getContentType(filepath), data) + }) + + r.GET("/favicon.svg", func(c *gin.Context) { + data, err := fs.ReadFile(distFS, "favicon.svg") + if err != nil { + c.Status(404) + return + } + c.Data(200, "image/svg+xml", data) + }) + + r.NoRoute(func(c *gin.Context) { + path := c.Request.URL.Path + if strings.HasPrefix(path, "/api/") || + strings.HasPrefix(path, "/v1/") || + strings.HasPrefix(path, "/health") { + c.JSON(404, gin.H{"error": "not found"}) + return + } + data, err := fs.ReadFile(distFS, "index.html") + if err != nil { + c.Status(500) + return + } + c.Data(200, "text/html; charset=utf-8", data) + }) + + t.Run("API 404", func(t *testing.T) { + req := httptest.NewRequest("GET", "/api/test", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != 404 { + t.Errorf("期望状态码 404, 实际 %d", w.Code) + } + }) + + t.Run("SPA fallback", func(t *testing.T) { + req := httptest.NewRequest("GET", "/providers", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != 200 { + t.Errorf("期望状态码 200, 实际 %d", w.Code) + } + }) + + t.Run("MIME type for JS", func(t *testing.T) { + req := httptest.NewRequest("GET", "/assets/test.js", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code == 200 { + expected := "application/javascript" + if w.Header().Get("Content-Type") != expected { + t.Errorf("期望 Content-Type %s, 实际 %s", expected, w.Header().Get("Content-Type")) + } + } else { + t.Log("文件不存在,跳过 MIME 类型验证") + } + }) + + t.Run("MIME type for CSS", func(t *testing.T) { + req := httptest.NewRequest("GET", "/assets/test.css", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code == 200 { + expected := "text/css" + if w.Header().Get("Content-Type") != expected { + t.Errorf("期望 Content-Type %s, 实际 %s", expected, w.Header().Get("Content-Type")) + } + } else { + t.Log("文件不存在,跳过 MIME 类型验证") + } + }) + + t.Log("静态文件服务测试通过") +} diff --git a/backend/go.mod b/backend/go.mod index 8a59480..99e3d99 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -2,12 +2,15 @@ module nex/backend go 1.26.2 +replace nex/embedfs => ../embedfs + tool ( github.com/golangci/golangci-lint/cmd/golangci-lint go.uber.org/mock/mockgen ) require ( + github.com/getlantern/systray v1.2.2 github.com/gin-gonic/gin v1.12.0 github.com/go-playground/validator/v10 v10.30.2 github.com/google/uuid v1.6.0 @@ -22,6 +25,7 @@ require ( gopkg.in/yaml.v3 v3.0.1 gorm.io/driver/sqlite v1.6.0 gorm.io/gorm v1.31.1 + nex/embedfs v0.0.0-00010101000000-000000000000 ) require ( @@ -74,11 +78,18 @@ require ( github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/fzipp/gocyclo v0.6.0 // indirect github.com/gabriel-vasile/mimetype v1.4.13 // indirect + github.com/getlantern/context v0.0.0-20190109183933-c447772a6520 // indirect + github.com/getlantern/errors v0.0.0-20190325191628-abdb3e3e36f7 // indirect + github.com/getlantern/golog v0.0.0-20190830074920-4ef2e798c2d7 // indirect + github.com/getlantern/hex v0.0.0-20190417191902-c6586a6fe0b7 // indirect + github.com/getlantern/hidden v0.0.0-20190325191715-f02dbb02be55 // indirect + github.com/getlantern/ops v0.0.0-20190325191751-d70cb0d6f85f // indirect github.com/ghostiam/protogetter v0.3.9 // indirect github.com/gin-contrib/sse v1.1.0 // indirect github.com/go-critic/go-critic v0.12.0 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-stack/stack v1.8.0 // indirect github.com/go-toolsmith/astcast v1.1.0 // indirect github.com/go-toolsmith/astcopy v1.1.0 // indirect github.com/go-toolsmith/astequal v1.2.0 // indirect @@ -153,6 +164,7 @@ require ( github.com/nishanths/predeclared v0.2.2 // indirect github.com/nunnatsa/ginkgolinter v0.19.1 // indirect github.com/olekukonko/tablewriter v0.0.5 // indirect + github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/polyfloyd/go-errorlint v1.7.1 // indirect diff --git a/backend/go.sum b/backend/go.sum index 4018f0a..d6f1911 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -163,6 +163,20 @@ github.com/fzipp/gocyclo v0.6.0 h1:lsblElZG7d3ALtGMx9fmxeTKZaLLpU8mET09yN4BBLo= github.com/fzipp/gocyclo v0.6.0/go.mod h1:rXPyn8fnlpa0R2csP/31uerbiVBugk5whMdlyaLkLoA= github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM= github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= +github.com/getlantern/context v0.0.0-20190109183933-c447772a6520 h1:NRUJuo3v3WGC/g5YiyF790gut6oQr5f3FBI88Wv0dx4= +github.com/getlantern/context v0.0.0-20190109183933-c447772a6520/go.mod h1:L+mq6/vvYHKjCX2oez0CgEAJmbq1fbb/oNJIWQkBybY= +github.com/getlantern/errors v0.0.0-20190325191628-abdb3e3e36f7 h1:6uJ+sZ/e03gkbqZ0kUG6mfKoqDb4XMAzMIwlajq19So= +github.com/getlantern/errors v0.0.0-20190325191628-abdb3e3e36f7/go.mod h1:l+xpFBrCtDLpK9qNjxs+cHU6+BAdlBaxHqikB6Lku3A= +github.com/getlantern/golog v0.0.0-20190830074920-4ef2e798c2d7 h1:guBYzEaLz0Vfc/jv0czrr2z7qyzTOGC9hiQ0VC+hKjk= +github.com/getlantern/golog v0.0.0-20190830074920-4ef2e798c2d7/go.mod h1:zx/1xUUeYPy3Pcmet8OSXLbF47l+3y6hIPpyLWoR9oc= +github.com/getlantern/hex v0.0.0-20190417191902-c6586a6fe0b7 h1:micT5vkcr9tOVk1FiH8SWKID8ultN44Z+yzd2y/Vyb0= +github.com/getlantern/hex v0.0.0-20190417191902-c6586a6fe0b7/go.mod h1:dD3CgOrwlzca8ed61CsZouQS5h5jIzkK9ZWrTcf0s+o= +github.com/getlantern/hidden v0.0.0-20190325191715-f02dbb02be55 h1:XYzSdCbkzOC0FDNrgJqGRo8PCMFOBFL9py72DRs7bmc= +github.com/getlantern/hidden v0.0.0-20190325191715-f02dbb02be55/go.mod h1:6mmzY2kW1TOOrVy+r41Za2MxXM+hhqTtY3oBKd2AgFA= +github.com/getlantern/ops v0.0.0-20190325191751-d70cb0d6f85f h1:wrYrQttPS8FHIRSlsrcuKazukx/xqO/PpLZzZXsF+EA= +github.com/getlantern/ops v0.0.0-20190325191751-d70cb0d6f85f/go.mod h1:D5ao98qkA6pxftxoqzibIBBrLSUli+kYnJqrgBf9cIA= +github.com/getlantern/systray v1.2.2 h1:dCEHtfmvkJG7HZ8lS/sLklTH4RKUcIsKrAD9sThoEBE= +github.com/getlantern/systray v1.2.2/go.mod h1:pXFOI1wwqwYXEhLPm9ZGjS2u/vVELeIgNMY5HvhHhcE= github.com/ghostiam/protogetter v0.3.9 h1:j+zlLLWzqLay22Cz/aYwTHKQ88GE2DQ6GkWSYFOI4lQ= github.com/ghostiam/protogetter v0.3.9/go.mod h1:WZ0nw9pfzsgxuRsPOFQomgDVSWtDLJRfQJEhsGbmQMA= github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= @@ -192,6 +206,7 @@ github.com/go-playground/validator/v10 v10.30.2 h1:JiFIMtSSHb2/XBUbWM4i/MpeQm9ZK github.com/go-playground/validator/v10 v10.30.2/go.mod h1:mAf2pIOVXjTEBrwUMGKkCWKKPs9NheYGabeB04txQSc= github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI= github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow= +github.com/go-stack/stack v1.8.0 h1:5SgMzNM5HxrEjV0ww2lTmX6E2Izsfxas4+YHWRs3Lsk= github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= @@ -397,6 +412,8 @@ github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/leonklingele/grouper v1.1.2 h1:o1ARBDLOmmasUaNDesWqWCIFH3u7hoFlM84YrjT3mIY= github.com/leonklingele/grouper v1.1.2/go.mod h1:6D0M/HVkhs2yRKRFZUoGjeDy7EZTfFBE9gl4kjmIGkA= +github.com/lxn/walk v0.0.0-20210112085537-c389da54e794/go.mod h1:E23UucZGqpuUANJooIbHWCufXvOcT6E7Stq81gU+CSQ= +github.com/lxn/win v0.0.0-20210218163916-a377121e959e/go.mod h1:KxxjdtRkfNoYDCUP5ryK7XJJNTnpC8atvtmTheChOtk= github.com/macabu/inamedparam v0.1.3 h1:2tk/phHkMlEL/1GNe/Yf6kkR/hkcUdAEY3L0hjYV1Mk= github.com/macabu/inamedparam v0.1.3/go.mod h1:93FLICAIk/quk7eaPPQvbzihUdn/QkGDwIZEoLtpH6I= github.com/maratori/testableexamples v1.0.0 h1:dU5alXRrD8WKSjOUnmJZuzdxWOEQ57+7s93SLMxb2vI= @@ -460,6 +477,8 @@ github.com/otiai10/curr v0.0.0-20150429015615-9b4961190c95/go.mod h1:9qAhocn7zKJ github.com/otiai10/curr v1.0.0/go.mod h1:LskTG5wDwr8Rs+nNQ+1LlxRjAtTZZjtJW4rMXl6j4vs= github.com/otiai10/mint v1.3.0/go.mod h1:F5AjcsTsWUqX+Na9fpHb52P8pcRX2CI6A3ctIT91xUo= github.com/otiai10/mint v1.3.1/go.mod h1:/yxELlJQ0ufhjUwhshSj+wFjZ78CnZ48/1wtmBH1OTc= +github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c h1:rp5dCmg/yLR3mgFuSOe4oEnDDmGLROTvMragMUXpTQw= +github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c/go.mod h1:X07ZCGwUbLaax7L0S3Tw4hpejzu63ZrrQiUe6W0hcy0= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= @@ -551,6 +570,7 @@ github.com/sivchari/containedctx v1.0.3 h1:x+etemjbsh2fB5ewm5FeLNi5bUjK0V8n0RB+W github.com/sivchari/containedctx v1.0.3/go.mod h1:c1RDvCbnJLtH4lLcYD/GqwiBSSf4F5Qk0xld2rBqzJ4= github.com/sivchari/tenv v1.12.1 h1:+E0QzjktdnExv/wwsnnyk4oqZBUfuh89YMQT1cyuvSY= github.com/sivchari/tenv v1.12.1/go.mod h1:1LjSOUCc25snIr5n3DtGGrENhX3LuWefcplwVGC24mw= +github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966/go.mod h1:sUM3LWHvSMaG192sy56D9F7CNvL7jUJVXoqM1QKLnog= github.com/sonatard/noctx v0.1.0 h1:JjqOc2WN16ISWAjAk8M5ej0RfExEXtkEyExl2hLW+OM= github.com/sonatard/noctx v0.1.0/go.mod h1:0RvBxqY8D4j9cTTTWE8ylt2vqj2EPI8fHmrxHdsaZ2c= github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= @@ -819,6 +839,7 @@ golang.org/x/sys v0.0.0-20200615200032-f1bc736245b1/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20200625212154-ddb9806d33ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201018230417-eeed37f84f13/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -832,6 +853,7 @@ golang.org/x/sys v0.0.0-20220114195835-da31bd327af9/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -1008,6 +1030,7 @@ google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp0 google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/Knetic/govaluate.v3 v3.0.0/go.mod h1:csKLBORsPbafmSCGTEh3U7Ozmsuq8ZSIlKk1bcqph0E= gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/embedfs/embedfs.go b/embedfs/embedfs.go new file mode 100644 index 0000000..988f5ee --- /dev/null +++ b/embedfs/embedfs.go @@ -0,0 +1,9 @@ +package embedfs + +import "embed" + +//go:embed assets/* +var Assets embed.FS + +//go:embed frontend-dist/* +var FrontendDist embed.FS diff --git a/embedfs/go.mod b/embedfs/go.mod new file mode 100644 index 0000000..fa30a0a --- /dev/null +++ b/embedfs/go.mod @@ -0,0 +1,3 @@ +module nex/embedfs + +go 1.26.2 diff --git a/frontend/.env.desktop b/frontend/.env.desktop new file mode 100644 index 0000000..a41b3e9 --- /dev/null +++ b/frontend/.env.desktop @@ -0,0 +1 @@ +VITE_API_BASE= diff --git a/go.work b/go.work new file mode 100644 index 0000000..d78f82a --- /dev/null +++ b/go.work @@ -0,0 +1,6 @@ +go 1.26.2 + +use ( + backend + embedfs +) diff --git a/go.work.sum b/go.work.sum new file mode 100644 index 0000000..defc4aa --- /dev/null +++ b/go.work.sum @@ -0,0 +1,104 @@ +cloud.google.com/go v0.116.0/go.mod h1:cEPSRWPzZEswwdr9BxE6ChEn01dWlTaF05LiC2Xs70U= +cloud.google.com/go/ai v0.8.0/go.mod h1:t3Dfk4cM61sytiggo2UyGsDVW3RF1qGZaUKDrZFyqkE= +cloud.google.com/go/auth v0.15.0/go.mod h1:WJDGqZ1o9E9wKIL+IwStfyn/+s59zl4Bi+1KQNVXLZ8= +cloud.google.com/go/auth/oauth2adapt v0.2.7/go.mod h1:NTbTTzfvPl1Y3V1nPpOgl2w6d/FjO7NNUQaWSox6ZMc= +cloud.google.com/go/compute/metadata v0.6.0/go.mod h1:FjyFAW1MW0C203CEOMDTu3Dk1FlqW3Rga40jzHL4hfg= +cloud.google.com/go/longrunning v0.5.7/go.mod h1:8GClkudohy1Fxm3owmBGid8W0pSgodEMwEAztp38Xng= +filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= +github.com/ClickHouse/ch-go v0.71.0/go.mod h1:NwbNc+7jaqfY58dmdDUbG4Jl22vThgx1cYjBw0vtgXw= +github.com/ClickHouse/clickhouse-go/v2 v2.43.0/go.mod h1:o6jf7JM/zveWC/PP277BLxjHy5KjnGX/jfljhM4s34g= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= +github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw= +github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= +github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= +github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= +github.com/cristalhq/acmd v0.12.0/go.mod h1:LG5oa43pE/BbxtfMoImHCQN++0Su7dzipdgBjMCBVDQ= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/ebitengine/purego v0.8.2/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= +github.com/elastic/go-sysinfo v1.15.4/go.mod h1:ZBVXmqS368dOn/jvijV/zHLfakWTYHBZPk3G244lHrU= +github.com/elastic/go-windows v1.0.2/go.mod h1:bGcDpBzXgYSqM0Gx3DM4+UxFj300SZLixie9u9ixLM8= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/go-faster/city v1.0.1/go.mod h1:jKcUJId49qdW3L1qKHH/3wPeUstCVpVSXTM6vO3VcTw= +github.com/go-faster/errors v0.7.1/go.mod h1:5ySTjWFiphBs07IKuiL69nxdfd5+fzh1u7FPGZP2quo= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= +github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= +github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= +github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= +github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI= +github.com/golangci/modinfo v0.3.3/go.mod h1:wytF1M5xl9u0ij8YSvhkEVPP3M5Mc7XLl1pxH3B2aUM= +github.com/google/generative-ai-go v0.19.0/go.mod h1:JYolL13VG7j79kM5BtHz4qwONHkeJQzOCkKXnpqtS/E= +github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= +github.com/googleapis/enterprise-certificate-proxy v0.3.4/go.mod h1:YKe7cfqYXjKGpGvmSg28/fFvhNzinZQm8DGnaburhGA= +github.com/googleapis/gax-go/v2 v2.14.1/go.mod h1:Hb/NubMaVM88SrNkvl8X/o8XWwDJEPqouaLeN2IUxoA= +github.com/gookit/color v1.5.4/go.mod h1:pZJOeOS8DM43rXbp4AZo1n9zCU2qjpcRko0b6/QJi9w= +github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jmoiron/sqlx v1.3.5/go.mod h1:nRVWtLre0KfCLJvgxzCsLVMogSvQ1zNJtpYr2Ccp0mQ= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60= +github.com/jordanlewis/gcassert v0.0.0-20250430164644-389ef753e22e/go.mod h1:ZybsQk6DWyN5t7An1MuPm1gtSZ1xDaTXS9ZjIOxvQrk= +github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= +github.com/magefile/mage v1.14.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A= +github.com/magiconair/properties v1.8.6/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPKd5NZ3oSwXrF60= +github.com/mfridman/xflag v0.1.0/go.mod h1:/483ywM5ZO5SuMVjrIGquYNE5CzLrj5Ux/LxWWnjRaE= +github.com/mgechev/dots v0.0.0-20210922191527-e955255bf517/go.mod h1:KQ7+USdGKfpPjXk4Ga+5XxQM4Lm4e3gAogrreFAYpOg= +github.com/microsoft/go-mssqldb v1.9.6/go.mod h1:yYMPDufyoF2vVuVCUGtZARr06DKFIhMrluTcgWlXpr4= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/moby/api v1.53.0/go.mod h1:8mb+ReTlisw4pS6BRzCMts5M49W5M7bKt1cJy/YbAqc= +github.com/moby/moby/client v0.2.2/go.mod h1:2EkIPVNCqR05CMIzL1mfA07t0HvVUUOl85pasRz/GmQ= +github.com/mozilla/tls-observatory v0.0.0-20210609171429-7bc42856d2e5/go.mod h1:FUqVoUPHSEdDR0MnFM3Dh8AU0pZHLXUD127SAJGER/s= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= +github.com/paulmach/orb v0.12.0/go.mod h1:5mULz1xQfs3bmQm63QEJA6lNGujuRafwA5S/EnuLaLU= +github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8= +github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= +github.com/phayes/checkstyle v0.0.0-20170904204023-bfd46e6a821d/go.mod h1:3OzsM7FXDQlpCiw2j81fOmAwQLnZnLGXVKUzeKQXIAw= +github.com/pierrec/lz4/v4 v4.1.25/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4= +github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= +github.com/quasilyte/go-ruleguard/rules v0.0.0-20211022131956-028d6511ab71/go.mod h1:4cgAphtvu7Ftv7vOT2ZOYhC6CvBxZixcasr8qIOTA50= +github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= +github.com/shirou/gopsutil/v4 v4.25.2/go.mod h1:34gBYJzyqCDT11b6bMHP0XCvWeU3J61XRT7a2EmCRTA= +github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= +github.com/spf13/jwalterweatherman v1.1.0/go.mod h1:aNWZUN0dPAAO/Ljvb5BEdw96iTZ0EXowPYD95IqWIGo= +github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI= +github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY= +github.com/tursodatabase/libsql-client-go v0.0.0-20251219100830-236aa1ff8acc/go.mod h1:08inkKyguB6CGGssc/JzhmQWwBgFQBgjlYFjxjRh7nU= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/quicktemplate v1.8.0/go.mod h1:qIqW8/igXt8fdrUln5kOSb+KWMaJ4Y8QUsfd1k6L2jM= +github.com/vertica/vertica-sql-go v1.3.5/go.mod h1:jnn2GFuv+O2Jcjktb7zyc4Utlbu9YVqpHH/lx63+1M4= +github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= +github.com/xdg-go/scram v1.2.0/go.mod h1:3dlrS0iBaWKYVt2ZfA4cj48umJZ+cAEbR6/SjLA88I8= +github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM= +github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778/go.mod h1:2MuV+tbUrU1zIOPMxZ5EncGwgmMJsa+9ucAQZXxsObs= +github.com/ydb-platform/ydb-go-genproto v0.0.0-20260128080146-c4ed16b24b37/go.mod h1:Er+FePu1dNUieD+XTMDduGpQuCPssK5Q4BjF+IIXJ3I= +github.com/ydb-platform/ydb-go-sdk/v3 v3.127.0/go.mod h1:stS1mQYjbJvwwYaYzKyFY9eMiuVXWWXQA6T+SpOLg9c= +github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI= +github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= +github.com/ziutek/mymysql v1.5.4/go.mod h1:LMSpPZ6DbqWFxNCHW77HeMg9I646SAhApZ/wKdgO/C0= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.59.0/go.mod h1:ijPqXp5P6IRRByFVVg9DY8P5HkxkHE5ARIa+86aXPf4= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0/go.mod h1:c7hN3ddxs/z6q9xwvfLPk+UHlWRQyaeR1LdgfL/66l0= +go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g= +go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc= +go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA= +go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +golang.org/x/oauth2 v0.26.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= +golang.org/x/telemetry v0.0.0-20260209163413-e7419c687ee4/go.mod h1:g5NllXBEermZrmR51cJDQxmJUHUOfRAaNyWBM+R+548= +golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= +golang.org/x/time v0.10.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +google.golang.org/api v0.223.0/go.mod h1:C+RS7Z+dDwds2b+zoAk5hN/eSfsiCn0UDrYof/M4d2M= +google.golang.org/genproto/googleapis/api v0.0.0-20241209162323-e6fa225c2576/go.mod h1:1R3kvZ1dtP3+4p4d3G8uJ8rFk/fWlScl38vanWACI08= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= +gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +howett.net/plist v1.0.1/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g= +rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= diff --git a/openspec/specs/desktop-app/spec.md b/openspec/specs/desktop-app/spec.md new file mode 100644 index 0000000..9758f23 --- /dev/null +++ b/openspec/specs/desktop-app/spec.md @@ -0,0 +1,123 @@ +# 桌面应用 + +## Purpose + +TBD - 提供跨平台桌面应用支持,将后端服务与前端静态资源打包为单一可执行文件 + +## Requirements + +### Requirement: 桌面应用启动 + +系统 SHALL 支持作为桌面应用启动,将后端服务与前端静态资源打包为单一可执行文件。 + +#### Scenario: 双击启动 +- **WHEN** 用户双击桌面应用可执行文件 +- **THEN** 系统启动后端服务 +- **AND** 系统托盘图标出现 +- **AND** 浏览器自动打开 `http://localhost:9826` 显示管理界面 + +#### Scenario: 单实例检查 +- **WHEN** 用户尝试启动第二个实例 +- **THEN** 系统检测到已有实例运行 +- **AND** 显示错误提示"已有 Nex 实例运行" +- **AND** 新实例退出 + +### Requirement: 系统托盘 + +系统 SHALL 提供跨平台系统托盘功能,支持托盘图标和菜单。 + +#### Scenario: 托盘图标显示 +- **WHEN** 桌面应用启动成功 +- **THEN** 系统托盘区域显示应用图标 +- **AND** 托盘图标 tooltip 显示"AI Gateway" + +#### Scenario: 托盘菜单显示 +- **WHEN** 用户点击托盘图标(左键或右键) +- **THEN** 显示托盘菜单 +- **AND** 菜单包含"打开管理界面"选项 +- **AND** 菜单包含"状态: 运行中"选项(禁用状态) +- **AND** 菜单包含"端口: 9826"选项(禁用状态) +- **AND** 菜单包含"关于"选项 +- **AND** 菜单包含"退出"选项 + +#### Scenario: 打开管理界面 +- **WHEN** 用户点击托盘菜单"打开管理界面" +- **THEN** 系统在浏览器中打开 `http://localhost:9826` + +#### Scenario: 浏览器打开失败 +- **WHEN** 系统无法打开浏览器(浏览器未安装等) +- **THEN** 托盘菜单仍可正常使用 +- **AND** 用户可手动访问 `http://localhost:9826` + +#### Scenario: 退出应用 +- **WHEN** 用户点击托盘菜单"退出" +- **THEN** 系统优雅关闭后端服务 +- **AND** 托盘图标消失 +- **AND** 应用进程退出 + +### Requirement: 静态文件服务 + +系统 SHALL 通过 Gin 同时服务 API 和前端静态资源。 + +#### Scenario: API 请求路由 +- **WHEN** 请求路径以 `/api/` 或 `/v1/` 开头 +- **THEN** 请求由现有业务 handler 处理 + +#### Scenario: 静态资源路由 +- **WHEN** 请求路径为 `/assets/*` +- **THEN** 返回嵌入的前端静态资源文件 + +#### Scenario: SPA 路由回退 +- **WHEN** 请求路径不匹配任何 API 或静态资源路由 +- **THEN** 返回 `index.html`(支持前端 SPA 路由) + +### Requirement: 端口冲突检测 + +系统 SHALL 在启动前检测端口是否可用。 + +#### Scenario: 端口可用 +- **WHEN** 端口 9826 未被占用 +- **THEN** 服务正常启动 + +#### Scenario: 端口被占用 +- **WHEN** 端口 9826 已被其他程序占用 +- **THEN** 显示错误提示"端口 9826 已被占用" +- **AND** 应用退出 + +### Requirement: 跨平台构建 + +系统 SHALL 支持跨平台构建和打包。 + +#### Scenario: macOS 构建 +- **WHEN** 执行 macOS 构建命令 +- **THEN** 生成 `nex-darwin-arm64` 和 `nex-darwin-amd64` 可执行文件 +- **AND** 可打包为 `.app` bundle + +#### Scenario: Windows 构建 +- **WHEN** 执行 Windows 构建命令 +- **THEN** 生成 `nex-windows-amd64.exe` 可执行文件 +- **AND** 运行时不显示控制台窗口 + +#### Scenario: Linux 构建 +- **WHEN** 执行 Linux 构建命令 +- **THEN** 生成 `nex-linux-amd64` 可执行文件 + +### Requirement: macOS .app 打包 + +系统 SHALL 支持打包为 macOS .app bundle。 + +#### Scenario: .app 结构 +- **WHEN** 执行打包脚本 +- **THEN** 生成 `Nex.app` 目录结构 +- **AND** 包含 `Contents/Info.plist` 元数据 +- **AND** 包含 `Contents/MacOS/nex` 可执行文件 +- **AND** 包含 `Contents/Resources/AppIcon.icns` 图标 +- **AND** `Info.plist` 中 `LSUIElement` 为 `true`(不显示 Dock 图标) + +### Requirement: 关于对话框 + +系统 SHALL 提供关于对话框显示应用信息。 + +#### Scenario: 显示关于 +- **WHEN** 用户点击托盘菜单"关于" +- **THEN** 显示对话框包含应用名称、项目链接 diff --git a/scripts/build/package-macos.sh b/scripts/build/package-macos.sh new file mode 100755 index 0000000..41fed5d --- /dev/null +++ b/scripts/build/package-macos.sh @@ -0,0 +1,68 @@ +#!/bin/bash + +VERSION="1.0.0" +APP_NAME="Nex" +BUNDLE_ID="io.nex.gateway" +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PROJECT_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)" +BUILD_DIR="${PROJECT_ROOT}/build" +ASSETS_DIR="${PROJECT_ROOT}/assets" + +echo "打包 macOS .app..." + +mkdir -p "${BUILD_DIR}/${APP_NAME}.app/Contents/MacOS" +mkdir -p "${BUILD_DIR}/${APP_NAME}.app/Contents/Resources" + +if [ -f "${BUILD_DIR}/nex-darwin-arm64" ]; then + cp "${BUILD_DIR}/nex-darwin-arm64" "${BUILD_DIR}/${APP_NAME}.app/Contents/MacOS/nex" +elif [ -f "${BUILD_DIR}/nex-darwin-amd64" ]; then + cp "${BUILD_DIR}/nex-darwin-amd64" "${BUILD_DIR}/${APP_NAME}.app/Contents/MacOS/nex" +else + echo "错误: 未找到 macOS 二进制文件,请先运行 make desktop-darwin" + exit 1 +fi + +if [ -f "${ASSETS_DIR}/AppIcon.icns" ]; then + cp "${ASSETS_DIR}/AppIcon.icns" "${BUILD_DIR}/${APP_NAME}.app/Contents/Resources/" +else + echo "警告: 未找到 AppIcon.icns" +fi + +cat > "${BUILD_DIR}/${APP_NAME}.app/Contents/Info.plist" << EOF + + + + + CFBundleDevelopmentRegion + zh_CN + CFBundleExecutable + nex + CFBundleIconFile + AppIcon + CFBundleIdentifier + ${BUNDLE_ID} + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + ${APP_NAME} Gateway + CFBundleDisplayName + ${APP_NAME} Gateway + CFBundlePackageType + APPL + CFBundleShortVersionString + ${VERSION} + CFBundleVersion + ${VERSION} + LSMinimumSystemVersion + 10.13 + LSUIElement + + NSHighResolutionCapable + + + +EOF + +chmod +x "${BUILD_DIR}/${APP_NAME}.app/Contents/MacOS/nex" + +echo "打包完成: ${BUILD_DIR}/${APP_NAME}.app"