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:
2026-10-08 23:28:16 +08:00
commit 0f927147cd
42 changed files with 6805 additions and 0 deletions
+445
View File
@@ -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
}