diff --git a/.github/workflows/Auto-docker-publish.yml b/.github/workflows/Auto-docker-publish.yml index d20cfaa..fd02333 100644 --- a/.github/workflows/Auto-docker-publish.yml +++ b/.github/workflows/Auto-docker-publish.yml @@ -196,8 +196,12 @@ jobs: path: web/dist - name: Build binary run: | + LDFLAGS="-s -w -X main.version=${{ needs.build-image.outputs.release_tag }}" + if [ "${{ matrix.goos }}" = "windows" ]; then + LDFLAGS="$LDFLAGS -H=windowsgui" + fi CGO_ENABLED=0 GOOS=${{ matrix.goos }} GOARCH=${{ matrix.goarch }} \ - go build -trimpath -ldflags="-s -w -X main.version=${{ needs.build-image.outputs.release_tag }}" \ + go build -trimpath -ldflags="$LDFLAGS" \ -o "dist/mebox-${{ matrix.goos }}-${{ matrix.goarch }}${{ matrix.ext }}" ./cmd/server - name: Package run: | diff --git a/.github/workflows/beta-build.yml b/.github/workflows/beta-build.yml index c03e6dd..44c7208 100644 --- a/.github/workflows/beta-build.yml +++ b/.github/workflows/beta-build.yml @@ -77,7 +77,7 @@ jobs: - name: Build linux/arm64 run: CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -trimpath -ldflags="-s -w -X main.version=${{ steps.version.outputs.full_version }}" -o dist/mebox-beta-linux-arm64 ./cmd/server - name: Build windows/amd64 - run: CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -trimpath -ldflags="-s -w -X main.version=${{ steps.version.outputs.full_version }}" -o dist/mebox-beta-windows-amd64.exe ./cmd/server + run: CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -trimpath -ldflags="-s -w -H=windowsgui -X main.version=${{ steps.version.outputs.full_version }}" -o dist/mebox-beta-windows-amd64.exe ./cmd/server - name: Upload artifacts uses: actions/upload-artifact@v4 diff --git a/README.md b/README.md index f108090..b48e5d1 100644 --- a/README.md +++ b/README.md @@ -225,6 +225,14 @@ npm --prefix web run dev # http://127.0.0.1:3000 CI 会在 Release 中提供 Windows / Linux / macOS 的 amd64、arm64 单文件可执行程序。 +Windows 本地打包: + +```powershell +.\scripts\build-windows.ps1 -Version dev +``` + +Windows 可执行程序使用项目 Logo,不显示控制台窗口;启动后会常驻系统托盘。托盘菜单可打开 MeBox、切换开机自启、查看日志、重启或退出。 + --- ## 鸣谢 diff --git a/README_EN.md b/README_EN.md index d1004ef..18ab276 100644 --- a/README_EN.md +++ b/README_EN.md @@ -199,6 +199,14 @@ npm --prefix web run dev Release builds ship single-file binaries for Windows, Linux, and macOS on amd64 and arm64. +Build the Windows executable locally: + +```powershell +.\scripts\build-windows.ps1 -Version dev +``` + +The Windows executable uses the project logo and runs without a console window. It stays in the notification area, with menu actions for opening MeBox, toggling auto-start, viewing logs, restarting, and exiting. + --- ## Acknowledgements diff --git a/cmd/server/application.go b/cmd/server/application.go new file mode 100644 index 0000000..2056c29 --- /dev/null +++ b/cmd/server/application.go @@ -0,0 +1,197 @@ +package main + +import ( + "context" + "fmt" + "os" + "sync" + "time" + + "go.uber.org/zap" + + "github.com/truewhile/MeBox/internal/config" + "github.com/truewhile/MeBox/internal/database" + "github.com/truewhile/MeBox/internal/helper" + "github.com/truewhile/MeBox/internal/repository" + "github.com/truewhile/MeBox/internal/service" +) + +// application owns the long-running MeBox server and all resources created +// during startup. Platform entry points decide how the application is +// controlled: a console signal loop or a Windows notification-area icon. +type application struct { + cfg *config.Config + logger *zap.Logger + embyCompatLogger *zap.Logger + serverManager *serverManager + services *service.Container + + closeMu sync.Mutex + closeFuncs []func() + shutdown sync.Once + shutdownErr error +} + +func newApplication() (*application, error) { + cfg, err := config.Load() + if err != nil { + return nil, fmt.Errorf("config load failed: %w", err) + } + + logger, closeLogger, err := newLoggerWithCloser(cfg) + if err != nil { + return nil, fmt.Errorf("logger init failed: %w", err) + } + + app := &application{ + cfg: cfg, + logger: logger, + closeFuncs: []func(){closeLogger}, + } + if err := app.start(); err != nil { + app.closeLoggers() + return nil, err + } + return app, nil +} + +func (a *application) start() error { + embyCompatLogger, closeEmbyCompatLogger, err := newEmbyCompatLogger(a.cfg) + if err != nil { + a.logger.Error("Emby compatibility logger init failed", zap.Error(err)) + return fmt.Errorf("Emby compatibility logger init failed: %w", err) + } + a.embyCompatLogger = embyCompatLogger + a.addCloser(closeEmbyCompatLogger) + + appVersion := effectiveVersion(version) + a.logger.Info("starting MeBox", + zap.String("version", appVersion), + zap.Int("port", a.cfg.App.Port), + zap.String("data_dir", a.cfg.App.DataDir), + zap.String("emby_compat_log", embyCompatLogPath(a.cfg)), + ) + + for _, dir := range []string{a.cfg.App.DataDir, a.cfg.Cache.CacheDir} { + if err := os.MkdirAll(dir, 0o750); err != nil { + a.logger.Error("create dir failed", zap.String("dir", dir), zap.Error(err)) + return fmt.Errorf("create dir %s failed: %w", dir, err) + } + } + + db, err := database.Open(a.cfg, a.logger) + if err != nil { + a.logger.Error("database open failed", zap.Error(err)) + return fmt.Errorf("database open failed: %w", err) + } + if err := waitForDatabase(db, a.logger); err != nil { + a.logger.Error("database not ready", zap.Error(err)) + return fmt.Errorf("database not ready: %w", err) + } + if err := database.AutoMigrate(db); err != nil { + a.logger.Error("auto-migrate failed", zap.Error(err)) + return fmt.Errorf("auto-migrate failed: %w", err) + } + if err := database.MigrateSQLiteToCurrentIfNeeded(a.cfg, db, a.logger); err != nil { + a.logger.Error("sqlite to postgres migration failed", zap.Error(err)) + return fmt.Errorf("sqlite to postgres migration failed: %w", err) + } + + repos := repository.New(db) + service.ApplyRuntimeSettings(context.Background(), a.cfg, repos, a.logger) + applyCPUThreadLimit(a.cfg, a.logger) + a.services = service.NewWithVersion(a.cfg, a.logger, repos, appVersion) + + // 一次性清洗历史脏数据: 老版本把单集 episode id / 单集名写进整剧字段, 导致 + // 同一部剧被拆成多张单集卡。清空被污染的字段并重置为 pending(借后续重刮修正)。 + if cleaned, err := a.services.NormalizePollutedEpisodeMetadata(context.Background()); err != nil { + a.logger.Warn("polluted episode metadata cleanup failed", zap.Error(err)) + } else if cleaned > 0 { + a.logger.Info("polluted episode metadata cleanup completed", zap.Int("media_count", cleaned)) + } + + if err := a.services.Auth.SeedAdmin(context.Background()); err != nil { + a.logger.Warn("seed admin failed", zap.Error(err)) + } + + router := buildRouter(a.cfg, a.logger, a.embyCompatLogger, a.services) + a.serverManager = newServerManager(a.cfg, a.logger, router) + a.services.ReloadHTTPServer = a.serverManager.Reload + if err := a.serverManager.Start(); err != nil { + a.logger.Error("listen failed", zap.Error(err)) + return fmt.Errorf("listen failed: %w", err) + } + + go func() { + scheme := "http" + if a.cfg.App.HTTPSEnabled { + scheme = "https" + } + if publicIP := getPublicIP(3 * time.Second); publicIP != "" { + a.logger.Info("server public endpoint", + zap.String("public", fmt.Sprintf("%s://%s:%d", scheme, publicIP, a.cfg.App.Port)), + ) + } + }() + helper.Go(a.logger, "services.boot", a.services.Boot) + return nil +} + +// Shutdown is idempotent and safe to call from both the tray handler and the +// systray exit callback. +func (a *application) Shutdown() error { + a.shutdown.Do(func() { + a.logger.Info("shutdown requested") + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + if a.serverManager != nil { + if err := a.serverManager.Shutdown(ctx); err != nil { + a.shutdownErr = err + a.logger.Error("graceful shutdown failed", zap.Error(err)) + } + } + cancel() + + if a.services != nil { + a.services.Close() + } + a.logger.Info("MeBox stopped") + _ = a.logger.Sync() + if a.embyCompatLogger != nil { + _ = a.embyCompatLogger.Sync() + } + a.closeLoggers() + }) + return a.shutdownErr +} + +func (a *application) addCloser(fn func()) { + if fn == nil { + return + } + a.closeMu.Lock() + a.closeFuncs = append(a.closeFuncs, fn) + a.closeMu.Unlock() +} + +func (a *application) closeLoggers() { + a.closeMu.Lock() + closers := append([]func(){}, a.closeFuncs...) + a.closeFuncs = nil + a.closeMu.Unlock() + + for i := len(closers) - 1; i >= 0; i-- { + closers[i]() + } +} + +func (a *application) localURL() string { + config.RuntimeMu.RLock() + scheme := "http" + if a.cfg.App.HTTPSEnabled { + scheme = "https" + } + port := a.cfg.App.Port + config.RuntimeMu.RUnlock() + return fmt.Sprintf("%s://127.0.0.1:%d", scheme, port) +} diff --git a/cmd/server/main.go b/cmd/server/main.go index d8161a6..083d53e 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -11,21 +11,8 @@ package main import ( - "context" - "fmt" "os" - "os/signal" "strings" - "syscall" - "time" - - "go.uber.org/zap" - - "github.com/truewhile/MeBox/internal/config" - "github.com/truewhile/MeBox/internal/database" - "github.com/truewhile/MeBox/internal/helper" - "github.com/truewhile/MeBox/internal/repository" - "github.com/truewhile/MeBox/internal/service" ) // version is overwritten at build time via -ldflags="-X main.version=...". @@ -46,105 +33,5 @@ func effectiveVersion(buildVersion string) string { } func main() { - cfg, err := config.Load() - if err != nil { - fmt.Fprintf(os.Stderr, "config load failed: %v\n", err) - os.Exit(1) - } - - logger, err := newLogger(cfg) - if err != nil { - fmt.Fprintf(os.Stderr, "logger init failed: %v\n", err) - os.Exit(1) - } - defer func() { _ = logger.Sync() }() - - embyCompatLogger, closeEmbyCompatLogger, err := newEmbyCompatLogger(cfg) - if err != nil { - logger.Fatal("Emby compatibility logger init failed", zap.Error(err)) - } - defer func() { - _ = embyCompatLogger.Sync() - closeEmbyCompatLogger() - }() - - appVersion := effectiveVersion(version) - logger.Info("starting MeBox", - zap.String("version", appVersion), - zap.Int("port", cfg.App.Port), - zap.String("data_dir", cfg.App.DataDir), - zap.String("emby_compat_log", embyCompatLogPath(cfg)), - ) - - // Ensure data / cache / web dirs exist. - for _, d := range []string{cfg.App.DataDir, cfg.Cache.CacheDir} { - if err := os.MkdirAll(d, 0o750); err != nil { - logger.Fatal("create dir failed", zap.String("dir", d), zap.Error(err)) - } - } - - db, err := database.Open(cfg, logger) - if err != nil { - logger.Fatal("database open failed", zap.Error(err)) - } - if err := waitForDatabase(db, logger); err != nil { - logger.Fatal("database not ready", zap.Error(err)) - } - if err := database.AutoMigrate(db); err != nil { - logger.Fatal("auto-migrate failed", zap.Error(err)) - } - if err := database.MigrateSQLiteToCurrentIfNeeded(cfg, db, logger); err != nil { - logger.Fatal("sqlite to postgres migration failed", zap.Error(err)) - } - - repos := repository.New(db) - service.ApplyRuntimeSettings(context.Background(), cfg, repos, logger) - applyCPUThreadLimit(cfg, logger) - services := service.NewWithVersion(cfg, logger, repos, appVersion) - - // 一次性清洗历史脏数据: 老版本把单集 episode id / 单集名写进整剧字段, 导致 - // 同一部剧被拆成多张单集卡。清空被污染的字段并重置为 pending(借后续重刮修正)。 - if cleaned, err := services.NormalizePollutedEpisodeMetadata(context.Background()); err != nil { - logger.Warn("polluted episode metadata cleanup failed", zap.Error(err)) - } else if cleaned > 0 { - logger.Info("polluted episode metadata cleanup completed", zap.Int("media_count", cleaned)) - } - - if err := services.Auth.SeedAdmin(context.Background()); err != nil { - logger.Warn("seed admin failed", zap.Error(err)) - } - - router := buildRouter(cfg, logger, embyCompatLogger, services) - - serverMgr := newServerManager(cfg, logger, router) - services.ReloadHTTPServer = serverMgr.Reload - if err := serverMgr.Start(); err != nil { - logger.Fatal("listen failed", zap.Error(err)) - } - go func() { - scheme := "http" - if cfg.App.HTTPSEnabled { - scheme = "https" - } - if publicIP := getPublicIP(3 * time.Second); publicIP != "" { - logger.Info("server public endpoint", - zap.String("public", fmt.Sprintf("%s://%s:%d", scheme, publicIP, cfg.App.Port)), - ) - } - }() - helper.Go(logger, "services.boot", services.Boot) - - // Graceful shutdown. - stop := make(chan os.Signal, 1) - signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM) - <-stop - logger.Info("shutdown requested") - - ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) - defer cancel() - if err := serverMgr.Shutdown(ctx); err != nil { - logger.Error("graceful shutdown failed", zap.Error(err)) - } - services.Close() - logger.Info("MeBox stopped") + runProgram() } diff --git a/cmd/server/program_other.go b/cmd/server/program_other.go new file mode 100644 index 0000000..43f3825 --- /dev/null +++ b/cmd/server/program_other.go @@ -0,0 +1,33 @@ +//go:build !windows + +package main + +import ( + "fmt" + "os" + "os/signal" + "syscall" +) + +func runProgram() { + app, err := newApplication() + if err != nil { + reportError("MeBox 启动失败", err) + os.Exit(1) + } + + stop := make(chan os.Signal, 1) + signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM) + <-stop + + if err := app.Shutdown(); err != nil { + reportError("MeBox 退出失败", err) + } +} + +func reportError(title string, err error) { + if err == nil { + return + } + fmt.Fprintf(os.Stderr, "%s: %v\n", title, err) +} diff --git a/cmd/server/program_windows.go b/cmd/server/program_windows.go new file mode 100644 index 0000000..623b03a --- /dev/null +++ b/cmd/server/program_windows.go @@ -0,0 +1,330 @@ +//go:build windows + +package main + +import ( + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "sync/atomic" + "syscall" + + "fyne.io/systray" + "golang.org/x/sys/windows" + "golang.org/x/sys/windows/registry" + + "github.com/truewhile/MeBox/internal/brand" +) + +const ( + runRegistryKey = `Software\Microsoft\Windows\CurrentVersion\Run` + runRegistryValue = "MeBox" + singleInstanceName = `Local\MeBox-Server` +) + +var errAlreadyRunning = errors.New("MeBox is already running") + +func runProgram() { + if err := prepareWorkingDirectory(); err != nil { + reportError("MeBox 启动失败", fmt.Errorf("切换工作目录失败: %w", err)) + return + } + + instance, err := acquireSingleInstance() + if errors.Is(err, errAlreadyRunning) { + reportError("MeBox", errors.New("MeBox 已在运行,请查看右下角托盘图标")) + return + } + if err != nil { + reportError("MeBox 启动失败", fmt.Errorf("创建单实例锁失败: %w", err)) + return + } + + app, err := newApplication() + if err != nil { + _ = instance.Close() + reportError("MeBox 启动失败", err) + return + } + + controller := &trayController{app: app} + systray.Run(controller.onReady, controller.onExit) + + // Release the mutex before starting the replacement process. The new + // process must be able to acquire it immediately after the old one exits. + _ = instance.Close() + + if !controller.readyClosed.Load() { + _ = app.Shutdown() + reportError("MeBox 启动失败", errors.New("系统托盘初始化失败")) + return + } + if controller.restartRequested.Load() { + if err := launchSelf(); err != nil { + reportError("MeBox 重启失败", err) + } + } +} + +type trayController struct { + app *application + + readyClosed atomic.Bool + restartRequested atomic.Bool + shutdownStarted atomic.Bool +} + +func (c *trayController) onReady() { + defer func() { + c.readyClosed.Store(true) + }() + + systray.SetIcon(brand.Icon) + systray.SetTooltip("MeBox") + + mOpen := systray.AddMenuItem("打开 MeBox", "在浏览器中打开 MeBox") + mAutoStart := systray.AddMenuItemCheckbox("开机自启", "登录 Windows 后自动启动 MeBox", autoStartEnabled()) + mLogs := systray.AddMenuItem("查看日志", "打开 MeBox 应用日志") + systray.AddSeparator() + mRestart := systray.AddMenuItem("重启 MeBox", "重启 MeBox 服务") + mQuit := systray.AddMenuItem("退出 MeBox", "停止服务并退出") + + initialAutoStart := mAutoStart.Checked() + go func() { + for { + select { + case <-mOpen.ClickedCh: + if err := openURL(c.app.localURL()); err != nil { + reportError("MeBox", fmt.Errorf("打开 MeBox 失败: %w", err)) + } + case <-mAutoStart.ClickedCh: + enable := !mAutoStart.Checked() + if err := setAutoStart(enable); err != nil { + if initialAutoStart { + mAutoStart.Check() + } else { + mAutoStart.Uncheck() + } + reportError("MeBox", fmt.Errorf("更新开机自启设置失败: %w", err)) + continue + } + if enable { + mAutoStart.Check() + } else { + mAutoStart.Uncheck() + } + initialAutoStart = enable + case <-mLogs.ClickedCh: + if err := c.app.openLog(); err != nil { + reportError("MeBox", fmt.Errorf("打开日志失败: %w", err)) + } + case <-mRestart.ClickedCh: + c.restart() + case <-mQuit.ClickedCh: + c.quit() + } + } + }() +} + +func (c *trayController) onExit() { + _ = c.app.Shutdown() +} + +func (c *trayController) quit() { + if !c.shutdownStarted.CompareAndSwap(false, true) { + return + } + go func() { + _ = c.app.Shutdown() + systray.Quit() + }() +} + +func (c *trayController) restart() { + if !c.shutdownStarted.CompareAndSwap(false, true) { + return + } + c.restartRequested.Store(true) + go func() { + _ = c.app.Shutdown() + systray.Quit() + }() +} + +func (a *application) openLog() error { + appLog, _, _ := logFilePaths(a.cfg) + if appLog != "" { + if _, err := os.Stat(appLog); err == nil { + return openPath(appLog) + } + _ = os.MkdirAll(filepath.Dir(appLog), 0o750) + return openPath(filepath.Dir(appLog)) + } + + logDir := filepath.Join(a.cfg.App.DataDir, "logs") + if err := os.MkdirAll(logDir, 0o750); err != nil { + return err + } + return openPath(logDir) +} + +func prepareWorkingDirectory() error { + exe, err := os.Executable() + if err != nil { + return err + } + exeDir := filepath.Dir(exe) + cwd, err := os.Getwd() + if err != nil { + return err + } + if samePath(exeDir, cwd) || looksLikeProjectDirectory(cwd) { + return nil + } + return os.Chdir(exeDir) +} + +func looksLikeProjectDirectory(dir string) bool { + for _, name := range []string{"go.mod", "config.yaml", "data", filepath.Join("web", "dist")} { + if _, err := os.Stat(filepath.Join(dir, name)); err == nil { + return true + } + } + return false +} + +func samePath(left, right string) bool { + return strings.EqualFold(filepath.Clean(left), filepath.Clean(right)) +} + +type singleInstance struct { + handle windows.Handle +} + +func acquireSingleInstance() (*singleInstance, error) { + name, err := windows.UTF16PtrFromString(singleInstanceName) + if err != nil { + return nil, err + } + handle, err := windows.CreateMutex(nil, false, name) + if errors.Is(err, windows.ERROR_ALREADY_EXISTS) { + if handle != 0 { + _ = windows.CloseHandle(handle) + } + return nil, errAlreadyRunning + } + if err != nil { + return nil, err + } + return &singleInstance{handle: handle}, nil +} + +func (s *singleInstance) Close() error { + if s == nil || s.handle == 0 { + return nil + } + err := windows.CloseHandle(s.handle) + s.handle = 0 + return err +} + +func launchSelf() error { + exe, err := os.Executable() + if err != nil { + return err + } + cwd, err := os.Getwd() + if err != nil { + return err + } + + cmd := exec.Command(exe, os.Args[1:]...) + cmd.Dir = cwd + cmd.Env = os.Environ() + cmd.SysProcAttr = &syscall.SysProcAttr{ + HideWindow: true, + CreationFlags: windows.CREATE_NEW_PROCESS_GROUP | windows.DETACHED_PROCESS, + } + if err := cmd.Start(); err != nil { + return err + } + return cmd.Process.Release() +} + +func autoStartEnabled() bool { + key, err := registry.OpenKey(registry.CURRENT_USER, runRegistryKey, registry.QUERY_VALUE) + if err != nil { + return false + } + defer key.Close() + + value, _, err := key.GetStringValue(runRegistryValue) + if err != nil { + return false + } + exe, err := os.Executable() + if err != nil { + return false + } + return strings.EqualFold(strings.TrimSpace(strings.Trim(value, `"`)), filepath.Clean(exe)) +} + +func setAutoStart(enabled bool) error { + key, _, err := registry.CreateKey(registry.CURRENT_USER, runRegistryKey, registry.SET_VALUE) + if err != nil { + return err + } + defer key.Close() + + if !enabled { + if err := key.DeleteValue(runRegistryValue); err != nil && !errors.Is(err, registry.ErrNotExist) { + return err + } + return nil + } + + exe, err := os.Executable() + if err != nil { + return err + } + return key.SetStringValue(runRegistryValue, syscall.EscapeArg(filepath.Clean(exe))) +} + +func openURL(url string) error { + return shellOpen(url) +} + +func openPath(path string) error { + return shellOpen(path) +} + +func shellOpen(target string) error { + targetPtr, err := windows.UTF16PtrFromString(target) + if err != nil { + return err + } + verbPtr, err := windows.UTF16PtrFromString("open") + if err != nil { + return err + } + return windows.ShellExecute(0, verbPtr, targetPtr, nil, nil, 1) +} + +func reportError(title string, err error) { + if err == nil { + return + } + text, textErr := windows.UTF16PtrFromString(title + "\r\n\r\n" + err.Error()) + if textErr != nil { + return + } + caption, captionErr := windows.UTF16PtrFromString("MeBox") + if captionErr != nil { + return + } + _, _ = windows.MessageBox(0, text, caption, windows.MB_OK|windows.MB_ICONERROR|windows.MB_SETFOREGROUND) +} diff --git a/cmd/server/resources_windows.go b/cmd/server/resources_windows.go new file mode 100644 index 0000000..d668f2c --- /dev/null +++ b/cmd/server/resources_windows.go @@ -0,0 +1,11 @@ +//go:build windows + +package main + +// Regenerate the linked Windows resources after changing the project logo or +// manifest: +// +// go generate ./cmd/server +// +//go:generate go run github.com/akavel/rsrc@v0.10.2 -arch amd64 -ico ../../internal/brand/logo.ico -manifest winres/mebox.manifest -o rsrc_windows_amd64.syso +//go:generate go run github.com/akavel/rsrc@v0.10.2 -arch arm64 -ico ../../internal/brand/logo.ico -manifest winres/mebox.manifest -o rsrc_windows_arm64.syso diff --git a/cmd/server/rsrc_windows_amd64.syso b/cmd/server/rsrc_windows_amd64.syso new file mode 100644 index 0000000..d704fd4 Binary files /dev/null and b/cmd/server/rsrc_windows_amd64.syso differ diff --git a/cmd/server/rsrc_windows_arm64.syso b/cmd/server/rsrc_windows_arm64.syso new file mode 100644 index 0000000..2125468 Binary files /dev/null and b/cmd/server/rsrc_windows_arm64.syso differ diff --git a/cmd/server/winres/mebox.manifest b/cmd/server/winres/mebox.manifest new file mode 100644 index 0000000..12e1a90 --- /dev/null +++ b/cmd/server/winres/mebox.manifest @@ -0,0 +1,28 @@ + + + + MeBox media server + + + + + + + + + + + + + + + true/pm + true + + + diff --git a/go.mod b/go.mod index 9109147..fa66d6b 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/truewhile/MeBox go 1.25.0 require ( + fyne.io/systray v1.12.2 github.com/aliyun/alibabacloud-oss-go-sdk-v2 v1.5.2 github.com/fsnotify/fsnotify v1.7.0 github.com/gin-contrib/gzip v1.2.6 @@ -48,6 +49,7 @@ require ( github.com/go-playground/validator/v10 v10.30.1 // indirect github.com/goccy/go-json v0.10.6 // indirect github.com/goccy/go-yaml v1.19.2 // indirect + github.com/godbus/dbus/v5 v5.1.0 // indirect github.com/hashicorp/hcl v1.0.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect diff --git a/go.sum b/go.sum index 3e60851..6d54e59 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +fyne.io/systray v1.12.2 h1:Y8DZxgLHsVQt6rY9Zrkkg+j67S7vv/1F2viOWKPpVeA= +fyne.io/systray v1.12.2/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs= github.com/aliyun/alibabacloud-oss-go-sdk-v2 v1.5.2 h1:40yUSXwdkWN851BHCq6uiDhleh7A4+0yIBS+IUAqZVY= github.com/aliyun/alibabacloud-oss-go-sdk-v2 v1.5.2/go.mod h1:FTzydeQVmR24FI0D6XWUOMKckjXehM/jgMn1xC+DA9M= github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= @@ -56,6 +58,8 @@ github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU= github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= +github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= diff --git a/internal/brand/brand.go b/internal/brand/brand.go new file mode 100644 index 0000000..8e34095 --- /dev/null +++ b/internal/brand/brand.go @@ -0,0 +1,10 @@ +// Package brand contains the embedded MeBox application artwork. +package brand + +import _ "embed" + +// Icon is the Windows ICO used by both the executable resource and the +// notification-area icon. +// +//go:embed logo.ico +var Icon []byte diff --git a/internal/brand/logo.ico b/internal/brand/logo.ico new file mode 100644 index 0000000..71e3b74 Binary files /dev/null and b/internal/brand/logo.ico differ diff --git a/scripts/build-windows.ps1 b/scripts/build-windows.ps1 new file mode 100644 index 0000000..0617485 --- /dev/null +++ b/scripts/build-windows.ps1 @@ -0,0 +1,42 @@ +param( + [string]$Output = "dist\mebox-windows-amd64.exe", + [string]$Version = "dev", + [switch]$SkipFrontend +) + +$ErrorActionPreference = "Stop" +$root = Split-Path -Parent $PSScriptRoot +Push-Location $root +try { + if (-not $SkipFrontend) { + Push-Location web + try { + npm ci + npm run build + } + finally { + Pop-Location + } + } + + if (-not (Test-Path "web\dist\index.html")) { + throw "web\dist\index.html is missing. Run without -SkipFrontend or build the frontend first." + } + + $outputDir = Split-Path -Parent $Output + if ($outputDir) { + New-Item -ItemType Directory -Force -Path $outputDir | Out-Null + } + $env:CGO_ENABLED = "0" + $env:GOOS = "windows" + $env:GOARCH = "amd64" + go build -trimpath ` + -ldflags="-s -w -H=windowsgui -X main.version=$Version" ` + -o $Output ` + ./cmd/server + + Write-Host "Built $Output" +} +finally { + Pop-Location +}