mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-28 03:06:38 +08:00
优化windows包,优化windos可执行文件功能
This commit is contained in:
@@ -196,8 +196,12 @@ jobs:
|
|||||||
path: web/dist
|
path: web/dist
|
||||||
- name: Build binary
|
- name: Build binary
|
||||||
run: |
|
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 }} \
|
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
|
-o "dist/mebox-${{ matrix.goos }}-${{ matrix.goarch }}${{ matrix.ext }}" ./cmd/server
|
||||||
- name: Package
|
- name: Package
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ jobs:
|
|||||||
- name: Build linux/arm64
|
- 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
|
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
|
- 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
|
- name: Upload artifacts
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
|
|||||||
@@ -225,6 +225,14 @@ npm --prefix web run dev # http://127.0.0.1:3000
|
|||||||
|
|
||||||
CI 会在 Release 中提供 Windows / Linux / macOS 的 amd64、arm64 单文件可执行程序。
|
CI 会在 Release 中提供 Windows / Linux / macOS 的 amd64、arm64 单文件可执行程序。
|
||||||
|
|
||||||
|
Windows 本地打包:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\scripts\build-windows.ps1 -Version dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Windows 可执行程序使用项目 Logo,不显示控制台窗口;启动后会常驻系统托盘。托盘菜单可打开 MeBox、切换开机自启、查看日志、重启或退出。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 鸣谢
|
## 鸣谢
|
||||||
|
|||||||
@@ -199,6 +199,14 @@ npm --prefix web run dev
|
|||||||
|
|
||||||
Release builds ship single-file binaries for Windows, Linux, and macOS on amd64 and arm64.
|
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
|
## Acknowledgements
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
+1
-114
@@ -11,21 +11,8 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
|
||||||
"strings"
|
"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=...".
|
// version is overwritten at build time via -ldflags="-X main.version=...".
|
||||||
@@ -46,105 +33,5 @@ func effectiveVersion(buildVersion string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
cfg, err := config.Load()
|
runProgram()
|
||||||
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")
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,28 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||||
|
<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0">
|
||||||
|
<assemblyIdentity
|
||||||
|
version="1.0.0.0"
|
||||||
|
processorArchitecture="*"
|
||||||
|
name="MeBox"
|
||||||
|
type="win32"
|
||||||
|
/>
|
||||||
|
<description>MeBox media server</description>
|
||||||
|
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||||
|
<security>
|
||||||
|
<requestedPrivileges>
|
||||||
|
<requestedExecutionLevel level="asInvoker" uiAccess="false" />
|
||||||
|
</requestedPrivileges>
|
||||||
|
</security>
|
||||||
|
</trustInfo>
|
||||||
|
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
|
||||||
|
<application>
|
||||||
|
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
|
||||||
|
</application>
|
||||||
|
</compatibility>
|
||||||
|
<application xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||||
|
<windowsSettings>
|
||||||
|
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/pm</dpiAware>
|
||||||
|
<longPathAware xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">true</longPathAware>
|
||||||
|
</windowsSettings>
|
||||||
|
</application>
|
||||||
|
</assembly>
|
||||||
@@ -3,6 +3,7 @@ module github.com/truewhile/MeBox
|
|||||||
go 1.25.0
|
go 1.25.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
fyne.io/systray v1.12.2
|
||||||
github.com/aliyun/alibabacloud-oss-go-sdk-v2 v1.5.2
|
github.com/aliyun/alibabacloud-oss-go-sdk-v2 v1.5.2
|
||||||
github.com/fsnotify/fsnotify v1.7.0
|
github.com/fsnotify/fsnotify v1.7.0
|
||||||
github.com/gin-contrib/gzip v1.2.6
|
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/go-playground/validator/v10 v10.30.1 // indirect
|
||||||
github.com/goccy/go-json v0.10.6 // indirect
|
github.com/goccy/go-json v0.10.6 // indirect
|
||||||
github.com/goccy/go-yaml v1.19.2 // 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/hashicorp/hcl v1.0.0 // indirect
|
||||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
|
|||||||
@@ -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 h1:40yUSXwdkWN851BHCq6uiDhleh7A4+0yIBS+IUAqZVY=
|
||||||
github.com/aliyun/alibabacloud-oss-go-sdk-v2 v1.5.2/go.mod h1:FTzydeQVmR24FI0D6XWUOMKckjXehM/jgMn1xC+DA9M=
|
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=
|
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-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 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||||
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
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 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
|
||||||
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
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=
|
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
|
|||||||
@@ -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
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 5.1 KiB |
Vendored
+42
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user