feat: EH Downloader 用 Go + Wails v2 重写
原 Electron 版在这台 Windows 机器上无法启动(0x80000003 崩在 V8 初始化), 整个业务逻辑用 Go 重写,前端界面与协议结论完全保留。 - internal/eh: 协议核心,画廊页分页 -> /s/ -> hath 直链 -> 下载 - internal/core: 并发引擎 / 状态存储 / 目录模板 / 油猴桥口 - app.go: Wails 绑定层,替代 Electron IPC - frontend/js/api.js: 适配层,renderer.js 一行未改 - 37 个测试,含 httptest 模拟 EH 的端到端链路验证 相比 Electron 版:90MB -> 12.4MB,180MB 内存 -> 27MB,构建 90s -> 2s。 顺带修掉原版代理探测的 bug(永远返回第一个端口,从不等连接结果)。
This commit is contained in:
@@ -0,0 +1,445 @@
|
||||
package main
|
||||
|
||||
// Wails 绑定层:把 Go 侧能力暴露给前端。
|
||||
// 前端通过 window.go.main.App.XXX() 调用(见 frontend/js/api.js)。
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
wruntime "github.com/wailsapp/wails/v2/pkg/runtime"
|
||||
|
||||
"eh-downloader-wails/internal/core"
|
||||
"eh-downloader-wails/internal/eh"
|
||||
)
|
||||
|
||||
// App 是 Wails 应用主体
|
||||
type App struct {
|
||||
ctx context.Context
|
||||
store *core.Store
|
||||
engine *core.Engine
|
||||
bridge *core.Bridge
|
||||
|
||||
// 事件序号(前端用来丢弃旧日志)
|
||||
logSeq int64
|
||||
logMu chan struct{}
|
||||
|
||||
engineCtx context.Context
|
||||
engineStop context.CancelFunc
|
||||
}
|
||||
|
||||
// LogEntry 是一条日志
|
||||
type LogEntry struct {
|
||||
Seq int64 `json:"seq"`
|
||||
Level string `json:"level"`
|
||||
Msg string `json:"msg"`
|
||||
At int64 `json:"at"`
|
||||
}
|
||||
|
||||
// AddTasksResult 导入结果
|
||||
type AddTasksResult struct {
|
||||
Added int `json:"added"`
|
||||
Skipped int `json:"skipped"`
|
||||
Warning string `json:"warning"`
|
||||
}
|
||||
|
||||
// StartResult 开始下载的结果
|
||||
type StartResult struct {
|
||||
Started bool `json:"started"`
|
||||
Reason string `json:"reason"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
// StatusResult 引擎状态
|
||||
type StatusResult struct {
|
||||
Running bool `json:"running"`
|
||||
Queued int `json:"queued"`
|
||||
}
|
||||
|
||||
// DirPreviewResult 目录预览
|
||||
type DirPreviewResult struct {
|
||||
OK bool `json:"ok"`
|
||||
Path string `json:"path"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
// statePath 状态文件路径(放用户配置目录)
|
||||
func statePath() string {
|
||||
dir, err := os.UserConfigDir()
|
||||
if err != nil {
|
||||
dir = "."
|
||||
}
|
||||
return filepath.Join(dir, core.AppName, "state.json")
|
||||
}
|
||||
|
||||
// NewApp 建应用
|
||||
func NewApp() *App {
|
||||
return &App{logMu: make(chan struct{}, 1)}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 生命周期
|
||||
|
||||
// startup Wails 启动钩子
|
||||
func (a *App) startup(ctx context.Context) {
|
||||
a.ctx = ctx
|
||||
a.store = core.NewStore(statePath())
|
||||
|
||||
s := a.store.Settings()
|
||||
if s.DownloadRoot == "" {
|
||||
s.DownloadRoot = core.DefaultDownloadRoot()
|
||||
a.store.UpdateSettings(map[string]interface{}{"downloadRoot": s.DownloadRoot})
|
||||
}
|
||||
|
||||
// 首次启动:EH 直连基本不通,自动探测本机常见代理端口
|
||||
if s.ProxyMode == "off" && s.Proxy == "" && !s.Probed {
|
||||
found := core.DetectLocalProxy([]int{7890, 7897, 1087, 1080, 8080, 10808, 10809, 8888})
|
||||
a.store.UpdateSettings(map[string]interface{}{"_probed": true})
|
||||
if found != "" {
|
||||
a.store.UpdateSettings(map[string]interface{}{"proxyMode": "http", "proxy": found})
|
||||
a.emitLog("info", "自动检测到本机代理 "+found+",已填入设置")
|
||||
} else {
|
||||
a.emitLog("warn", "没探测到本机代理,EH 在国内通常需要代理,请在设置里填")
|
||||
}
|
||||
a.store.Flush()
|
||||
}
|
||||
|
||||
if n := a.store.RecoverOnStart(); n > 0 {
|
||||
a.emitLog("info", fmt.Sprintf("启动时归位了 %d 个中断的任务", n))
|
||||
}
|
||||
|
||||
a.engine = core.NewEngine(a.store, &sink{a}, s)
|
||||
a.engineCtx, a.engineStop = context.WithCancel(context.Background())
|
||||
|
||||
a.bridge = core.NewBridge(
|
||||
a.store,
|
||||
a.emitLog,
|
||||
func(t core.Task, added bool, batch bool) {
|
||||
a.emitTaskUpdate(t)
|
||||
how := "已在列表中"
|
||||
if added {
|
||||
how = "已加入待下载"
|
||||
}
|
||||
title := ""
|
||||
if t.Title != "" {
|
||||
title = " · " + t.Title
|
||||
}
|
||||
a.emitLog("ok", fmt.Sprintf("油猴推送:%s %s%s", t.GID, how, title))
|
||||
// 窗口最小化/隐藏时唤起一下,让用户看得到
|
||||
wruntime.WindowUnminimise(a.ctx)
|
||||
wruntime.WindowShow(a.ctx)
|
||||
},
|
||||
)
|
||||
a.bridge.Start(s.BridgePort)
|
||||
}
|
||||
|
||||
// shutdown Wails 退出钩子
|
||||
func (a *App) shutdown(ctx context.Context) {
|
||||
if a.engineStop != nil {
|
||||
a.engineStop()
|
||||
}
|
||||
if a.bridge != nil {
|
||||
a.bridge.Stop()
|
||||
}
|
||||
if a.store != nil {
|
||||
a.store.Stop()
|
||||
a.store.Flush()
|
||||
}
|
||||
}
|
||||
|
||||
// sink 把引擎事件转成 Wails 事件
|
||||
type sink struct{ a *App }
|
||||
|
||||
func (s *sink) TaskUpdate(t core.Task) { s.a.emitTaskUpdate(t) }
|
||||
func (s *sink) Log(level, msg string) { s.a.emitLog(level, msg) }
|
||||
func (s *sink) Done() { s.a.emitEvent("engine:done", map[string]interface{}{}) }
|
||||
|
||||
// ---------------------------------------------------------------- 事件
|
||||
|
||||
func (a *App) emitEvent(name string, data interface{}) {
|
||||
if a.ctx == nil {
|
||||
return
|
||||
}
|
||||
wruntime.EventsEmit(a.ctx, name, data)
|
||||
}
|
||||
|
||||
func (a *App) emitLog(level, msg string) {
|
||||
// 串行化自增,避免并发下序号乱序
|
||||
a.logMu <- struct{}{}
|
||||
a.logSeq++
|
||||
entry := LogEntry{Seq: a.logSeq, Level: level, Msg: msg, At: time.Now().UnixMilli()}
|
||||
<-a.logMu
|
||||
|
||||
a.emitEvent("log", entry)
|
||||
}
|
||||
|
||||
func (a *App) emitTaskUpdate(t core.Task) {
|
||||
a.emitEvent("task:update", t)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 设置
|
||||
|
||||
// GetSettings 取设置
|
||||
func (a *App) GetSettings() core.Settings {
|
||||
return a.store.Settings()
|
||||
}
|
||||
|
||||
// SetSettings 更新设置
|
||||
func (a *App) SetSettings(patch map[string]interface{}) core.Settings {
|
||||
next := a.store.UpdateSettings(patch)
|
||||
|
||||
// 同步到引擎
|
||||
if _, ok := patch["concurrency"]; ok {
|
||||
a.engine.SetConcurrency(next.Concurrency)
|
||||
}
|
||||
if _, ok := patch["timeout"]; ok {
|
||||
a.engine.Client().SetTimeout(time.Duration(next.Timeout) * time.Millisecond)
|
||||
}
|
||||
if _, ok := patch["xres"]; ok {
|
||||
a.engine.Client().SetXres(next.Xres)
|
||||
}
|
||||
if _, ok := patch["proxy"]; ok {
|
||||
a.engine.Client().SetProxy(next.Proxy, next.ProxyMode)
|
||||
}
|
||||
if _, ok := patch["proxyMode"]; ok {
|
||||
a.engine.Client().SetProxy(next.Proxy, next.ProxyMode)
|
||||
}
|
||||
|
||||
// 端口变了就重启桥口
|
||||
if _, ok := patch["bridgePort"]; ok && a.bridge.Port() != next.BridgePort {
|
||||
a.bridge.Stop()
|
||||
a.bridge.Start(next.BridgePort)
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
// PreviewDir 目录模板实时预览
|
||||
func (a *App) PreviewDir(tpl string) DirPreviewResult {
|
||||
g := previewGallery()
|
||||
return DirPreviewResult{OK: true, Path: core.BuildDir(tpl, g)}
|
||||
}
|
||||
|
||||
func previewGallery() *eh.Gallery {
|
||||
return &eh.Gallery{
|
||||
GID: "4239274",
|
||||
Title: "[Noxe] 0820 Qingxiao [AI Generated]",
|
||||
Meta: eh.GalleryMeta{
|
||||
Artist: "Noxe",
|
||||
Group: "CriticalHAI",
|
||||
Series: "wuthering waves",
|
||||
Category: "misc",
|
||||
Uploader: "velius11",
|
||||
Tags: []string{"qingxiao", "rover", "big breasts", "x-ray", "ai generated"},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Placeholders 可用占位符
|
||||
func (a *App) Placeholders() []string {
|
||||
return core.ListPlaceholders()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 任务
|
||||
|
||||
// ListTasks 全部任务
|
||||
func (a *App) ListTasks() []core.Task {
|
||||
return a.store.Tasks()
|
||||
}
|
||||
|
||||
// AddTasks 导入链接文本
|
||||
func (a *App) AddTasks(text string) AddTasksResult {
|
||||
parsed := eh.ParseGalleryInput(text)
|
||||
if len(parsed) == 0 {
|
||||
for _, g := range eh.ExtractGIDs(text) {
|
||||
parsed = append(parsed, eh.GalleryRef{GID: g, Raw: g})
|
||||
}
|
||||
}
|
||||
if len(parsed) == 0 {
|
||||
return AddTasksResult{
|
||||
Warning: "没解析出任何画廊链接。支持 https://e-hentai.org/g/{gid}/{token}/ 格式或裸 gid。",
|
||||
}
|
||||
}
|
||||
|
||||
noToken := 0
|
||||
for _, p := range parsed {
|
||||
if p.Token == "" {
|
||||
noToken++
|
||||
}
|
||||
}
|
||||
added := a.store.AddTasks(parsed)
|
||||
|
||||
warning := ""
|
||||
if noToken > 0 {
|
||||
warning = fmt.Sprintf("%d 个只有 gid 没有 token,可能取不到画廊(EH 需要完整链接)", noToken)
|
||||
}
|
||||
a.emitLog("info", fmt.Sprintf("导入 %d 个画廊,新增 %d 个", len(parsed), added))
|
||||
return AddTasksResult{Added: added, Skipped: len(parsed) - added, Warning: warning}
|
||||
}
|
||||
|
||||
// RemoveTask 删单个
|
||||
func (a *App) RemoveTask(gid, token string) []core.Task {
|
||||
a.store.RemoveTask(gid, token)
|
||||
return a.store.Tasks()
|
||||
}
|
||||
|
||||
// ClearFinished 清除已完成(不动本地文件)
|
||||
func (a *App) ClearFinished() map[string]interface{} {
|
||||
n := a.store.ClearFinished()
|
||||
a.emitLog("info", fmt.Sprintf("清除了 %d 个已完成任务(本地已下载文件未动)", n))
|
||||
return map[string]interface{}{"removed": n, "tasks": a.store.Tasks()}
|
||||
}
|
||||
|
||||
// CountDone 已完成数
|
||||
func (a *App) CountDone() int { return a.store.CountDone() }
|
||||
|
||||
// ClearAll 清空任务列表(不动本地文件)
|
||||
func (a *App) ClearAll(onlySelected bool) map[string]interface{} {
|
||||
scope := "全部"
|
||||
if onlySelected {
|
||||
scope = "选中的"
|
||||
}
|
||||
n := a.store.ClearAll(onlySelected)
|
||||
if n > 0 {
|
||||
a.emitLog("warn", fmt.Sprintf("删除了%s %d 个任务(本地已下载文件未动)", scope, n))
|
||||
} else {
|
||||
a.emitLog("info", "没有可删除的任务")
|
||||
}
|
||||
return map[string]interface{}{"removed": n, "tasks": a.store.Tasks()}
|
||||
}
|
||||
|
||||
// RequeueFailed 失败重排
|
||||
func (a *App) RequeueFailed() []core.Task {
|
||||
n := a.store.RequeueFailed()
|
||||
a.emitLog("info", fmt.Sprintf("重新排队 %d 个任务", n))
|
||||
return a.store.Tasks()
|
||||
}
|
||||
|
||||
// SelectAll 标记勾选
|
||||
func (a *App) SelectAll(gids []string) []core.Task {
|
||||
return a.store.SelectAll(gids)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 引擎
|
||||
|
||||
// Status 引擎状态
|
||||
func (a *App) Status() StatusResult {
|
||||
return StatusResult{Running: a.engine.IsRunning(), Queued: a.engine.QueueLength()}
|
||||
}
|
||||
|
||||
// Start 开始下载
|
||||
func (a *App) Start(onlyGids []string) StartResult {
|
||||
if a.engine.IsRunning() {
|
||||
return StartResult{Started: false, Reason: "已经在跑了"}
|
||||
}
|
||||
|
||||
all := a.store.Tasks()
|
||||
targets := make([]core.Task, 0, len(all))
|
||||
withToken := 0
|
||||
for _, t := range all {
|
||||
switch t.Status {
|
||||
case "queued", "failed", "partial":
|
||||
default:
|
||||
continue
|
||||
}
|
||||
if len(onlyGids) > 0 && !containsStr(onlyGids, t.GID) {
|
||||
continue
|
||||
}
|
||||
if t.Token != "" {
|
||||
withToken++
|
||||
}
|
||||
targets = append(targets, t)
|
||||
}
|
||||
|
||||
if len(targets) == 0 {
|
||||
return StartResult{Started: false, Reason: "没有待处理的任务"}
|
||||
}
|
||||
if withToken == 0 {
|
||||
return StartResult{Started: false, Reason: "任务里没有带 token 的画廊,无法开始"}
|
||||
}
|
||||
|
||||
a.engine.Enqueue(targets)
|
||||
|
||||
ctx := a.engineCtx
|
||||
if ctx == nil {
|
||||
a.engineCtx, a.engineStop = context.WithCancel(context.Background())
|
||||
ctx = a.engineCtx
|
||||
}
|
||||
go a.engine.Start(ctx)
|
||||
|
||||
a.emitLog("info", fmt.Sprintf("开始下载 %d 个画廊(并发 %d)", len(targets), len(targets)))
|
||||
return StartResult{Started: true, Count: len(targets)}
|
||||
}
|
||||
|
||||
// Stop 停止下载
|
||||
func (a *App) Stop() map[string]interface{} {
|
||||
a.engine.Stop()
|
||||
a.emitLog("warn", "已请求停止,等待当前请求结束")
|
||||
return map[string]interface{}{"stopped": true}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 系统
|
||||
|
||||
// PickDir 选目录
|
||||
func (a *App) PickDir() string {
|
||||
p, _ := wruntime.OpenDirectoryDialog(a.ctx, wruntime.OpenDialogOptions{
|
||||
Title: "选择下载目录",
|
||||
})
|
||||
return p
|
||||
}
|
||||
|
||||
// OpenPath 打开路径
|
||||
func (a *App) OpenPath(p string) {
|
||||
wruntime.BrowserOpenURL(a.ctx, "file:///"+filepath.ToSlash(p))
|
||||
}
|
||||
|
||||
// ShowItem 在资源管理器里定位
|
||||
func (a *App) ShowItem(p string) {
|
||||
wruntime.BrowserOpenURL(a.ctx, "file:///"+filepath.ToSlash(filepath.Dir(p)))
|
||||
}
|
||||
|
||||
// ExportFailed 导出失败清单
|
||||
func (a *App) ExportFailed(gid string) string {
|
||||
var target *core.Task
|
||||
for _, x := range a.store.Tasks() {
|
||||
if x.GID == gid {
|
||||
c := x
|
||||
target = &c
|
||||
break
|
||||
}
|
||||
}
|
||||
if target == nil || len(target.FailedList) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
path, _ := wruntime.SaveFileDialog(a.ctx, wruntime.SaveDialogOptions{
|
||||
Title: "导出失败清单",
|
||||
DefaultFilename: gid + "-failed.txt",
|
||||
Filters: []wruntime.FileFilter{
|
||||
{DisplayName: "文本文件 (*.txt)", Pattern: "*.txt"},
|
||||
},
|
||||
})
|
||||
if path == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
var sb string
|
||||
for _, f := range target.FailedList {
|
||||
sb += fmt.Sprintf("# page %d %s\t%s\n", f.Page, f.File, f.Error)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(sb), 0o644); err != nil {
|
||||
a.emitLog("error", "导出失败:"+err.Error())
|
||||
return ""
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func containsStr(list []string, s string) bool {
|
||||
for _, x := range list {
|
||||
if x == s {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
Reference in New Issue
Block a user