starmusic-pc
1package main
2
3import (
4 "context"
5 "crypto/md5"
6 "encoding/base64"
7 "encoding/hex"
8 "fmt"
9 "io"
10 "net/http"
11 "os"
12 "os/exec"
13 "path/filepath"
14 "regexp"
15 "runtime"
16 "strings"
17 "sync"
18 "time"
19
20 wr "github.com/wailsapp/wails/v2/pkg/runtime"
21)
22
23// appVersion 桌面端版本号(与移动端 1.3.x 功能对齐)
24const appVersion = "1.3.6"
25
26// appVersionCode 桌面端版本代码。
27// 服务端 api.php?action=check&v=<versionCode> 用它判断是否需要更新,
28// 因此必须与发布时 update.json 的 versionCode 保持同一套编号(与移动端同源)。
29const appVersionCode = 136
30
31const desktopUA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " +
32 "(KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36 StarmusicDesktop/1.3.6"
33
34// App 是暴露给前端的 Wails 绑定对象。
35// 所有方法都会被生成为 window.go.main.App.Xxx,前端通过 js-bridge 调用。
36type App struct {
37 ctx context.Context
38 client *http.Client
39
40 mu sync.Mutex
41 minimode bool
42 restore [4]int // width, height, x, y
43 // windowShown 是否已经主动显示过窗口。
44 // runtime.WindowShow 会顺带 SetForegroundWindow/SetFocus,
45 // 重复调用会抢走用户当前焦点,因此只允许生效一次。
46 windowShown bool
47}
48
49// NewApp 创建 App 实例
50func NewApp() *App {
51 return &App{
52 client: &http.Client{
53 Transport: &http.Transport{
54 Proxy: http.ProxyFromEnvironment,
55 MaxIdleConns: 64,
56 MaxIdleConnsPerHost: 12,
57 IdleConnTimeout: 60 * time.Second,
58 },
59 },
60 }
61}
62
63func (a *App) startup(ctx context.Context) {
64 a.ctx = ctx
65 wr.LogInfo(ctx, "starmusic-pc starting, go="+runtime.Version())
66
67 // 兜底显示窗口。
68 // 窗口是以 StartHidden 方式创建的,只有显式 Show 才会出现;
69 // 万一尺寸校准环节出错,也不能让用户面对"进程启动了但没有界面"。
70 go func() {
71 time.Sleep(windowShowFallbackDelay)
72 a.showWindowOnce()
73 }()
74}
75
76func (a *App) domReady(ctx context.Context) {
77 // 先按屏幕可用区域校准尺寸,再显示窗口,
78 // 避免"先按大尺寸弹出来、再缩回去"的闪动
79 a.fitWindowToScreen()
80 a.showWindowOnce()
81 wr.EventsEmit(ctx, "app:ready", map[string]any{"version": appVersion})
82}
83
84// showWindowOnce 幂等地显示窗口。
85// 只有第一次调用真正生效:runtime.WindowShow 内部会 SetForegroundWindow,
86// 重复调用会把焦点从用户正在使用的其它程序抢回来。
87func (a *App) showWindowOnce() {
88 a.mu.Lock()
89 shown := a.windowShown
90 a.windowShown = true
91 a.mu.Unlock()
92 if shown {
93 return
94 }
95 wr.WindowShow(a.ctx)
96}
97
98func (a *App) shutdown(ctx context.Context) {
99 wr.LogInfo(ctx, "starmusic-pc shutdown")
100}
101
102// ============================================================
103// 启动窗口尺寸
104// ============================================================
105
106// 窗口尺寸,单位是逻辑像素(与 options.App 的 Width/Height 同一坐标系)。
107//
108// design* 只是"理想尺寸":屏幕装得下才用,装不下就按屏幕收缩。
109// 原因是固定高度在小屏笔记本(1366×768、1440×900 等)上会超出屏幕,
110// 窗口底部(播放条)被裁到屏幕外,用户既看不到也点不着。
111const (
112 designWindowWidth = 1280
113 designWindowHeight = 780
114
115 // 最小可用尺寸:小于这个宽度侧边栏 + 内容区会挤成一团
116 minWindowWidth = 1000
117 minWindowHeight = 620
118
119 // 窗口最多占屏幕的比例:给任务栏与四周视觉边距留余量
120 maxScreenWidthRatio = 0.94
121 maxScreenHeightRatio = 0.90
122
123 // 屏幕特别小时的兜底下限:宁可挤一点,也不要缩成一条缝
124 floorWindowWidth = 860
125 floorWindowHeight = 560
126
127 // 兜底显示窗口的延迟(正常路径下 domReady 会远早于此)
128 windowShowFallbackDelay = 3 * time.Second
129)
130
131// currentScreenSize 返回当前窗口所在屏幕的逻辑像素尺寸,取不到时返回 0, 0
132func (a *App) currentScreenSize() (int, int) {
133 screens, err := wr.ScreenGetAll(a.ctx)
134 if err != nil {
135 wr.LogError(a.ctx, "ScreenGetAll failed: "+err.Error())
136 return 0, 0
137 }
138 if len(screens) == 0 {
139 return 0, 0
140 }
141 // 多屏时优先"当前窗口所在屏幕",其次主屏,最后退回第一个
142 pick := screens[0]
143 for _, s := range screens {
144 if s.IsCurrent {
145 pick = s
146 break
147 }
148 if s.IsPrimary {
149 pick = s
150 }
151 }
152 return pick.Size.Width, pick.Size.Height
153}
154
155// fitWindowToScreen 把窗口收缩到屏幕可用区域内并居中。
156//
157// 注意最小尺寸也要跟着屏幕走:minWindowHeight 若大于可用高度,
158// 用户不但无法把窗口缩小,Windows 还会拒绝任何小于该值的尺寸调整,
159// 溢出的窗口就永远修不回来了。
160func (a *App) fitWindowToScreen() {
161 screenW, screenH := a.currentScreenSize()
162 if screenW <= 0 || screenH <= 0 {
163 // 取不到屏幕信息:保留设计尺寸,交给兜底逻辑显示
164 return
165 }
166
167 availW := int(float64(screenW) * maxScreenWidthRatio)
168 availH := int(float64(screenH) * maxScreenHeightRatio)
169
170 width := min(designWindowWidth, availW)
171 height := min(designWindowHeight, availH)
172 // 屏幕过小的情况下不再强行撑到下限,避免把窗口顶出屏幕
173 width = max(width, min(floorWindowWidth, availW))
174 height = max(height, min(floorWindowHeight, availH))
175
176 wr.WindowSetMinSize(a.ctx, min(minWindowWidth, width), min(minWindowHeight, height))
177 wr.WindowSetSize(a.ctx, width, height)
178 wr.WindowCenter(a.ctx)
179
180 wr.LogInfo(a.ctx, fmt.Sprintf("window fit screen %dx%d -> %dx%d", screenW, screenH, width, height))
181}
182
183// ============================================================
184// 应用信息
185// ============================================================
186
187// appDataDir 应用数据根目录:%AppData%\starmusic-pc
188// 说明:Windows 上 os.UserConfigDir() 即 %AppData%(Roaming)。
189func appDataDir() string {
190 if dir, err := os.UserConfigDir(); err == nil {
191 return filepath.Join(dir, "starmusic-pc")
192 }
193 return filepath.Join(os.TempDir(), "starmusic-pc")
194}
195
196// AppInfo 应用与运行环境信息
197type AppInfo struct {
198 Version string `json:"version"`
199 // VersionCode 版本代码,更新检查时作为 v 参数上报
200 VersionCode int `json:"versionCode"`
201 Platform string `json:"platform"`
202 Arch string `json:"arch"`
203 OSName string `json:"osName"`
204 // DataDir 应用数据根目录(日志 + WebView2 数据)
205 DataDir string `json:"dataDir"`
206 // StorageDir 业务数据的物理位置:localStorage(设置/歌单/音源脚本/背景图)都在这里
207 StorageDir string `json:"storageDir"`
208 // Downloads 默认下载目录
209 Downloads string `json:"downloads"`
210 ConfigDir string `json:"configDir"`
211 HomeDir string `json:"homeDir"`
212 BuildType string `json:"buildType"`
213 GoVersion string `json:"goVersion"`
214 ScreenWidth int `json:"screenWidth"`
215 ScreenHeight int `json:"screenHeight"`
216}
217
218// GetAppInfo 返回应用版本、运行平台与常用目录
219func (a *App) GetAppInfo() AppInfo {
220 env := wr.Environment(a.ctx)
221 cfg, _ := os.UserConfigDir()
222 home, _ := os.UserHomeDir()
223 w, h := wr.WindowGetSize(a.ctx)
224 data := appDataDir()
225 return AppInfo{
226 Version: appVersion,
227 VersionCode: appVersionCode,
228 Platform: env.Platform,
229 Arch: env.Arch,
230 OSName: runtime.GOOS,
231 DataDir: data,
232 StorageDir: filepath.Join(data, "webview", "EBWebView", "Default", "Local Storage", "leveldb"),
233 Downloads: a.defaultDownloadDir(),
234 ConfigDir: cfg,
235 HomeDir: home,
236 BuildType: env.BuildType,
237 GoVersion: runtime.Version(),
238 ScreenWidth: w,
239 ScreenHeight: h,
240 }
241}
242
243// GetDataDir 返回应用数据根目录
244func (a *App) GetDataDir() string { return appDataDir() }
245
246// ============================================================
247// 文件选择 / 保存
248// ============================================================
249
250// PickedFile 选中的文本文件
251type PickedFile struct {
252 Path string `json:"path"`
253 Name string `json:"name"`
254 Content string `json:"content"`
255 Size int64 `json:"size"`
256}
257
258// PickScriptFile 打开原生对话框选择一个音源脚本(.js)
259func (a *App) PickScriptFile() (*PickedFile, error) {
260 path, err := wr.OpenFileDialog(a.ctx, wr.OpenDialogOptions{
261 Title: "选择音源脚本文件",
262 Filters: []wr.FileFilter{
263 {DisplayName: "JavaScript 音源脚本 (*.js)", Pattern: "*.js"},
264 {DisplayName: "文本文件 (*.txt)", Pattern: "*.txt"},
265 {DisplayName: "所有文件 (*.*)", Pattern: "*.*"},
266 },
267 })
268 if err != nil {
269 return nil, err
270 }
271 return a.readTextFile(path)
272}
273
274// PickTextFile 打开原生对话框选择一个文本文件(歌单链接/歌词/备份)
275func (a *App) PickTextFile() (*PickedFile, error) {
276 path, err := wr.OpenFileDialog(a.ctx, wr.OpenDialogOptions{
277 Title: "选择文件",
278 Filters: []wr.FileFilter{
279 {DisplayName: "文本 / JSON (*.txt;*.json;*.lrc)", Pattern: "*.txt;*.json;*.lrc"},
280 {DisplayName: "所有文件 (*.*)", Pattern: "*.*"},
281 },
282 })
283 if err != nil {
284 return nil, err
285 }
286 return a.readTextFile(path)
287}
288
289func (a *App) readTextFile(path string) (*PickedFile, error) {
290 if path == "" {
291 return nil, nil // 用户取消
292 }
293 data, err := os.ReadFile(path)
294 if err != nil {
295 return nil, fmt.Errorf("读取文件失败:%s", err.Error())
296 }
297 return &PickedFile{
298 Path: path,
299 Name: filepath.Base(path),
300 Content: string(data),
301 Size: int64(len(data)),
302 }, nil
303}
304
305// SaveTextFile 打开原生另存为对话框写入文本,返回保存路径(取消时返回空串)
306func (a *App) SaveTextFile(defaultName, content string) (string, error) {
307 path, err := wr.SaveFileDialog(a.ctx, wr.SaveDialogOptions{
308 Title: "保存文件",
309 DefaultFilename: defaultName,
310 Filters: []wr.FileFilter{
311 {DisplayName: "文本文件 (*.txt)", Pattern: "*.txt"},
312 {DisplayName: "所有文件 (*.*)", Pattern: "*.*"},
313 },
314 })
315 if err != nil {
316 return "", err
317 }
318 if path == "" {
319 return "", nil
320 }
321 if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
322 return "", fmt.Errorf("写入文件失败:%s", err.Error())
323 }
324 return path, nil
325}
326
327// SaveJSONFile 打开原生另存为对话框写入 JSON,返回保存路径(取消时返回空串)
328func (a *App) SaveJSONFile(defaultName, content string) (string, error) {
329 path, err := wr.SaveFileDialog(a.ctx, wr.SaveDialogOptions{
330 Title: "保存数据文件",
331 DefaultFilename: defaultName,
332 Filters: []wr.FileFilter{
333 {DisplayName: "JSON 文件 (*.json)", Pattern: "*.json"},
334 {DisplayName: "所有文件 (*.*)", Pattern: "*.*"},
335 },
336 })
337 if err != nil {
338 return "", err
339 }
340 if path == "" {
341 return "", nil
342 }
343 if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
344 return "", fmt.Errorf("写入文件失败:%s", err.Error())
345 }
346 return path, nil
347}
348
349// PickedImage 选中的图片(带 dataURL,便于前端 canvas 压缩后持久化)
350type PickedImage struct {
351 Path string `json:"path"`
352 Name string `json:"name"`
353 DataURL string `json:"dataUrl"`
354 Size int64 `json:"size"`
355}
356
357// PickImageFile 打开原生对话框选择一张图片并返回 base64 dataURL
358//
359// title 由调用方给出(如 uni.chooseImage 的 title),留空则用默认文案。
360// 注意:参数个数必须与前端 invoke('PickImageFile', title) 一致 ——
361// Wails 校验实参个数,多传或漏传都会直接抛
362// "error parsing arguments: received N arguments ... expected M"。
363func (a *App) PickImageFile(title string) (*PickedImage, error) {
364 if title == "" {
365 title = "选择背景图片"
366 }
367 path, err := wr.OpenFileDialog(a.ctx, wr.OpenDialogOptions{
368 Title: title,
369 Filters: []wr.FileFilter{
370 {DisplayName: "图片 (*.png;*.jpg;*.jpeg;*.webp;*.bmp;*.gif)", Pattern: "*.png;*.jpg;*.jpeg;*.webp;*.bmp;*.gif"},
371 {DisplayName: "所有文件 (*.*)", Pattern: "*.*"},
372 },
373 })
374 if err != nil {
375 return nil, err
376 }
377 if path == "" {
378 return nil, nil
379 }
380 data, err := os.ReadFile(path)
381 if err != nil {
382 return nil, fmt.Errorf("读取图片失败:%s", err.Error())
383 }
384 mime := mimeByExt(path)
385 return &PickedImage{
386 Path: path,
387 Name: filepath.Base(path),
388 DataURL: "data:" + mime + ";base64," + base64.StdEncoding.EncodeToString(data),
389 Size: int64(len(data)),
390 }, nil
391}
392
393func mimeByExt(path string) string {
394 switch strings.ToLower(filepath.Ext(path)) {
395 case ".png":
396 return "image/png"
397 case ".jpg", ".jpeg":
398 return "image/jpeg"
399 case ".webp":
400 return "image/webp"
401 case ".gif":
402 return "image/gif"
403 case ".bmp":
404 return "image/bmp"
405 case ".svg":
406 return "image/svg+xml"
407 default:
408 return "application/octet-stream"
409 }
410}
411
412// PickDirectory 打开原生目录选择对话框
413func (a *App) PickDirectory(title string) (string, error) {
414 if title == "" {
415 title = "选择目录"
416 }
417 return wr.OpenDirectoryDialog(a.ctx, wr.OpenDialogOptions{
418 Title: title,
419 })
420}
421
422// ============================================================
423// 下载(原生写盘 + 进度事件)
424// ============================================================
425
426// DownloadResult 下载结果
427type DownloadResult struct {
428 Path string `json:"path"`
429 Name string `json:"name"`
430 Size int64 `json:"size"`
431 Dir string `json:"dir"`
432}
433
434// DownloadFile 把远端音频直连下载到本地磁盘,过程中通过 download:progress 事件推送进度
435func (a *App) DownloadFile(fileURL, filename, dir string) (*DownloadResult, error) {
436 if strings.TrimSpace(fileURL) == "" {
437 return nil, fmt.Errorf("下载地址为空")
438 }
439 ctx, cancel := context.WithTimeout(a.ctx, 30*time.Minute)
440 defer cancel()
441
442 req, err := http.NewRequestWithContext(ctx, http.MethodGet, fileURL, nil)
443 if err != nil {
444 return nil, fmt.Errorf("下载地址无效")
445 }
446 req.Header.Set("User-Agent", desktopUA)
447 req.Header.Set("Referer", "https://music.163.com/")
448
449 resp, err := a.client.Do(req)
450 if err != nil {
451 return nil, fmt.Errorf("网络请求失败:%s", err.Error())
452 }
453 defer resp.Body.Close()
454 if resp.StatusCode < 200 || resp.StatusCode >= 300 {
455 return nil, fmt.Errorf("服务器返回 HTTP %d", resp.StatusCode)
456 }
457
458 if dir == "" {
459 dir = a.defaultDownloadDir()
460 }
461 if err := os.MkdirAll(dir, 0o755); err != nil {
462 return nil, fmt.Errorf("创建下载目录失败:%s", err.Error())
463 }
464 target := uniquePath(filepath.Join(dir, sanitizeFileName(filename)))
465
466 out, err := os.Create(target)
467 if err != nil {
468 return nil, fmt.Errorf("创建文件失败:%s", err.Error())
469 }
470
471 total := resp.ContentLength
472 id := fmt.Sprintf("dl%d", time.Now().UnixNano())
473 var written int64
474 buf := make([]byte, 128*1024)
475 lastEmit := time.Now().Add(-time.Second)
476
477 emit := func(done bool) {
478 pct := 0
479 if total > 0 {
480 pct = int(written * 100 / total)
481 } else if done {
482 pct = 100
483 }
484 wr.EventsEmit(a.ctx, "download:progress", map[string]any{
485 "id": id, "name": filename, "received": written,
486 "total": total, "percent": pct, "done": done,
487 })
488 }
489
490 for {
491 n, rerr := resp.Body.Read(buf)
492 if n > 0 {
493 if _, werr := out.Write(buf[:n]); werr != nil {
494 out.Close()
495 _ = os.Remove(target)
496 return nil, fmt.Errorf("写入磁盘失败:%s", werr.Error())
497 }
498 written += int64(n)
499 if time.Since(lastEmit) > 100*time.Millisecond {
500 lastEmit = time.Now()
501 emit(false)
502 }
503 }
504 if rerr == io.EOF {
505 break
506 }
507 if rerr != nil {
508 out.Close()
509 _ = os.Remove(target)
510 return nil, fmt.Errorf("下载中断:%s", rerr.Error())
511 }
512 }
513 _ = out.Close()
514 emit(true)
515
516 return &DownloadResult{
517 Path: target,
518 Name: filepath.Base(target),
519 Size: written,
520 Dir: dir,
521 }, nil
522}
523
524var illegalNameChars = regexp.MustCompile(`[\\/:*?"<>|\r\n\t]+`)
525
526// sanitizeFileName 清理文件名中的非法字符
527func sanitizeFileName(name string) string {
528 name = strings.TrimSpace(illegalNameChars.ReplaceAllString(name, "_"))
529 name = strings.Join(strings.Fields(name), " ")
530 if name == "" {
531 name = "starmusic-" + time.Now().Format("20060102-150405")
532 }
533 // Windows 保留名规避
534 upper := strings.ToUpper(strings.TrimSuffix(name, filepath.Ext(name)))
535 switch upper {
536 case "CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "LPT1", "LPT2", "LPT3":
537 name = "_" + name
538 }
539 runes := []rune(name)
540 if len(runes) > 120 {
541 ext := filepath.Ext(name)
542 name = string(runes[:120-len([]rune(ext))]) + ext
543 }
544 return name
545}
546
547// uniquePath 目标文件已存在时追加 (1)、(2)…
548func uniquePath(path string) string {
549 if _, err := os.Stat(path); os.IsNotExist(err) {
550 return path
551 }
552 ext := filepath.Ext(path)
553 base := strings.TrimSuffix(path, ext)
554 for i := 1; i < 500; i++ {
555 candidate := fmt.Sprintf("%s (%d)%s", base, i, ext)
556 if _, err := os.Stat(candidate); os.IsNotExist(err) {
557 return candidate
558 }
559 }
560 return fmt.Sprintf("%s-%d%s", base, time.Now().Unix(), ext)
561}
562
563// defaultDownloadDir 默认下载目录:<用户>/Downloads/starmusic
564func (a *App) defaultDownloadDir() string {
565 home, err := os.UserHomeDir()
566 if err != nil {
567 return filepath.Join(os.TempDir(), "starmusic")
568 }
569 dl := filepath.Join(home, "Downloads")
570 if fi, serr := os.Stat(dl); serr == nil && fi.IsDir() {
571 return filepath.Join(dl, "starmusic")
572 }
573 return filepath.Join(home, "starmusic-downloads")
574}
575
576// GetDefaultDownloadDir 返回默认下载目录
577func (a *App) GetDefaultDownloadDir() string { return a.defaultDownloadDir() }
578
579// ShowInFolder 在系统文件管理器中定位文件
580func (a *App) ShowInFolder(path string) error {
581 if strings.TrimSpace(path) == "" {
582 return fmt.Errorf("路径为空")
583 }
584 abs, err := filepath.Abs(path)
585 if err != nil {
586 abs = path
587 }
588 switch runtime.GOOS {
589 case "windows":
590 return exec.Command("explorer", "/select,"+strings.ReplaceAll(abs, "/", "\\")).Start()
591 case "darwin":
592 return exec.Command("open", "-R", abs).Start()
593 default:
594 dir := abs
595 if fi, serr := os.Stat(abs); serr == nil && !fi.IsDir() {
596 dir = filepath.Dir(abs)
597 }
598 return exec.Command("xdg-open", dir).Start()
599 }
600}
601
602// OpenPath 用系统默认程序打开文件或目录
603func (a *App) OpenPath(path string) error {
604 if strings.TrimSpace(path) == "" {
605 return fmt.Errorf("路径为空")
606 }
607 switch runtime.GOOS {
608 case "windows":
609 return exec.Command("cmd", "/c", "start", "", path).Start()
610 case "darwin":
611 return exec.Command("open", path).Start()
612 default:
613 return exec.Command("xdg-open", path).Start()
614 }
615}
616
617// OpenExternalURL 用系统默认浏览器打开链接
618func (a *App) OpenExternalURL(url string) {
619 if strings.TrimSpace(url) == "" {
620 return
621 }
622 wr.BrowserOpenURL(a.ctx, url)
623}
624
625// OpenConfigFolder 打开应用数据目录(%AppData%\starmusic-pc)
626// 该目录同时包含:client.log(客户端日志)与 webview\(WebView2 数据,业务配置都在里面)
627func (a *App) OpenConfigFolder() error {
628 target := appDataDir()
629 _ = os.MkdirAll(target, 0o755)
630 return a.OpenPath(target)
631}
632
633// OpenStorageFolder 直接定位到 localStorage 所在目录(用于备份/排障)
634func (a *App) OpenStorageFolder() error {
635 target := filepath.Join(appDataDir(), "webview", "EBWebView", "Default", "Local Storage", "leveldb")
636 if _, err := os.Stat(target); os.IsNotExist(err) {
637 // 尚未首次落下数据时回退到数据根目录,避免报错
638 return a.OpenConfigFolder()
639 }
640 return a.OpenPath(target)
641}
642
643// ============================================================
644// 剪贴板
645// ============================================================
646
647// SetClipboard 写入系统剪贴板
648func (a *App) SetClipboard(text string) bool {
649 return wr.ClipboardSetText(a.ctx, text) == nil
650}
651
652// GetClipboard 读取系统剪贴板
653func (a *App) GetClipboard() string {
654 text, err := wr.ClipboardGetText(a.ctx)
655 if err != nil {
656 return ""
657 }
658 return text
659}
660
661// ============================================================
662// HTTP 代理(关键)
663//
664// WebView 内直接 fetch 第三方音乐接口会被 CORS 拦截(响应无
665// Access-Control-Allow-Origin)。桌面版把请求交给 Go 侧发出,
666// 既绕开 CORS,也顺带解决 Referer / User-Agent 伪装问题。
667// ============================================================
668
669// HttpRequestOptions 前端发起的 HTTP 请求描述
670type HttpRequestOptions struct {
671 Method string `json:"method"`
672 URL string `json:"url"`
673 Headers map[string]string `json:"headers"`
674 Body string `json:"body"`
675 TimeoutMs int `json:"timeoutMs"`
676 Binary bool `json:"binary"`
677}
678
679// HttpResponse 代理请求的返回结果(Binary 为 true 时 Body 是 base64)
680type HttpResponse struct {
681 StatusCode int `json:"statusCode"`
682 Header map[string]string `json:"header"`
683 Body string `json:"body"`
684 Binary bool `json:"binary"`
685 URL string `json:"url"`
686}
687
688// HttpRequest 由 Go 侧代发 HTTP 请求,绕开浏览器同源策略
689func (a *App) HttpRequest(opts HttpRequestOptions) (*HttpResponse, error) {
690 if strings.TrimSpace(opts.URL) == "" {
691 return nil, fmt.Errorf("请求地址为空")
692 }
693 timeout := time.Duration(opts.TimeoutMs) * time.Millisecond
694 if timeout <= 0 || timeout > 120*time.Second {
695 timeout = 20 * time.Second
696 }
697 ctx, cancel := context.WithTimeout(a.ctx, timeout)
698 defer cancel()
699
700 method := strings.ToUpper(strings.TrimSpace(opts.Method))
701 if method == "" {
702 method = http.MethodGet
703 }
704
705 var bodyReader io.Reader
706 if opts.Body != "" && method != http.MethodGet && method != http.MethodHead {
707 bodyReader = strings.NewReader(opts.Body)
708 }
709 req, err := http.NewRequestWithContext(ctx, method, opts.URL, bodyReader)
710 if err != nil {
711 return nil, fmt.Errorf("请求地址无效")
712 }
713
714 // 先写入调用方给出的头,再补齐默认值(不覆盖调用方设置)
715 for k, v := range opts.Headers {
716 req.Header.Set(k, v)
717 }
718 if req.Header.Get("User-Agent") == "" {
719 req.Header.Set("User-Agent", desktopUA)
720 }
721 if req.Header.Get("Accept") == "" {
722 req.Header.Set("Accept", "*/*")
723 }
724 if req.Header.Get("Accept-Language") == "" {
725 req.Header.Set("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8")
726 }
727
728 resp, err := a.client.Do(req)
729 if err != nil {
730 if ctx.Err() == context.DeadlineExceeded {
731 return nil, fmt.Errorf("请求超时")
732 }
733 return nil, fmt.Errorf("网络请求失败:%s", err.Error())
734 }
735 defer resp.Body.Close()
736
737 header := make(map[string]string, len(resp.Header))
738 for k, v := range resp.Header {
739 if len(v) > 0 {
740 header[strings.ToLower(k)] = v[0]
741 }
742 }
743
744 out := &HttpResponse{
745 StatusCode: resp.StatusCode,
746 Header: header,
747 Binary: opts.Binary,
748 URL: resp.Request.URL.String(),
749 }
750
751 // 限制响应体大小,避免异常接口把内存打满(音频直链不走这里)
752 const maxBody = 12 << 20 // 12MB
753 raw, err := io.ReadAll(io.LimitReader(resp.Body, maxBody))
754 if err != nil {
755 return nil, fmt.Errorf("读取响应失败:%s", err.Error())
756 }
757 if opts.Binary {
758 out.Body = base64.StdEncoding.EncodeToString(raw)
759 } else {
760 out.Body = string(raw)
761 }
762 return out, nil
763}
764
765// ============================================================
766// 窗口控制
767// ============================================================
768
769// WindowMinimise 最小化窗口
770func (a *App) WindowMinimise() { wr.WindowMinimise(a.ctx) }
771
772// WindowToggleMaximise 最大化/还原窗口
773func (a *App) WindowToggleMaximise() { wr.WindowToggleMaximise(a.ctx) }
774
775// WindowClose 关闭窗口(退出应用)
776func (a *App) WindowClose() { wr.Quit(a.ctx) }
777
778// WindowIsMaximised 是否处于最大化状态
779func (a *App) WindowIsMaximised() bool { return wr.WindowIsMaximised(a.ctx) }
780
781// ToggleFullscreen 切换全屏
782func (a *App) ToggleFullscreen() bool {
783 if wr.WindowIsFullscreen(a.ctx) {
784 wr.WindowUnfullscreen(a.ctx)
785 return false
786 }
787 wr.WindowFullscreen(a.ctx)
788 return true
789}
790
791// SetAlwaysOnTop 设置窗口置顶
792func (a *App) SetAlwaysOnTop(on bool) {
793 wr.WindowSetAlwaysOnTop(a.ctx, on)
794}
795
796// SetMiniMode 切换迷你播放器模式(小窗 + 置顶),返回切换后的状态
797func (a *App) SetMiniMode(on bool) bool {
798 a.mu.Lock()
799 defer a.mu.Unlock()
800
801 if on && !a.minimode {
802 w, h := wr.WindowGetSize(a.ctx)
803 x, y := wr.WindowGetPosition(a.ctx)
804 a.restore = [4]int{w, h, x, y}
805 a.minimode = true
806 wr.WindowSetAlwaysOnTop(a.ctx, true)
807 wr.WindowSetSize(a.ctx, 460, 148)
808 wr.WindowSetPosition(a.ctx, x+w-460, y+h-148)
809 return true
810 }
811 if !on && a.minimode {
812 a.minimode = false
813 wr.WindowSetAlwaysOnTop(a.ctx, false)
814 r := a.restore
815 if r[0] > 0 && r[1] > 0 {
816 wr.WindowSetSize(a.ctx, r[0], r[1])
817 wr.WindowSetPosition(a.ctx, r[2], r[3])
818 } else {
819 wr.WindowSetSize(a.ctx, 1360, 880)
820 wr.WindowCenter(a.ctx)
821 }
822 return false
823 }
824 return a.minimode
825}
826
827// IsMiniMode 当前是否处于迷你模式
828func (a *App) IsMiniMode() bool {
829 a.mu.Lock()
830 defer a.mu.Unlock()
831 return a.minimode
832}
833
834// SetWindowTitle 设置窗口标题
835func (a *App) SetWindowTitle(title string) {
836 wr.WindowSetTitle(a.ctx, title)
837}
838
839// ============================================================
840// 本地日志(便于排查)
841// ============================================================
842
843// WriteClientLog 把前端日志追加写入应用数据目录,便于排查线上问题
844func (a *App) WriteClientLog(level, message string) {
845 target := appDataDir()
846 if err := os.MkdirAll(target, 0o755); err != nil {
847 return
848 }
849 f, err := os.OpenFile(filepath.Join(target, "client.log"), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
850 if err != nil {
851 return
852 }
853 defer f.Close()
854 _, _ = fmt.Fprintf(f, "[%s][%s] %s\n", time.Now().Format("2006-01-02 15:04:05"), level, message)
855}
856
857// ============================================================
858// 在线更新(应用内下载 + 安装)
859//
860// 与新版 Go 后端(backend/handler_update.go)配合:
861// GET api.php?action=check&v=<versionCode>&platform=pc
862// → { code:0, data:{ version, versionCode, log, force, size, md5, fileName, url } }
863// GET api.php?action=download&platform=pc → 安装包文件流(支持 Range)
864// 旧版 PHP 后端忽略 platform 参数,行为与安卓端一致,不会报错。
865// ============================================================
866
867// UpdateDownloadResult 更新包下载结果
868type UpdateDownloadResult struct {
869 Path string `json:"path"`
870 Name string `json:"name"`
871 Size int64 `json:"size"`
872 Dir string `json:"dir"`
873 // Md5 实际校验值(服务端未提供 md5 时前端可展示)
874 Md5 string `json:"md5"`
875}
876
877// updateDir 更新包存放目录:%AppData%\starmusic-pc\updates
878// 放在应用数据目录下,避免污染用户下载目录;安装完成后由用户自行清理。
879func (a *App) updateDir() string { return filepath.Join(appDataDir(), "updates") }
880
881// DownloadUpdate 下载更新安装包到本地并校验 MD5,过程中通过 update:progress 事件推送进度。
882// expectedMd5 为空时跳过校验(旧版后端不返回 md5)。
883func (a *App) DownloadUpdate(fileURL, filename, expectedMd5 string) (*UpdateDownloadResult, error) {
884 if strings.TrimSpace(fileURL) == "" {
885 return nil, fmt.Errorf("更新地址为空")
886 }
887 // 安装包可能上百 MB,给足超时;仍受 context 约束,避免网络挂死
888 ctx, cancel := context.WithTimeout(a.ctx, 60*time.Minute)
889 defer cancel()
890
891 req, err := http.NewRequestWithContext(ctx, http.MethodGet, fileURL, nil)
892 if err != nil {
893 return nil, fmt.Errorf("更新地址无效")
894 }
895 req.Header.Set("User-Agent", desktopUA)
896 req.Header.Set("Accept", "application/octet-stream")
897
898 resp, err := a.client.Do(req)
899 if err != nil {
900 if ctx.Err() == context.DeadlineExceeded {
901 return nil, fmt.Errorf("下载超时,请重试")
902 }
903 return nil, fmt.Errorf("网络请求失败:%s", err.Error())
904 }
905 defer resp.Body.Close()
906 if resp.StatusCode < 200 || resp.StatusCode >= 300 {
907 return nil, fmt.Errorf("服务器返回 HTTP %d", resp.StatusCode)
908 }
909
910 dir := a.updateDir()
911 if err := os.MkdirAll(dir, 0o755); err != nil {
912 return nil, fmt.Errorf("创建更新目录失败:%s", err.Error())
913 }
914 // 固定用同一个文件名:重复下载直接覆盖,不产生 (1)(2) 副本
915 name := sanitizeFileName(filename)
916 target := filepath.Join(dir, name)
917
918 out, err := os.Create(target)
919 if err != nil {
920 return nil, fmt.Errorf("创建文件失败:%s", err.Error())
921 }
922 // 失败路径统一清理半成品,避免下次误当成完整安装包
923 cleanup := func() {
924 _ = out.Close()
925 _ = os.Remove(target)
926 }
927
928 total := resp.ContentLength
929 sum := md5.New()
930 var written int64
931 buf := make([]byte, 256*1024)
932 lastEmit := time.Now().Add(-time.Second)
933
934 emit := func(done bool) {
935 percent := 0
936 if total > 0 {
937 percent = int(written * 100 / total)
938 } else if done {
939 percent = 100
940 }
941 wr.EventsEmit(a.ctx, "update:progress", map[string]any{
942 "name": name, "received": written, "total": total,
943 "percent": percent, "done": done,
944 })
945 }
946
947 for {
948 n, rerr := resp.Body.Read(buf)
949 if n > 0 {
950 if _, werr := out.Write(buf[:n]); werr != nil {
951 cleanup()
952 return nil, fmt.Errorf("写入磁盘失败:%s", werr.Error())
953 }
954 _, _ = sum.Write(buf[:n])
955 written += int64(n)
956 if time.Since(lastEmit) > 150*time.Millisecond {
957 lastEmit = time.Now()
958 emit(false)
959 }
960 }
961 if rerr == io.EOF {
962 break
963 }
964 if rerr != nil {
965 cleanup()
966 if ctx.Err() == context.DeadlineExceeded {
967 return nil, fmt.Errorf("下载超时,请重试")
968 }
969 return nil, fmt.Errorf("下载中断:%s", rerr.Error())
970 }
971 }
972 if err := out.Close(); err != nil {
973 _ = os.Remove(target)
974 return nil, fmt.Errorf("保存文件失败:%s", err.Error())
975 }
976
977 got := hex.EncodeToString(sum.Sum(nil))
978 if expectedMd5 != "" && !strings.EqualFold(got, expectedMd5) {
979 _ = os.Remove(target)
980 return nil, fmt.Errorf("安装包校验失败(MD5 不一致),请重新下载")
981 }
982 emit(true)
983
984 return &UpdateDownloadResult{Path: target, Name: name, Size: written, Dir: dir, Md5: got}, nil
985}
986
987// ApplyUpdate 启动已下载的安装程序,并退出当前进程以便安装包覆盖文件。
988// 安装包(build/windows/installer/project.nsi)自身会 taskkill starmusic.exe,
989// 这里主动退出只是为了让覆盖更早发生,避免"文件被占用"。
990func (a *App) ApplyUpdate(installerPath string) error {
991 path := strings.TrimSpace(installerPath)
992 if path == "" {
993 return fmt.Errorf("安装包路径为空")
994 }
995 abs, err := filepath.Abs(path)
996 if err != nil {
997 return fmt.Errorf("安装包路径无效")
998 }
999 // 只允许运行更新目录内的文件,避免被前端任意路径调用
1000 dir, err := filepath.Abs(a.updateDir())
1001 if err != nil || !strings.HasPrefix(abs, dir+string(os.PathSeparator)) {
1002 return fmt.Errorf("安装包不在更新目录中,无法自动安装")
1003 }
1004 if st, statErr := os.Stat(abs); statErr != nil || st.IsDir() {
1005 return fmt.Errorf("安装包不存在,请重新下载")
1006 }
1007
1008 var cmd *exec.Cmd
1009 switch strings.ToLower(filepath.Ext(abs)) {
1010 case ".exe":
1011 cmd = exec.Command(abs)
1012 case ".msi":
1013 cmd = exec.Command("msiexec", "/i", abs)
1014 default:
1015 return fmt.Errorf("该安装包无法自动安装,请在文件夹中手动处理")
1016 }
1017 if err := cmd.Start(); err != nil {
1018 return fmt.Errorf("无法启动安装程序:%s", err.Error())
1019 }
1020 // 子进程独立于本进程存活(Windows 下父进程退出不会连带结束子进程)
1021 go func() { _ = cmd.Wait() }()
1022
1023 // 留一点时间让前端提示先渲染出来,再退出
1024 go func() {
1025 time.Sleep(1200 * time.Millisecond)
1026 wr.Quit(a.ctx)
1027 }()
1028 return nil
1029}