package main import ( "context" "crypto/md5" "encoding/base64" "encoding/hex" "fmt" "io" "net/http" "os" "os/exec" "path/filepath" "regexp" "runtime" "strings" "sync" "time" wr "github.com/wailsapp/wails/v2/pkg/runtime" ) // appVersion 桌面端版本号(与移动端 1.3.x 功能对齐) const appVersion = "1.3.6" // appVersionCode 桌面端版本代码。 // 服务端 api.php?action=check&v= 用它判断是否需要更新, // 因此必须与发布时 update.json 的 versionCode 保持同一套编号(与移动端同源)。 const appVersionCode = 136 const desktopUA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36 StarmusicDesktop/1.3.6" // App 是暴露给前端的 Wails 绑定对象。 // 所有方法都会被生成为 window.go.main.App.Xxx,前端通过 js-bridge 调用。 type App struct { ctx context.Context client *http.Client mu sync.Mutex minimode bool restore [4]int // width, height, x, y // windowShown 是否已经主动显示过窗口。 // runtime.WindowShow 会顺带 SetForegroundWindow/SetFocus, // 重复调用会抢走用户当前焦点,因此只允许生效一次。 windowShown bool } // NewApp 创建 App 实例 func NewApp() *App { return &App{ client: &http.Client{ Transport: &http.Transport{ Proxy: http.ProxyFromEnvironment, MaxIdleConns: 64, MaxIdleConnsPerHost: 12, IdleConnTimeout: 60 * time.Second, }, }, } } func (a *App) startup(ctx context.Context) { a.ctx = ctx wr.LogInfo(ctx, "starmusic-pc starting, go="+runtime.Version()) // 兜底显示窗口。 // 窗口是以 StartHidden 方式创建的,只有显式 Show 才会出现; // 万一尺寸校准环节出错,也不能让用户面对"进程启动了但没有界面"。 go func() { time.Sleep(windowShowFallbackDelay) a.showWindowOnce() }() } func (a *App) domReady(ctx context.Context) { // 先按屏幕可用区域校准尺寸,再显示窗口, // 避免"先按大尺寸弹出来、再缩回去"的闪动 a.fitWindowToScreen() a.showWindowOnce() wr.EventsEmit(ctx, "app:ready", map[string]any{"version": appVersion}) } // showWindowOnce 幂等地显示窗口。 // 只有第一次调用真正生效:runtime.WindowShow 内部会 SetForegroundWindow, // 重复调用会把焦点从用户正在使用的其它程序抢回来。 func (a *App) showWindowOnce() { a.mu.Lock() shown := a.windowShown a.windowShown = true a.mu.Unlock() if shown { return } wr.WindowShow(a.ctx) } func (a *App) shutdown(ctx context.Context) { wr.LogInfo(ctx, "starmusic-pc shutdown") } // ============================================================ // 启动窗口尺寸 // ============================================================ // 窗口尺寸,单位是逻辑像素(与 options.App 的 Width/Height 同一坐标系)。 // // design* 只是"理想尺寸":屏幕装得下才用,装不下就按屏幕收缩。 // 原因是固定高度在小屏笔记本(1366×768、1440×900 等)上会超出屏幕, // 窗口底部(播放条)被裁到屏幕外,用户既看不到也点不着。 const ( designWindowWidth = 1280 designWindowHeight = 780 // 最小可用尺寸:小于这个宽度侧边栏 + 内容区会挤成一团 minWindowWidth = 1000 minWindowHeight = 620 // 窗口最多占屏幕的比例:给任务栏与四周视觉边距留余量 maxScreenWidthRatio = 0.94 maxScreenHeightRatio = 0.90 // 屏幕特别小时的兜底下限:宁可挤一点,也不要缩成一条缝 floorWindowWidth = 860 floorWindowHeight = 560 // 兜底显示窗口的延迟(正常路径下 domReady 会远早于此) windowShowFallbackDelay = 3 * time.Second ) // currentScreenSize 返回当前窗口所在屏幕的逻辑像素尺寸,取不到时返回 0, 0 func (a *App) currentScreenSize() (int, int) { screens, err := wr.ScreenGetAll(a.ctx) if err != nil { wr.LogError(a.ctx, "ScreenGetAll failed: "+err.Error()) return 0, 0 } if len(screens) == 0 { return 0, 0 } // 多屏时优先"当前窗口所在屏幕",其次主屏,最后退回第一个 pick := screens[0] for _, s := range screens { if s.IsCurrent { pick = s break } if s.IsPrimary { pick = s } } return pick.Size.Width, pick.Size.Height } // fitWindowToScreen 把窗口收缩到屏幕可用区域内并居中。 // // 注意最小尺寸也要跟着屏幕走:minWindowHeight 若大于可用高度, // 用户不但无法把窗口缩小,Windows 还会拒绝任何小于该值的尺寸调整, // 溢出的窗口就永远修不回来了。 func (a *App) fitWindowToScreen() { screenW, screenH := a.currentScreenSize() if screenW <= 0 || screenH <= 0 { // 取不到屏幕信息:保留设计尺寸,交给兜底逻辑显示 return } availW := int(float64(screenW) * maxScreenWidthRatio) availH := int(float64(screenH) * maxScreenHeightRatio) width := min(designWindowWidth, availW) height := min(designWindowHeight, availH) // 屏幕过小的情况下不再强行撑到下限,避免把窗口顶出屏幕 width = max(width, min(floorWindowWidth, availW)) height = max(height, min(floorWindowHeight, availH)) wr.WindowSetMinSize(a.ctx, min(minWindowWidth, width), min(minWindowHeight, height)) wr.WindowSetSize(a.ctx, width, height) wr.WindowCenter(a.ctx) wr.LogInfo(a.ctx, fmt.Sprintf("window fit screen %dx%d -> %dx%d", screenW, screenH, width, height)) } // ============================================================ // 应用信息 // ============================================================ // appDataDir 应用数据根目录:%AppData%\starmusic-pc // 说明:Windows 上 os.UserConfigDir() 即 %AppData%(Roaming)。 func appDataDir() string { if dir, err := os.UserConfigDir(); err == nil { return filepath.Join(dir, "starmusic-pc") } return filepath.Join(os.TempDir(), "starmusic-pc") } // AppInfo 应用与运行环境信息 type AppInfo struct { Version string `json:"version"` // VersionCode 版本代码,更新检查时作为 v 参数上报 VersionCode int `json:"versionCode"` Platform string `json:"platform"` Arch string `json:"arch"` OSName string `json:"osName"` // DataDir 应用数据根目录(日志 + WebView2 数据) DataDir string `json:"dataDir"` // StorageDir 业务数据的物理位置:localStorage(设置/歌单/音源脚本/背景图)都在这里 StorageDir string `json:"storageDir"` // Downloads 默认下载目录 Downloads string `json:"downloads"` ConfigDir string `json:"configDir"` HomeDir string `json:"homeDir"` BuildType string `json:"buildType"` GoVersion string `json:"goVersion"` ScreenWidth int `json:"screenWidth"` ScreenHeight int `json:"screenHeight"` } // GetAppInfo 返回应用版本、运行平台与常用目录 func (a *App) GetAppInfo() AppInfo { env := wr.Environment(a.ctx) cfg, _ := os.UserConfigDir() home, _ := os.UserHomeDir() w, h := wr.WindowGetSize(a.ctx) data := appDataDir() return AppInfo{ Version: appVersion, VersionCode: appVersionCode, Platform: env.Platform, Arch: env.Arch, OSName: runtime.GOOS, DataDir: data, StorageDir: filepath.Join(data, "webview", "EBWebView", "Default", "Local Storage", "leveldb"), Downloads: a.defaultDownloadDir(), ConfigDir: cfg, HomeDir: home, BuildType: env.BuildType, GoVersion: runtime.Version(), ScreenWidth: w, ScreenHeight: h, } } // GetDataDir 返回应用数据根目录 func (a *App) GetDataDir() string { return appDataDir() } // ============================================================ // 文件选择 / 保存 // ============================================================ // PickedFile 选中的文本文件 type PickedFile struct { Path string `json:"path"` Name string `json:"name"` Content string `json:"content"` Size int64 `json:"size"` } // PickScriptFile 打开原生对话框选择一个音源脚本(.js) func (a *App) PickScriptFile() (*PickedFile, error) { path, err := wr.OpenFileDialog(a.ctx, wr.OpenDialogOptions{ Title: "选择音源脚本文件", Filters: []wr.FileFilter{ {DisplayName: "JavaScript 音源脚本 (*.js)", Pattern: "*.js"}, {DisplayName: "文本文件 (*.txt)", Pattern: "*.txt"}, {DisplayName: "所有文件 (*.*)", Pattern: "*.*"}, }, }) if err != nil { return nil, err } return a.readTextFile(path) } // PickTextFile 打开原生对话框选择一个文本文件(歌单链接/歌词/备份) func (a *App) PickTextFile() (*PickedFile, error) { path, err := wr.OpenFileDialog(a.ctx, wr.OpenDialogOptions{ Title: "选择文件", Filters: []wr.FileFilter{ {DisplayName: "文本 / JSON (*.txt;*.json;*.lrc)", Pattern: "*.txt;*.json;*.lrc"}, {DisplayName: "所有文件 (*.*)", Pattern: "*.*"}, }, }) if err != nil { return nil, err } return a.readTextFile(path) } func (a *App) readTextFile(path string) (*PickedFile, error) { if path == "" { return nil, nil // 用户取消 } data, err := os.ReadFile(path) if err != nil { return nil, fmt.Errorf("读取文件失败:%s", err.Error()) } return &PickedFile{ Path: path, Name: filepath.Base(path), Content: string(data), Size: int64(len(data)), }, nil } // SaveTextFile 打开原生另存为对话框写入文本,返回保存路径(取消时返回空串) func (a *App) SaveTextFile(defaultName, content string) (string, error) { path, err := wr.SaveFileDialog(a.ctx, wr.SaveDialogOptions{ Title: "保存文件", DefaultFilename: defaultName, Filters: []wr.FileFilter{ {DisplayName: "文本文件 (*.txt)", Pattern: "*.txt"}, {DisplayName: "所有文件 (*.*)", Pattern: "*.*"}, }, }) if err != nil { return "", err } if path == "" { return "", nil } if err := os.WriteFile(path, []byte(content), 0o644); err != nil { return "", fmt.Errorf("写入文件失败:%s", err.Error()) } return path, nil } // SaveJSONFile 打开原生另存为对话框写入 JSON,返回保存路径(取消时返回空串) func (a *App) SaveJSONFile(defaultName, content string) (string, error) { path, err := wr.SaveFileDialog(a.ctx, wr.SaveDialogOptions{ Title: "保存数据文件", DefaultFilename: defaultName, Filters: []wr.FileFilter{ {DisplayName: "JSON 文件 (*.json)", Pattern: "*.json"}, {DisplayName: "所有文件 (*.*)", Pattern: "*.*"}, }, }) if err != nil { return "", err } if path == "" { return "", nil } if err := os.WriteFile(path, []byte(content), 0o644); err != nil { return "", fmt.Errorf("写入文件失败:%s", err.Error()) } return path, nil } // PickedImage 选中的图片(带 dataURL,便于前端 canvas 压缩后持久化) type PickedImage struct { Path string `json:"path"` Name string `json:"name"` DataURL string `json:"dataUrl"` Size int64 `json:"size"` } // PickImageFile 打开原生对话框选择一张图片并返回 base64 dataURL // // title 由调用方给出(如 uni.chooseImage 的 title),留空则用默认文案。 // 注意:参数个数必须与前端 invoke('PickImageFile', title) 一致 —— // Wails 校验实参个数,多传或漏传都会直接抛 // "error parsing arguments: received N arguments ... expected M"。 func (a *App) PickImageFile(title string) (*PickedImage, error) { if title == "" { title = "选择背景图片" } path, err := wr.OpenFileDialog(a.ctx, wr.OpenDialogOptions{ Title: title, Filters: []wr.FileFilter{ {DisplayName: "图片 (*.png;*.jpg;*.jpeg;*.webp;*.bmp;*.gif)", Pattern: "*.png;*.jpg;*.jpeg;*.webp;*.bmp;*.gif"}, {DisplayName: "所有文件 (*.*)", Pattern: "*.*"}, }, }) if err != nil { return nil, err } if path == "" { return nil, nil } data, err := os.ReadFile(path) if err != nil { return nil, fmt.Errorf("读取图片失败:%s", err.Error()) } mime := mimeByExt(path) return &PickedImage{ Path: path, Name: filepath.Base(path), DataURL: "data:" + mime + ";base64," + base64.StdEncoding.EncodeToString(data), Size: int64(len(data)), }, nil } func mimeByExt(path string) string { switch strings.ToLower(filepath.Ext(path)) { case ".png": return "image/png" case ".jpg", ".jpeg": return "image/jpeg" case ".webp": return "image/webp" case ".gif": return "image/gif" case ".bmp": return "image/bmp" case ".svg": return "image/svg+xml" default: return "application/octet-stream" } } // PickDirectory 打开原生目录选择对话框 func (a *App) PickDirectory(title string) (string, error) { if title == "" { title = "选择目录" } return wr.OpenDirectoryDialog(a.ctx, wr.OpenDialogOptions{ Title: title, }) } // ============================================================ // 下载(原生写盘 + 进度事件) // ============================================================ // DownloadResult 下载结果 type DownloadResult struct { Path string `json:"path"` Name string `json:"name"` Size int64 `json:"size"` Dir string `json:"dir"` } // DownloadFile 把远端音频直连下载到本地磁盘,过程中通过 download:progress 事件推送进度 func (a *App) DownloadFile(fileURL, filename, dir string) (*DownloadResult, error) { if strings.TrimSpace(fileURL) == "" { return nil, fmt.Errorf("下载地址为空") } ctx, cancel := context.WithTimeout(a.ctx, 30*time.Minute) defer cancel() req, err := http.NewRequestWithContext(ctx, http.MethodGet, fileURL, nil) if err != nil { return nil, fmt.Errorf("下载地址无效") } req.Header.Set("User-Agent", desktopUA) req.Header.Set("Referer", "https://music.163.com/") resp, err := a.client.Do(req) if err != nil { return nil, fmt.Errorf("网络请求失败:%s", err.Error()) } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { return nil, fmt.Errorf("服务器返回 HTTP %d", resp.StatusCode) } if dir == "" { dir = a.defaultDownloadDir() } if err := os.MkdirAll(dir, 0o755); err != nil { return nil, fmt.Errorf("创建下载目录失败:%s", err.Error()) } target := uniquePath(filepath.Join(dir, sanitizeFileName(filename))) out, err := os.Create(target) if err != nil { return nil, fmt.Errorf("创建文件失败:%s", err.Error()) } total := resp.ContentLength id := fmt.Sprintf("dl%d", time.Now().UnixNano()) var written int64 buf := make([]byte, 128*1024) lastEmit := time.Now().Add(-time.Second) emit := func(done bool) { pct := 0 if total > 0 { pct = int(written * 100 / total) } else if done { pct = 100 } wr.EventsEmit(a.ctx, "download:progress", map[string]any{ "id": id, "name": filename, "received": written, "total": total, "percent": pct, "done": done, }) } for { n, rerr := resp.Body.Read(buf) if n > 0 { if _, werr := out.Write(buf[:n]); werr != nil { out.Close() _ = os.Remove(target) return nil, fmt.Errorf("写入磁盘失败:%s", werr.Error()) } written += int64(n) if time.Since(lastEmit) > 100*time.Millisecond { lastEmit = time.Now() emit(false) } } if rerr == io.EOF { break } if rerr != nil { out.Close() _ = os.Remove(target) return nil, fmt.Errorf("下载中断:%s", rerr.Error()) } } _ = out.Close() emit(true) return &DownloadResult{ Path: target, Name: filepath.Base(target), Size: written, Dir: dir, }, nil } var illegalNameChars = regexp.MustCompile(`[\\/:*?"<>|\r\n\t]+`) // sanitizeFileName 清理文件名中的非法字符 func sanitizeFileName(name string) string { name = strings.TrimSpace(illegalNameChars.ReplaceAllString(name, "_")) name = strings.Join(strings.Fields(name), " ") if name == "" { name = "starmusic-" + time.Now().Format("20060102-150405") } // Windows 保留名规避 upper := strings.ToUpper(strings.TrimSuffix(name, filepath.Ext(name))) switch upper { case "CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "LPT1", "LPT2", "LPT3": name = "_" + name } runes := []rune(name) if len(runes) > 120 { ext := filepath.Ext(name) name = string(runes[:120-len([]rune(ext))]) + ext } return name } // uniquePath 目标文件已存在时追加 (1)、(2)… func uniquePath(path string) string { if _, err := os.Stat(path); os.IsNotExist(err) { return path } ext := filepath.Ext(path) base := strings.TrimSuffix(path, ext) for i := 1; i < 500; i++ { candidate := fmt.Sprintf("%s (%d)%s", base, i, ext) if _, err := os.Stat(candidate); os.IsNotExist(err) { return candidate } } return fmt.Sprintf("%s-%d%s", base, time.Now().Unix(), ext) } // defaultDownloadDir 默认下载目录:<用户>/Downloads/starmusic func (a *App) defaultDownloadDir() string { home, err := os.UserHomeDir() if err != nil { return filepath.Join(os.TempDir(), "starmusic") } dl := filepath.Join(home, "Downloads") if fi, serr := os.Stat(dl); serr == nil && fi.IsDir() { return filepath.Join(dl, "starmusic") } return filepath.Join(home, "starmusic-downloads") } // GetDefaultDownloadDir 返回默认下载目录 func (a *App) GetDefaultDownloadDir() string { return a.defaultDownloadDir() } // ShowInFolder 在系统文件管理器中定位文件 func (a *App) ShowInFolder(path string) error { if strings.TrimSpace(path) == "" { return fmt.Errorf("路径为空") } abs, err := filepath.Abs(path) if err != nil { abs = path } switch runtime.GOOS { case "windows": return exec.Command("explorer", "/select,"+strings.ReplaceAll(abs, "/", "\\")).Start() case "darwin": return exec.Command("open", "-R", abs).Start() default: dir := abs if fi, serr := os.Stat(abs); serr == nil && !fi.IsDir() { dir = filepath.Dir(abs) } return exec.Command("xdg-open", dir).Start() } } // OpenPath 用系统默认程序打开文件或目录 func (a *App) OpenPath(path string) error { if strings.TrimSpace(path) == "" { return fmt.Errorf("路径为空") } switch runtime.GOOS { case "windows": return exec.Command("cmd", "/c", "start", "", path).Start() case "darwin": return exec.Command("open", path).Start() default: return exec.Command("xdg-open", path).Start() } } // OpenExternalURL 用系统默认浏览器打开链接 func (a *App) OpenExternalURL(url string) { if strings.TrimSpace(url) == "" { return } wr.BrowserOpenURL(a.ctx, url) } // OpenConfigFolder 打开应用数据目录(%AppData%\starmusic-pc) // 该目录同时包含:client.log(客户端日志)与 webview\(WebView2 数据,业务配置都在里面) func (a *App) OpenConfigFolder() error { target := appDataDir() _ = os.MkdirAll(target, 0o755) return a.OpenPath(target) } // OpenStorageFolder 直接定位到 localStorage 所在目录(用于备份/排障) func (a *App) OpenStorageFolder() error { target := filepath.Join(appDataDir(), "webview", "EBWebView", "Default", "Local Storage", "leveldb") if _, err := os.Stat(target); os.IsNotExist(err) { // 尚未首次落下数据时回退到数据根目录,避免报错 return a.OpenConfigFolder() } return a.OpenPath(target) } // ============================================================ // 剪贴板 // ============================================================ // SetClipboard 写入系统剪贴板 func (a *App) SetClipboard(text string) bool { return wr.ClipboardSetText(a.ctx, text) == nil } // GetClipboard 读取系统剪贴板 func (a *App) GetClipboard() string { text, err := wr.ClipboardGetText(a.ctx) if err != nil { return "" } return text } // ============================================================ // HTTP 代理(关键) // // WebView 内直接 fetch 第三方音乐接口会被 CORS 拦截(响应无 // Access-Control-Allow-Origin)。桌面版把请求交给 Go 侧发出, // 既绕开 CORS,也顺带解决 Referer / User-Agent 伪装问题。 // ============================================================ // HttpRequestOptions 前端发起的 HTTP 请求描述 type HttpRequestOptions struct { Method string `json:"method"` URL string `json:"url"` Headers map[string]string `json:"headers"` Body string `json:"body"` TimeoutMs int `json:"timeoutMs"` Binary bool `json:"binary"` } // HttpResponse 代理请求的返回结果(Binary 为 true 时 Body 是 base64) type HttpResponse struct { StatusCode int `json:"statusCode"` Header map[string]string `json:"header"` Body string `json:"body"` Binary bool `json:"binary"` URL string `json:"url"` } // HttpRequest 由 Go 侧代发 HTTP 请求,绕开浏览器同源策略 func (a *App) HttpRequest(opts HttpRequestOptions) (*HttpResponse, error) { if strings.TrimSpace(opts.URL) == "" { return nil, fmt.Errorf("请求地址为空") } timeout := time.Duration(opts.TimeoutMs) * time.Millisecond if timeout <= 0 || timeout > 120*time.Second { timeout = 20 * time.Second } ctx, cancel := context.WithTimeout(a.ctx, timeout) defer cancel() method := strings.ToUpper(strings.TrimSpace(opts.Method)) if method == "" { method = http.MethodGet } var bodyReader io.Reader if opts.Body != "" && method != http.MethodGet && method != http.MethodHead { bodyReader = strings.NewReader(opts.Body) } req, err := http.NewRequestWithContext(ctx, method, opts.URL, bodyReader) if err != nil { return nil, fmt.Errorf("请求地址无效") } // 先写入调用方给出的头,再补齐默认值(不覆盖调用方设置) for k, v := range opts.Headers { req.Header.Set(k, v) } if req.Header.Get("User-Agent") == "" { req.Header.Set("User-Agent", desktopUA) } if req.Header.Get("Accept") == "" { req.Header.Set("Accept", "*/*") } if req.Header.Get("Accept-Language") == "" { req.Header.Set("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8") } resp, err := a.client.Do(req) if err != nil { if ctx.Err() == context.DeadlineExceeded { return nil, fmt.Errorf("请求超时") } return nil, fmt.Errorf("网络请求失败:%s", err.Error()) } defer resp.Body.Close() header := make(map[string]string, len(resp.Header)) for k, v := range resp.Header { if len(v) > 0 { header[strings.ToLower(k)] = v[0] } } out := &HttpResponse{ StatusCode: resp.StatusCode, Header: header, Binary: opts.Binary, URL: resp.Request.URL.String(), } // 限制响应体大小,避免异常接口把内存打满(音频直链不走这里) const maxBody = 12 << 20 // 12MB raw, err := io.ReadAll(io.LimitReader(resp.Body, maxBody)) if err != nil { return nil, fmt.Errorf("读取响应失败:%s", err.Error()) } if opts.Binary { out.Body = base64.StdEncoding.EncodeToString(raw) } else { out.Body = string(raw) } return out, nil } // ============================================================ // 窗口控制 // ============================================================ // WindowMinimise 最小化窗口 func (a *App) WindowMinimise() { wr.WindowMinimise(a.ctx) } // WindowToggleMaximise 最大化/还原窗口 func (a *App) WindowToggleMaximise() { wr.WindowToggleMaximise(a.ctx) } // WindowClose 关闭窗口(退出应用) func (a *App) WindowClose() { wr.Quit(a.ctx) } // WindowIsMaximised 是否处于最大化状态 func (a *App) WindowIsMaximised() bool { return wr.WindowIsMaximised(a.ctx) } // ToggleFullscreen 切换全屏 func (a *App) ToggleFullscreen() bool { if wr.WindowIsFullscreen(a.ctx) { wr.WindowUnfullscreen(a.ctx) return false } wr.WindowFullscreen(a.ctx) return true } // SetAlwaysOnTop 设置窗口置顶 func (a *App) SetAlwaysOnTop(on bool) { wr.WindowSetAlwaysOnTop(a.ctx, on) } // SetMiniMode 切换迷你播放器模式(小窗 + 置顶),返回切换后的状态 func (a *App) SetMiniMode(on bool) bool { a.mu.Lock() defer a.mu.Unlock() if on && !a.minimode { w, h := wr.WindowGetSize(a.ctx) x, y := wr.WindowGetPosition(a.ctx) a.restore = [4]int{w, h, x, y} a.minimode = true wr.WindowSetAlwaysOnTop(a.ctx, true) wr.WindowSetSize(a.ctx, 460, 148) wr.WindowSetPosition(a.ctx, x+w-460, y+h-148) return true } if !on && a.minimode { a.minimode = false wr.WindowSetAlwaysOnTop(a.ctx, false) r := a.restore if r[0] > 0 && r[1] > 0 { wr.WindowSetSize(a.ctx, r[0], r[1]) wr.WindowSetPosition(a.ctx, r[2], r[3]) } else { wr.WindowSetSize(a.ctx, 1360, 880) wr.WindowCenter(a.ctx) } return false } return a.minimode } // IsMiniMode 当前是否处于迷你模式 func (a *App) IsMiniMode() bool { a.mu.Lock() defer a.mu.Unlock() return a.minimode } // SetWindowTitle 设置窗口标题 func (a *App) SetWindowTitle(title string) { wr.WindowSetTitle(a.ctx, title) } // ============================================================ // 本地日志(便于排查) // ============================================================ // WriteClientLog 把前端日志追加写入应用数据目录,便于排查线上问题 func (a *App) WriteClientLog(level, message string) { target := appDataDir() if err := os.MkdirAll(target, 0o755); err != nil { return } f, err := os.OpenFile(filepath.Join(target, "client.log"), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) if err != nil { return } defer f.Close() _, _ = fmt.Fprintf(f, "[%s][%s] %s\n", time.Now().Format("2006-01-02 15:04:05"), level, message) } // ============================================================ // 在线更新(应用内下载 + 安装) // // 与新版 Go 后端(backend/handler_update.go)配合: // GET api.php?action=check&v=&platform=pc // → { code:0, data:{ version, versionCode, log, force, size, md5, fileName, url } } // GET api.php?action=download&platform=pc → 安装包文件流(支持 Range) // 旧版 PHP 后端忽略 platform 参数,行为与安卓端一致,不会报错。 // ============================================================ // UpdateDownloadResult 更新包下载结果 type UpdateDownloadResult struct { Path string `json:"path"` Name string `json:"name"` Size int64 `json:"size"` Dir string `json:"dir"` // Md5 实际校验值(服务端未提供 md5 时前端可展示) Md5 string `json:"md5"` } // updateDir 更新包存放目录:%AppData%\starmusic-pc\updates // 放在应用数据目录下,避免污染用户下载目录;安装完成后由用户自行清理。 func (a *App) updateDir() string { return filepath.Join(appDataDir(), "updates") } // DownloadUpdate 下载更新安装包到本地并校验 MD5,过程中通过 update:progress 事件推送进度。 // expectedMd5 为空时跳过校验(旧版后端不返回 md5)。 func (a *App) DownloadUpdate(fileURL, filename, expectedMd5 string) (*UpdateDownloadResult, error) { if strings.TrimSpace(fileURL) == "" { return nil, fmt.Errorf("更新地址为空") } // 安装包可能上百 MB,给足超时;仍受 context 约束,避免网络挂死 ctx, cancel := context.WithTimeout(a.ctx, 60*time.Minute) defer cancel() req, err := http.NewRequestWithContext(ctx, http.MethodGet, fileURL, nil) if err != nil { return nil, fmt.Errorf("更新地址无效") } req.Header.Set("User-Agent", desktopUA) req.Header.Set("Accept", "application/octet-stream") resp, err := a.client.Do(req) if err != nil { if ctx.Err() == context.DeadlineExceeded { return nil, fmt.Errorf("下载超时,请重试") } return nil, fmt.Errorf("网络请求失败:%s", err.Error()) } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { return nil, fmt.Errorf("服务器返回 HTTP %d", resp.StatusCode) } dir := a.updateDir() if err := os.MkdirAll(dir, 0o755); err != nil { return nil, fmt.Errorf("创建更新目录失败:%s", err.Error()) } // 固定用同一个文件名:重复下载直接覆盖,不产生 (1)(2) 副本 name := sanitizeFileName(filename) target := filepath.Join(dir, name) out, err := os.Create(target) if err != nil { return nil, fmt.Errorf("创建文件失败:%s", err.Error()) } // 失败路径统一清理半成品,避免下次误当成完整安装包 cleanup := func() { _ = out.Close() _ = os.Remove(target) } total := resp.ContentLength sum := md5.New() var written int64 buf := make([]byte, 256*1024) lastEmit := time.Now().Add(-time.Second) emit := func(done bool) { percent := 0 if total > 0 { percent = int(written * 100 / total) } else if done { percent = 100 } wr.EventsEmit(a.ctx, "update:progress", map[string]any{ "name": name, "received": written, "total": total, "percent": percent, "done": done, }) } for { n, rerr := resp.Body.Read(buf) if n > 0 { if _, werr := out.Write(buf[:n]); werr != nil { cleanup() return nil, fmt.Errorf("写入磁盘失败:%s", werr.Error()) } _, _ = sum.Write(buf[:n]) written += int64(n) if time.Since(lastEmit) > 150*time.Millisecond { lastEmit = time.Now() emit(false) } } if rerr == io.EOF { break } if rerr != nil { cleanup() if ctx.Err() == context.DeadlineExceeded { return nil, fmt.Errorf("下载超时,请重试") } return nil, fmt.Errorf("下载中断:%s", rerr.Error()) } } if err := out.Close(); err != nil { _ = os.Remove(target) return nil, fmt.Errorf("保存文件失败:%s", err.Error()) } got := hex.EncodeToString(sum.Sum(nil)) if expectedMd5 != "" && !strings.EqualFold(got, expectedMd5) { _ = os.Remove(target) return nil, fmt.Errorf("安装包校验失败(MD5 不一致),请重新下载") } emit(true) return &UpdateDownloadResult{Path: target, Name: name, Size: written, Dir: dir, Md5: got}, nil } // ApplyUpdate 启动已下载的安装程序,并退出当前进程以便安装包覆盖文件。 // 安装包(build/windows/installer/project.nsi)自身会 taskkill starmusic.exe, // 这里主动退出只是为了让覆盖更早发生,避免"文件被占用"。 func (a *App) ApplyUpdate(installerPath string) error { path := strings.TrimSpace(installerPath) if path == "" { return fmt.Errorf("安装包路径为空") } abs, err := filepath.Abs(path) if err != nil { return fmt.Errorf("安装包路径无效") } // 只允许运行更新目录内的文件,避免被前端任意路径调用 dir, err := filepath.Abs(a.updateDir()) if err != nil || !strings.HasPrefix(abs, dir+string(os.PathSeparator)) { return fmt.Errorf("安装包不在更新目录中,无法自动安装") } if st, statErr := os.Stat(abs); statErr != nil || st.IsDir() { return fmt.Errorf("安装包不存在,请重新下载") } var cmd *exec.Cmd switch strings.ToLower(filepath.Ext(abs)) { case ".exe": cmd = exec.Command(abs) case ".msi": cmd = exec.Command("msiexec", "/i", abs) default: return fmt.Errorf("该安装包无法自动安装,请在文件夹中手动处理") } if err := cmd.Start(); err != nil { return fmt.Errorf("无法启动安装程序:%s", err.Error()) } // 子进程独立于本进程存活(Windows 下父进程退出不会连带结束子进程) go func() { _ = cmd.Wait() }() // 留一点时间让前端提示先渲染出来,再退出 go func() { time.Sleep(1200 * time.Millisecond) wr.Quit(a.ctx) }() return nil }