Files
eh-downloader/app.go
T
toor 0f927147cd 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(永远返回第一个端口,从不等连接结果)。
2026-10-08 23:28:16 +08:00

446 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}