commit 0f927147cddf80c9e0de6ada7211e5d6ffcfe7e4 Author: jack Date: Thu Oct 8 23:28:16 2026 +0800 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(永远返回第一个端口,从不等连接结果)。 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c8dc78b --- /dev/null +++ b/.gitignore @@ -0,0 +1,18 @@ +# Wails build output +build/bin +build/app + +# Node (build-time only) +node_modules +frontend/dist + +# Go +*.exe +*.test +*.out +vendor/ + +# Editors +.idea/ +.vscode/ +*.swp diff --git a/README.md b/README.md new file mode 100644 index 0000000..cd6ed60 --- /dev/null +++ b/README.md @@ -0,0 +1,251 @@ +# EH Downloader · Wails 版 + +**E-Hentai 画廊批量下载工具** —— 原 Electron 版用 Go + Wails v2 重写。 + +> 私有项目,仅供自用,未授权分发。 + +## 为什么重写 + +原作者在 macOS 上用 Electron 开发,项目打包要在 Mac 上跑。这台 Windows 机器上 +Electron 起不来(`0x80000003`,V8 初始化崩溃,Electron 33/40 表现一致)。 +于是整个业务逻辑用 Go 重写,前端界面和协议结论完全保留。 + +对比: + +| | Electron 版 | Wails 版 | +|---|---|---| +| 安装包 | 90 MB | **12 MB** | +| 内存占用 | ~180 MB | **~26 MB** | +| 冷启动 | ~2 s | **~0.2 s** | +| 运行时 | Chromium 180MB | 系统 WebView2 | +| 原生依赖 | 无(但要带 Chromium) | 无 | + +## 环境要求 + +- **Go ≥ 1.21** +- **Wails CLI**:`go install github.com/wailsapp/wails/v2/cmd/wails@latest` +- **WebView2 Runtime**(Windows 10/11 自带或从微软官网装) + +## 快速开始 + +```bash +# 国内建议先配镜像(否则 go mod 会卡在 proxy.golang.org) +go env -w GOPROXY=https://goproxy.cn,https://mirrors.aliyun.com/goproxy/,direct +go env -w GOSUMDB=off + +git clone +cd eh-downloader-wails +go mod download + +wails dev # 开发模式(带热重载) +go test ./... # 跑测试(37 个用例) +wails build # 打包 → build/bin/eh-downloader-wails.exe +``` + +macOS 打包:`wails build -platform darwin/universal` +Linux:`wails build -platform linux/amd64` + +## 功能 + +和原 Electron 版完全一致: + +- **批量下载** —— 一行一个链接,粘进去就走 +- **断点续传** —— 以文件系统为准,中途断网/关窗口/删状态文件都不影响续传 +- **油猴联动** —— 浏览时推送画廊,列表页勾选后批量推送 +- **目录模板** —— 按作者 / 社团 / 分类 / 标签自动归档 +- **任务清理** —— 只删列表项,本地文件一个都不动 +- **代理支持** —— HTTP / SOCKS5,首次启动自动探测本机代理 +- **失败重试 + 日志面板** —— 指数退避重试,失败清单可导出 + +## 架构 + +``` +eh-downloader-wails/ +├── main.go Wails 启动配置(窗口尺寸、绑定注册) +├── app.go ★ 绑定层:Go 方法暴露给前端 +├── internal/ +│ ├── eh/ ★ 协议核心(可独立测试,无 UI 依赖) +│ │ ├── eh.go ①画廊页分页 → ②/s/ → ③hath直链 → 下载 +│ │ ├── eh_test.go 单元测试 +│ │ └── e2e_test.go 端到端链路测试(httptest 模拟 EH) +│ └── core/ +│ ├── engine.go 任务编排、并发池、退避重试、断点续传 +│ ├── store.go JSON 原子写 + 任务状态机 +│ ├── paths.go 目录模板引擎 + 路径安全清洗 +│ ├── bridge.go 本地 HTTP 桥口(供油猴推送) +│ └── *_test.go 单测 +├── frontend/ +│ ├── index.html 界面(沿用原版) +│ ├── js/api.js ★ IPC 适配层:window.eh.* → Wails bindings +│ ├── js/renderer.js 渲染逻辑(沿用原版,未改) +│ └── css/style.css 样式(沿用原版) +└── userscript/ 油猴脚本(沿用原版,协议未变) +``` + +### 与 Electron 版的对应关系 + +| Electron | Wails | +|---|---| +| `ipcMain.handle('settings:get')` | `func (a *App) GetSettings() Settings` | +| `contextBridge` preload | `frontend/js/api.js` 适配层 | +| `webContents.send('log')` | `runtime.EventsEmit(ctx, "log", ...)` | +| `axios` | `net/http` | +| `https-proxy-agent` | `net/http.Transport.Proxy` | +| `socks-proxy-agent` | `golang.org/x/net/proxy` | +| `fs.promises.rename` 原子写 | 同(`os.Rename`) | +| `AbortController` | `context.WithCancel` | + +前端业务代码(`renderer.js`)**一行没改** —— 原来调 `window.eh.*`, +现在由 `api.js` 把这些调用转发到 Wails 的 Go 绑定。 + +## 协议逆向(2026 版,结论沿用原项目实测) + +每个画廊需要 **2N+1 次请求**(N = 页数),无法绕过: + +| 阶段 | 请求 | 说明 | +|---|---|---| +| 1 | `GET /g/{gid}/{token}/` | 拿全部页的 `/s/` 链接 + 原始文件名 | +| 2 | `GET /s/{key}/{gid}-{page}` | 拿该页 hath 全图 URL,**N 次** | +| 3 | `GET ` | 下载图片本身,**N 次** | + +### 三个必须知道的坑 + +**1. 画廊页每屏只列 20 张,必须翻 `?p=N` 分页** + +``` +/g/{gid}/{token}/ → 1-20 +/g/{gid}/{token}/?p=1 → 21-40 +``` + +不做这个就只会下到前 20 张(且不报错,静默截断)。 +`FetchGalleryPage` 有分页循环,按 `Showing X - Y of N images` 自动翻页。 +e2e 测试里用 45 张的 mock 专门验证了这个。 + +**2. `/s/` 的 key 必须和页号配对** + +`/s/bb06b5db77/4239250-1` 里的 `bb06b5db77` 是第 1 页专属的 key。 +拿它去请求第 2 页会返回 200 但内容是错的。 + +**3. 图片 URL 每次都不一样** + +``` +https://oclzqqk.gyktyewfonyf.hath.network:39656/h/4e9af9f.../keystamp=1791448200-...;xres=800/NH.webp + ↑随机子域 ↑随机端口 ↑带时间戳的签名 +``` + +随机子域名 + 随机端口 + 时间戳签名 —— **不存在固定直链**, +只能实时从 `/s/` 页面解析。代码在 `ResolveImageURL`。 + +> 这就是网上老爬虫失效的真正原因。它不是"解码"问题, +> 就是从 HTML 里正则提取一条 URL,没有签名计算。 + +### 其他实测结论 + +- **Referer 不是必需的**,裸请求也能 200。代码仍带 UA 兜底。 +- 图片实际格式是 **WebP**。文件名用画廊页 `title="Page N: 原名"` 里的原名。 +- `xres` 参数:数字是**长边上限**,`xres=org` 请求原图。 +- 并发 10 未触发限流,但建议 **2~4**。 + +### 已失效的老接口 + +| 老写法 | 现状 | +|---|---| +| 页面内 `var gmetadata = {...}` | 已移除 | +| `/img/{gid}/{gid}/{gid}-{page}.jpg` | 已改为 `/{2字符}/{哈希}/{gid}-{n}.webp` | +| `?pkl=&pjt=` 懒加载 | 已移除 | +| `api.e-hentai.org` 的 `method=gdata` | 对所有 gid 返回 `Gallery not found` | +| 表单提交 | 已改为 JSON body,`gidlist` 必须是数组 | + +## 目录结构模板 + +默认 `{title}`。可用占位符: + +| 占位符 | 含义 | 兜底值 | +|---|---|---| +| `{title}` | 画廊标题 | — | +| `{gid}` | 画廊号 | — | +| `{artist}` | 作者:`artist:` → 标题 `[X]` → 上传者 | `未知作者` | +| `{group}` | 社团:`group:` → 标题 `(X)` | `未知社团` | +| `{series}` | 系列(`parody:`) | `无系列` | +| `{category}` | 分类 | `未分类` | +| `{lang}` | 语言 | `nolang` | +| `{uploader}` | 上传者 | `unknown` | +| `{tag}` `{tag1}` `{tag2}` | 第 N 个普通标签 | 空 | +| `{tags}` | 全部普通标签,`_` 连接 | 空 | +| `{tags:3}` | 前 3 个普通标签 | 空 | + +常用预设:`{artist}/{title}`、`{group}/{title}`、`{category}/{group}/{title}`。 + +**换模板不会重下已完成的图** —— 引擎下之前会先去老的「标题目录」里看一眼。 + +每段路径都会清洗:`/ \ : * ? " < > |` 和控制字符换成 `_`, +`.` / `..` 直接作废(防目录穿越),超长截到 110 字符,末尾的点/空格去掉, +**Windows 保留设备名(CON/PRN/NUL/COM1…)会自动加前缀**(Go 版比原版多这层)。 + +## 设置 + +| 项 | 默认 | 说明 | +|---|---|---| +| 下载目录 | `~/Downloads/EH` | 每个画廊一个子目录 | +| 代理模式 | 自动探测 | off / http / socks5 | +| 代理地址 | 自动探测 | 常见端口 7890/7897/1087/1080/8080/10808/10809/8888 | +| 并发数 | 3 | 建议 2~4 | +| 超时 | 30s | | +| 下载分辨率 | 站点默认 | `auto` / `org` / 数字 | +| 目录模板 | `{title}` | | +| 油猴推送端口 | 37777 | | + +状态文件: +- Windows:`%APPDATA%\EH Downloader\state.json` +- macOS:`~/Library/Application Support/EH Downloader/state.json` +- Linux:`~/.config/EH Downloader/state.json` + +## 本地桥口(油猴推送) + +App 启动时在 `127.0.0.1:37777` 起 HTTP 服务(**只绑回环**): + +| 接口 | 说明 | +|---|---| +| `GET /ping` | 探活,返回 `{ok:true}` | +| `POST /add` | 推送单个,需带 `X-EHDL-Token: ehdl-local-9f2a4c7e` | +| `POST /add-batch` | 批量推送,`{items:[...]}`,一次最多 500 个 | +| `GET /info` | 版本与端口 | + +安全措施:**固定 token + Origin 白名单**(只放行 e-hentai.org / exhentai.org)。 + +> 油猴脚本用 `GM_xmlhttpRequest` 而不是 `fetch`,直接绕开 CORS。 + +## 已知限制 + +- **只支持 E-Hentai**,不支持 ExHentai(需账号 cookie,本工具不含登录)。 + 受限画廊会报「画廊受限或不存在」。 +- 每张图 2 次请求,大画廊慢。206 张约 413 次请求、并发 3 时约 8 分钟。 +- `xres=org` 并非所有画廊都支持。 +- 下载的是 WebP,不是上传者提交的原始 jpg/png。 + +## 测试 + +```bash +go test ./... # 全部 +go test ./internal/eh/ -v # 协议层(含端到端) +go test -race ./... # 竞态检测 +``` + +37 个用例覆盖: +- 输入解析(多行/分隔符/去重/注释) +- hath 直链正则(含不该匹配的负例) +- xres 改写(auto/org/数字) +- 画廊页分页(45 张 mock 验证 3 屏翻页) +- key 与页码配对 +- meta 解析 + 标题约定兜底 +- 目录模板展开 + 路径穿越防护 + Windows 保留名 +- 断点续传、取消清理 .part +- 状态机(清除已完成/失败重排/崩溃归位/文件损坏回落) + +## 免责声明 + +仅供学习与技术研究使用。使用者自行遵守 +[E-Hentai 的站点条款](https://e-hentai.org/policy.php) 及相关法律法规, +尊重内容作者的著作权,不要将下载内容用于传播或商业用途。 + +**本项目为私有项目,保留所有权利。未经作者许可,请勿分发或二次发布。** diff --git a/app.go b/app.go new file mode 100644 index 0000000..c315af6 --- /dev/null +++ b/app.go @@ -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 +} diff --git a/build/README.md b/build/README.md new file mode 100644 index 0000000..1ae2f67 --- /dev/null +++ b/build/README.md @@ -0,0 +1,35 @@ +# Build Directory + +The build directory is used to house all the build files and assets for your application. + +The structure is: + +* bin - Output directory +* darwin - macOS specific files +* windows - Windows specific files + +## Mac + +The `darwin` directory holds files specific to Mac builds. +These may be customised and used as part of the build. To return these files to the default state, simply delete them +and +build with `wails build`. + +The directory contains the following files: + +- `Info.plist` - the main plist file used for Mac builds. It is used when building using `wails build`. +- `Info.dev.plist` - same as the main plist file but used when building using `wails dev`. + +## Windows + +The `windows` directory contains the manifest and rc files used when building with `wails build`. +These may be customised for your application. To return these files to the default state, simply delete them and +build with `wails build`. + +- `icon.ico` - The icon used for the application. This is used when building using `wails build`. If you wish to + use a different icon, simply replace this file with your own. If it is missing, a new `icon.ico` file + will be created using the `appicon.png` file in the build directory. +- `installer/*` - The files used to create the Windows installer. These are used when building using `wails build`. +- `info.json` - Application details used for Windows builds. The data here will be used by the Windows installer, + as well as the application itself (right click the exe -> properties -> details) +- `wails.exe.manifest` - The main application manifest file. \ No newline at end of file diff --git a/build/appicon.png b/build/appicon.png new file mode 100644 index 0000000..63617fe Binary files /dev/null and b/build/appicon.png differ diff --git a/build/darwin/Info.dev.plist b/build/darwin/Info.dev.plist new file mode 100644 index 0000000..c20334b --- /dev/null +++ b/build/darwin/Info.dev.plist @@ -0,0 +1,68 @@ + + + + CFBundlePackageType + APPL + CFBundleName + {{.Info.ProductName}} + CFBundleExecutable + {{.OutputFilename}} + CFBundleIdentifier + com.wails.{{safeBundleID .Name}} + CFBundleVersion + {{.Info.ProductVersion}} + CFBundleGetInfoString + {{.Info.Comments}} + CFBundleShortVersionString + {{.Info.ProductVersion}} + CFBundleIconFile + iconfile + LSMinimumSystemVersion + 10.13.0 + NSHighResolutionCapable + true + NSHumanReadableCopyright + {{.Info.Copyright}} + {{if .Info.FileAssociations}} + CFBundleDocumentTypes + + {{range .Info.FileAssociations}} + + CFBundleTypeExtensions + + {{.Ext}} + + CFBundleTypeName + {{.Name}} + CFBundleTypeRole + {{.Role}} + CFBundleTypeIconFile + {{.IconName}} + + {{end}} + + {{end}} + {{if .Info.Protocols}} + CFBundleURLTypes + + {{range .Info.Protocols}} + + CFBundleURLName + com.wails.{{.Scheme}} + CFBundleURLSchemes + + {{.Scheme}} + + CFBundleTypeRole + {{.Role}} + + {{end}} + + {{end}} + NSAppTransportSecurity + + NSAllowsLocalNetworking + + + + diff --git a/build/darwin/Info.plist b/build/darwin/Info.plist new file mode 100644 index 0000000..4de2887 --- /dev/null +++ b/build/darwin/Info.plist @@ -0,0 +1,63 @@ + + + + CFBundlePackageType + APPL + CFBundleName + {{.Info.ProductName}} + CFBundleExecutable + {{.OutputFilename}} + CFBundleIdentifier + com.wails.{{safeBundleID .Name}} + CFBundleVersion + {{.Info.ProductVersion}} + CFBundleGetInfoString + {{.Info.Comments}} + CFBundleShortVersionString + {{.Info.ProductVersion}} + CFBundleIconFile + iconfile + LSMinimumSystemVersion + 10.13.0 + NSHighResolutionCapable + true + NSHumanReadableCopyright + {{.Info.Copyright}} + {{if .Info.FileAssociations}} + CFBundleDocumentTypes + + {{range .Info.FileAssociations}} + + CFBundleTypeExtensions + + {{.Ext}} + + CFBundleTypeName + {{.Name}} + CFBundleTypeRole + {{.Role}} + CFBundleTypeIconFile + {{.IconName}} + + {{end}} + + {{end}} + {{if .Info.Protocols}} + CFBundleURLTypes + + {{range .Info.Protocols}} + + CFBundleURLName + com.wails.{{.Scheme}} + CFBundleURLSchemes + + {{.Scheme}} + + CFBundleTypeRole + {{.Role}} + + {{end}} + + {{end}} + + diff --git a/build/windows/icon.ico b/build/windows/icon.ico new file mode 100644 index 0000000..f334798 Binary files /dev/null and b/build/windows/icon.ico differ diff --git a/build/windows/info.json b/build/windows/info.json new file mode 100644 index 0000000..9727946 --- /dev/null +++ b/build/windows/info.json @@ -0,0 +1,15 @@ +{ + "fixed": { + "file_version": "{{.Info.ProductVersion}}" + }, + "info": { + "0000": { + "ProductVersion": "{{.Info.ProductVersion}}", + "CompanyName": "{{.Info.CompanyName}}", + "FileDescription": "{{.Info.ProductName}}", + "LegalCopyright": "{{.Info.Copyright}}", + "ProductName": "{{.Info.ProductName}}", + "Comments": "{{.Info.Comments}}" + } + } +} \ No newline at end of file diff --git a/build/windows/installer/project.nsi b/build/windows/installer/project.nsi new file mode 100644 index 0000000..90fabb3 --- /dev/null +++ b/build/windows/installer/project.nsi @@ -0,0 +1,122 @@ +Unicode true + +#### +## Please note: Template replacements don't work in this file. They are provided with default defines like +## mentioned underneath. +## If the keyword is not defined, "wails_tools.nsh" will populate them with the values from ProjectInfo. +## If they are defined here, "wails_tools.nsh" will not touch them. This allows to use this project.nsi manually +## from outside of Wails for debugging and development of the installer. +## +## For development first make a wails nsis build to populate the "wails_tools.nsh": +## > wails build --target windows/amd64 --nsis +## Then you can call makensis on this file with specifying the path to your binary: +## For a AMD64 only installer: +## > makensis -DARG_WAILS_AMD64_BINARY=..\..\bin\app.exe +## For a ARM64 only installer: +## > makensis -DARG_WAILS_ARM64_BINARY=..\..\bin\app.exe +## For a installer with both architectures: +## > makensis -DARG_WAILS_AMD64_BINARY=..\..\bin\app-amd64.exe -DARG_WAILS_ARM64_BINARY=..\..\bin\app-arm64.exe +#### +## The following information is taken from the ProjectInfo file, but they can be overwritten here. +#### +## !define INFO_PROJECTNAME "MyProject" # Default "{{.Name}}" +## !define INFO_COMPANYNAME "MyCompany" # Default "{{.Info.CompanyName}}" +## !define INFO_PRODUCTNAME "MyProduct" # Default "{{.Info.ProductName}}" +## !define INFO_PRODUCTVERSION "1.0.0" # Default "{{.Info.ProductVersion}}" +## !define INFO_COPYRIGHT "Copyright" # Default "{{.Info.Copyright}}" +### +## !define PRODUCT_EXECUTABLE "Application.exe" # Default "${INFO_PROJECTNAME}.exe" +## !define UNINST_KEY_NAME "UninstKeyInRegistry" # Default "${INFO_COMPANYNAME}${INFO_PRODUCTNAME}" +#### +## !define REQUEST_EXECUTION_LEVEL "admin" # Default "admin" see also https://nsis.sourceforge.io/Docs/Chapter4.html +#### +## Include the wails tools +#### +!include "wails_tools.nsh" + +# The version information for this two must consist of 4 parts +VIProductVersion "${INFO_PRODUCTVERSION}.0" +VIFileVersion "${INFO_PRODUCTVERSION}.0" + +VIAddVersionKey "CompanyName" "${INFO_COMPANYNAME}" +VIAddVersionKey "FileDescription" "${INFO_PRODUCTNAME} Installer" +VIAddVersionKey "ProductVersion" "${INFO_PRODUCTVERSION}" +VIAddVersionKey "FileVersion" "${INFO_PRODUCTVERSION}" +VIAddVersionKey "LegalCopyright" "${INFO_COPYRIGHT}" +VIAddVersionKey "ProductName" "${INFO_PRODUCTNAME}" + +# Enable HiDPI support. https://nsis.sourceforge.io/Reference/ManifestDPIAware +ManifestDPIAware true + +!include "MUI.nsh" + +!define MUI_ICON "..\icon.ico" +!define MUI_UNICON "..\icon.ico" +# !define MUI_WELCOMEFINISHPAGE_BITMAP "resources\leftimage.bmp" #Include this to add a bitmap on the left side of the Welcome Page. Must be a size of 164x314 +!define MUI_FINISHPAGE_NOAUTOCLOSE # Wait on the INSTFILES page so the user can take a look into the details of the installation steps +!define MUI_ABORTWARNING # This will warn the user if they exit from the installer. + +!insertmacro MUI_PAGE_WELCOME # Welcome to the installer page. +# !insertmacro MUI_PAGE_LICENSE "resources\eula.txt" # Adds a EULA page to the installer +!insertmacro MUI_PAGE_DIRECTORY # In which folder install page. +!insertmacro MUI_PAGE_INSTFILES # Installing page. +!insertmacro MUI_PAGE_FINISH # Finished installation page. + +!insertmacro MUI_UNPAGE_INSTFILES # Uinstalling page + +!insertmacro MUI_LANGUAGE "English" # Set the Language of the installer + +## The following two statements can be used to sign the installer and the uninstaller. The path to the binaries are provided in %1 +#!uninstfinalize 'signtool --file "%1"' +#!finalize 'signtool --file "%1"' + +Name "${INFO_PRODUCTNAME}" +OutFile "..\..\bin\${INFO_PROJECTNAME}-${ARCH}-installer.exe" # Name of the installer's file. +!ifdef WAILS_INSTALL_SCOPE + !if "${WAILS_INSTALL_SCOPE}" == "user" + InstallDir "$LOCALAPPDATA\Programs\${INFO_PRODUCTNAME}" + !else + InstallDir "$PROGRAMFILES64\${INFO_COMPANYNAME}\${INFO_PRODUCTNAME}" + !endif +!else + InstallDir "$PROGRAMFILES64\${INFO_COMPANYNAME}\${INFO_PRODUCTNAME}" +!endif # Default installing folder ($PROGRAMFILES is Program Files folder). +ShowInstDetails show # This will always show the installation details. + +Function .onInit + !insertmacro wails.checkArchitecture +FunctionEnd + +Section + !insertmacro wails.setShellContext + + !insertmacro wails.webview2runtime + + SetOutPath $INSTDIR + + !insertmacro wails.files + + CreateShortcut "$SMPROGRAMS\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${PRODUCT_EXECUTABLE}" + CreateShortCut "$DESKTOP\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${PRODUCT_EXECUTABLE}" + + !insertmacro wails.associateFiles + !insertmacro wails.associateCustomProtocols + + !insertmacro wails.writeUninstaller +SectionEnd + +Section "uninstall" + !insertmacro wails.setShellContext + + RMDir /r "$AppData\${PRODUCT_EXECUTABLE}" # Remove the WebView2 DataPath + + RMDir /r $INSTDIR + + Delete "$SMPROGRAMS\${INFO_PRODUCTNAME}.lnk" + Delete "$DESKTOP\${INFO_PRODUCTNAME}.lnk" + + !insertmacro wails.unassociateFiles + !insertmacro wails.unassociateCustomProtocols + + !insertmacro wails.deleteUninstaller +SectionEnd diff --git a/build/windows/installer/wails_tools.nsh b/build/windows/installer/wails_tools.nsh new file mode 100644 index 0000000..6dd9057 --- /dev/null +++ b/build/windows/installer/wails_tools.nsh @@ -0,0 +1,284 @@ +# DO NOT EDIT - Generated automatically by `wails build` + +!include "x64.nsh" +!include "WinVer.nsh" +!include "FileFunc.nsh" + +!ifndef INFO_PROJECTNAME + !define INFO_PROJECTNAME "{{.Name}}" +!endif +!ifndef INFO_COMPANYNAME + !define INFO_COMPANYNAME "{{.Info.CompanyName}}" +!endif +!ifndef INFO_PRODUCTNAME + !define INFO_PRODUCTNAME "{{.Info.ProductName}}" +!endif +!ifndef INFO_PRODUCTVERSION + !define INFO_PRODUCTVERSION "{{.Info.ProductVersion}}" +!endif +!ifndef INFO_COPYRIGHT + !define INFO_COPYRIGHT "{{.Info.Copyright}}" +!endif +!ifndef PRODUCT_EXECUTABLE + !define PRODUCT_EXECUTABLE "${INFO_PROJECTNAME}.exe" +!endif +!ifndef UNINST_KEY_NAME + !define UNINST_KEY_NAME "${INFO_COMPANYNAME}${INFO_PRODUCTNAME}" +!endif +!define UNINST_KEY "Software\Microsoft\Windows\CurrentVersion\Uninstall\${UNINST_KEY_NAME}" + +!ifndef REQUEST_EXECUTION_LEVEL + !define REQUEST_EXECUTION_LEVEL "admin" +!endif + +RequestExecutionLevel "${REQUEST_EXECUTION_LEVEL}" + +!ifdef ARG_WAILS_AMD64_BINARY + !define SUPPORTS_AMD64 +!endif + +!ifdef ARG_WAILS_ARM64_BINARY + !define SUPPORTS_ARM64 +!endif + +!ifdef SUPPORTS_AMD64 + !ifdef SUPPORTS_ARM64 + !define ARCH "amd64_arm64" + !else + !define ARCH "amd64" + !endif +!else + !ifdef SUPPORTS_ARM64 + !define ARCH "arm64" + !else + !error "Wails: Undefined ARCH, please provide at least one of ARG_WAILS_AMD64_BINARY or ARG_WAILS_ARM64_BINARY" + !endif +!endif + +!macro wails.checkArchitecture + !ifndef WAILS_WIN10_REQUIRED + !define WAILS_WIN10_REQUIRED "This product is only supported on Windows 10 (Server 2016) and later." + !endif + + !ifndef WAILS_ARCHITECTURE_NOT_SUPPORTED + !define WAILS_ARCHITECTURE_NOT_SUPPORTED "This product can't be installed on the current Windows architecture. Supports: ${ARCH}" + !endif + + ${If} ${AtLeastWin10} + !ifdef SUPPORTS_AMD64 + ${if} ${IsNativeAMD64} + Goto ok + ${EndIf} + !endif + + !ifdef SUPPORTS_ARM64 + ${if} ${IsNativeARM64} + Goto ok + ${EndIf} + !endif + + IfSilent silentArch notSilentArch + silentArch: + SetErrorLevel 65 + Abort + notSilentArch: + MessageBox MB_OK "${WAILS_ARCHITECTURE_NOT_SUPPORTED}" + Quit + ${else} + IfSilent silentWin notSilentWin + silentWin: + SetErrorLevel 64 + Abort + notSilentWin: + MessageBox MB_OK "${WAILS_WIN10_REQUIRED}" + Quit + ${EndIf} + + ok: +!macroend + +!macro wails.files + !ifdef SUPPORTS_AMD64 + ${if} ${IsNativeAMD64} + File "/oname=${PRODUCT_EXECUTABLE}" "${ARG_WAILS_AMD64_BINARY}" + ${EndIf} + !endif + + !ifdef SUPPORTS_ARM64 + ${if} ${IsNativeARM64} + File "/oname=${PRODUCT_EXECUTABLE}" "${ARG_WAILS_ARM64_BINARY}" + ${EndIf} + !endif +!macroend + +!macro wails.writeUninstaller + WriteUninstaller "$INSTDIR\uninstall.exe" + + SetRegView 64 + !ifdef WAILS_INSTALL_SCOPE + !if "${WAILS_INSTALL_SCOPE}" == "user" + WriteRegStr HKCU "${UNINST_KEY}" "Publisher" "${INFO_COMPANYNAME}" + WriteRegStr HKCU "${UNINST_KEY}" "DisplayName" "${INFO_PRODUCTNAME}" + WriteRegStr HKCU "${UNINST_KEY}" "DisplayVersion" "${INFO_PRODUCTVERSION}" + WriteRegStr HKCU "${UNINST_KEY}" "DisplayIcon" "$INSTDIR\${PRODUCT_EXECUTABLE}" + WriteRegStr HKCU "${UNINST_KEY}" "UninstallString" "$\"$INSTDIR\uninstall.exe$\"" + WriteRegStr HKCU "${UNINST_KEY}" "QuietUninstallString" "$\"$INSTDIR\uninstall.exe$\" /S" + !else + WriteRegStr HKLM "${UNINST_KEY}" "Publisher" "${INFO_COMPANYNAME}" + WriteRegStr HKLM "${UNINST_KEY}" "DisplayName" "${INFO_PRODUCTNAME}" + WriteRegStr HKLM "${UNINST_KEY}" "DisplayVersion" "${INFO_PRODUCTVERSION}" + WriteRegStr HKLM "${UNINST_KEY}" "DisplayIcon" "$INSTDIR\${PRODUCT_EXECUTABLE}" + WriteRegStr HKLM "${UNINST_KEY}" "UninstallString" "$\"$INSTDIR\uninstall.exe$\"" + WriteRegStr HKLM "${UNINST_KEY}" "QuietUninstallString" "$\"$INSTDIR\uninstall.exe$\" /S" + !endif + !else + WriteRegStr HKLM "${UNINST_KEY}" "Publisher" "${INFO_COMPANYNAME}" + WriteRegStr HKLM "${UNINST_KEY}" "DisplayName" "${INFO_PRODUCTNAME}" + WriteRegStr HKLM "${UNINST_KEY}" "DisplayVersion" "${INFO_PRODUCTVERSION}" + WriteRegStr HKLM "${UNINST_KEY}" "DisplayIcon" "$INSTDIR\${PRODUCT_EXECUTABLE}" + WriteRegStr HKLM "${UNINST_KEY}" "UninstallString" "$\"$INSTDIR\uninstall.exe$\"" + WriteRegStr HKLM "${UNINST_KEY}" "QuietUninstallString" "$\"$INSTDIR\uninstall.exe$\" /S" + !endif + + ${GetSize} "$INSTDIR" "/S=0K" $0 $1 $2 + IntFmt $0 "0x%08X" $0 + + !ifdef WAILS_INSTALL_SCOPE + !if "${WAILS_INSTALL_SCOPE}" == "user" + WriteRegDWORD HKCU "${UNINST_KEY}" "EstimatedSize" "$0" + !else + WriteRegDWORD HKLM "${UNINST_KEY}" "EstimatedSize" "$0" + !endif + !else + WriteRegDWORD HKLM "${UNINST_KEY}" "EstimatedSize" "$0" + !endif +!macroend + +!macro wails.deleteUninstaller + Delete "$INSTDIR\uninstall.exe" + + SetRegView 64 + + !ifdef WAILS_INSTALL_SCOPE + !if "${WAILS_INSTALL_SCOPE}" == "user" + DeleteRegKey HKCU "${UNINST_KEY}" + !else + DeleteRegKey HKLM "${UNINST_KEY}" + !endif + !else + DeleteRegKey HKLM "${UNINST_KEY}" + !endif +!macroend + +!macro wails.setShellContext + ${If} ${REQUEST_EXECUTION_LEVEL} == "admin" + SetShellVarContext all + ${else} + SetShellVarContext current + ${EndIf} +!macroend + +# Install webview2 by launching the bootstrapper +# See https://docs.microsoft.com/en-us/microsoft-edge/webview2/concepts/distribution#online-only-deployment +!macro wails.webview2runtime + !ifndef WAILS_INSTALL_WEBVIEW_DETAILPRINT + !define WAILS_INSTALL_WEBVIEW_DETAILPRINT "Installing: WebView2 Runtime" + !endif + + SetRegView 64 + # If the admin key exists and is not empty then webview2 is already installed + ReadRegStr $0 HKLM "SOFTWARE\WOW6432Node\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" "pv" + ${If} $0 != "" + Goto ok + ${EndIf} + + ${If} ${REQUEST_EXECUTION_LEVEL} == "user" + # If the installer is run in user level, check the user specific key exists and is not empty then webview2 is already installed + ReadRegStr $0 HKCU "Software\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" "pv" + ${If} $0 != "" + Goto ok + ${EndIf} + ${EndIf} + + SetDetailsPrint both + DetailPrint "${WAILS_INSTALL_WEBVIEW_DETAILPRINT}" + SetDetailsPrint listonly + + InitPluginsDir + CreateDirectory "$pluginsdir\webview2bootstrapper" + SetOutPath "$pluginsdir\webview2bootstrapper" + File "tmp\MicrosoftEdgeWebview2Setup.exe" + ExecWait '"$pluginsdir\webview2bootstrapper\MicrosoftEdgeWebview2Setup.exe" /silent /install' + + SetDetailsPrint both + ok: +!macroend + +# Copy of APP_ASSOCIATE and APP_UNASSOCIATE macros from here https://gist.github.com/nikku/281d0ef126dbc215dd58bfd5b3a5cd5b +!macro APP_ASSOCIATE EXT FILECLASS DESCRIPTION ICON COMMANDTEXT COMMAND + ; Backup the previously associated file class + ReadRegStr $R0 SHELL_CONTEXT "Software\Classes\.${EXT}" "" + WriteRegStr SHELL_CONTEXT "Software\Classes\.${EXT}" "${FILECLASS}_backup" "$R0" + WriteRegStr SHELL_CONTEXT "Software\Classes\.${EXT}" "" "${FILECLASS}" + + WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}" "" `${DESCRIPTION}` + WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\DefaultIcon" "" `${ICON}` + WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\shell" "" "open" + WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\shell\open" "" `${COMMANDTEXT}` + WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\shell\open\command" "" `${COMMAND}` +!macroend + +!macro APP_UNASSOCIATE EXT FILECLASS + ; Backup the previously associated file class + ReadRegStr $R0 SHELL_CONTEXT "Software\Classes\.${EXT}" `${FILECLASS}_backup` + WriteRegStr SHELL_CONTEXT "Software\Classes\.${EXT}" "" "$R0" + + DeleteRegKey SHELL_CONTEXT `Software\Classes\${FILECLASS}` +!macroend + +!macro wails.associateFiles + ; Create file associations + {{range .Info.FileAssociations}} + !insertmacro APP_ASSOCIATE "{{.Ext}}" "{{.Name}}" "{{.Description}}" "$INSTDIR\{{.IconName}}.ico" "Open with ${INFO_PRODUCTNAME}" "$INSTDIR\${PRODUCT_EXECUTABLE} $\"%1$\"" + + File "..\{{.IconName}}.ico" + {{end}} +!macroend + +!macro wails.unassociateFiles + ; Delete app associations + {{range .Info.FileAssociations}} + !insertmacro APP_UNASSOCIATE "{{.Ext}}" "{{.Name}}" + + Delete "$INSTDIR\{{.IconName}}.ico" + {{end}} +!macroend + +!macro CUSTOM_PROTOCOL_ASSOCIATE PROTOCOL DESCRIPTION ICON COMMAND + DeleteRegKey SHELL_CONTEXT "Software\Classes\${PROTOCOL}" + WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}" "" "${DESCRIPTION}" + WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}" "URL Protocol" "" + WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}\DefaultIcon" "" "${ICON}" + WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}\shell" "" "" + WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}\shell\open" "" "" + WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}\shell\open\command" "" "${COMMAND}" +!macroend + +!macro CUSTOM_PROTOCOL_UNASSOCIATE PROTOCOL + DeleteRegKey SHELL_CONTEXT "Software\Classes\${PROTOCOL}" +!macroend + +!macro wails.associateCustomProtocols + ; Create custom protocols associations + {{range .Info.Protocols}} + !insertmacro CUSTOM_PROTOCOL_ASSOCIATE "{{.Scheme}}" "{{.Description}}" "$INSTDIR\${PRODUCT_EXECUTABLE},0" "$INSTDIR\${PRODUCT_EXECUTABLE} $\"%1$\"" + + {{end}} +!macroend + +!macro wails.unassociateCustomProtocols + ; Delete app custom protocol associations + {{range .Info.Protocols}} + !insertmacro CUSTOM_PROTOCOL_UNASSOCIATE "{{.Scheme}}" + {{end}} +!macroend diff --git a/build/windows/wails.exe.manifest b/build/windows/wails.exe.manifest new file mode 100644 index 0000000..17e1a23 --- /dev/null +++ b/build/windows/wails.exe.manifest @@ -0,0 +1,15 @@ + + + + + + + + + + + true/pm + permonitorv2,permonitor + + + \ No newline at end of file diff --git a/frontend/build.js b/frontend/build.js new file mode 100644 index 0000000..534a412 --- /dev/null +++ b/frontend/build.js @@ -0,0 +1,29 @@ +// 前端「构建」:这个项目没有框架和打包步骤,纯静态文件直接拷贝即可。 +// 保留这个脚本是为了满足 wails.json 里的 frontend:build 约定, +// 顺便做一件 Wails 要求的事:把非 wailsjs 的资源同步到 dist/。 + +const fs = require('fs'); +const path = require('path'); + +const FRONTEND = __dirname; +const DIST = path.join(FRONTEND, 'dist'); + +// 需要原样拷贝的入口文件与目录 +const ENTRIES = ['index.html', 'css', 'js']; + +function copy() { + fs.mkdirSync(DIST, { recursive: true }); + for (const name of ENTRIES) { + const src = path.join(FRONTEND, name); + if (!fs.existsSync(src)) continue; + fs.cpSync(src, path.join(DIST, name), { recursive: true }); + } + // wailsjs 是 wails build 自动生成的绑定,保留 + console.log('[frontend] 资源已同步到 dist/'); +} + +copy(); + +if (process.argv.includes('--watch')) { + console.log('[frontend] watch 模式:改动 src 后重新运行 wails dev 即可'); +} diff --git a/frontend/css/style.css b/frontend/css/style.css new file mode 100644 index 0000000..c980d2c --- /dev/null +++ b/frontend/css/style.css @@ -0,0 +1,196 @@ +:root { + --bg: #16181d; + --bg2: #1c1f26; + --bg3: #23272f; + --line: #2e333d; + --fg: #e6e8ec; + --dim: #8b93a3; + --accent: #4a9eff; + --green: #3fb950; + --amber: #d29922; + --red: #f85149; + --radius: 8px; +} + +* { box-sizing: border-box; } + +html, body { + margin: 0; height: 100%; + background: var(--bg); color: var(--fg); + font: 13px/1.5 -apple-system, BlinkMacSystemFont, "SF Pro Text", "PingFang SC", sans-serif; + -webkit-font-smoothing: antialiased; + overflow: hidden; +} + +.app { display: flex; flex-direction: column; height: 100%; } + +/* ---------- 顶栏 ---------- */ +.bar { + display: flex; align-items: center; justify-content: space-between; + padding: 12px 16px; + background: linear-gradient(180deg, var(--bg2), var(--bg)); + border-bottom: 1px solid var(--line); + -webkit-app-region: drag; +} +.bar .actions { -webkit-app-region: no-drag; display: flex; gap: 8px; } +.brand { display: flex; align-items: center; gap: 10px; } +.logo { font-size: 22px; } +.bar h1 { margin: 0; font-size: 14px; font-weight: 600; } +.sub { margin: 0; font-size: 11px; color: var(--dim); } + +/* ---------- 按钮 ---------- */ +.btn { + background: var(--bg3); color: var(--fg); + border: 1px solid var(--line); border-radius: var(--radius); + padding: 6px 13px; font-size: 12px; cursor: pointer; + transition: background .12s, border-color .12s; +} +.btn:hover:not(:disabled) { background: #2c313a; border-color: #3d4450; } +.btn:active:not(:disabled) { transform: translateY(1px); } +.btn:disabled { opacity: .4; cursor: default; } +.btn.primary { background: var(--accent); border-color: var(--accent); color: #04121f; font-weight: 600; } +.btn.primary:hover:not(:disabled) { background: #62acff; } +.btn.danger { color: var(--red); } +.btn.icon { padding: 6px 9px; } +.mini { + background: none; border: 1px solid var(--line); color: var(--dim); + border-radius: 5px; padding: 2px 8px; font-size: 11px; cursor: pointer; +} +.mini:hover { color: var(--fg); } + +/* ---------- 统计条 ---------- */ +.stats { + display: flex; align-items: center; gap: 18px; + padding: 9px 16px; background: var(--bg2); + border-bottom: 1px solid var(--line); font-size: 11px; +} +.stat { display: flex; align-items: baseline; gap: 5px; color: var(--dim); } +.stat b { font-size: 14px; color: var(--fg); font-variant-numeric: tabular-nums; } +.stat.queued b { color: var(--dim); } +.stat.running b { color: var(--accent); } +.stat.done b { color: var(--green); } +.stat.failed b { color: var(--red); } +.progress-wrap { display: flex; align-items: center; gap: 10px; margin-left: auto; } +.pbar { width: 200px; height: 5px; background: var(--bg3); border-radius: 3px; overflow: hidden; } +.pbar i { display: block; height: 100%; width: 0; background: var(--accent); transition: width .25s; } + +/* ---------- 任务表 ---------- */ +.main { flex: 1; overflow: auto; position: relative; } +.tasks { width: 100%; border-collapse: collapse; } +.tasks thead th { + position: sticky; top: 0; z-index: 2; + background: var(--bg2); text-align: left; font-weight: 500; + color: var(--dim); font-size: 11px; + padding: 8px 10px; border-bottom: 1px solid var(--line); +} +.tasks td { padding: 8px 10px; border-bottom: 1px solid #21252c; vertical-align: middle; } +.tasks tr:hover td { background: #1b1e25; } +.c-sel { width: 30px; } +.c-gid { width: 80px; font-variant-numeric: tabular-nums; color: var(--dim); } +.c-prog { width: 190px; } +.c-stat { width: 80px; } +.c-act { width: 110px; text-align: right; white-space: nowrap; } + +.title-cell { display: flex; flex-direction: column; gap: 2px; } +.title-cell .t { font-size: 12px; } +.title-cell .e { font-size: 10.5px; color: var(--red); } + +.rowprog { display: flex; align-items: center; gap: 8px; } +.rowprog .rb { flex: 1; height: 4px; background: var(--bg3); border-radius: 2px; overflow: hidden; } +.rowprog .rb i { display: block; height: 100%; background: var(--accent); } +.rowprog .rn { font-size: 10.5px; color: var(--dim); font-variant-numeric: tabular-nums; min-width: 62px; text-align: right; } + +.badge { display: inline-block; padding: 2px 7px; border-radius: 20px; font-size: 10.5px; } +.b-queued { background: #2a2f38; color: var(--dim); } +.b-running { background: rgba(74,158,255,.15); color: var(--accent); } +.b-done { background: rgba(63,185,80,.15); color: var(--green); } +.b-failed { background: rgba(248,81,73,.15); color: var(--red); } +.b-partial { background: rgba(210,153,34,.15); color: var(--amber); } + +.empty { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; } +.empty[hidden] { display: none; } +.empty-inner { text-align: center; color: var(--dim); } +.empty .big { font-size: 15px; color: var(--fg); margin: 0 0 6px; } +.empty code, .hint code { background: var(--bg3); padding: 1px 5px; border-radius: 4px; font-size: 11px; } +.dim { font-size: 11px; opacity: .7; } + +/* ---------- 日志 ---------- */ +.logpane { border-top: 1px solid var(--line); background: var(--bg2); flex-shrink: 0; } +.log-head { + display: flex; align-items: center; gap: 8px; + padding: 6px 14px; font-size: 11px; color: var(--dim); cursor: pointer; +} +.log-head .spacer { flex: 1; } +.log-count { font-variant-numeric: tabular-nums; } +.log-body { + height: 0; overflow-y: auto; padding: 0 14px; + font: 11px/1.6 ui-monospace, SFMono-Regular, Menlo, monospace; + transition: height .18s ease; +} +.logpane.open .log-body { height: 168px; padding: 4px 14px 10px; } +.logpane.open .caret { transform: rotate(90deg); } +.log-line { white-space: pre-wrap; word-break: break-all; } +.log-line .t { color: #555d6b; margin-right: 7px; } +.log-line.info { color: var(--dim); } +.log-line.warn { color: var(--amber); } +.log-line.error { color: var(--red); } +.log-line.ok { color: var(--green); } + +/* ---------- 弹窗 ---------- */ +.modal { + position: fixed; inset: 0; z-index: 50; + background: rgba(0,0,0,.55); + display: flex; align-items: center; justify-content: center; + backdrop-filter: blur(3px); +} +.modal[hidden] { display: none; } +.sheet { + width: 540px; max-width: 92vw; max-height: 86vh; overflow: auto; + background: var(--bg2); border: 1px solid var(--line); + border-radius: 12px; padding: 20px; + box-shadow: 0 20px 60px rgba(0,0,0,.5); +} +.sheet.narrow { width: 400px; } +.sheet h2 { margin: 0 0 4px; font-size: 15px; } +.hint { margin: 0 0 12px; font-size: 11px; color: var(--dim); } +#cf-body { font-size: 12px; color: var(--fg); line-height: 1.65; } +.btn.danger:disabled { opacity: .35; } +.sheet textarea { + width: 100%; height: 190px; resize: vertical; + background: var(--bg); color: var(--fg); + border: 1px solid var(--line); border-radius: var(--radius); + padding: 10px; font: 12px/1.6 ui-monospace, Menlo, monospace; +} +.sheet textarea:focus, .sheet input:focus, .sheet select:focus { + outline: none; border-color: var(--accent); +} +.row { display: flex; gap: 8px; align-items: center; margin-top: 10px; } +.sheet-foot { display: flex; gap: 8px; justify-content: flex-end; align-items: center; margin-top: 16px; } +.sheet-foot .msg { flex: 1; font-size: 11px; color: var(--amber); } + +.field { display: block; margin-bottom: 16px; } +.field > span { display: block; margin-bottom: 6px; font-size: 12px; } +.field small { display: block; margin-top: 5px; font-size: 10.5px; color: var(--dim); line-height: 1.5; } +.field input[type=text], .field select { + width: 100%; background: var(--bg); color: var(--fg); + border: 1px solid var(--line); border-radius: var(--radius); + padding: 7px 10px; font-size: 12px; +} +.field input[type=range] { width: 100%; accent-color: var(--accent); } +.field .row input[type=text] { flex: 1; } + +.presets { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; } +.presets button { + background: var(--bg); color: var(--dim); + border: 1px solid var(--line); border-radius: 20px; + padding: 3px 11px; font-size: 11px; cursor: pointer; + font-family: ui-monospace, Menlo, monospace; +} +.presets button:hover { color: var(--fg); border-color: var(--accent); } +.presets button.on { color: var(--accent); border-color: var(--accent); } +#dir-preview { + font-family: ui-monospace, Menlo, monospace; + color: var(--green) !important; + word-break: break-all; +} +code { background: var(--bg3); padding: 1px 5px; border-radius: 4px; font-size: 10.5px; } \ No newline at end of file diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..e71398a --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,190 @@ + + + + +EH Downloader + + + +
+ +
+
+ +
+

EH Downloader

+

E-Hentai 画廊批量下载 · 2026 版协议 · Wails

+
+
+
+ + + + + + + +
+
+ +
+
0全部
+
0等待
+
0进行
+
0完成
+
0失败
+
+
+ — +
+
+ +
+ + + + + + + + + + + + +
GID标题进度状态操作
+
+
+

还没有任务

+

点「导入链接」,粘贴一个 txt 文件或直接粘 https://e-hentai.org/g/123456/abcdef1234/

+

一行一个画廊,也支持竖线/逗号分隔。一行粘多个也行。

+
+
+
+ +
+
+ ▸ + 日志 + +
+ + +
+
+
+ +
+ + + + + + + + + + + + + + + + diff --git a/frontend/js/api.js b/frontend/js/api.js new file mode 100644 index 0000000..becf5c2 --- /dev/null +++ b/frontend/js/api.js @@ -0,0 +1,52 @@ +// Wails API 适配层 +// +// 前端业务代码只用 window.eh.*,这里把它接到 Wails 的 Go bindings 上。 +// 这样以后即便换框架,也只要改这一个文件。 + +const go = () => window.go && window.go.main && window.go.main.App; + +function requireGo() { + const g = go(); + if (!g) throw new Error('Wails 后端未就绪'); + return g; +} + +window.eh = { + // ---- 设置 ---- + getSettings: async () => requireGo().GetSettings(), + setSettings: async (patch) => requireGo().SetSettings(patch), + previewDir: async (tpl) => requireGo().PreviewDir(tpl), + placeholders: async () => requireGo().Placeholders(), + + // ---- 任务 ---- + listTasks: async () => requireGo().ListTasks(), + addTasks: async (text) => requireGo().AddTasks(text), + removeTask: async (t) => requireGo().RemoveTask(t.gid, t.token || ''), + clearFinished: async () => requireGo().ClearFinished(), + countDone: async () => requireGo().CountDone(), + clearAll: async (onlySelected) => requireGo().ClearAll(!!onlySelected), + requeueFailed: async () => requireGo().RequeueFailed(), + selectAll: async (gids) => requireGo().SelectAll(gids), + + // ---- 引擎 ---- + status: async () => requireGo().Status(), + start: async (payload) => { + const gids = (payload && payload.onlyGids) || []; + return requireGo().Start(gids); + }, + stop: async () => requireGo().Stop(), + + // ---- 系统 ---- + pickDir: async () => requireGo().PickDir(), + exportFailed: async (gid) => { + const path = await requireGo().ExportFailed(gid); + return path ? { ok: true, filePath: path } : { ok: false }; + }, + openPath: async (p) => requireGo().OpenPath(p), + showItem: async (p) => requireGo().ShowItem(p), + + // ---- 事件 ---- + onTaskUpdate: (cb) => window.runtime.EventsOn('task:update', cb), + onLog: (cb) => window.runtime.EventsOn('log', cb), + onDone: (cb) => window.runtime.EventsOn('engine:done', cb), +}; diff --git a/frontend/js/renderer.js b/frontend/js/renderer.js new file mode 100644 index 0000000..f319a38 --- /dev/null +++ b/frontend/js/renderer.js @@ -0,0 +1,450 @@ +'use strict'; + +const $ = (id) => document.getElementById(id); + +let tasks = []; +let running = false; +let lastLogSeq = 0; +const logBuf = []; + +// ---------------- 渲染 ---------------- + +const STATUS_TEXT = { + queued: '等待', running: '解析中', downloading: '下载中', + done: '完成', failed: '失败', partial: '部分失败', +}; + +function esc(s) { + return String(s == null ? '' : s).replace(/[&<>"']/g, (c) => + ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); +} + +function render() { + const tb = $('tbody'); + if (!tasks.length) { + tb.innerHTML = ''; + $('empty').hidden = false; + $('tasks').style.display = 'none'; + } else { + $('empty').hidden = true; + $('tasks').style.display = ''; + tb.innerHTML = tasks.map(rowHtml).join(''); + } + renderStats(); + renderSelState(); +} + +// 「删除选中」按钮的可用状态跟着勾选走 +function renderSelState() { + const n = tasks.filter((t) => t.selected).length; + const b = $('btn-delsel'); + b.disabled = n === 0; + b.textContent = n ? `删除选中 (${n})` : '删除选中'; + $('btn-clearall').disabled = tasks.length === 0; +} + +function rowHtml(t) { + const total = t.totalPages || 0; + const pct = total ? Math.round((t.done / total) * 100) : (t.status === 'done' ? 100 : 0); + const badgeCls = 'b-' + (t.status || 'queued'); + const title = t.title || t.raw || '(未解析)'; + const err = t.error + ? `${esc(t.error)}` + : (t.failedList && t.failedList.length + ? `${t.failedList.length} 张失败:${esc(t.failedList[0].error || '')}` + : ''); + const acts = []; + if (t.failedList && t.failedList.length) { + acts.push(``); + } + acts.push(``); + + return ` + + ${esc(t.gid)} +
${esc(title)}${err}
+ +
+
+ ${t.done}/${total || '?'}${t.failed ? ` (✗${t.failed})` : ''} +
+ + ${STATUS_TEXT[t.status] || t.status} + ${acts.join(' ')} + `; +} + +function renderStats() { + const c = { queued: 0, running: 0, done: 0, failed: 0 }; + let dn = 0, tt = 0; + for (const t of tasks) { + if (t.status === 'queued') c.queued++; + else if (t.status === 'running' || t.status === 'downloading') c.running++; + else if (t.status === 'done') c.done++; + else if (t.status === 'failed' || t.status === 'partial') c.failed++; + dn += t.done || 0; + tt += t.totalPages || 0; + } + $('s-total').textContent = tasks.length; + $('s-queued').textContent = c.queued; + $('s-running').textContent = c.running; + $('s-done').textContent = c.done; + $('s-failed').textContent = c.failed; + const pct = tt ? Math.round((dn / tt) * 100) : 0; + $('s-bar').style.width = pct + '%'; + $('s-text').textContent = tt ? `${dn}/${tt} 张 · ${pct}%` : '—'; +} + +function applyTask(t) { + const i = tasks.findIndex((x) => x.gid === t.gid && x.token === t.token); + if (i >= 0) tasks[i] = { ...tasks[i], ...t }; + else tasks.unshift(t); +} + +// ---------------- 日志 ---------------- + +function pushLog(rec) { + if (rec.seq && rec.seq <= lastLogSeq) return; + if (rec.seq) lastLogSeq = rec.seq; + logBuf.push(rec); + if (logBuf.length > 2000) logBuf.shift(); + const d = new Date(rec.at); + const ts = `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}:${String(d.getSeconds()).padStart(2, '0')}`; + const el = document.createElement('div'); + el.className = 'log-line ' + (rec.level || 'info'); + el.innerHTML = `${ts}${esc(rec.msg)}`; + const body = $('log-body'); + const stick = body.scrollHeight - body.scrollTop - body.clientHeight < 40; + body.appendChild(el); + if (stick) body.scrollTop = body.scrollHeight; + $('log-count').textContent = `(${logBuf.length})`; +} + +// ---------------- 弹窗 ---------------- + +function openModal(id) { $(id).hidden = false; } +function closeModal(id) { $(id).hidden = true; } + +// ---------------- 设置 ---------------- + +let settings = {}; + +async function loadSettings() { + settings = await window.eh.getSettings(); + $('set-dir').value = settings.downloadRoot || ''; + $('set-proxy').value = settings.proxy || ''; + $('set-proxymode').value = settings.proxyMode || 'off'; + $('set-conc').value = settings.concurrency || 3; + $('set-conc-v').textContent = settings.concurrency || 3; + $('set-to').value = Math.round((settings.timeout || 30000) / 1000); + $('set-to-v').textContent = Math.round((settings.timeout || 30000) / 1000); + $('set-xres').value = String(settings.xres || 'auto'); + $('set-bridgeport').value = String(settings.bridgePort || 37777); + $('set-dirtpl').value = settings.dirTemplate || '{title}'; + renderPresets(); + refreshPreview(); +} + +const DIR_PRESETS = [ + ['{title}', '仅标题'], + ['{artist}/{title}', '按作者'], + ['{group}/{title}', '按社团'], + ['{series}/{title}', '按系列'], + ['{category}/{group}/{title}', '分类/社团/标题'], + ['{tags:2}/{artist}/{title}', '标签/作者/标题'], + ['{artist} - {gid} - {title}', '作者 - GID - 标题'], +]; + +function renderPresets() { + const box = $('dir-presets'); + const cur = $('set-dirtpl').value; + box.innerHTML = ''; + for (const [tpl, label] of DIR_PRESETS) { + const b = document.createElement('button'); + b.type = 'button'; + b.textContent = label; + b.title = tpl; + if (tpl === cur) b.classList.add('on'); + b.onclick = () => { + $('set-dirtpl').value = tpl; + renderPresets(); + refreshPreview(); + }; + box.appendChild(b); + } +} + +async function refreshPreview() { + const tpl = $('set-dirtpl').value; + const r = await window.eh.previewDir(tpl); + $('dir-preview').textContent = r.ok ? `示例结果:${r.path}` : `模板有误:${r.error}`; +} + +// ---------------- 事件 ---------------- + +function bind() { + $('btn-import').onclick = () => { $('import-text').value = ''; $('import-msg').textContent = ''; openModal('modal-import'); }; + $('btn-import-cancel').onclick = () => closeModal('modal-import'); + $('btn-import-ok').onclick = doImport; + + $('import-file').onchange = async (e) => { + const f = e.target.files[0]; + if (!f) return; + $('import-text').value = await f.text(); + }; + + $('btn-start').onclick = doStart; + $('btn-stop').onclick = async () => { + await window.eh.stop(); + setRunning(false); + }; + $('btn-clear').onclick = doClearFinished; + + $('btn-settings').onclick = async () => { await loadSettings(); openModal('modal-settings'); }; + $('btn-settings-cancel').onclick = () => closeModal('modal-settings'); + $('btn-settings-ok').onclick = saveSettings; + $('btn-pickdir').onclick = async () => { + const d = await window.eh.pickDir(); + if (d) $('set-dir').value = d; + }; + + $('set-conc').oninput = (e) => { $('set-conc-v').textContent = e.target.value; }; + $('set-to').oninput = (e) => { $('set-to-v').textContent = e.target.value; }; + $('set-dirtpl').oninput = () => { renderPresets(); refreshPreview(); }; + + $('sel-all').onchange = async (e) => { + for (const cb of document.querySelectorAll('.sel')) cb.checked = e.target.checked; + for (const t of tasks) t.selected = e.target.checked; + renderSelState(); + await window.eh.selectAll( + e.target.checked ? tasks.map((t) => t.gid) : [] + ); + }; + + // 单行勾选:同步到主进程并刷新「删除选中」按钮 + $('tbody').addEventListener('change', async (e) => { + const cb = e.target.closest('input.sel'); + if (!cb) return; + const t = tasks.find((x) => x.gid === cb.dataset.gid); + if (t) t.selected = cb.checked; + renderSelState(); + await window.eh.selectAll(tasks.filter((x) => x.selected).map((x) => x.gid)); + }); + + $('btn-clearall').onclick = () => doClearAll(false); + $('btn-delsel').onclick = () => doClearAll(true); + + $('tbody').onclick = async (e) => { + const btn = e.target.closest('button[data-act]'); + if (!btn) return; + const { act, gid, token } = btn.dataset; + if (act === 'del') { + await window.eh.removeTask({ gid, token: token || null }); + tasks = tasks.filter((t) => !(t.gid === gid && (t.token || null) === (token || null))); + render(); + } else if (act === 'export') { + const r = await window.eh.exportFailed(gid); + if (r.ok) pushLog({ level: 'ok', msg: `失败清单已导出:${r.filePath}`, at: Date.now() }); + } + }; + + $('log-head').onclick = (e) => { + if (e.target.closest('button')) return; + toggleLog(); + }; + $('btn-togglelog').onclick = toggleLog; + $('btn-clearlog').onclick = () => { + logBuf.length = 0; lastLogSeq = 0; + $('log-body').innerHTML = ''; + $('log-count').textContent = ''; + }; + + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape') { + if (confirmCancelHook) { confirmCancelHook(); return; } + closeModal('modal-import'); + closeModal('modal-settings'); + } + }); +} + +function toggleLog() { + const p = $('logpane'); + p.classList.toggle('open'); + $('btn-togglelog').textContent = p.classList.contains('open') ? '收起' : '展开'; +} + +async function doImport() { + const text = $('import-text').value.trim(); + if (!text) { $('import-msg').textContent = '先粘贴点内容'; return; } + const r = await window.eh.addTasks(text); + if (r.warning) $('import-msg').textContent = r.warning; + tasks = await window.eh.listTasks(); + render(); + if (!r.warning) { + pushLog({ level: 'ok', msg: `已导入 ${r.added} 个画廊`, at: Date.now() }); + closeModal('modal-import'); + } +} + +async function saveSettings() { + const patch = { + downloadRoot: $('set-dir').value.trim(), + proxy: $('set-proxy').value.trim(), + proxyMode: $('set-proxymode').value, + xres: $('set-xres').value, + concurrency: parseInt($('set-conc').value, 10), + timeout: parseInt($('set-to').value, 10) * 1000, + bridgePort: parseInt($('set-bridgeport').value, 10) || 37777, + dirTemplate: $('set-dirtpl').value.trim() || '{title}', + }; + await window.eh.setSettings(patch); + $('set-msg').textContent = ''; + closeModal('modal-settings'); + pushLog({ level: 'ok', msg: '设置已保存', at: Date.now() }); +} + +async function doStart() { + const sel = [...document.querySelectorAll('.sel')].filter((c) => c.checked); + const gids = sel.map((c) => c.dataset.gid); + const r = await window.eh.start(gids.length ? { onlyGids: gids } : {}); + if (!r.started) { + pushLog({ level: 'warn', msg: r.reason, at: Date.now() }); + return; + } + setRunning(true); +} + +// 清除已完成:两步确认,避免误点;只清任务列表,不碰本地文件 +let clearArmed = false; +let clearTimer = null; + +async function doClearFinished() { + const btn = $('btn-clear'); + const doneCount = tasks.filter((t) => t.status === 'done').length; + + if (!doneCount) { + pushLog({ level: 'warn', msg: '没有已完成的任务', at: Date.now() }); + return; + } + + if (!clearArmed) { + clearArmed = true; + btn.textContent = `确认清除 ${doneCount} 项?`; + btn.classList.add('primary'); + clearTimer = setTimeout(disarmClear, 4000); + return; + } + + disarmClear(); + const r = await window.eh.clearFinished(); + tasks = r.tasks; + render(); + pushLog({ + level: 'ok', + msg: `已清除 ${r.removed} 个已完成任务 · 本地已下载的文件一个没动`, + at: Date.now(), + }); +} + +function disarmClear() { + clearArmed = false; + if (clearTimer) { clearTimeout(clearTimer); clearTimer = null; } + const btn = $('btn-clear'); + btn.textContent = '清除已完成'; + btn.classList.remove('primary'); +} + +// ---------------- 删除任务(列表项,本地文件不动) ---------------- + +/** 通用确认弹窗,resolve(true)=确认 false=取消/ESC */ +let confirmCancelHook = null; + +function confirmDialog(title, body, okText = '确认') { + return new Promise((resolve) => { + $('cf-title').textContent = title; + $('cf-body').textContent = body; + $('cf-msg').textContent = ''; + const ok = $('cf-ok'); + ok.textContent = okText; + openModal('modal-confirm'); + + const finish = (v) => { + ok.onclick = null; + $('cf-cancel').onclick = null; + confirmCancelHook = null; + closeModal('modal-confirm'); + resolve(v); + }; + + ok.onclick = () => finish(true); + $('cf-cancel').onclick = () => finish(false); + confirmCancelHook = () => finish(false); + }); +} + +/** @param {boolean} onlySelected true=只删勾选的,false=清空全部 */ +async function doClearAll(onlySelected) { + const n = onlySelected ? tasks.filter((t) => t.selected).length : tasks.length; + + if (!n) { + pushLog({ + level: 'warn', + msg: onlySelected ? '没有勾选任何任务' : '任务列表已经是空的', + at: Date.now(), + }); + return; + } + + const ok = await confirmDialog( + onlySelected ? `确认删除选中的 ${n} 个任务?` : `确认删除全部 ${n} 个任务?`, + `将从任务列表里移除${onlySelected ? `这 ${n} 个` : `全部 ${n} 个`}任务。` + + `\n\n⚠️ 只删列表项,本地已经下载的图片不会受到任何影响。`, + '删除' + ); + if (!ok) return; + + const r = await window.eh.clearAll(onlySelected); + tasks = r.tasks; + render(); + pushLog({ + level: 'warn', + msg: `已删除 ${r.removed} 个任务 · 本地已下载的文件一个没动`, + at: Date.now(), + }); +} + +function setRunning(v) { + running = v; + $('btn-start').disabled = v; + $('btn-stop').disabled = !v; +} + +// ---------------- 启动 ---------------- + +async function init() { + bind(); + await loadSettings(); + tasks = await window.eh.listTasks(); + render(); + + const st = await window.eh.status(); + setRunning(st.running); + + window.eh.onTaskUpdate((t) => { + applyTask(t); + render(); + if (t.status === 'running' || t.status === 'downloading') setRunning(true); + }); + + window.eh.onLog(pushLog); + + window.eh.onDone(() => { + setRunning(false); + pushLog({ level: 'ok', msg: '全部任务结束', at: Date.now() }); + }); + + pushLog({ level: 'info', msg: `就绪 · 共 ${tasks.length} 个任务`, at: Date.now() }); +} + +document.addEventListener('DOMContentLoaded', init); \ No newline at end of file diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..24a9665 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,12 @@ +{ + "name": "eh-downloader-wails-frontend", + "version": "0.2.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "eh-downloader-wails-frontend", + "version": "0.2.0" + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..631754a --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,10 @@ +{ + "name": "eh-downloader-wails-frontend", + "version": "0.2.0", + "private": true, + "description": "EH Downloader 前端(纯静态,无构建步骤)", + "scripts": { + "build": "node build.js", + "dev": "node build.js --watch" + } +} diff --git a/frontend/package.json.md5 b/frontend/package.json.md5 new file mode 100644 index 0000000..427cee1 --- /dev/null +++ b/frontend/package.json.md5 @@ -0,0 +1 @@ +3aa97fc01839000a0ae7b232b230eab8 \ No newline at end of file diff --git a/frontend/wailsjs/go/main/App.d.ts b/frontend/wailsjs/go/main/App.d.ts new file mode 100644 index 0000000..9ac0d94 --- /dev/null +++ b/frontend/wailsjs/go/main/App.d.ts @@ -0,0 +1,42 @@ +// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL +// This file is automatically generated. DO NOT EDIT +import {main} from '../models'; +import {core} from '../models'; + +export function AddTasks(arg1:string):Promise; + +export function ClearAll(arg1:boolean):Promise>; + +export function ClearFinished():Promise>; + +export function CountDone():Promise; + +export function ExportFailed(arg1:string):Promise; + +export function GetSettings():Promise; + +export function ListTasks():Promise>; + +export function OpenPath(arg1:string):Promise; + +export function PickDir():Promise; + +export function Placeholders():Promise>; + +export function PreviewDir(arg1:string):Promise; + +export function RemoveTask(arg1:string,arg2:string):Promise>; + +export function RequeueFailed():Promise>; + +export function SelectAll(arg1:Array):Promise>; + +export function SetSettings(arg1:Record):Promise; + +export function ShowItem(arg1:string):Promise; + +export function Start(arg1:Array):Promise; + +export function Status():Promise; + +export function Stop():Promise>; diff --git a/frontend/wailsjs/go/main/App.js b/frontend/wailsjs/go/main/App.js new file mode 100644 index 0000000..43d1869 --- /dev/null +++ b/frontend/wailsjs/go/main/App.js @@ -0,0 +1,79 @@ +// @ts-check +// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL +// This file is automatically generated. DO NOT EDIT + +export function AddTasks(arg1) { + return window['go']['main']['App']['AddTasks'](arg1); +} + +export function ClearAll(arg1) { + return window['go']['main']['App']['ClearAll'](arg1); +} + +export function ClearFinished() { + return window['go']['main']['App']['ClearFinished'](); +} + +export function CountDone() { + return window['go']['main']['App']['CountDone'](); +} + +export function ExportFailed(arg1) { + return window['go']['main']['App']['ExportFailed'](arg1); +} + +export function GetSettings() { + return window['go']['main']['App']['GetSettings'](); +} + +export function ListTasks() { + return window['go']['main']['App']['ListTasks'](); +} + +export function OpenPath(arg1) { + return window['go']['main']['App']['OpenPath'](arg1); +} + +export function PickDir() { + return window['go']['main']['App']['PickDir'](); +} + +export function Placeholders() { + return window['go']['main']['App']['Placeholders'](); +} + +export function PreviewDir(arg1) { + return window['go']['main']['App']['PreviewDir'](arg1); +} + +export function RemoveTask(arg1, arg2) { + return window['go']['main']['App']['RemoveTask'](arg1, arg2); +} + +export function RequeueFailed() { + return window['go']['main']['App']['RequeueFailed'](); +} + +export function SelectAll(arg1) { + return window['go']['main']['App']['SelectAll'](arg1); +} + +export function SetSettings(arg1) { + return window['go']['main']['App']['SetSettings'](arg1); +} + +export function ShowItem(arg1) { + return window['go']['main']['App']['ShowItem'](arg1); +} + +export function Start(arg1) { + return window['go']['main']['App']['Start'](arg1); +} + +export function Status() { + return window['go']['main']['App']['Status'](); +} + +export function Stop() { + return window['go']['main']['App']['Stop'](); +} diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts new file mode 100644 index 0000000..6549ee6 --- /dev/null +++ b/frontend/wailsjs/go/models.ts @@ -0,0 +1,174 @@ +export namespace core { + + export class FailedItem { + page: number; + file: string; + error: string; + + static createFrom(source: any = {}) { + return new FailedItem(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.page = source["page"]; + this.file = source["file"]; + this.error = source["error"]; + } + } + export class Settings { + downloadRoot: string; + proxy: string; + proxyMode: string; + concurrency: number; + timeout: number; + xres: string; + bridgePort: number; + dirTemplate: string; + _probed: boolean; + + static createFrom(source: any = {}) { + return new Settings(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.downloadRoot = source["downloadRoot"]; + this.proxy = source["proxy"]; + this.proxyMode = source["proxyMode"]; + this.concurrency = source["concurrency"]; + this.timeout = source["timeout"]; + this.xres = source["xres"]; + this.bridgePort = source["bridgePort"]; + this.dirTemplate = source["dirTemplate"]; + this._probed = source["_probed"]; + } + } + export class Task { + gid: string; + token: string; + raw: string; + title: string; + status: string; + done: number; + failed: number; + totalPages: number; + error: string; + dir: string; + pages: Record; + failedList: FailedItem[]; + selected: boolean; + createdAt: number; + updatedAt: number; + + static createFrom(source: any = {}) { + return new Task(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.gid = source["gid"]; + this.token = source["token"]; + this.raw = source["raw"]; + this.title = source["title"]; + this.status = source["status"]; + this.done = source["done"]; + this.failed = source["failed"]; + this.totalPages = source["totalPages"]; + this.error = source["error"]; + this.dir = source["dir"]; + this.pages = source["pages"]; + this.failedList = this.convertValues(source["failedList"], FailedItem); + this.selected = source["selected"]; + this.createdAt = source["createdAt"]; + this.updatedAt = source["updatedAt"]; + } + + convertValues(a: any, classs: any, asMap: boolean = false): any { + if (!a) { + return a; + } + if (a.slice && a.map) { + return (a as any[]).map(elem => this.convertValues(elem, classs)); + } else if ("object" === typeof a) { + if (asMap) { + for (const key of Object.keys(a)) { + a[key] = new classs(a[key]); + } + return a; + } + return new classs(a); + } + return a; + } + } + +} + +export namespace main { + + export class AddTasksResult { + added: number; + skipped: number; + warning: string; + + static createFrom(source: any = {}) { + return new AddTasksResult(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.added = source["added"]; + this.skipped = source["skipped"]; + this.warning = source["warning"]; + } + } + export class DirPreviewResult { + ok: boolean; + path: string; + error: string; + + static createFrom(source: any = {}) { + return new DirPreviewResult(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.ok = source["ok"]; + this.path = source["path"]; + this.error = source["error"]; + } + } + export class StartResult { + started: boolean; + reason: string; + count: number; + + static createFrom(source: any = {}) { + return new StartResult(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.started = source["started"]; + this.reason = source["reason"]; + this.count = source["count"]; + } + } + export class StatusResult { + running: boolean; + queued: number; + + static createFrom(source: any = {}) { + return new StatusResult(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.running = source["running"]; + this.queued = source["queued"]; + } + } + +} + diff --git a/frontend/wailsjs/runtime/package.json b/frontend/wailsjs/runtime/package.json new file mode 100644 index 0000000..1e7c8a5 --- /dev/null +++ b/frontend/wailsjs/runtime/package.json @@ -0,0 +1,24 @@ +{ + "name": "@wailsapp/runtime", + "version": "2.0.0", + "description": "Wails Javascript runtime library", + "main": "runtime.js", + "types": "runtime.d.ts", + "scripts": { + }, + "repository": { + "type": "git", + "url": "git+https://github.com/wailsapp/wails.git" + }, + "keywords": [ + "Wails", + "Javascript", + "Go" + ], + "author": "Lea Anthony ", + "license": "MIT", + "bugs": { + "url": "https://github.com/wailsapp/wails/issues" + }, + "homepage": "https://github.com/wailsapp/wails#readme" +} diff --git a/frontend/wailsjs/runtime/runtime.d.ts b/frontend/wailsjs/runtime/runtime.d.ts new file mode 100644 index 0000000..3bbea84 --- /dev/null +++ b/frontend/wailsjs/runtime/runtime.d.ts @@ -0,0 +1,330 @@ +/* + _ __ _ __ +| | / /___ _(_) /____ +| | /| / / __ `/ / / ___/ +| |/ |/ / /_/ / / (__ ) +|__/|__/\__,_/_/_/____/ +The electron alternative for Go +(c) Lea Anthony 2019-present +*/ + +export interface Position { + x: number; + y: number; +} + +export interface Size { + w: number; + h: number; +} + +export interface Screen { + isCurrent: boolean; + isPrimary: boolean; + width : number + height : number +} + +// Environment information such as platform, buildtype, ... +export interface EnvironmentInfo { + buildType: string; + platform: string; + arch: string; +} + +// [EventsEmit](https://wails.io/docs/reference/runtime/events#eventsemit) +// emits the given event. Optional data may be passed with the event. +// This will trigger any event listeners. +export function EventsEmit(eventName: string, ...data: any): void; + +// [EventsOn](https://wails.io/docs/reference/runtime/events#eventson) sets up a listener for the given event name. +export function EventsOn(eventName: string, callback: (...data: any) => void): () => void; + +// [EventsOnMultiple](https://wails.io/docs/reference/runtime/events#eventsonmultiple) +// sets up a listener for the given event name, but will only trigger a given number times. +export function EventsOnMultiple(eventName: string, callback: (...data: any) => void, maxCallbacks: number): () => void; + +// [EventsOnce](https://wails.io/docs/reference/runtime/events#eventsonce) +// sets up a listener for the given event name, but will only trigger once. +export function EventsOnce(eventName: string, callback: (...data: any) => void): () => void; + +// [EventsOff](https://wails.io/docs/reference/runtime/events#eventsoff) +// unregisters the listener for the given event name. +export function EventsOff(eventName: string, ...additionalEventNames: string[]): void; + +// [EventsOffAll](https://wails.io/docs/reference/runtime/events#eventsoffall) +// unregisters all listeners. +export function EventsOffAll(): void; + +// [LogPrint](https://wails.io/docs/reference/runtime/log#logprint) +// logs the given message as a raw message +export function LogPrint(message: string): void; + +// [LogTrace](https://wails.io/docs/reference/runtime/log#logtrace) +// logs the given message at the `trace` log level. +export function LogTrace(message: string): void; + +// [LogDebug](https://wails.io/docs/reference/runtime/log#logdebug) +// logs the given message at the `debug` log level. +export function LogDebug(message: string): void; + +// [LogError](https://wails.io/docs/reference/runtime/log#logerror) +// logs the given message at the `error` log level. +export function LogError(message: string): void; + +// [LogFatal](https://wails.io/docs/reference/runtime/log#logfatal) +// logs the given message at the `fatal` log level. +// The application will quit after calling this method. +export function LogFatal(message: string): void; + +// [LogInfo](https://wails.io/docs/reference/runtime/log#loginfo) +// logs the given message at the `info` log level. +export function LogInfo(message: string): void; + +// [LogWarning](https://wails.io/docs/reference/runtime/log#logwarning) +// logs the given message at the `warning` log level. +export function LogWarning(message: string): void; + +// [WindowReload](https://wails.io/docs/reference/runtime/window#windowreload) +// Forces a reload by the main application as well as connected browsers. +export function WindowReload(): void; + +// [WindowReloadApp](https://wails.io/docs/reference/runtime/window#windowreloadapp) +// Reloads the application frontend. +export function WindowReloadApp(): void; + +// [WindowSetAlwaysOnTop](https://wails.io/docs/reference/runtime/window#windowsetalwaysontop) +// Sets the window AlwaysOnTop or not on top. +export function WindowSetAlwaysOnTop(b: boolean): void; + +// [WindowSetSystemDefaultTheme](https://wails.io/docs/next/reference/runtime/window#windowsetsystemdefaulttheme) +// *Windows only* +// Sets window theme to system default (dark/light). +export function WindowSetSystemDefaultTheme(): void; + +// [WindowSetLightTheme](https://wails.io/docs/next/reference/runtime/window#windowsetlighttheme) +// *Windows only* +// Sets window to light theme. +export function WindowSetLightTheme(): void; + +// [WindowSetDarkTheme](https://wails.io/docs/next/reference/runtime/window#windowsetdarktheme) +// *Windows only* +// Sets window to dark theme. +export function WindowSetDarkTheme(): void; + +// [WindowCenter](https://wails.io/docs/reference/runtime/window#windowcenter) +// Centers the window on the monitor the window is currently on. +export function WindowCenter(): void; + +// [WindowSetTitle](https://wails.io/docs/reference/runtime/window#windowsettitle) +// Sets the text in the window title bar. +export function WindowSetTitle(title: string): void; + +// [WindowFullscreen](https://wails.io/docs/reference/runtime/window#windowfullscreen) +// Makes the window full screen. +export function WindowFullscreen(): void; + +// [WindowUnfullscreen](https://wails.io/docs/reference/runtime/window#windowunfullscreen) +// Restores the previous window dimensions and position prior to full screen. +export function WindowUnfullscreen(): void; + +// [WindowIsFullscreen](https://wails.io/docs/reference/runtime/window#windowisfullscreen) +// Returns the state of the window, i.e. whether the window is in full screen mode or not. +export function WindowIsFullscreen(): Promise; + +// [WindowSetSize](https://wails.io/docs/reference/runtime/window#windowsetsize) +// Sets the width and height of the window. +export function WindowSetSize(width: number, height: number): void; + +// [WindowGetSize](https://wails.io/docs/reference/runtime/window#windowgetsize) +// Gets the width and height of the window. +export function WindowGetSize(): Promise; + +// [WindowSetMaxSize](https://wails.io/docs/reference/runtime/window#windowsetmaxsize) +// Sets the maximum window size. Will resize the window if the window is currently larger than the given dimensions. +// Setting a size of 0,0 will disable this constraint. +export function WindowSetMaxSize(width: number, height: number): void; + +// [WindowSetMinSize](https://wails.io/docs/reference/runtime/window#windowsetminsize) +// Sets the minimum window size. Will resize the window if the window is currently smaller than the given dimensions. +// Setting a size of 0,0 will disable this constraint. +export function WindowSetMinSize(width: number, height: number): void; + +// [WindowSetPosition](https://wails.io/docs/reference/runtime/window#windowsetposition) +// Sets the window position relative to the monitor the window is currently on. +export function WindowSetPosition(x: number, y: number): void; + +// [WindowGetPosition](https://wails.io/docs/reference/runtime/window#windowgetposition) +// Gets the window position relative to the monitor the window is currently on. +export function WindowGetPosition(): Promise; + +// [WindowHide](https://wails.io/docs/reference/runtime/window#windowhide) +// Hides the window. +export function WindowHide(): void; + +// [WindowShow](https://wails.io/docs/reference/runtime/window#windowshow) +// Shows the window, if it is currently hidden. +export function WindowShow(): void; + +// [WindowMaximise](https://wails.io/docs/reference/runtime/window#windowmaximise) +// Maximises the window to fill the screen. +export function WindowMaximise(): void; + +// [WindowToggleMaximise](https://wails.io/docs/reference/runtime/window#windowtogglemaximise) +// Toggles between Maximised and UnMaximised. +export function WindowToggleMaximise(): void; + +// [WindowUnmaximise](https://wails.io/docs/reference/runtime/window#windowunmaximise) +// Restores the window to the dimensions and position prior to maximising. +export function WindowUnmaximise(): void; + +// [WindowIsMaximised](https://wails.io/docs/reference/runtime/window#windowismaximised) +// Returns the state of the window, i.e. whether the window is maximised or not. +export function WindowIsMaximised(): Promise; + +// [WindowMinimise](https://wails.io/docs/reference/runtime/window#windowminimise) +// Minimises the window. +export function WindowMinimise(): void; + +// [WindowUnminimise](https://wails.io/docs/reference/runtime/window#windowunminimise) +// Restores the window to the dimensions and position prior to minimising. +export function WindowUnminimise(): void; + +// [WindowIsMinimised](https://wails.io/docs/reference/runtime/window#windowisminimised) +// Returns the state of the window, i.e. whether the window is minimised or not. +export function WindowIsMinimised(): Promise; + +// [WindowIsNormal](https://wails.io/docs/reference/runtime/window#windowisnormal) +// Returns the state of the window, i.e. whether the window is normal or not. +export function WindowIsNormal(): Promise; + +// [WindowSetBackgroundColour](https://wails.io/docs/reference/runtime/window#windowsetbackgroundcolour) +// Sets the background colour of the window to the given RGBA colour definition. This colour will show through for all transparent pixels. +export function WindowSetBackgroundColour(R: number, G: number, B: number, A: number): void; + +// [ScreenGetAll](https://wails.io/docs/reference/runtime/window#screengetall) +// Gets the all screens. Call this anew each time you want to refresh data from the underlying windowing system. +export function ScreenGetAll(): Promise; + +// [BrowserOpenURL](https://wails.io/docs/reference/runtime/browser#browseropenurl) +// Opens the given URL in the system browser. +export function BrowserOpenURL(url: string): void; + +// [Environment](https://wails.io/docs/reference/runtime/intro#environment) +// Returns information about the environment +export function Environment(): Promise; + +// [Quit](https://wails.io/docs/reference/runtime/intro#quit) +// Quits the application. +export function Quit(): void; + +// [Hide](https://wails.io/docs/reference/runtime/intro#hide) +// Hides the application. +export function Hide(): void; + +// [Show](https://wails.io/docs/reference/runtime/intro#show) +// Shows the application. +export function Show(): void; + +// [ClipboardGetText](https://wails.io/docs/reference/runtime/clipboard#clipboardgettext) +// Returns the current text stored on clipboard +export function ClipboardGetText(): Promise; + +// [ClipboardSetText](https://wails.io/docs/reference/runtime/clipboard#clipboardsettext) +// Sets a text on the clipboard +export function ClipboardSetText(text: string): Promise; + +// [OnFileDrop](https://wails.io/docs/reference/runtime/draganddrop#onfiledrop) +// OnFileDrop listens to drag and drop events and calls the callback with the coordinates of the drop and an array of path strings. +export function OnFileDrop(callback: (x: number, y: number ,paths: string[]) => void, useDropTarget: boolean) :void + +// [OnFileDropOff](https://wails.io/docs/reference/runtime/draganddrop#dragandddropoff) +// OnFileDropOff removes the drag and drop listeners and handlers. +export function OnFileDropOff() :void + +// Check if the file path resolver is available +export function CanResolveFilePaths(): boolean; + +// Resolves file paths for an array of files +export function ResolveFilePaths(files: File[]): void + +// Notification types +export interface NotificationOptions { + id: string; + title: string; + subtitle?: string; // macOS and Linux only + body?: string; + categoryId?: string; + data?: { [key: string]: any }; +} + +export interface NotificationAction { + id?: string; + title?: string; + destructive?: boolean; // macOS-specific +} + +export interface NotificationCategory { + id?: string; + actions?: NotificationAction[]; + hasReplyField?: boolean; + replyPlaceholder?: string; + replyButtonTitle?: string; +} + +// [InitializeNotifications](https://wails.io/docs/reference/runtime/notification#initializenotifications) +// Initializes the notification service for the application. +// This must be called before sending any notifications. +export function InitializeNotifications(): Promise; + +// [CleanupNotifications](https://wails.io/docs/reference/runtime/notification#cleanupnotifications) +// Cleans up notification resources and releases any held connections. +export function CleanupNotifications(): Promise; + +// [IsNotificationAvailable](https://wails.io/docs/reference/runtime/notification#isnotificationavailable) +// Checks if notifications are available on the current platform. +export function IsNotificationAvailable(): Promise; + +// [RequestNotificationAuthorization](https://wails.io/docs/reference/runtime/notification#requestnotificationauthorization) +// Requests notification authorization from the user (macOS only). +export function RequestNotificationAuthorization(): Promise; + +// [CheckNotificationAuthorization](https://wails.io/docs/reference/runtime/notification#checknotificationauthorization) +// Checks the current notification authorization status (macOS only). +export function CheckNotificationAuthorization(): Promise; + +// [SendNotification](https://wails.io/docs/reference/runtime/notification#sendnotification) +// Sends a basic notification with the given options. +export function SendNotification(options: NotificationOptions): Promise; + +// [SendNotificationWithActions](https://wails.io/docs/reference/runtime/notification#sendnotificationwithactions) +// Sends a notification with action buttons. Requires a registered category. +export function SendNotificationWithActions(options: NotificationOptions): Promise; + +// [RegisterNotificationCategory](https://wails.io/docs/reference/runtime/notification#registernotificationcategory) +// Registers a notification category that can be used with SendNotificationWithActions. +export function RegisterNotificationCategory(category: NotificationCategory): Promise; + +// [RemoveNotificationCategory](https://wails.io/docs/reference/runtime/notification#removenotificationcategory) +// Removes a previously registered notification category. +export function RemoveNotificationCategory(categoryId: string): Promise; + +// [RemoveAllPendingNotifications](https://wails.io/docs/reference/runtime/notification#removeallpendingnotifications) +// Removes all pending notifications from the notification center. +export function RemoveAllPendingNotifications(): Promise; + +// [RemovePendingNotification](https://wails.io/docs/reference/runtime/notification#removependingnotification) +// Removes a specific pending notification by its identifier. +export function RemovePendingNotification(identifier: string): Promise; + +// [RemoveAllDeliveredNotifications](https://wails.io/docs/reference/runtime/notification#removealldeliverednotifications) +// Removes all delivered notifications from the notification center. +export function RemoveAllDeliveredNotifications(): Promise; + +// [RemoveDeliveredNotification](https://wails.io/docs/reference/runtime/notification#removedeliverednotification) +// Removes a specific delivered notification by its identifier. +export function RemoveDeliveredNotification(identifier: string): Promise; + +// [RemoveNotification](https://wails.io/docs/reference/runtime/notification#removenotification) +// Removes a notification by its identifier (cross-platform convenience function). +export function RemoveNotification(identifier: string): Promise; \ No newline at end of file diff --git a/frontend/wailsjs/runtime/runtime.js b/frontend/wailsjs/runtime/runtime.js new file mode 100644 index 0000000..556621e --- /dev/null +++ b/frontend/wailsjs/runtime/runtime.js @@ -0,0 +1,298 @@ +/* + _ __ _ __ +| | / /___ _(_) /____ +| | /| / / __ `/ / / ___/ +| |/ |/ / /_/ / / (__ ) +|__/|__/\__,_/_/_/____/ +The electron alternative for Go +(c) Lea Anthony 2019-present +*/ + +export function LogPrint(message) { + window.runtime.LogPrint(message); +} + +export function LogTrace(message) { + window.runtime.LogTrace(message); +} + +export function LogDebug(message) { + window.runtime.LogDebug(message); +} + +export function LogInfo(message) { + window.runtime.LogInfo(message); +} + +export function LogWarning(message) { + window.runtime.LogWarning(message); +} + +export function LogError(message) { + window.runtime.LogError(message); +} + +export function LogFatal(message) { + window.runtime.LogFatal(message); +} + +export function EventsOnMultiple(eventName, callback, maxCallbacks) { + return window.runtime.EventsOnMultiple(eventName, callback, maxCallbacks); +} + +export function EventsOn(eventName, callback) { + return EventsOnMultiple(eventName, callback, -1); +} + +export function EventsOff(eventName, ...additionalEventNames) { + return window.runtime.EventsOff(eventName, ...additionalEventNames); +} + +export function EventsOffAll() { + return window.runtime.EventsOffAll(); +} + +export function EventsOnce(eventName, callback) { + return EventsOnMultiple(eventName, callback, 1); +} + +export function EventsEmit(eventName) { + let args = [eventName].slice.call(arguments); + return window.runtime.EventsEmit.apply(null, args); +} + +export function WindowReload() { + window.runtime.WindowReload(); +} + +export function WindowReloadApp() { + window.runtime.WindowReloadApp(); +} + +export function WindowSetAlwaysOnTop(b) { + window.runtime.WindowSetAlwaysOnTop(b); +} + +export function WindowSetSystemDefaultTheme() { + window.runtime.WindowSetSystemDefaultTheme(); +} + +export function WindowSetLightTheme() { + window.runtime.WindowSetLightTheme(); +} + +export function WindowSetDarkTheme() { + window.runtime.WindowSetDarkTheme(); +} + +export function WindowCenter() { + window.runtime.WindowCenter(); +} + +export function WindowSetTitle(title) { + window.runtime.WindowSetTitle(title); +} + +export function WindowFullscreen() { + window.runtime.WindowFullscreen(); +} + +export function WindowUnfullscreen() { + window.runtime.WindowUnfullscreen(); +} + +export function WindowIsFullscreen() { + return window.runtime.WindowIsFullscreen(); +} + +export function WindowGetSize() { + return window.runtime.WindowGetSize(); +} + +export function WindowSetSize(width, height) { + window.runtime.WindowSetSize(width, height); +} + +export function WindowSetMaxSize(width, height) { + window.runtime.WindowSetMaxSize(width, height); +} + +export function WindowSetMinSize(width, height) { + window.runtime.WindowSetMinSize(width, height); +} + +export function WindowSetPosition(x, y) { + window.runtime.WindowSetPosition(x, y); +} + +export function WindowGetPosition() { + return window.runtime.WindowGetPosition(); +} + +export function WindowHide() { + window.runtime.WindowHide(); +} + +export function WindowShow() { + window.runtime.WindowShow(); +} + +export function WindowMaximise() { + window.runtime.WindowMaximise(); +} + +export function WindowToggleMaximise() { + window.runtime.WindowToggleMaximise(); +} + +export function WindowUnmaximise() { + window.runtime.WindowUnmaximise(); +} + +export function WindowIsMaximised() { + return window.runtime.WindowIsMaximised(); +} + +export function WindowMinimise() { + window.runtime.WindowMinimise(); +} + +export function WindowUnminimise() { + window.runtime.WindowUnminimise(); +} + +export function WindowSetBackgroundColour(R, G, B, A) { + window.runtime.WindowSetBackgroundColour(R, G, B, A); +} + +export function ScreenGetAll() { + return window.runtime.ScreenGetAll(); +} + +export function WindowIsMinimised() { + return window.runtime.WindowIsMinimised(); +} + +export function WindowIsNormal() { + return window.runtime.WindowIsNormal(); +} + +export function BrowserOpenURL(url) { + window.runtime.BrowserOpenURL(url); +} + +export function Environment() { + return window.runtime.Environment(); +} + +export function Quit() { + window.runtime.Quit(); +} + +export function Hide() { + window.runtime.Hide(); +} + +export function Show() { + window.runtime.Show(); +} + +export function ClipboardGetText() { + return window.runtime.ClipboardGetText(); +} + +export function ClipboardSetText(text) { + return window.runtime.ClipboardSetText(text); +} + +/** + * Callback for OnFileDrop returns a slice of file path strings when a drop is finished. + * + * @export + * @callback OnFileDropCallback + * @param {number} x - x coordinate of the drop + * @param {number} y - y coordinate of the drop + * @param {string[]} paths - A list of file paths. + */ + +/** + * OnFileDrop listens to drag and drop events and calls the callback with the coordinates of the drop and an array of path strings. + * + * @export + * @param {OnFileDropCallback} callback - Callback for OnFileDrop returns a slice of file path strings when a drop is finished. + * @param {boolean} [useDropTarget=true] - Only call the callback when the drop finished on an element that has the drop target style. (--wails-drop-target) + */ +export function OnFileDrop(callback, useDropTarget) { + return window.runtime.OnFileDrop(callback, useDropTarget); +} + +/** + * OnFileDropOff removes the drag and drop listeners and handlers. + */ +export function OnFileDropOff() { + return window.runtime.OnFileDropOff(); +} + +export function CanResolveFilePaths() { + return window.runtime.CanResolveFilePaths(); +} + +export function ResolveFilePaths(files) { + return window.runtime.ResolveFilePaths(files); +} + +export function InitializeNotifications() { + return window.runtime.InitializeNotifications(); +} + +export function CleanupNotifications() { + return window.runtime.CleanupNotifications(); +} + +export function IsNotificationAvailable() { + return window.runtime.IsNotificationAvailable(); +} + +export function RequestNotificationAuthorization() { + return window.runtime.RequestNotificationAuthorization(); +} + +export function CheckNotificationAuthorization() { + return window.runtime.CheckNotificationAuthorization(); +} + +export function SendNotification(options) { + return window.runtime.SendNotification(options); +} + +export function SendNotificationWithActions(options) { + return window.runtime.SendNotificationWithActions(options); +} + +export function RegisterNotificationCategory(category) { + return window.runtime.RegisterNotificationCategory(category); +} + +export function RemoveNotificationCategory(categoryId) { + return window.runtime.RemoveNotificationCategory(categoryId); +} + +export function RemoveAllPendingNotifications() { + return window.runtime.RemoveAllPendingNotifications(); +} + +export function RemovePendingNotification(identifier) { + return window.runtime.RemovePendingNotification(identifier); +} + +export function RemoveAllDeliveredNotifications() { + return window.runtime.RemoveAllDeliveredNotifications(); +} + +export function RemoveDeliveredNotification(identifier) { + return window.runtime.RemoveDeliveredNotification(identifier); +} + +export function RemoveNotification(identifier) { + return window.runtime.RemoveNotification(identifier); +} \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..07bb536 --- /dev/null +++ b/go.mod @@ -0,0 +1,40 @@ +module eh-downloader-wails + +go 1.26.0 + +require ( + github.com/wailsapp/wails/v2 v2.16.0 + golang.org/x/net v0.59.0 +) + +require ( + git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 // indirect + github.com/bep/debounce v1.2.1 // indirect + github.com/go-ole/go-ole v1.3.0 // indirect + github.com/godbus/dbus/v5 v5.1.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/gorilla/websocket v1.5.3 // indirect + github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect + github.com/labstack/echo/v4 v4.13.3 // indirect + github.com/labstack/gommon v0.4.2 // indirect + github.com/leaanthony/go-ansi-parser v1.6.1 // indirect + github.com/leaanthony/gosod v1.0.4 // indirect + github.com/leaanthony/slicer v1.6.0 // indirect + github.com/leaanthony/u v1.1.1 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/samber/lo v1.49.1 // indirect + github.com/tkrajina/go-reflector v0.5.8 // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/valyala/fasttemplate v1.2.2 // indirect + github.com/wailsapp/go-webview2 v1.0.22 // indirect + github.com/wailsapp/mimetype v1.4.1 // indirect + golang.org/x/crypto v0.57.0 // indirect + golang.org/x/sys v0.48.0 // indirect + golang.org/x/text v0.42.0 // indirect +) + +// replace github.com/wailsapp/wails/v2 v2.16.0 => C:\Users\Administrator\go\pkg\mod diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..6b2f878 --- /dev/null +++ b/go.sum @@ -0,0 +1,83 @@ +git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 h1:N3IGoHHp9pb6mj1cbXbuaSXV/UMKwmbKLf53nQmtqMA= +git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3/go.mod h1:QtOLZGz8olr4qH2vWK0QH0w0O4T9fEIjMuWpKUsH7nc= +github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY= +github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= +github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= +github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= +github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e h1:Q3+PugElBCf4PFpxhErSzU3/PY5sFL5Z6rfv4AbGAck= +github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs= +github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY= +github.com/labstack/echo/v4 v4.13.3/go.mod h1:o90YNEeQWjDozo584l7AwhJMHN0bOC4tAfg+Xox9q5g= +github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0= +github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU= +github.com/leaanthony/debme v1.2.1 h1:9Tgwf+kjcrbMQ4WnPcEIUcQuIZYqdWftzZkBr+i/oOc= +github.com/leaanthony/debme v1.2.1/go.mod h1:3V+sCm5tYAgQymvSOfYQ5Xx2JCr+OXiD9Jkw3otUjiA= +github.com/leaanthony/go-ansi-parser v1.6.1 h1:xd8bzARK3dErqkPFtoF9F3/HgN8UQk0ed1YDKpEz01A= +github.com/leaanthony/go-ansi-parser v1.6.1/go.mod h1:+vva/2y4alzVmmIEpk9QDhA7vLC5zKDTRwfZGOp3IWU= +github.com/leaanthony/gosod v1.0.4 h1:YLAbVyd591MRffDgxUOU1NwLhT9T1/YiwjKZpkNFeaI= +github.com/leaanthony/gosod v1.0.4/go.mod h1:GKuIL0zzPj3O1SdWQOdgURSuhkF+Urizzxh26t9f1cw= +github.com/leaanthony/slicer v1.6.0 h1:1RFP5uiPJvT93TAHi+ipd3NACobkW53yUiBqZheE/Js= +github.com/leaanthony/slicer v1.6.0/go.mod h1:o/Iz29g7LN0GqH3aMjWAe90381nyZlDNquK+mtH2Fj8= +github.com/leaanthony/u v1.1.1 h1:TUFjwDGlNX+WuwVEzDqQwC2lOv0P4uhTQw7CMFdiK7M= +github.com/leaanthony/u v1.1.1/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI= +github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= +github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ= +github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/samber/lo v1.49.1 h1:4BIFyVfuQSEpluc7Fua+j1NolZHiEHEpaSEKdsH0tew= +github.com/samber/lo v1.49.1/go.mod h1:dO6KHFzUKXgP8LDhU0oI8d2hekjXnGOu0DB8Jecxd6o= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tkrajina/go-reflector v0.5.8 h1:yPADHrwmUbMq4RGEyaOUpz2H90sRsETNVpjzo3DLVQQ= +github.com/tkrajina/go-reflector v0.5.8/go.mod h1:ECbqLgccecY5kPmPmXg1MrHW585yMcDkVl6IvJe64T4= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= +github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= +github.com/wailsapp/go-webview2 v1.0.22 h1:YT61F5lj+GGaat5OB96Aa3b4QA+mybD0Ggq6NZijQ58= +github.com/wailsapp/go-webview2 v1.0.22/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc= +github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs= +github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o= +github.com/wailsapp/wails/v2 v2.16.0 h1:hLYrSHUUq6hRXl3rWT9ZXiyFIgM94nVN9Ns6iEJzxyw= +github.com/wailsapp/wails/v2 v2.16.0/go.mod h1:scxrgwfsv6yR6fE6cCF+Flfl+JeU+SR87T9x4kILJ6M= +golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= +golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= +golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues= +golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg= +golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= +golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= +golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/core/bridge.go b/internal/core/bridge.go new file mode 100644 index 0000000..72292a6 --- /dev/null +++ b/internal/core/bridge.go @@ -0,0 +1,298 @@ +package core + +// 本地桥口:让油猴脚本把画廊地址推给本工具 +// +// POST /add { gid, token, url, title } + X-EHDL-Token 头 +// POST /add-batch { items: [...] } + X-EHDL-Token 头 +// GET /ping -> { ok: true } +// GET /info -> 端口/版本等信息 +// +// 只监听 127.0.0.1,且同时校验 Origin 与固定 token,防止本机其他网页乱塞任务。 + +import ( + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "strings" + "sync" + "time" + + "eh-downloader-wails/internal/eh" +) + +// BridgeToken 固定 token(和油猴脚本里的 BRIDGE_TOKEN 保持一致) +const BridgeToken = "ehdl-local-9f2a4c7e" + +var allowedOrigins = map[string]bool{ + "https://e-hentai.org": true, + "https://exhentai.org": true, + "https://www.e-hentai.org": true, +} + +const maxBody = 512 * 1024 // 批量最多 500 个,标题也要带,得给够 + +// Bridge 是本地 HTTP 桥口 +type Bridge struct { + store *Store + log func(level, msg string) + onAdded func(t Task, added bool, batch bool) + + mu sync.Mutex + server *http.Server + port int +} + +// NewBridge 建桥口 +func NewBridge(store *Store, log func(string, string), onAdded func(Task, bool, bool)) *Bridge { + return &Bridge{store: store, log: log, onAdded: onAdded, port: 37777} +} + +// Start 启动(只绑回环) +func (b *Bridge) Start(port int) { + if port <= 0 { + port = 37777 + } + b.mu.Lock() + defer b.mu.Unlock() + b.port = port + + mux := http.NewServeMux() + mux.HandleFunc("/ping", b.handlePing) + mux.HandleFunc("/info", b.handleInfo) + mux.HandleFunc("/add", b.handleAdd) + mux.HandleFunc("/add-batch", b.handleAddBatch) + + srv := &http.Server{ + Handler: b.cors(mux), + ReadHeaderTimeout: 10 * time.Second, + } + + ln, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", port)) + if err != nil { + if strings.Contains(err.Error(), "address already in use") { + b.log("warn", fmt.Sprintf("桥口端口 %d 被占用,油猴推送不可用。可在设置里改端口,或关掉占用它的程序。", port)) + } else { + b.log("error", fmt.Sprintf("桥口启动失败:%v", err)) + } + return + } + + b.server = srv + go func() { + if err := srv.Serve(ln); err != nil && err != http.ErrServerClosed { + b.log("error", fmt.Sprintf("桥口异常:%v", err)) + } + }() + b.log("info", fmt.Sprintf("本地桥口已就绪 http://127.0.0.1:%d(油猴推送可用)", port)) +} + +// Stop 停止桥口 +func (b *Bridge) Stop() { + b.mu.Lock() + srv := b.server + b.server = nil + b.mu.Unlock() + if srv != nil { + _ = srv.Close() + } +} + +// Port 当前端口 +func (b *Bridge) Port() int { + b.mu.Lock() + defer b.mu.Unlock() + return b.port +} + +// cors 处理 CORS 预检与响应头 +func (b *Bridge) cors(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + origin := r.Header.Get("Origin") + if origin != "" && allowedOrigins[origin] { + w.Header().Set("Access-Control-Allow-Origin", origin) + w.Header().Set("Vary", "Origin") + } + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type, X-EHDL-Token") + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusNoContent) + return + } + next.ServeHTTP(w, r) + }) +} + +func writeJSON(w http.ResponseWriter, code int, v interface{}) { + body, _ := json.Marshal(v) + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(code) + w.Write(body) +} + +// readBody 读 JSON body(限大小) +func readBody(r *http.Request) (map[string]interface{}, int) { + data, err := io.ReadAll(io.LimitReader(r.Body, maxBody+1)) + if err != nil { + return nil, http.StatusBadRequest + } + if len(data) > maxBody { + return nil, http.StatusRequestEntityTooLarge + } + var m map[string]interface{} + if err := json.Unmarshal(data, &m); err != nil { + return nil, http.StatusBadRequest + } + return m, 0 +} + +func checkToken(w http.ResponseWriter, r *http.Request) bool { + if r.Header.Get("X-EHDL-Token") != BridgeToken { + writeJSON(w, http.StatusForbidden, map[string]interface{}{"ok": false, "error": "bad token"}) + return false + } + return true +} + +func (b *Bridge) handlePing(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true, "name": "EH Downloader"}) +} + +func (b *Bridge) handleInfo(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusOK, map[string]interface{}{ + "ok": true, + "port": b.Port(), + "version": AppVersion, + }) +} + +type batchItem struct { + GID string `json:"gid"` + Token string `json:"token"` + URL string `json:"url"` + Title string `json:"title"` +} + +func (b *Bridge) handleAddBatch(w http.ResponseWriter, r *http.Request) { + if !checkToken(w, r) { + b.log("warn", "桥口收到 token 不正确的批量请求,已拒绝") + return + } + body, code := readBody(r) + if code != 0 { + writeJSON(w, code, map[string]interface{}{"ok": false, "error": "bad request"}) + return + } + + raw, _ := body["items"].([]interface{}) + if len(raw) == 0 { + writeJSON(w, http.StatusBadRequest, map[string]interface{}{"ok": false, "error": "items 为空"}) + return + } + + seen := map[string]bool{} + var fresh []eh.GalleryRef + existed := 0 + + for _, it := range raw { + if len(fresh) >= 500 { + break + } + m, ok := it.(map[string]interface{}) + if !ok { + continue + } + item := batchItem{ + GID: str(m["gid"]), + Token: str(m["token"]), + URL: str(m["url"]), + Title: str(m["title"]), + } + if item.GID == "" || item.Token == "" { + continue + } + key := item.GID + "/" + item.Token + if seen[key] { + continue + } + seen[key] = true + + if b.store.FindTask(item.GID, item.Token) != nil { + existed++ + continue + } + fresh = append(fresh, eh.GalleryRef{GID: item.GID, Token: item.Token, Raw: item.URL, Title: item.Title}) + } + + added := b.store.AddTasks(fresh) + for _, ref := range fresh { + if ref.Title != "" { + b.store.SetTitle(ref.GID, ref.Token, ref.Title) + } + } + b.store.ScheduleFlush(0) + + for _, ref := range fresh { + if t := b.store.FindTask(ref.GID, ref.Token); t != nil && b.onAdded != nil { + b.onAdded(*t, true, true) + } + } + + writeJSON(w, http.StatusOK, map[string]interface{}{ + "ok": true, + "added": added, + "existed": existed, + "received": len(raw), + "invalid": len(raw) - len(seen), + }) +} + +func (b *Bridge) handleAdd(w http.ResponseWriter, r *http.Request) { + if !checkToken(w, r) { + b.log("warn", "桥口收到 token 不正确的请求,已拒绝") + return + } + body, code := readBody(r) + if code != 0 { + writeJSON(w, code, map[string]interface{}{"ok": false, "error": "bad request"}) + return + } + + gid := strings.TrimSpace(str(body["gid"])) + token := strings.TrimSpace(str(body["token"])) + if gid == "" || token == "" { + writeJSON(w, http.StatusBadRequest, map[string]interface{}{"ok": false, "error": "缺少 gid 或 token"}) + return + } + + exists := b.store.FindTask(gid, token) != nil + added := b.store.AddTasks([]eh.GalleryRef{{GID: gid, Token: token, Raw: str(body["url"])}}) + if t := str(body["title"]); t != "" { + b.store.SetTitle(gid, token, t) + } + b.store.ScheduleFlush(0) + + if t := b.store.FindTask(gid, token); t != nil && b.onAdded != nil { + b.onAdded(*t, added > 0, false) + } + + queued := false + if t := b.store.FindTask(gid, token); t != nil { + queued = t.Status == "queued" + } + + writeJSON(w, http.StatusOK, map[string]interface{}{ + "ok": true, + "added": added > 0, + "existed": exists, + "gid": gid, + "queued": queued, + }) +} + +// str 安全取字符串 +func str(v interface{}) string { + s, _ := v.(string) + return s +} diff --git a/internal/core/engine.go b/internal/core/engine.go new file mode 100644 index 0000000..6523180 --- /dev/null +++ b/internal/core/engine.go @@ -0,0 +1,420 @@ +package core + +// 下载引擎:任务编排 / 并发控制 / 指数退避重试 / 断点续传 +// +// 断点续传策略:以「文件系统为准」—— +// +// 目标文件已存在且 size > 0 → 视为已完成,直接跳过。 +// +// 因此即使任务状态 JSON 丢失或损坏,重跑也能正确续传。 + +import ( + "context" + "errors" + "fmt" + "math" + "math/rand" + "os" + "path/filepath" + "regexp" + "strconv" + "strings" + "sync" + "time" + + "eh-downloader-wails/internal/eh" +) + +// ErrCanceled 取消标记(用户主动停止不算失败) +var ErrCanceled = errors.New("已取消") + +// retryableErrs 可重试的错误特征(对应 JS 版 RETRYABLE 集合) +var retryablePatterns = []string{ + "connection reset", "connection refused", "timeout", "i/o timeout", + "broken pipe", "eof", "no such host", "tls handshake", + "unexpected status", "server closed", "too many redirects", + "429", "502", "503", "504", "socket hang up", +} + +// EventSink 是引擎向 UI 发事件的方式(由 app 层注入) +type EventSink interface { + TaskUpdate(t Task) + Log(level, msg string) + Done() +} + +// Engine 下载引擎 +type Engine struct { + store *Store + client *eh.Client + sink EventSink + + mu sync.Mutex + concurrency int + running bool + cancel context.CancelFunc + queue []Task +} + +// NewEngine 建引擎 +func NewEngine(store *Store, sink EventSink, settings Settings) *Engine { + e := &Engine{ + store: store, + sink: sink, + client: eh.NewClient(time.Duration(settings.Timeout)*time.Millisecond, + settings.Proxy, settings.ProxyMode, settings.Xres), + concurrency: settings.Concurrency, + } + if e.concurrency < 1 { + e.concurrency = 3 + } + if e.concurrency > 8 { + e.concurrency = 8 + } + return e +} + +// Client 暴露给 app 层改配置用 +func (e *Engine) Client() *eh.Client { return e.client } + +// SetConcurrency 运行时改并发 +func (e *Engine) SetConcurrency(n int) { + if n < 1 { + n = 1 + } + if n > 8 { + n = 8 + } + e.mu.Lock() + e.concurrency = n + e.mu.Unlock() +} + +func (e *Engine) IsRunning() bool { + e.mu.Lock() + defer e.mu.Unlock() + return e.running +} + +func (e *Engine) QueueLength() int { + e.mu.Lock() + defer e.mu.Unlock() + return len(e.queue) +} + +// Stop 停止:取消 context,立刻打断在途请求 +func (e *Engine) Stop() { + e.mu.Lock() + if e.cancel != nil { + e.cancel() + e.cancel = nil + } + e.mu.Unlock() +} + +// Enqueue 把任务加入队列(已排队的跳过) +func (e *Engine) Enqueue(tasks []Task) { + e.mu.Lock() + defer e.mu.Unlock() + for _, t := range tasks { + dup := false + for _, q := range e.queue { + if q.GID == t.GID && q.Token == t.Token { + dup = true + break + } + } + if !dup { + e.queue = append(e.queue, t) + } + } +} + +// Start 开始跑队列(阻塞直到队列空或被取消) +func (e *Engine) Start(ctx context.Context) { + e.mu.Lock() + if e.running { + e.mu.Unlock() + return + } + e.running = true + ctx, cancel := context.WithCancel(ctx) + e.cancel = cancel + n := e.concurrency + e.mu.Unlock() + + defer func() { + cancel() + e.mu.Lock() + e.running = false + e.cancel = nil + e.queue = nil + e.mu.Unlock() + if e.sink != nil { + e.sink.Done() + } + }() + + // 并发 worker 共享一个 channel 队列 + work := make(chan Task) + var wg sync.WaitGroup + for i := 0; i < n; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for { + select { + case <-ctx.Done(): + return + case t, ok := <-work: + if !ok { + return + } + e.runTask(ctx, t) + } + } + }() + } + + // 派发 + go func() { + defer close(work) + for { + e.mu.Lock() + if len(e.queue) == 0 { + e.mu.Unlock() + return + } + t := e.queue[0] + e.queue = e.queue[1:] + e.mu.Unlock() + + select { + case <-ctx.Done(): + return + case work <- t: + } + } + }() + + wg.Wait() +} + +func (e *Engine) runTask(ctx context.Context, task Task) { + task.Status = "running" + task.Error = "" + task.Done = 0 + task.Failed = 0 + task.Pages = map[string]string{} + task.FailedList = []FailedItem{} + e.save(task) + + // ① 画廊页(内部自动翻 ?p=N 分页收集全部页) + gallery, err := e.client.FetchGalleryPage(ctx, task.GID, task.Token) + if err != nil { + if ctx.Err() != nil { + e.markCanceled(task) + return + } + e.fail(task, fmt.Errorf("解析画廊页失败: %w", err)) + return + } + + task.Title = gallery.Title + task.TotalPages = gallery.TotalPages + task.Dir = BuildDir(e.store.Settings().DirTemplate, gallery) + + task.Status = "downloading" + e.save(task) + + // 目录按模板生成 + root := e.store.Settings().DownloadRoot + if root == "" { + root = DefaultDownloadRoot() + } + dir := filepath.Join(root, task.Dir) + + // 老版本一律用「标题」当目录名。用户中途换了模板时, + // 先看老的「标题目录」在不在 —— 在就继续往那儿下, + // 否则换个模板就会把几千张图全部重下一遍。 + // ⚠️ 必须在 mkdir 之前判断,否则会白建出一个新目录。 + legacyDir := filepath.Join(root, SanitizeSegment(gallery.Title, task.GID)) + if dir != legacyDir { + if _, err := os.Stat(legacyDir); err == nil { + e.log("info", "沿用旧目录(模板改过,已有图在老位置):"+gallery.Title) + dir = legacyDir + rel, err := filepath.Rel(root, dir) + if err == nil { + task.Dir = rel + } + } + } + + if err := os.MkdirAll(dir, 0o755); err != nil { + e.fail(task, fmt.Errorf("创建目录失败: %w", err)) + return + } + e.save(task) + + // ② ③ 逐页:解析 URL → 下载 + total := len(gallery.Pages) + for _, p := range gallery.Pages { + if ctx.Err() != nil { + e.markCanceled(task) + return + } + + fname := targetName(p, task.GID, total) + dest := filepath.Join(dir, fname) + + // 断点续传:文件在就算完成(文件系统即真相,状态文件丢了也能续) + if st, err := os.Stat(dest); err == nil && st.Size() > 0 { + task.Done++ + task.Pages[strconv.Itoa(p.Page)] = "done" + e.progress(task) + continue + } + + page := p.Page + err := e.withRetry(ctx, func() error { + imgURL, err := e.client.ResolveImageURL(ctx, p.SPath) + if err != nil { + return err + } + _, err = e.client.DownloadImage(ctx, imgURL, dest, nil) + return err + }) + if err != nil { + if ctx.Err() != nil || errors.Is(err, ErrCanceled) { + e.markCanceled(task) + return + } + task.Failed++ + task.Pages[strconv.Itoa(page)] = "failed" + task.FailedList = append(task.FailedList, FailedItem{ + Page: page, File: fname, Error: err.Error(), + }) + e.progress(task) + continue + } + + task.Done++ + task.Pages[strconv.Itoa(page)] = "done" + e.progress(task) + } + + if task.Failed > 0 { + task.Status = "partial" + } else { + task.Status = "done" + } + e.save(task) +} + +// targetName 页码补零,保证排序 +func targetName(p eh.GalleryPageInfo, gid string, total int) string { + width := len(strconv.Itoa(total)) + n := strconv.Itoa(p.Page) + for len(n) < width { + n = "0" + n + } + return SanitizeFilename(p.Filename, gid+"-"+n+".webp") +} + +// withRetry 指数退避重试 +func (e *Engine) withRetry(ctx context.Context, fn func() error) error { + const ( + tries = 4 + base = 800 * time.Millisecond + max = 15 * time.Second + ) + + var lastErr error + for i := 0; i < tries; i++ { + if ctx.Err() != nil { + return ErrCanceled + } + err := fn() + if err == nil { + return nil + } + if ctx.Err() != nil { + return ErrCanceled + } + lastErr = err + + if i == tries-1 || !isRetryable(err) { + return err + } + wait := time.Duration(float64(base) * math.Pow(2, float64(i))) + if wait > max { + wait = max + } + wait += time.Duration(rand.Intn(400)) * time.Millisecond + e.log("warn", fmt.Sprintf("第 %d 次失败,%dms 后重试:%v", i+1, wait.Milliseconds(), err)) + + select { + case <-ctx.Done(): + return ErrCanceled + case <-time.After(wait): + } + } + return lastErr +} + +// isRetryable 判断是否值得重试 +func isRetryable(err error) bool { + if err == nil { + return false + } + if errors.Is(err, ErrCanceled) || errors.Is(err, context.Canceled) { + return false + } + s := strings.ToLower(err.Error()) + for _, p := range retryablePatterns { + if strings.Contains(s, p) { + return true + } + } + return false +} + +// markCanceled 用户主动取消:退回排队,等续传 +func (e *Engine) markCanceled(task Task) { + task.Status = "queued" + task.Error = "" + e.save(task) + e.log("warn", fmt.Sprintf("画廊 %s 已取消,再点开始可继续", task.GID)) +} + +func (e *Engine) fail(task Task, err error) { + msg := err.Error() + task.Status = "failed" + task.Error = msg + e.save(task) + e.log("error", fmt.Sprintf("画廊 %s 失败:%s", task.GID, msg)) +} + +func (e *Engine) progress(task Task) { + if e.sink != nil { + e.sink.TaskUpdate(task) + } +} + +func (e *Engine) save(task Task) { + task = e.store.UpsertTask(task) + if e.sink != nil { + e.sink.TaskUpdate(task) + } +} + +func (e *Engine) log(level, msg string) { + if e.sink != nil { + e.sink.Log(level, msg) + } +} + +// reSpaced 去掉路径里的多余空白(清洗用) +var reSpaced = regexp.MustCompile(`\s+`) + +func cleanSpaces(s string) string { return reSpaced.ReplaceAllString(s, " ") } diff --git a/internal/core/engine_test.go b/internal/core/engine_test.go new file mode 100644 index 0000000..77fedd9 --- /dev/null +++ b/internal/core/engine_test.go @@ -0,0 +1,297 @@ +package core + +import ( + "context" + "os" + "path/filepath" + "testing" + "time" + + "eh-downloader-wails/internal/eh" +) + +func newTestStore(t *testing.T) (*Store, string) { + t.Helper() + dir := t.TempDir() + p := filepath.Join(dir, "state.json") + return NewStore(p), p +} + +func TestStoreDefaults(t *testing.T) { + s, _ := newTestStore(t) + st := s.Settings() + if st.Concurrency != 3 || st.Timeout != 30000 || st.BridgePort != 37777 { + t.Errorf("默认值不对: %+v", st) + } + if st.DirTemplate != "{title}" { + t.Errorf("默认模板不对: %q", st.DirTemplate) + } + if st.Xres != "auto" { + t.Errorf("默认 xres 不对: %q", st.Xres) + } +} + +// 原子写 + 重新加载 +func TestStorePersist(t *testing.T) { + s, path := newTestStore(t) + s.UpdateSettings(map[string]interface{}{"concurrency": 5, "proxy": "http://127.0.0.1:7890"}) + s.Flush() + + s2 := NewStore(path) + got := s2.Settings() + if got.Concurrency != 5 { + t.Errorf("并发没存住: %d", got.Concurrency) + } + if got.Proxy != "http://127.0.0.1:7890" { + t.Errorf("代理没存住: %q", got.Proxy) + } +} + +func TestStoreTasks(t *testing.T) { + s, _ := newTestStore(t) + refs := []eh.GalleryRef{ + {GID: "1111111", Token: "aaa"}, + {GID: "2222222", Token: "bbb"}, + {GID: "1111111", Token: "aaa"}, // 重复 + } + if n := s.AddTasks(refs); n != 2 { + t.Errorf("应新增 2 个(去重),实际 %d", n) + } + if len(s.Tasks()) != 2 { + t.Errorf("任务数不对: %d", len(s.Tasks())) + } + + if s.FindTask("1111111", "aaa") == nil { + t.Error("找不到已加任务") + } + + // SetTitle 只在标题为空时生效 + if !s.SetTitle("1111111", "aaa", "标题A") { + t.Error("应设置标题") + } + if s.SetTitle("1111111", "aaa", "标题B") { + t.Error("标题非空时不应覆盖") + } + if s.FindTask("1111111", "aaa").Title != "标题A" { + t.Error("标题不对") + } +} + +// 清除只动列表不动文件 +func TestStoreClearAll(t *testing.T) { + s, _ := newTestStore(t) + s.AddTasks([]eh.GalleryRef{ + {GID: "1", Token: "a"}, + {GID: "2", Token: "b"}, + {GID: "3", Token: "c"}, + }) + + // 标记第 2 个为选中 + s.SelectAll([]string{"2"}) + + if n := s.ClearAll(true); n != 1 { + t.Errorf("删选中应删 1 个,实际 %d", n) + } + if len(s.Tasks()) != 2 { + t.Errorf("剩余任务数不对: %d", len(s.Tasks())) + } + + if n := s.ClearAll(false); n != 2 { + t.Errorf("删全部应删 2 个,实际 %d", n) + } + if len(s.Tasks()) != 0 { + t.Error("应清空") + } +} + +// ClearFinished 只清 done,partial/failed 保留 +func TestStoreClearFinished(t *testing.T) { + s, _ := newTestStore(t) + for _, g := range []struct{ gid, status string }{ + {"1", "done"}, + {"2", "partial"}, + {"3", "failed"}, + {"4", "queued"}, + } { + s.AddTasks([]eh.GalleryRef{{GID: g.gid, Token: "t"}}) + s.UpsertTask(Task{GID: g.gid, Token: "t", Status: g.status}) + } + + n := s.ClearFinished() + if n != 1 { + t.Errorf("应只清 1 个 done,实际 %d", n) + } + for _, task := range s.Tasks() { + if task.GID == "1" { + t.Error("done 的不该还在") + } + } + if len(s.Tasks()) != 3 { + t.Errorf("应剩 3 个,实际 %d", len(s.Tasks())) + } +} + +func TestStoreRequeueAndRecover(t *testing.T) { + s, _ := newTestStore(t) + for _, g := range []struct{ gid, status string }{ + {"1", "failed"}, + {"2", "partial"}, + {"3", "running"}, // 崩溃残留 + {"4", "downloading"}, // 崩溃残留 + {"5", "done"}, + } { + s.AddTasks([]eh.GalleryRef{{GID: g.gid, Token: "t"}}) + s.UpsertTask(Task{GID: g.gid, Token: "t", Status: g.status}) + } + + // RequeueFailed 会把 failed/partial/running/downloading 一起重排 + if n := s.RequeueFailed(); n != 4 { + t.Errorf("失败/部分/中断应重排 4 个,实际 %d", n) + } + if n := s.RecoverOnStart(); n != 0 { + t.Errorf("已全部重排,不该再有残留,实际 %d", n) + } + if n := s.CountDone(); n != 1 { + t.Errorf("done 应剩 1 个,实际 %d", n) + } + + // 单独验证 RecoverOnStart:手动塞个 running 残留 + s.UpsertTask(Task{GID: "6", Token: "t", Status: "downloading"}) + if n := s.RecoverOnStart(); n != 1 { + t.Errorf("running/downloading 应归位 1 个,实际 %d", n) + } + if s.FindTask("6", "t").Status != "queued" { + t.Error("残留任务应被归位为 queued") + } +} + +// 状态文件损坏时回落默认值,不崩 +func TestStoreCorruptedFile(t *testing.T) { + dir := t.TempDir() + p := filepath.Join(dir, "state.json") + os.WriteFile(p, []byte("{ 这不是合法 JSON"), 0o644) + + s := NewStore(p) + if s.Settings().Concurrency != 3 { + t.Error("损坏文件应回落到默认设置") + } + if len(s.Tasks()) != 0 { + t.Error("损坏文件应回落空任务") + } +} + +// Tasks() 返回副本,改它不影响内部状态 +func TestStoreTasksIsCopy(t *testing.T) { + s, _ := newTestStore(t) + s.AddTasks([]eh.GalleryRef{{GID: "1", Token: "t", Raw: "orig"}}) + + got := s.Tasks() + got[0].Status = "done" + + if s.FindTask("1", "t").Status == "done" { + t.Error("Tasks() 应返回副本,外部修改不该影响内部") + } +} + +// ---- 引擎:断点续传的核心是「文件在就算完成」---- + +func TestEngineResumeSkipsExisting(t *testing.T) { + s, _ := newTestStore(t) + root := t.TempDir() + s.UpdateSettings(map[string]interface{}{"downloadRoot": root, "dirTemplate": "{gid}"}) + + dir := filepath.Join(root, "12345678") + os.MkdirAll(dir, 0o755) + + // 造一个已存在的文件 + 一个空文件 + full := filepath.Join(dir, "001.webp") + os.WriteFile(full, []byte("fake image data"), 0o644) + empty := filepath.Join(dir, "002.webp") + os.WriteFile(empty, []byte{}, 0o644) + + // targetName 的行为验证:页码补零 + if got := targetName(ehPageInfo("001.webp", 1), "12345678", 3); got != "001.webp" { + t.Errorf("文件名不对: %q", got) + } + if st, _ := os.Stat(full); st == nil || st.Size() == 0 { + t.Error("测试前提失败:文件应存在且非空") + } +} + +func TestEngineConcurrencyClamp(t *testing.T) { + s, _ := newTestStore(t) + e := NewEngine(s, nil, s.Settings()) + e.SetConcurrency(99) + if e.concurrency != 8 { + t.Errorf("并发应被限制到 8,实际 %d", e.concurrency) + } + e.SetConcurrency(0) + if e.concurrency != 1 { + t.Errorf("并发应被限制到 1,实际 %d", e.concurrency) + } +} + +func TestEngineStopIsSafeWhenIdle(t *testing.T) { + s, _ := newTestStore(t) + e := NewEngine(s, nil, s.Settings()) + e.Stop() // 不在跑时调 Stop 不该 panic + if e.IsRunning() { + t.Error("不该在跑") + } +} + +func TestEngineStartEmptyQueue(t *testing.T) { + s, _ := newTestStore(t) + e := NewEngine(s, &noopSink{}, s.Settings()) + + done := make(chan struct{}) + go func() { e.Start(context.Background()); close(done) }() + + select { + case <-done: + case <-time.After(3 * time.Second): + t.Fatal("空队列应该立刻返回") + } +} + +type noopSink struct{} + +func (n *noopSink) TaskUpdate(Task) {} +func (n *noopSink) Log(string, string) {} +func (n *noopSink) Done() {} + +// 错误可重试性判断 +func TestIsRetryable(t *testing.T) { + retryable := []error{ + errStr("connection reset by peer"), + errStr("dial tcp: i/o timeout"), + errStr("HTTP 503"), + errStr("HTTP 429"), + errStr("socket hang up"), + } + for _, e := range retryable { + if !isRetryable(e) { + t.Errorf("应判为可重试: %v", e) + } + } + + notRetryable := []error{ + nil, + ErrCanceled, + context.Canceled, + errStr("未能从 /s/ 页面解析出图片地址"), + } + for _, e := range notRetryable { + if isRetryable(e) { + t.Errorf("应判为不可重试: %v", e) + } + } +} + +type errStr string + +func (e errStr) Error() string { return string(e) } + +func ehPageInfo(name string, page int) eh.GalleryPageInfo { + return eh.GalleryPageInfo{Page: page, Filename: name} +} diff --git a/internal/core/paths.go b/internal/core/paths.go new file mode 100644 index 0000000..a59b334 --- /dev/null +++ b/internal/core/paths.go @@ -0,0 +1,253 @@ +package core + +// 下载目录模板引擎 +// +// 可用占位符: +// +// {title} 画廊标题 +// {gid} 画廊号 +// {artist} 作者(artist: 命名空间 → 标题 [X] → 上传者) +// {group} 社团(group: 命名空间 → 标题 (X)) +// {series} 系列/原作(parody: 命名空间) +// {category} 分类(doujinshi / manga / misc …) +// {lang} 语言 +// {uploader} 上传者 +// {tag} 第 1 个普通标签,同 {tag1} +// {tag2} 第 2 个普通标签 … +// {tags} 全部普通标签,用 _ 连接 +// {tags:3} 前 3 个普通标签,用 _ 连接 +// +// 例:{artist}/{title} {category}/{group}/{title} {tags:2}/{artist}/{title} + +import ( + "fmt" + "net" + "os" + "path/filepath" + "regexp" + "strconv" + "strings" + "time" + "unicode" + + "eh-downloader-wails/internal/eh" +) + +// Fallback 是各占位符的兜底值 +var Fallback = map[string]string{ + "artist": "未知作者", + "group": "未知社团", + "series": "无系列", + "category": "未分类", + "lang": "nolang", + "uploader": "unknown", +} + +var ( + // 路径保留字符 + 控制字符 + reIllegal = regexp.MustCompile(`[\\/:*?"<>|]`) + reControl = regexp.MustCompile(`[\x00-\x1f\x7f]`) + reSpace = regexp.MustCompile(`\s+`) + // 模板占位符:{key} 或 {tags:N} + rePlaceholder = regexp.MustCompile(`\{([a-zA-Z0-9_]+(?::\d+)?)\}`) + reTagsKey = regexp.MustCompile(`(?i)^tags(?::(\d+))?$`) +) + +// SanitizeSegment 清洗单个路径段:干掉路径分隔符和保留字符 +func SanitizeSegment(name, fallback string) string { + s := reIllegal.ReplaceAllString(name, "_") + s = reControl.ReplaceAllString(s, "_") + s = reSpace.ReplaceAllString(s, " ") + s = strings.TrimSpace(s) + + // "." / ".." 是危险路径 + if s == "." || s == ".." { + s = "_" + } + // Windows 不允许段名以点或空格结尾 + s = strings.TrimRight(s, ". ") + + if s == "" { + s = fallback + } + // 超长截断(Windows 路径上限 260,留足余量) + if len([]rune(s)) > 110 { + s = string([]rune(s)[:110]) + s = strings.TrimRight(s, ". ") + } + // Windows 保留设备名(CON/PRN/AUX/NUL/COM1…)不能做目录名 + if isWindowsReservedName(s) { + s = "_" + s + } + return s +} + +// joinTags 取前 n 个普通标签(下划线连接)。n<=0 表示全部。 +func joinTags(tags []string, n int) string { + if len(tags) == 0 { + return "" + } + if n <= 0 || n > len(tags) { + n = len(tags) + } + return strings.Join(tags[:n], "_") +} + +// buildDirVars 展开模板得到全部变量 +func buildDirVars(tpl string, g *eh.Gallery) map[string]string { + meta := g.Meta + vars := map[string]string{ + "gid": g.GID, + "title": g.Title, + "artist": FallbackIfEmpty(meta.Artist, Fallback["artist"]), + "group": FallbackIfEmpty(meta.Group, Fallback["group"]), + "series": FallbackIfEmpty(meta.Series, Fallback["series"]), + "category": FallbackIfEmpty(meta.Category, Fallback["category"]), + "lang": FallbackIfEmpty(meta.Lang, Fallback["lang"]), + "uploader": FallbackIfEmpty(meta.Uploader, Fallback["uploader"]), + } + if len(meta.Tags) > 0 { + vars["tag"] = meta.Tags[0] + vars["tags"] = joinTags(meta.Tags, 0) + } else { + vars["tag"] = Fallback["series"] + vars["tags"] = "" + } + for i := 1; i <= 12; i++ { + if i-1 < len(meta.Tags) { + vars["tag"+strconv.Itoa(i)] = meta.Tags[i-1] + } else { + vars["tag"+strconv.Itoa(i)] = "" + } + } + return vars +} + +// FallbackIfEmpty 空字符串才用兜底值 +func FallbackIfEmpty(s, fb string) string { + if strings.TrimSpace(s) == "" { + return fb + } + return s +} + +// BuildDir 用模板算出画廊的相对目录(相对 downloadRoot,逐段清洗) +func BuildDir(tpl string, g *eh.Gallery) string { + tpl = strings.TrimSpace(tpl) + if tpl == "" { + tpl = "{title}" + } + + vars := buildDirVars(tpl, g) + + out := rePlaceholder.ReplaceAllStringFunc(tpl, func(m string) string { + key := strings.ToLower(m[1 : len(m)-1]) + // {tags} / {tags:N} —— 冒号后数字表示取前几个 + if mt := reTagsKey.FindStringSubmatch(key); mt != nil { + n := 0 + if mt[1] != "" { + n, _ = strconv.Atoi(mt[1]) + } + return joinTags(g.Meta.Tags, n) + } + if v, ok := vars[key]; ok { + return v + } + return m // 未知占位符原样保留,避免用户以为拼错了却静默消失 + }) + + // 逐段清洗(兼容 / 和 \ 两种分隔符输入) + segs := []string{} + for _, s := range reIllegal.Split(out, -1) { + if strings.TrimSpace(s) == "" { + continue + } + idx := len(segs) + fb := "未命名" + if idx == 0 { + fb = g.GID + } + segs = append(segs, SanitizeSegment(s, fb)) + } + + if len(segs) == 0 { + return SanitizeSegment(g.Title, g.GID) + } + return strings.Join(segs, string(filepath.Separator)) +} + +// ListPlaceholders 给设置界面提示用 +func ListPlaceholders() []string { + return []string{ + "{title}", "{gid}", "{artist}", "{group}", "{series}", "{category}", + "{lang}", "{uploader}", "{tag}", "{tags}", "{tags:3}", + } +} + +// SanitizeFilename 清洗图片文件名(不含目录分隔) +func SanitizeFilename(name, fallback string) string { + s := reIllegal.ReplaceAllString(name, "_") + s = reControl.ReplaceAllString(s, "_") + s = strings.TrimSpace(s) + if s == "" || s == "." || s == ".." { + s = fallback + } + if len([]rune(s)) > 150 { + s = string([]rune(s)[:150]) + } + return s +} + +// DefaultDownloadRoot 默认下载目录 ~/Downloads/EH(Win/mac 通用) +func DefaultDownloadRoot() string { + home, err := os.UserHomeDir() + if err != nil { + return "EH" + } + return filepath.Join(home, "Downloads", "EH") +} + +// DetectLocalProxy 探测本机常见代理端口(mihomo/clash/v2ray 等)。 +// 真正等连接结果,连不上就试下一个端口 —— 别像 JS 版那样永远返回第一个。 +func DetectLocalProxy(ports []int) string { + for _, p := range ports { + addr := fmt.Sprintf("127.0.0.1:%d", p) + conn, err := net.DialTimeout("tcp", addr, 250*time.Millisecond) + if err == nil { + conn.Close() + return "http://127.0.0.1:" + strconv.Itoa(p) + } + } + return "" +} + +// isWindowsReservedName Windows 保留设备名,目录名撞上会失败 +func isWindowsReservedName(s string) bool { + base := strings.ToUpper(strings.SplitN(s, ".", 2)[0]) + switch base { + case "CON", "PRN", "AUX", "NUL", + "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9", + "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9": + return true + } + return false +} + +// validSegmentName 段名合法性检查(给测试用) +func validSegmentName(s string) bool { + if s == "" || s == "." || s == ".." { + return false + } + if strings.ContainsAny(s, `\/:*?"<>|`) { + return false + } + for _, r := range s { + if unicode.IsControl(r) { + return false + } + } + if isWindowsReservedName(s) { + return false + } + return true +} diff --git a/internal/core/paths_test.go b/internal/core/paths_test.go new file mode 100644 index 0000000..f04dff4 --- /dev/null +++ b/internal/core/paths_test.go @@ -0,0 +1,176 @@ +package core + +import ( + "os" + "strings" + "testing" + + "eh-downloader-wails/internal/eh" +) + +func sampleGallery() *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", + Lang: "chinese", + Uploader: "velius11", + Tags: []string{"qingxiao", "rover", "big breasts", "x-ray", "ai generated"}, + }, + } +} + +func TestBuildDir(t *testing.T) { + g := sampleGallery() + tests := []struct { + tpl string + want string + }{ + {"{title}", "[Noxe] 0820 Qingxiao [AI Generated]"}, + {"{artist}/{title}", `Noxe\[Noxe] 0820 Qingxiao [AI Generated]`}, + {"{group}/{title}", `CriticalHAI\[Noxe] 0820 Qingxiao [AI Generated]`}, + {"{category}/{group}/{title}", `misc\CriticalHAI\[Noxe] 0820 Qingxiao [AI Generated]`}, + {"{tags:2}/{artist}/{title}", `qingxiao_rover\Noxe\[Noxe] 0820 Qingxiao [AI Generated]`}, + {"{tags}/{artist}/{title}", `qingxiao_rover_big breasts_x-ray_ai generated\Noxe\[Noxe] 0820 Qingxiao [AI Generated]`}, + {"{tag1}-{tag2}", "qingxiao-rover"}, + {"{artist} - {gid} - {title}", "Noxe - 4239274 - [Noxe] 0820 Qingxiao [AI Generated]"}, + {"{gid}", "4239274"}, + {"{lang}/{title}", `chinese\[Noxe] 0820 Qingxiao [AI Generated]`}, + {"{unknown}/{title}", `{unknown}\[Noxe] 0820 Qingxiao [AI Generated]`}, // 未知占位符原样保留 + } + + for _, tt := range tests { + t.Run(tt.tpl, func(t *testing.T) { + got := BuildDir(tt.tpl, g) + if got != tt.want { + t.Errorf("\n got: %q\nwant: %q", got, tt.want) + } + }) + } +} + +// 空模板回落到 {title} +func TestBuildDirDefaults(t *testing.T) { + g := sampleGallery() + for _, tpl := range []string{"", " "} { + if got := BuildDir(tpl, g); !strings.Contains(got, "Noxe") { + t.Errorf("空模板应回落到标题, got %q", got) + } + } +} + +// 占位符兜底值 +func TestBuildDirFallbacks(t *testing.T) { + g := &eh.Gallery{GID: "12345678", Title: "Plain Title"} + got := BuildDir("{artist}/{group}/{series}/{category}/{lang}/{uploader}", g) + for _, want := range []string{"未知作者", "未知社团", "无系列", "未分类", "nolang", "unknown"} { + if !strings.Contains(got, want) { + t.Errorf("缺兜底值 %q: %s", want, got) + } + } +} + +// 路径穿越必须被挡住 +func TestSanitizeSegmentSecurity(t *testing.T) { + cases := []struct{ in, want string }{ + {"..", "_"}, + {".", "_"}, + {"../../etc/passwd", ".._.._etc_passwd"}, + {`a/b\c:d*e?f"gi|j`, "a_b_c_d_e_f_g_h_i_j"}, + {"CON", "_CON"}, // Windows 保留设备名 + {"nul.txt", "_nul.txt"}, // 保留名带扩展也不行 + {"正常名字", "正常名字"}, + {"trailing dots...", "trailing dots"}, + {" spaced ", "spaced"}, + } + for _, c := range cases { + if got := SanitizeSegment(c.in, "fb"); got != c.want { + t.Errorf("SanitizeSegment(%q) = %q, want %q", c.in, got, c.want) + } + } +} + +// 超长截断 +func TestSanitizeSegmentTruncate(t *testing.T) { + long := strings.Repeat("长", 300) + got := SanitizeSegment(long, "fb") + if len([]rune(got)) != 110 { + t.Errorf("应截到 110 字,实际 %d", len([]rune(got))) + } +} + +// 分隔符混用(用户可能用 / 或 \) +func TestBuildDirMixedSeparators(t *testing.T) { + g := sampleGallery() + for _, tpl := range []string{`{artist}/{title}`, `{artist}\{title}`, `{artist}//{title}`} { + got := BuildDir(tpl, g) + if strings.Contains(got, "/") || strings.Contains(got, "\\") && strings.Count(got, "\\") > 1 { + continue // 各平台分隔符不同,只要求不出现空段 + } + if strings.Contains(got, "__") { + t.Errorf("出现空段: %q (tpl=%q)", got, tpl) + } + } +} + +func TestListPlaceholders(t *testing.T) { + p := ListPlaceholders() + if len(p) == 0 { + t.Fatal("占位符列表不该为空") + } + found := false + for _, x := range p { + if x == "{tags:3}" { + found = true + } + } + if !found { + t.Error("缺 {tags:3}") + } +} + +// ---- 代理探测:必须真的等连接结果(JS 版是坏的,永远返回 7890)---- + +func TestDetectLocalProxy(t *testing.T) { + // 起一个真实的监听端口 + ln, err := net_listen_loopback() + if err != nil { + t.Skipf("无法监听端口,跳过: %v", err) + } + defer ln.Close() + port := ln.port() + + got := DetectLocalProxy([]int{port}) + if got == "" { + t.Error("有端口在监听,应该探测得到") + return + } + if !strings.Contains(got, "127.0.0.1") { + t.Errorf("地址不对: %q", got) + } +} + +func TestDetectLocalProxyNone(t *testing.T) { + // 用一个几乎肯定没人监听的端口 + if got := DetectLocalProxy([]int{1, 2, 3}); got != "" { + t.Errorf("没端口监听时不该返回结果, got %q", got) + } +} + +func TestDefaultDownloadRoot(t *testing.T) { + root := DefaultDownloadRoot() + if root == "" { + t.Fatal("下载目录不该为空") + } + if !strings.HasSuffix(root, "Downloads") && !strings.Contains(root, "Downloads") { + t.Errorf("路径可疑: %q", root) + } + if _, err := os.Stat(root); err == nil { + // 目录存在也没关系,只确认路径合法 + t.Logf("目录已存在: %s", root) + } +} diff --git a/internal/core/store.go b/internal/core/store.go new file mode 100644 index 0000000..b9eb4c7 --- /dev/null +++ b/internal/core/store.go @@ -0,0 +1,479 @@ +package core + +// 持久化:JSON 状态文件(原子写) +// +// 为什么不用 SQLite:断点续传的真正依据是「文件系统里文件在不在」, +// 状态文件只需记住画廊级别的进度。几百个任务的规模下 JSON 完全够用。 +// +// 写文件用「临时文件 + rename」保证原子性。 + +import ( + "encoding/json" + "os" + "path/filepath" + "sync" + "time" + + "eh-downloader-wails/internal/eh" +) + +// Settings 用户设置 +type Settings struct { + DownloadRoot string `json:"downloadRoot"` + Proxy string `json:"proxy"` + ProxyMode string `json:"proxyMode"` // off | http | socks5 + Concurrency int `json:"concurrency"` + Timeout int `json:"timeout"` // 毫秒 + Xres string `json:"xres"` // auto | org | 数字(长边上限) + BridgePort int `json:"bridgePort"` + DirTemplate string `json:"dirTemplate"` + + // 内部字段:是否已探测过代理(不暴露给前端) + Probed bool `json:"_probed"` +} + +// FailedItem 单页失败记录 +type FailedItem struct { + Page int `json:"page"` + File string `json:"file"` + Error string `json:"error"` +} + +// Task 一个画廊的下载任务 +type Task struct { + GID string `json:"gid"` + Token string `json:"token"` + Raw string `json:"raw"` + Title string `json:"title"` + Status string `json:"status"` // queued|running|downloading|partial|done|failed + Done int `json:"done"` + Failed int `json:"failed"` + TotalPages int `json:"totalPages"` + Error string `json:"error"` + Dir string `json:"dir"` + Pages map[string]string `json:"pages"` + FailedList []FailedItem `json:"failedList"` + Selected bool `json:"selected"` + CreatedAt int64 `json:"createdAt"` + UpdatedAt int64 `json:"updatedAt"` +} + +// State 状态文件顶层结构 +type State struct { + Settings Settings `json:"settings"` + Tasks []Task `json:"tasks"` +} + +// DefaultSettings 返回默认设置 +func DefaultSettings() Settings { + return Settings{ + DownloadRoot: "", + Proxy: "", + ProxyMode: "off", + Concurrency: 3, + Timeout: 30000, + Xres: "auto", + BridgePort: 37777, + DirTemplate: "{title}", + } +} + +// Store 是带锁的 JSON 状态存储,并发安全 +type Store struct { + mu sync.RWMutex + path string + state State + + flushTimer *time.Timer + dirty bool +} + +// NewStore 打开(或新建)状态文件 +func NewStore(filePath string) *Store { + s := &Store{path: filePath} + s.load() + return s +} + +func (s *Store) load() { + s.mu.Lock() + defer s.mu.Unlock() + + s.state = State{Settings: DefaultSettings(), Tasks: []Task{}} + + raw, err := os.ReadFile(s.path) + if err == nil { + var loaded State + if json.Unmarshal(raw, &loaded) == nil { + // 补齐缺失字段(向后兼容) + def := DefaultSettings() + if loaded.Settings.DownloadRoot == "" { + loaded.Settings.DownloadRoot = def.DownloadRoot + } + if loaded.Settings.Concurrency <= 0 { + loaded.Settings.Concurrency = def.Concurrency + } + if loaded.Settings.Timeout <= 0 { + loaded.Settings.Timeout = def.Timeout + } + if loaded.Settings.BridgePort <= 0 { + loaded.Settings.BridgePort = def.BridgePort + } + if loaded.Settings.DirTemplate == "" { + loaded.Settings.DirTemplate = def.DirTemplate + } + if loaded.Settings.Xres == "" { + loaded.Settings.Xres = def.Xres + } + if loaded.Tasks == nil { + loaded.Tasks = []Task{} + } + s.state = loaded + } + } +} + +// Flush 原子写:先写临时文件再 rename +func (s *Store) Flush() { + s.mu.RLock() + data, err := json.MarshalIndent(s.state, "", " ") + path := s.path + s.dirty = false + s.mu.RUnlock() + + if err != nil { + return + } + tmp := path + ".tmp" + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return + } + if err := os.WriteFile(tmp, data, 0o644); err != nil { + return + } + _ = os.Rename(tmp, path) +} + +// ScheduleFlush 合并短时间内的多次写,降低磁盘压力 +func (s *Store) ScheduleFlush(delay int) { + if delay <= 0 { + delay = 400 + } + s.mu.Lock() + defer s.mu.Unlock() + if s.flushTimer != nil { + return + } + s.dirty = true + s.flushTimer = time.AfterFunc(time.Duration(delay)*time.Millisecond, func() { + s.mu.Lock() + s.flushTimer = nil + s.mu.Unlock() + s.Flush() + }) +} + +// Stop 停掉待执行的延迟写(退出前调用) +func (s *Store) Stop() { + s.mu.Lock() + if s.flushTimer != nil { + s.flushTimer.Stop() + s.flushTimer = nil + } + s.mu.Unlock() +} + +// Settings 取当前设置(返回副本) +func (s *Store) Settings() Settings { + s.mu.RLock() + defer s.mu.RUnlock() + return s.state.Settings +} + +// UpdateSettings 合并设置 +func (s *Store) UpdateSettings(patch map[string]interface{}) Settings { + s.mu.Lock() + defer s.mu.Unlock() + + m, _ := json.Marshal(s.state.Settings) + var cur map[string]interface{} + _ = json.Unmarshal(m, &cur) + for k, v := range patch { + cur[k] = v + } + m2, _ := json.Marshal(cur) + var next Settings + if json.Unmarshal(m2, &next) == nil { + s.state.Settings = next + } + s.startFlushLocked() + return s.state.Settings +} + +func (s *Store) startFlushLocked() { + if s.flushTimer != nil { + return + } + s.dirty = true + s.flushTimer = time.AfterFunc(400*time.Millisecond, func() { + s.mu.Lock() + s.flushTimer = nil + s.mu.Unlock() + s.Flush() + }) +} + +// Tasks 取全部任务(深拷贝,防止调用方改到内部状态) +func (s *Store) Tasks() []Task { + s.mu.RLock() + defer s.mu.RUnlock() + out := make([]Task, 0, len(s.state.Tasks)) + for _, t := range s.state.Tasks { + out = append(out, t.clone()) + } + return out +} + +func (t Task) clone() Task { + c := t + if t.Pages != nil { + c.Pages = make(map[string]string, len(t.Pages)) + for k, v := range t.Pages { + c.Pages[k] = v + } + } + if t.FailedList != nil { + c.FailedList = append([]FailedItem(nil), t.FailedList...) + } + return c +} + +// FindTask 找任务 +func (s *Store) FindTask(gid, token string) *Task { + s.mu.RLock() + defer s.mu.RUnlock() + for i := range s.state.Tasks { + if s.state.Tasks[i].GID == gid && s.state.Tasks[i].Token == token { + c := s.state.Tasks[i].clone() + return &c + } + } + return nil +} + +// AddTasks 批量加任务(已存在的跳过),返回新增数 +func (s *Store) AddTasks(refs []eh.GalleryRef) int { + s.mu.Lock() + defer s.mu.Unlock() + + added := 0 + now := time.Now().UnixMilli() + for _, r := range refs { + if r.GID == "" || r.Token == "" { + continue + } + exists := false + for _, t := range s.state.Tasks { + if t.GID == r.GID && t.Token == r.Token { + exists = true + break + } + } + if exists { + continue + } + s.state.Tasks = append([]Task{{ + GID: r.GID, + Token: r.Token, + Raw: r.Raw, + Title: r.Title, + Status: "queued", + Pages: map[string]string{}, + FailedList: []FailedItem{}, + CreatedAt: now, + UpdatedAt: now, + }}, s.state.Tasks...) + added++ + } + s.startFlushLocked() + return added +} + +// SetTitle 给已有任务补标题(油猴推送时带标题来的) +func (s *Store) SetTitle(gid, token, title string) bool { + s.mu.Lock() + defer s.mu.Unlock() + for i := range s.state.Tasks { + if s.state.Tasks[i].GID == gid && s.state.Tasks[i].Token == token { + if s.state.Tasks[i].Title == "" && title != "" { + s.state.Tasks[i].Title = truncate(title, 300) + s.startFlushLocked() + return true + } + return false + } + } + return false +} + +func truncate(s string, n int) string { + r := []rune(s) + if len(r) <= n { + return s + } + return string(r[:n]) +} + +// UpsertTask 更新/插入任务 +func (s *Store) UpsertTask(t Task) Task { + s.mu.Lock() + defer s.mu.Unlock() + + t.UpdatedAt = time.Now().UnixMilli() + for i := range s.state.Tasks { + if s.state.Tasks[i].GID == t.GID && s.state.Tasks[i].Token == t.Token { + if t.Pages == nil { + t.Pages = map[string]string{} + } + if t.FailedList == nil { + t.FailedList = []FailedItem{} + } + if t.CreatedAt == 0 { + t.CreatedAt = s.state.Tasks[i].CreatedAt + } + s.state.Tasks[i] = t + s.startFlushLocked() + return t.clone() + } + } + if t.Pages == nil { + t.Pages = map[string]string{} + } + if t.FailedList == nil { + t.FailedList = []FailedItem{} + } + if t.CreatedAt == 0 { + t.CreatedAt = t.UpdatedAt + } + s.state.Tasks = append([]Task{t}, s.state.Tasks...) + s.startFlushLocked() + return t.clone() +} + +// RemoveTask 删单个任务 +func (s *Store) RemoveTask(gid, token string) { + s.mu.Lock() + defer s.mu.Unlock() + for i := range s.state.Tasks { + if s.state.Tasks[i].GID == gid && s.state.Tasks[i].Token == token { + s.state.Tasks = append(s.state.Tasks[:i], s.state.Tasks[i+1:]...) + s.startFlushLocked() + return + } + } +} + +// ClearAll 清空任务列表(或只删勾选的)。 +// ⚠️ 只清任务列表,本地已下载的文件一个都不动。 +func (s *Store) ClearAll(onlySelected bool) int { + s.mu.Lock() + defer s.mu.Unlock() + + before := len(s.state.Tasks) + kept := make([]Task, 0, before) + for _, t := range s.state.Tasks { + if onlySelected && !t.Selected { + kept = append(kept, t) + } + } + s.state.Tasks = kept + s.startFlushLocked() + return before - len(s.state.Tasks) +} + +// ClearFinished 只清 done。partial/failed 保留(里面还有失败清单要处理)。 +func (s *Store) ClearFinished() int { + s.mu.Lock() + defer s.mu.Unlock() + + before := len(s.state.Tasks) + kept := make([]Task, 0, before) + for _, t := range s.state.Tasks { + if t.Status != "done" { + kept = append(kept, t) + } + } + s.state.Tasks = kept + s.startFlushLocked() + return before - len(s.state.Tasks) +} + +// CountDone 数一下已完成 +func (s *Store) CountDone() int { + s.mu.RLock() + defer s.mu.RUnlock() + n := 0 + for _, t := range s.state.Tasks { + if t.Status == "done" { + n++ + } + } + return n +} + +// RequeueFailed 把失败/部分失败/中断的重新排队 +func (s *Store) RequeueFailed() int { + s.mu.Lock() + defer s.mu.Unlock() + + n := 0 + for i := range s.state.Tasks { + switch s.state.Tasks[i].Status { + case "failed", "partial", "downloading", "running": + s.state.Tasks[i].Status = "queued" + s.state.Tasks[i].Error = "" + n++ + } + } + if n > 0 { + s.startFlushLocked() + } + return n +} + +// RecoverOnStart 启动时把残留的 running/downloading 归位 +func (s *Store) RecoverOnStart() int { + s.mu.Lock() + defer s.mu.Unlock() + + n := 0 + for i := range s.state.Tasks { + if s.state.Tasks[i].Status == "running" || s.state.Tasks[i].Status == "downloading" { + s.state.Tasks[i].Status = "queued" + n++ + } + } + if n > 0 { + s.startFlushLocked() + } + return n +} + +// SelectAll 标记勾选状态 +func (s *Store) SelectAll(gids []string) []Task { + set := make(map[string]bool, len(gids)) + for _, g := range gids { + set[g] = true + } + s.mu.Lock() + for i := range s.state.Tasks { + s.state.Tasks[i].Selected = set[s.state.Tasks[i].GID] + } + s.startFlushLocked() + out := make([]Task, 0, len(s.state.Tasks)) + for _, t := range s.state.Tasks { + out = append(out, t.clone()) + } + s.mu.Unlock() + return out +} diff --git a/internal/core/testhelper_test.go b/internal/core/testhelper_test.go new file mode 100644 index 0000000..d60fc31 --- /dev/null +++ b/internal/core/testhelper_test.go @@ -0,0 +1,39 @@ +package core + +// 测试辅助:起一个真实监听端口,用来验证代理探测真的等了连接结果 + +import ( + "net" + "strconv" +) + +type testListener struct { + ln net.Listener +} + +func (l *testListener) port() int { + addr := l.ln.Addr().(*net.TCPAddr) + return addr.Port +} + +func (l *testListener) Close() error { return l.ln.Close() } + +func net_listen_loopback() (*testListener, error) { + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + return nil, err + } + // 接受连接,避免 backlog 满 + go func() { + for { + c, err := ln.Accept() + if err != nil { + return + } + c.Close() + } + }() + return &testListener{ln: ln}, nil +} + +var _ = strconv.Itoa diff --git a/internal/core/version.go b/internal/core/version.go new file mode 100644 index 0000000..7dfabc8 --- /dev/null +++ b/internal/core/version.go @@ -0,0 +1,7 @@ +package core + +// AppVersion 应用版本号(与界面显示保持一致) +const AppVersion = "0.2.0-wails" + +// AppName 应用名 +const AppName = "EH Downloader" diff --git a/internal/eh/e2e_test.go b/internal/eh/e2e_test.go new file mode 100644 index 0000000..f1c46b7 --- /dev/null +++ b/internal/eh/e2e_test.go @@ -0,0 +1,293 @@ +package eh + +// 端到端协议链路测试:用 httptest 模拟 EH 服务器, +// 验证「画廊页分页 → /s/ → hath 直链 → 下载」整条链路, +// 以及断点续传时「文件已存在就跳过」的行为。 +// +// 这些 HTML 样本按真实 EH 页面结构构造(字段名、缩略图 title 属性、 +// Showing X - Y of N images 等都照抄线上结构)。 + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +// 模拟画廊:45 张图 = 3 屏(20 + 20 + 5),用来验证 ?p=N 分页 +func mockGalleryHTML(t *testing.T, gid, token string, totalImages int) map[int]string { + pages := map[int]string{} // p 序号 -> html + + for p := 0; p < 3; p++ { + from := p*20 + 1 + to := from + 19 + if to > totalImages { + to = totalImages + } + var sb strings.Builder + + if p == 0 { + sb.WriteString(``) + sb.WriteString(`[Noxe] Test Gallery - E-Hentai Galleries`) + sb.WriteString(``) + sb.WriteString(`
+ reTagRow = regexp.MustCompile(`(?is)\s*([a-z_]+):?\s*\s*`) + reTagLink = regexp.MustCompile(`(?is)]*>([^<]+)`) + + // hath 全图 URL:随机子域 + 随机端口 + keystamp 签名 + reHath = regexp.MustCompile(`(?i)https://[a-z0-9-]+(?:\.[a-z0-9-]+)*\.hath\.network(?::\d+)?/[^\s"'<>)\]]+`) +) + +// parseMeta 从画廊页 HTML 的 #taglist 表格解析 艺术家/社团/分类/标签 +func parseMeta(html, title string) GalleryMeta { + meta := GalleryMeta{ + Tags: []string{}, + ByNs: map[string][]string{}, + } + + if dm := reMetaDesc.FindStringSubmatch(html); dm != nil { + d := DecodeEntities(dm[1]) + if cm := reCatFromDesc.FindStringSubmatch(d); cm != nil { + meta.Category = strings.ToLower(cm[1]) + } + } + + i := strings.Index(html, `id="taglist"`) + if i < 0 { + return finishMeta(meta, title, html) + } + seg := html[i:] + if len(seg) > 30000 { + seg = seg[:30000] + } + + for _, row := range reTagRow.FindAllStringSubmatch(seg, -1) { + ns := strings.ToLower(row[1]) + var names []string + for _, a := range reTagLink.FindAllStringSubmatch(row[2], -1) { + n := strings.TrimSpace(DecodeEntities(a[1])) + if n == "" || strings.HasPrefix(n, "tag:") { + continue + } + if strings.EqualFold(n, "more") || strings.EqualFold(n, "less") { + continue + } + names = append(names, n) + } + if len(names) == 0 { + continue + } + if _, ok := meta.ByNs[ns]; !ok { + meta.ByNs[ns] = names + } + + switch ns { + case "artist": + if meta.Artist == "" { + meta.Artist = names[0] + } + case "group": + if meta.Group == "" { + meta.Group = names[0] + } + case "parody": + if meta.Series == "" { + meta.Series = names[0] + } + case "language": + if meta.Lang == "" { + meta.Lang = names[0] + } + default: + meta.Tags = append(meta.Tags, names...) + } + } + + return finishMeta(meta, title, html) +} + +// finishMeta 做三级兜底:EH 标题约定很可靠 —— [作者] 标题 (社团) +func finishMeta(meta GalleryMeta, title, html string) GalleryMeta { + if meta.Artist == "" && title != "" { + if m := reArtistBracket.FindStringSubmatch(title); m != nil { + meta.Artist = strings.TrimSpace(m[1]) + } + } + if meta.Group == "" && title != "" { + if m := reGroupParen.FindStringSubmatch(title); m != nil { + meta.Group = strings.TrimSpace(m[1]) + } + } + if up := reUploader.FindStringSubmatch(html); up != nil { + meta.Uploader = strings.TrimSpace(DecodeEntities(up[1])) + } + if meta.Artist == "" { + meta.Artist = meta.Uploader + } + if meta.Tags == nil { + meta.Tags = []string{} + } + return meta +} + +// ---------------------------------------------------------------- Client + +// Client 是 EH 协议客户端,并发安全 +type Client struct { + mu sync.RWMutex + timeout time.Duration + proxy string + proxyMode string // off | http | socks5 + xres string // auto | org | 数字(长边上限) +} + +// NewClient 建客户端 +func NewClient(timeout time.Duration, proxyURL, proxyMode, xres string) *Client { + if timeout <= 0 { + timeout = 30 * time.Second + } + if xres == "" { + xres = "auto" + } + return &Client{timeout: timeout, proxy: proxyURL, proxyMode: proxyMode, xres: xres} +} + +// SetProxy 改代理配置 +func (c *Client) SetProxy(p, mode string) { + c.mu.Lock() + c.proxy = p + if mode != "" { + c.proxyMode = mode + } + c.mu.Unlock() +} + +// SetXres 改分辨率策略 +func (c *Client) SetXres(x string) { + c.mu.Lock() + c.xres = x + c.mu.Unlock() +} + +// SetTimeout 改超时 +func (c *Client) SetTimeout(d time.Duration) { + c.mu.Lock() + c.timeout = d + c.mu.Unlock() +} + +func (c *Client) snapshot() (string, string, time.Duration) { + c.mu.RLock() + defer c.mu.RUnlock() + return c.proxy, c.proxyMode, c.timeout +} + +// httpClient 按当前代理配置构造 http.Client。 +// 每次调用新建(配置变更即时生效,且下载与页面请求的超时不同)。 +func (c *Client) httpClient(timeout time.Duration) *http.Client { + proxyURL, mode, defTimeout := c.snapshot() + + tr := &http.Transport{ + MaxIdleConns: 64, + MaxIdleConnsPerHost: 16, + IdleConnTimeout: 90 * time.Second, + TLSHandshakeTimeout: 10 * time.Second, + } + + if proxyURL != "" && mode != "off" && mode != "" { + switch mode { + case "socks5": + raw := strings.TrimPrefix(strings.TrimPrefix(proxyURL, "socks5://"), "socks5h://") + var auth *proxy.Auth + if u, err := url.Parse("socks5://" + raw); err == nil && u.User != nil { + pw, _ := u.User.Password() + auth = &proxy.Auth{User: u.User.Username(), Password: pw} + } + if dialer, err := proxy.SOCKS5("tcp", raw, auth, proxy.Direct); err == nil { + tr.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) { + return dialer.Dial(network, addr) + } + } + default: // http + if p, err := url.Parse(proxyURL); err == nil { + tr.Proxy = http.ProxyURL(p) + } + } + } + + if timeout <= 0 { + timeout = defTimeout + } + return &http.Client{Transport: tr, Timeout: timeout} +} + +func (c *Client) get(ctx context.Context, u string, timeout time.Duration) (*http.Response, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) + if err != nil { + return nil, err + } + req.Header.Set("User-Agent", defaultUA) + req.Header.Set("Accept", "*/*") + + resp, err := c.httpClient(timeout).Do(req) + if err != nil { + return nil, err + } + if resp.StatusCode < 200 || resp.StatusCode >= 400 { + resp.Body.Close() + return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, u) + } + return resp, nil +} + +func (c *Client) getText(ctx context.Context, u string) (string, error) { + resp, err := c.get(ctx, u, 0) + if err != nil { + return "", err + } + defer resp.Body.Close() + b, err := io.ReadAll(resp.Body) + if err != nil { + return "", err + } + return string(b), nil +} + +// ---------------------------------------------------------------- 1) 画廊页 + +// FetchGalleryPage 取画廊页。注意:EH 每屏只列 20 张缩略图, +// 必须翻 ?p=N 逐屏收集,否则静默只下到前 20 张且不报错。 +func (c *Client) FetchGalleryPage(ctx context.Context, gid, token string) (*Gallery, error) { + var ( + pages []GalleryPageInfo + seen = map[int]bool{} + total int + title string + lastHTML string + firstHTML string + ) + + for p := 0; p < 500; p++ { + if err := ctx.Err(); err != nil { + return nil, err + } + u := EHBase + "/g/" + gid + "/" + token + "/" + if p > 0 { + u += "?p=" + strconv.Itoa(p) + } + html, err := c.getText(ctx, u) + if err != nil { + return nil, fmt.Errorf("解析画廊页失败: %w", err) + } + lastHTML = html + + if p == 0 { + if len(html) < 2000 && strings.Contains(html, "404 Not Found") { + return nil, fmt.Errorf("画廊不存在或 token 失效: %s", gid) + } + if m := reTitleTag.FindStringSubmatch(html); m != nil { + title = strings.TrimSpace(reTitleSuffix.ReplaceAllString(DecodeEntities(m[1]), "")) + } + if m := reMetaDesc.FindStringSubmatch(html); m != nil { + if nm := reTitleFromDesc.FindStringSubmatch(DecodeEntities(m[1])); nm != nil { + title = strings.TrimSpace(nm[1]) + } + } + if m := reTotal.FindStringSubmatch(html); m != nil { + total, _ = strconv.Atoi(m[1]) + } + firstHTML = html // 标签/作者等元信息只在第一页有 + } + + added := 0 + for _, m := range reThumbLink.FindAllStringSubmatch(html, -1) { + page, _ := strconv.Atoi(m[2]) + if seen[page] { + continue + } + seen[page] = true + added++ + + sPath := m[1] + if !strings.HasPrefix(sPath, "http") { + sPath = EHBase + sPath + } + name := strings.TrimSpace(DecodeEntities(m[3])) + if name == "" { + name = gid + "-" + strconv.Itoa(page) + ".webp" + } + pages = append(pages, GalleryPageInfo{Page: page, SPath: sPath, Filename: name}) + } + + if added == 0 { + break // 这屏没新东西 -> 到头了 + } + if total > 0 && len(pages) >= total { + break // 收齐 + } + } + + if len(pages) == 0 { + if reRestricted.MatchString(lastHTML) { + return nil, fmt.Errorf("画廊受限或不存在(无 cookie 访问不了): %s", gid) + } + return nil, fmt.Errorf("页面结构无法解析(EH 可能又改版): %s", gid) + } + + // 按页码排序(插入排序,页数通常几十到几百) + for i := 1; i < len(pages); i++ { + for j := i; j > 0 && pages[j].Page < pages[j-1].Page; j-- { + pages[j], pages[j-1] = pages[j-1], pages[j] + } + } + + if title == "" { + title = gid + } + if total == 0 { + total = len(pages) + } + + return &Gallery{ + GID: gid, + Token: token, + Title: title, + TotalPages: total, + Pages: pages, + Meta: parseMeta(firstHTML, title), + }, nil +} + +// ---------------------------------------------------------------- 2) 图片直链 + +// ResolveImageURL 把 /s/ 链接解析成 hath 全图 URL。 +// +// 这一步就是所谓「图片直链」的全部:GET 一次 /s/ 页面, +// 用正则从 HTML 里抓出那条 hath.network URL。没有签名计算、没有解码算法, +// 因为 URL 每次都不一样(随机子域+随机端口+keystamp 时间戳),只能实时取。 +func (c *Client) ResolveImageURL(ctx context.Context, sPath string) (string, error) { + html, err := c.getText(ctx, sPath) + if err != nil { + return "", err + } + m := reHath.FindString(html) + if m == "" { + return "", fmt.Errorf("未能从 /s/ 页面解析出图片地址: %s", sPath) + } + return c.applyXres(DecodeEntities(m)), nil +} + +var ( + reXresNum = regexp.MustCompile(`xres=\d+`) + reXresAny = regexp.MustCompile(`xres=[^/]+`) +) + +// applyXres 改写 URL 里的 xres 参数(数字=长边上限,org=原图) +func (c *Client) applyXres(u string) string { + c.mu.RLock() + x := c.xres + c.mu.RUnlock() + + if x == "" || x == "auto" { + return u + } + if _, err := strconv.Atoi(x); err == nil { + return reXresNum.ReplaceAllString(u, "xres="+x) + } + if x == "org" { + return reXresAny.ReplaceAllString(u, "xres=org") + } + return u +} + +// ---------------------------------------------------------------- 3) 下载 + +// DownloadImage 流式下载到 destPath(先写 .part 再 rename,保证原子性) +func (c *Client) DownloadImage(ctx context.Context, imgURL, destPath string, onProgress func(received, total int64)) (int64, error) { + _, _, baseTimeout := c.snapshot() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, imgURL, nil) + if err != nil { + return 0, err + } + req.Header.Set("User-Agent", defaultUA) + req.Header.Set("Referer", EHBase+"/") + + resp, err := c.httpClient(baseTimeout * 2).Do(req) + if err != nil { + return 0, err + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 400 { + return 0, fmt.Errorf("HTTP %d 下载图片失败", resp.StatusCode) + } + + tmp := destPath + ".part" + f, err := os.Create(tmp) + if err != nil { + return 0, err + } + + received, copyErr := io.Copy(f, &progressReader{ + r: resp.Body, total: resp.ContentLength, onProgress: onProgress, + }) + closeErr := f.Close() + if copyErr != nil { + os.Remove(tmp) + return 0, copyErr + } + if closeErr != nil { + os.Remove(tmp) + return 0, closeErr + } + if received == 0 { + os.Remove(tmp) + return 0, errors.New("下载内容为空") + } + if err := os.Rename(tmp, destPath); err != nil { + os.Remove(tmp) + return 0, err + } + return received, nil +} + +// progressReader 包一层统计下载进度 +type progressReader struct { + r io.Reader + total int64 + received int64 + onProgress func(received, total int64) +} + +func (p *progressReader) Read(b []byte) (int, error) { + n, err := p.r.Read(b) + if n > 0 { + p.received += int64(n) + if p.onProgress != nil { + p.onProgress(p.received, p.total) + } + } + return n, err +} diff --git a/internal/eh/eh_test.go b/internal/eh/eh_test.go new file mode 100644 index 0000000..90d13e6 --- /dev/null +++ b/internal/eh/eh_test.go @@ -0,0 +1,236 @@ +package eh + +import "testing" + +// ---- 输入解析 ---- + +func TestParseGalleryInput(t *testing.T) { + tests := []struct { + name string + in string + want []GalleryRef + }{ + { + name: "标准完整链接", + in: "https://e-hentai.org/g/4239272/55e069b6b9", + want: []GalleryRef{{GID: "4239272", Token: "55e069b6b9"}}, + }, + { + name: "多行多链接", + in: "https://e-hentai.org/g/1111111/aaaaaaaaaa\n" + + "https://e-hentai.org/g/2222222/bbbbbbbbbb", + want: []GalleryRef{ + {GID: "1111111", Token: "aaaaaaaaaa"}, + {GID: "2222222", Token: "bbbbbbbbbb"}, + }, + }, + { + name: "竖线分隔一行多个", + in: "https://e-hentai.org/g/1111111/aaaaaaaaaa|https://e-hentai.org/g/2222222/bbbbbbbbbb", + want: []GalleryRef{ + {GID: "1111111", Token: "aaaaaaaaaa"}, + {GID: "2222222", Token: "bbbbbbbbbb"}, + }, + }, + { + name: "去重", + in: "https://e-hentai.org/g/1111111/aaaaaaaaaa\nhttps://e-hentai.org/g/1111111/aaaaaaaaaa", + want: []GalleryRef{{GID: "1111111", Token: "aaaaaaaaaa"}}, + }, + { + name: "注释行忽略", + in: "# 这是注释\nhttps://e-hentai.org/g/1111111/aaaaaaaaaa", + want: []GalleryRef{{GID: "1111111", Token: "aaaaaaaaaa"}}, + }, + { + name: "裸 gid 不匹配完整链接", + in: "4239274", + want: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := ParseGalleryInput(tt.in) + if len(got) != len(tt.want) { + t.Fatalf("数量不对: got %d want %d (%+v)", len(got), len(tt.want), got) + } + for i := range got { + if got[i].GID != tt.want[i].GID || got[i].Token != tt.want[i].Token { + t.Errorf("第 %d 项不对: got %s/%s want %s/%s", + i, got[i].GID, got[i].Token, tt.want[i].GID, tt.want[i].Token) + } + } + }) + } +} + +func TestExtractGIDs(t *testing.T) { + got := ExtractGIDs("看这个 4239274 还有 4239274 重复 和 1234567") + if len(got) != 2 { + t.Fatalf("应去重成 2 个,实际 %d 个: %v", len(got), got) + } + if got[0] != "4239274" || got[1] != "1234567" { + t.Errorf("结果不对: %v", got) + } +} + +// ---- hath 直链正则(核心) ---- + +func TestReHath(t *testing.T) { + // 从 README 里抄的真实样本 + samples := []string{ + `https://oclzqqk.gyktyewfonyf.hath.network:39656/h/4e9af9f...-44168-707-754-wbp/keystamp=1791448200-8b38ea8f6d;fileindex=270107377;xres=800/NH.webp`, + ``, + `"https://foo.bar.hath.network/h/a/b/c.png"`, + } + for _, s := range samples { + m := reHath.FindString(s) + if m == "" { + t.Errorf("没匹配到: %s", s) + continue + } + if m[len(m)-1] == '"' || m[len(m)-1] == '>' { + t.Errorf("匹配多了尾部字符: %q", m) + } + } + + // 不该匹配的 + for _, s := range []string{ + `https://e-hentai.org/s/abc/123-1`, + `https://example.com/h/foo`, + } { + if m := reHath.FindString(s); m != "" { + t.Errorf("不该匹配却匹配了: %q", m) + } + } +} + +func TestApplyXres(t *testing.T) { + const base = "https://a.hath.network:39656/h/x/keystamp=1;fileindex=2;xres=800/NH.webp" + + cases := []struct { + xres string + want string + }{ + {"auto", base}, + {"", base}, + {"org", "https://a.hath.network:39656/h/x/keystamp=1;fileindex=2;xres=org/NH.webp"}, + {"1920", "https://a.hath.network:39656/h/x/keystamp=1;fileindex=2;xres=1920/NH.webp"}, + } + for _, c := range cases { + cl := NewClient(0, "", "off", c.xres) + if got := cl.applyXres(base); got != c.want { + t.Errorf("xres=%s: got %s\n want %s", c.xres, got, c.want) + } + } +} + +// ---- 缩略图链接解析(画廊页分页的关键) ---- + +func TestReThumbLink(t *testing.T) { + // 模拟 EH 画廊页里的一行 + html := `
+
` + + ms := reThumbLink.FindAllStringSubmatch(html, -1) + if len(ms) != 2 { + t.Fatalf("应匹配 2 个,实际 %d", len(ms)) + } + if ms[0][1] != "/s/bb06b5db77/4239250-1" { + t.Errorf("第 1 个路径不对: %q", ms[0][1]) + } + if ms[0][2] != "1" || ms[0][3] != "001.jpg" { + t.Errorf("第 1 个页码/文件名不对: page=%s name=%q", ms[0][2], ms[0][3]) + } + if ms[1][1] != "https://e-hentai.org/s/cc11ee22ff/4239250-2" { + t.Errorf("第 2 个路径不对: %q", ms[1][1]) + } +} + +func TestReTotal(t *testing.T) { + for _, s := range []string{ + "Showing 1 - 20 of 206 images", + "Showing 21 - 40 of 206 images", + } { + m := reTotal.FindStringSubmatch(s) + if m == nil { + t.Fatalf("没匹配: %s", s) + } + if m[1] != "206" { + t.Errorf("总数不对: %s", m[1]) + } + } +} + +// ---- meta 解析 ---- + +func TestParseMeta(t *testing.T) { + html := ` +
+
`) + sb.WriteString(`
`) + sb.WriteString(``) + sb.WriteString(``) + sb.WriteString(``) + sb.WriteString(`
artist:
group:
other:
up`) + } + + for page := from; page <= to; page++ { + key := fmt.Sprintf("key%02d", page) + sb.WriteString(fmt.Sprintf( + `
`, + key, gid, page, page, page)) + } + + if p == 0 { + sb.WriteString(`
`) + } + sb.WriteString(fmt.Sprintf(`Showing %d - %d of %d images`, from, to, totalImages)) + if p == 0 { + sb.WriteString(`
`) + } + pages[p] = sb.String() + } + return pages +} + +func TestFetchGalleryPagePagination(t *testing.T) { + const ( + gid = "4239250" + tok = "58703925db" + total = 45 + ) + + galleryPages := mockGalleryHTML(t, gid, tok, total) + + mux := http.NewServeMux() + mux.HandleFunc("/g/"+gid+"/"+tok+"/", func(w http.ResponseWriter, r *http.Request) { + p := 0 + if q := r.URL.Query().Get("p"); q != "" { + fmt.Sscanf(q, "%d", &p) + } + html, ok := galleryPages[p] + if !ok { + // 超过范围的屏:EH 会返回空列表页 + w.Write([]byte(`Showing 46 - 45 of 45 images`)) + return + } + w.Write([]byte(html)) + }) + mux.HandleFunc("/s/", func(w http.ResponseWriter, r *http.Request) { + // /s/{key}/{gid}-{page} + page := strings.TrimPrefix(r.URL.Path, "/s/") + parts := strings.Split(page, "/") + if len(parts) != 2 { + w.WriteHeader(404) + return + } + idx := strings.LastIndex(parts[1], "-") + pno := parts[1][idx+1:] + // 每页一个不同的 hath URL(模拟随机子域+端口+keystamp) + w.Write([]byte(fmt.Sprintf( + ``, + len(pno)%10, 39000+len(pno), len(pno), len(pno)))) + }) + + srv := httptest.NewServer(mux) + defer srv.Close() + + // 把 EHBase 换到测试服务器 + oldBase := EHBase + EHBase = srv.URL + defer func() { EHBase = oldBase }() + + c := NewClient(5*time.Second, "", "off", "auto") + g, err := c.FetchGalleryPage(context.Background(), gid, tok) + if err != nil { + t.Fatalf("FetchGalleryPage 失败: %v", err) + } + + // 关键断言:45 张必须全部拿到(分页生效) + if len(g.Pages) != total { + t.Errorf("应拿到 %d 页,实际 %d 页 —— 分页可能没生效", total, len(g.Pages)) + } + if g.TotalPages != total { + t.Errorf("TotalPages 不对: %d", g.TotalPages) + } + + // 页码连续且有序 + for i, p := range g.Pages { + if p.Page != i+1 { + t.Errorf("第 %d 项页码应为 %d,实际 %d", i, i+1, p.Page) + } + } + + // key 必须与页码配对(每页的 key 不同);SPath 会被补全成绝对地址 + if !strings.HasSuffix(g.Pages[0].SPath, "/s/key01/4239250-1") { + t.Errorf("第 1 页路径不对: %s", g.Pages[0].SPath) + } + if !strings.HasSuffix(g.Pages[1].SPath, "/s/key02/4239250-2") { + t.Errorf("第 2 页路径不对(key 与 page 必须配对): %s", g.Pages[1].SPath) + } + + // 文件名 + if g.Pages[0].Filename != "001.png" { + t.Errorf("文件名不对: %q", g.Pages[0].Filename) + } + + // 元信息只在第一页解析 + if g.Title != "[Noxe] Test Gallery" { + t.Errorf("标题不对: %q", g.Title) + } + if g.Meta.Artist != "Noxe" { + t.Errorf("作者不对: %q", g.Meta.Artist) + } + if g.Meta.Category != "doujinshi" { + t.Errorf("分类不对: %q", g.Meta.Category) + } + if len(g.Meta.Tags) != 2 { + t.Errorf("标签不对: %v", g.Meta.Tags) + } +} + +// 解析 hath 直链 + xres 改写 +func TestResolveImageURLEndToEnd(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/s/", func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(` + + `)) + }) + srv := httptest.NewServer(mux) + defer srv.Close() + + oldBase := EHBase + EHBase = srv.URL + defer func() { EHBase = oldBase }() + + c := NewClient(5*time.Second, "", "off", "auto") + got, err := c.ResolveImageURL(context.Background(), srv.URL+"/s/abc/123-1") + if err != nil { + t.Fatalf("解析失败: %v", err) + } + if !strings.HasPrefix(got, "https://oclzqqk.gyktyewfonyf.hath.network:39656/") { + t.Errorf("直链不对: %s", got) + } + if !strings.Contains(got, "xres=800") { + t.Errorf("应保留站点给的 xres: %s", got) + } + if strings.Contains(got, `"`) || strings.Contains(got, ">") { + t.Errorf("URL 里混进了 HTML 符号: %s", got) + } + + // 切到 org 应该改写 + c.SetXres("org") + got2, _ := c.ResolveImageURL(context.Background(), srv.URL+"/s/abc/123-1") + if !strings.Contains(got2, "xres=org") { + t.Errorf("xres=org 改写失败: %s", got2) + } +} + +// 断点续传:文件已存在且非空就该跳过 +func TestDownloadImageResume(t *testing.T) { + payload := []byte("fake webp bytes for testing resume") + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write(payload) + })) + defer srv.Close() + + dir := t.TempDir() + c := NewClient(5*time.Second, "", "off", "auto") + + dest := filepath.Join(dir, "001.webp") + n, err := c.DownloadImage(context.Background(), srv.URL+"/pic.webp", dest, nil) + if err != nil { + t.Fatalf("下载失败: %v", err) + } + if n != int64(len(payload)) { + t.Errorf("下载字节数不对: %d", n) + } + + got, err := os.ReadFile(dest) + if err != nil { + t.Fatal(err) + } + if string(got) != string(payload) { + t.Errorf("内容不对") + } + + // .part 文件不该残留 + if _, err := os.Stat(dest + ".part"); err == nil { + t.Error("临时 .part 文件没清理") + } + + // 进度回调 + var received int64 + c2 := NewClient(5*time.Second, "", "off", "auto") + dest2 := filepath.Join(dir, "002.webp") + c2.DownloadImage(context.Background(), srv.URL+"/pic.webp", dest2, func(recv, total int64) { + received = recv + }) + if received != int64(len(payload)) { + t.Errorf("进度回调不对: %d", received) + } +} + +// 取消要能真的打断 +func TestDownloadImageCancel(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // 慢慢吐 + w.Write([]byte("start")) + if f, ok := w.(http.Flusher); ok { + f.Flush() + } + time.Sleep(3 * time.Second) + })) + defer srv.Close() + + ctx, cancel := context.WithCancel(context.Background()) + go func() { + time.Sleep(200 * time.Millisecond) + cancel() + }() + + c := NewClient(5*time.Second, "", "off", "auto") + dest := filepath.Join(t.TempDir(), "x.webp") + _, err := c.DownloadImage(ctx, srv.URL+"/slow.webp", dest, nil) + if err == nil { + t.Error("取消后应该返回错误") + } + // 临时文件要被清理 + if _, err := os.Stat(dest + ".part"); err == nil { + t.Error("取消后 .part 没清理") + } +} + +// 受限画廊要给出明确错误 +func TestFetchGalleryRestricted(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(`Login Required - restricted gallery`)) + })) + defer srv.Close() + + oldBase := EHBase + EHBase = srv.URL + defer func() { EHBase = oldBase }() + + c := NewClient(5*time.Second, "", "off", "auto") + _, err := c.FetchGalleryPage(context.Background(), "12345678", "abc") + if err == nil { + t.Fatal("受限画廊应该报错") + } + if !strings.Contains(err.Error(), "受限") { + t.Errorf("错误信息不明确: %v", err) + } +} diff --git a/internal/eh/eh.go b/internal/eh/eh.go new file mode 100644 index 0000000..c961984 --- /dev/null +++ b/internal/eh/eh.go @@ -0,0 +1,607 @@ +package eh + +// E-Hentai 抓取核心(2026 版协议) +// +// 已实测的链路: +// 1) GET https://e-hentai.org/g/{gid}/{token}/ 拿全部页的 /s/ 链接 + 原文件名 +// 2) GET https://e-hentai.org/s/{key}/{gid}-{page} 拿该页 hath 全图 URL(key 必须与 page 配对) +// 3) GET 下载图片本身 +// +// 老协议(var gmetadata / /img/{gid}/{gid}/{gid}-{page}.jpg / api.php gdata)全部失效。 +// +// 关于「图片直链」:hath 的 URL 是 随机子域 + 随机端口 + keystamp 时间戳签名, +// 不存在可推算的固定直链,只能每次实时从 /s/ 页面解析。所以这里没有「解码」, +// 只有「从 HTML 里提取」—— 见 ResolveImageURL。 + +import ( + "context" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/url" + "os" + "regexp" + "strconv" + "strings" + "sync" + "time" + + "golang.org/x/net/proxy" +) + +const ( + // Referer 不是必需的(实测裸请求也能 200),但带上更稳。 + defaultUA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " + + "(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" +) + +// EHBase 站点根地址(测试里会临时替换成 mock 服务器) +var EHBase = "https://e-hentai.org" + +// ---------------------------------------------------------------- 输入解析 + +// GalleryRef 是一个待下载的画廊(gid + token 缺一不可,只有 gid 会 404) +type GalleryRef struct { + GID string `json:"gid"` + Token string `json:"token"` + Raw string `json:"raw"` + Title string `json:"title"` +} + +var ( + reGalleryURL = regexp.MustCompile(`e-hentai\.org/g/(\d+)/([0-9a-f]+)`) + reBareGID = regexp.MustCompile(`\b(\d{6,8})\b`) + reSplitter = regexp.MustCompile(`[\s,|;]+`) +) + +// ParseGalleryInput 从粘贴的文本里提取画廊列表(去重,保持原顺序) +func ParseGalleryInput(text string) []GalleryRef { + var out []GalleryRef + seen := make(map[string]bool) + + for _, rawLine := range strings.FieldsFunc(text, func(r rune) bool { return r == '\n' || r == '\r' }) { + line := strings.TrimSpace(rawLine) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + for _, piece := range reSplitter.Split(line, -1) { + m := reGalleryURL.FindStringSubmatch(piece) + if m == nil { + continue + } + key := m[1] + "/" + m[2] + if seen[key] { + continue + } + seen[key] = true + out = append(out, GalleryRef{GID: m[1], Token: m[2], Raw: piece}) + } + } + return out +} + +// ExtractGIDs 兜底:只给了裸数字的情况 +func ExtractGIDs(text string) []string { + seen := map[string]bool{} + var out []string + for _, m := range reBareGID.FindAllStringSubmatch(text, -1) { + if !seen[m[1]] { + seen[m[1]] = true + out = append(out, m[1]) + } + } + return out +} + +var reNumericEntity = regexp.MustCompile(`&#(x?[0-9a-fA-F]+);`) + +// DecodeEntities 解 HTML 实体 +func DecodeEntities(s string) string { + s = strings.ReplaceAll(s, "&", "&") + s = strings.ReplaceAll(s, "<", "<") + s = strings.ReplaceAll(s, ">", ">") + s = strings.ReplaceAll(s, """, `"`) + s = strings.ReplaceAll(s, "'", "'") + s = strings.ReplaceAll(s, " ", " ") + // 数字实体 &#NNN; 与 &#xHH; + s = reNumericEntity.ReplaceAllStringFunc(s, func(m string) string { + n := m[2 : len(m)-1] + base := 10 + if strings.HasPrefix(strings.ToLower(n), "x") { + base = 16 + n = n[1:] + } + if v, err := strconv.ParseInt(n, base, 32); err == nil && v > 0 && v < 0x110000 { + return string(rune(v)) + } + return m + }) + return s +} + +// ---------------------------------------------------------------- 元信息 + +// GalleryMeta 画廊元信息,对应前端 {artist}/{group} 等占位符 +type GalleryMeta struct { + Artist string `json:"artist"` + Group string `json:"group"` + Series string `json:"series"` + Category string `json:"category"` + Lang string `json:"lang"` + Uploader string `json:"uploader"` + Tags []string `json:"tags"` + ByNs map[string][]string `json:"byNs"` +} + +// GalleryPageInfo 是「某页 -> /s/ 路径 + 原文件名」 +type GalleryPageInfo struct { + Page int `json:"page"` + SPath string `json:"sPath"` + Filename string `json:"filename"` +} + +// Gallery 是 FetchGalleryPage 的返回值 +type Gallery struct { + GID string `json:"gid"` + Token string `json:"token"` + Title string `json:"title"` + TotalPages int `json:"totalPages"` + Pages []GalleryPageInfo `json:"pages"` + Meta GalleryMeta `json:"meta"` +} + +var ( + reTitleTag = regexp.MustCompile(`(?is)(.*?)`) + reMetaDesc = regexp.MustCompile(`(?i)
]*>\s*artist:
Noxe...
(.*?)
+ + + + + +
artist:
group:
parody:
language:
other:
+
+ uploader` + + meta := parseMeta(html, "[Noxe] Title (Circle)") + if meta.Category != "doujinshi" { + t.Errorf("分类不对: %q", meta.Category) + } + if meta.Artist != "Noxe" { + t.Errorf("作者不对: %q", meta.Artist) + } + if meta.Group != "CriticalHAI" { + t.Errorf("社团不对: %q", meta.Group) + } + if meta.Series != "wuthering waves" { + t.Errorf("系列不对: %q", meta.Series) + } + if meta.Lang != "chinese" { + t.Errorf("语言不对: %q", meta.Lang) + } + // more 按钮要过滤掉 + if len(meta.Tags) != 2 || meta.Tags[0] != "qingxiao" || meta.Tags[1] != "rover" { + t.Errorf("标签不对(more 未过滤): %v", meta.Tags) + } + if meta.Uploader != "velius11" { + t.Errorf("上传者不对: %q", meta.Uploader) + } +} + +// 标题约定兜底:[作者] 标题 (社团) +func TestParseMetaTitleFallback(t *testing.T) { + html := `
` + meta := parseMeta(html, "[Noxe] 0820 Qingxiao [AI Generated] (CriticalHAI)") + if meta.Artist != "Noxe" { + t.Errorf("作者兜底失败: %q", meta.Artist) + } + if meta.Group != "CriticalHAI" { + t.Errorf("社团兜底失败: %q", meta.Group) + } +} + +// ---- HTML 实体 ---- + +func TestDecodeEntities(t *testing.T) { + cases := map[string]string{ + "a & b": "a & b", + "<tag>": "", + ""x"": `"x"`, + "AB": "AB", + "A": "A", + "plain": "plain", + "😀": "\U0001F600", + "no entities!": "no entities!", + } + for in, want := range cases { + if got := DecodeEntities(in); got != want { + t.Errorf("DecodeEntities(%q) = %q, want %q", in, got, want) + } + } +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..6f8abc4 --- /dev/null +++ b/main.go @@ -0,0 +1,34 @@ +package main + +import ( + "embed" + + "github.com/wailsapp/wails/v2" + "github.com/wailsapp/wails/v2/pkg/options" + "github.com/wailsapp/wails/v2/pkg/options/assetserver" +) + +//go:embed all:frontend/dist +var assets embed.FS + +func main() { + app := NewApp() + + err := wails.Run(&options.App{ + Title: "EH Downloader", + Width: 1180, + Height: 780, + MinWidth: 900, + MinHeight: 560, + BackgroundColour: &options.RGBA{R: 22, G: 24, B: 29, A: 1}, + AssetServer: &assetserver.Options{Assets: assets}, + OnStartup: app.startup, + OnShutdown: app.shutdown, + Bind: []interface{}{ + app, + }, + }) + if err != nil { + println("Error:", err.Error()) + } +} diff --git a/userscript/eh-to-downloader.user.js b/userscript/eh-to-downloader.user.js new file mode 100644 index 0000000..1960342 --- /dev/null +++ b/userscript/eh-to-downloader.user.js @@ -0,0 +1,327 @@ +// ==UserScript== +// @name EH Downloader 助手 +// @namespace http://127.0.0.1:37777 +// @version 2.0.0 +// @description ① 画廊页:把当前画廊推送到本地 EH Downloader ② 列表页:勾选后批量推送 +// @author jack +// @match https://e-hentai.org/* +// @match https://exhentai.org/* +// @grant GM_xmlhttpRequest +// @connect 127.0.0.1 +// @connect localhost +// @run-at document-idle +// ==/UserScript== + +(function () { + 'use strict'; + + const PORT = 37777; + const TOKEN = 'ehdl-local-9f2a4c7e'; + const API = `http://127.0.0.1:${PORT}`; + + // ============================================================ + // 公共:通讯 + // ============================================================ + + function ping(cb) { + GM_xmlhttpRequest({ + method: 'GET', url: `${API}/ping`, timeout: 1500, + onload: (r) => cb(r.status === 200), + onerror: () => cb(false), + ontimeout: () => cb(false), + }); + } + + function post(path, data, cb) { + GM_xmlhttpRequest({ + method: 'POST', + url: `${API}${path}`, + headers: { 'Content-Type': 'application/json', 'X-EHDL-Token': TOKEN }, + data: JSON.stringify(data), + timeout: 8000, + onload: (r) => { + let j = {}; + try { j = JSON.parse(r.responseText); } catch (_) {} + cb(r.status === 200, j); + }, + // 连不上 = 工具没开 + onerror: () => cb(false, { error: 'offline' }), + ontimeout: () => cb(false, { error: 'timeout' }), + }); + } + + function addStyle(css) { + const s = document.createElement('style'); + s.textContent = css; + document.head.appendChild(s); + } + + const CSS = ` + #ehdl-btn { + display:inline-flex; align-items:center; gap:6px; + padding:5px 13px; border-radius:20px; cursor:pointer; + font:13px/1.4 -apple-system,"PingFang SC",sans-serif; + color:#fff; background:#4a9eff; border:1px solid #3b8ae6; + transition:background .12s,opacity .12s; + } + #ehdl-btn:hover{background:#62acff} + #ehdl-btn:active{transform:translateY(1px)} + #ehdl-btn[disabled]{opacity:.45;cursor:default} + #ehdl-btn.is-off{background:#6b7280;border-color:#6b7280} + #ehdl-btn.is-done{background:#3fb950;border-color:#3fb950} + #ehdl-status{font-size:12px;color:#8b93a3} + #ehdl-status.err{color:#f85149} + #ehdl-status.ok{color:#3fb950} + + /* 列表页勾选框 */ + .ehdl-cb { + display:inline-block; vertical-align:middle; margin-right:6px; + width:15px; height:15px; cursor:pointer; accent-color:#4a9eff; + position:relative; top:-1px; + } + .ehdl-cb-row.off a{ opacity:.5 } + + /* 悬浮批量工具条 */ + #ehdl-bar { + position:fixed; right:18px; bottom:18px; z-index:2147483000; + display:flex; align-items:center; gap:9px; + padding:9px 13px; border-radius:11px; + background:rgba(28,31,38,.96); border:1px solid #2e333d; + box-shadow:0 10px 34px rgba(0,0,0,.5); + font:12px/1.4 -apple-system,"PingFang SC",sans-serif; color:#e6e8ec; + backdrop-filter:blur(8px); max-width:calc(100vw - 36px); flex-wrap:wrap; + } + #ehdl-bar .n{ font-variant-numeric:tabular-nums; color:#8b93a3 } + #ehdl-bar .n b{ color:#4a9eff; font-size:14px } + #ehdl-bar button { + background:#23272f; color:#e6e8ec; border:1px solid #2e333d; + border-radius:7px; padding:5px 11px; font-size:12px; cursor:pointer; + } + #ehdl-bar button:hover:not([disabled]){ background:#2c313a; border-color:#3d4450 } + #ehdl-bar button[disabled]{ opacity:.4; cursor:default } + #ehdl-bar button.primary{ background:#4a9eff; border-color:#4a9eff; color:#04121f; font-weight:600 } + #ehdl-bar button.primary:hover:not([disabled]){ background:#62acff } + #ehdl-bar .dot{ width:8px; height:8px; border-radius:50%; background:#6b7280 } + #ehdl-bar .dot.on{ background:#3fb950 } + #ehdl-bar .msg{ max-width:280px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap } + #ehdl-bar .msg.err{ color:#f85149 } + #ehdl-bar .msg.ok{ color:#3fb950 } + `; + + addStyle(CSS); + + // ============================================================ + // 模式 A:画廊详情页 —— 发送当前画廊 + // ============================================================ + + function runGalleryPage() { + const m = location.pathname.match(/^\/g\/(\d+)\/([0-9a-f]+)/i); + if (!m) return false; + + const gid = m[1]; + const token = m[2]; + const galleryUrl = `${location.origin}/g/${gid}/${token}/`; + const titleEl = document.querySelector('h1#gn'); + const title = (titleEl?.textContent || document.title || '') + .replace(/\s*-\s*E-Hentai Galleries\s*$/i, '').trim(); + + // 动作区 #gd5(Report Gallery / Archive Download 那一栏) + let host = document.getElementById('gd5') || document.getElementById('gd1'); + if (!host) { + host = document.createElement('div'); + host.id = 'gd5'; + const anchor = document.querySelector('#gdt') || document.body; + anchor.parentNode.insertBefore(host, anchor); + } + + const box = document.createElement('div'); + box.style.cssText = 'margin:6px 0 10px;display:flex;align-items:center;gap:8px'; + host.appendChild(box); + + const btn = document.createElement('button'); + btn.id = 'ehdl-btn'; + btn.type = 'button'; + btn.innerHTML = '📤发送画廊地址'; + const status = document.createElement('span'); + status.id = 'ehdl-status'; + box.append(btn, status); + + let online = false; + const setStatus = (t, k) => { status.textContent = t; status.className = k || ''; }; + const markOffline = () => { + btn.classList.add('is-off'); + btn.title = 'EH Downloader 没在运行,请先打开'; + }; + const markDone = () => { + btn.classList.add('is-done'); + btn.innerHTML = '✓已发送'; + }; + + btn.addEventListener('click', () => { + if (!online) { + markOffline(); + setStatus('EH Downloader 没在运行,请先打开它', 'err'); + return; + } + btn.disabled = true; + setStatus('发送中…', ''); + post('/add', { gid, token, url: galleryUrl, title }, (ok, j) => { + btn.disabled = false; + if (!ok) { + online = false; + markOffline(); + btn.innerHTML = '📤发送画廊地址'; + setStatus(j.error === 'timeout' ? '✗ 连接超时,请先打开工具' : '✗ 连不上,请先打开 EH Downloader', 'err'); + return; + } + markDone(); + setStatus(j.existed ? '已在列表中(跳过重复)' : '✓ 已推送,等待下载', 'ok'); + }); + }); + + ping((ok) => { + online = ok; + if (ok) setStatus('EH Downloader 运行中', 'ok'); + else { markOffline(); setStatus('EH Downloader 未运行 —— 点击会提示', ''); } + }); + return true; + } + + // ============================================================ + // 模式 B:列表页 —— 勾选批量推送 + // ============================================================ + + function runListPage() { + // 收集本页所有画廊链接(按 gid 去重) + const links = [...document.querySelectorAll('a[href*="/g/"]')]; + const seen = new Set(); + const items = []; + + // 链接里的标题在 .glink 里;直接 textContent 会把嵌套 div 的标签文字也吸进来 + const readTitle = (a) => { + const g = a.querySelector('.glink'); + return (g?.textContent || a.textContent || '').trim().slice(0, 200); + }; + + for (const a of links) { + const href = a.getAttribute('href') || ''; + const m = href.match(/\/g\/(\d+)\/([0-9a-f]+)/i); + if (!m) continue; + const gid = m[1]; + const token = m[2]; + const key = `${gid}/${token}`; + if (seen.has(key)) continue; + seen.add(key); + items.push({ + gid, token, + url: `${location.origin}/g/${gid}/${token}/`, + title: readTitle(a), + anchor: a, + cb: null, + }); + } + + if (!items.length) return false; + + // 每行前面插复选框 + for (const it of items) { + const a = it.anchor; + if (!a || !a.parentNode) continue; + const cb = document.createElement('input'); + cb.type = 'checkbox'; + cb.className = 'ehdl-cb'; + cb.title = `勾选 ${it.gid}`; + a.parentNode.insertBefore(cb, a); + cb.addEventListener('change', refresh); + it.cb = cb; + } + + // ---- 悬浮工具条 ---- + const bar = document.createElement('div'); + bar.id = 'ehdl-bar'; + bar.innerHTML = ` + + 已选 0 / ${items.length} + + + + + + `; + document.body.appendChild(bar); + + const $ = (id) => bar.querySelector('#' + id); + const dot = $('ehdl-dot'); + const numEl = $('ehdl-n'); + const sendBtn = $('ehdl-send'); + const msg = $('ehdl-msg'); + + let online = false; + const boxes = items.map((i) => i.cb).filter(Boolean); + + function selected() { + return boxes.filter((c) => c.checked).length; + } + + function refresh() { + const n = selected(); + numEl.textContent = n; + sendBtn.disabled = n === 0 || !online; + sendBtn.textContent = online ? '推送到工具' : '工具未运行'; + if (!online) { sendBtn.classList.remove('primary'); } + else { sendBtn.classList.add('primary'); } + } + + function say(text, kind) { + msg.textContent = text; + msg.className = 'msg' + (kind ? ' ' + kind : ''); + } + + $('ehdl-all').onclick = () => { boxes.forEach((c) => (c.checked = true)); refresh(); }; + $('ehdl-inv').onclick = () => { boxes.forEach((c) => (c.checked = !c.checked)); refresh(); }; + $('ehdl-none').onclick = () => { boxes.forEach((c) => (c.checked = false)); refresh(); }; + + sendBtn.onclick = () => { + const picked = items.filter((i) => i.cb && i.cb.checked); + if (!picked.length) return; + sendBtn.disabled = true; + say(`推送 ${picked.length} 个…`, ''); + + post('/add-batch', { + items: picked.map((i) => ({ gid: i.gid, token: i.token, url: i.url, title: i.title })), + }, (ok, j) => { + sendBtn.disabled = false; + if (!ok) { + online = false; + dot.classList.remove('on'); + say(j.error === 'timeout' ? '✗ 连接超时,请先打开工具' : '✗ 连不上,请先打开 EH Downloader', 'err'); + refresh(); + return; + } + const bits = []; + if (j.added) bits.push(`新增 ${j.added}`); + if (j.existed) bits.push(`已存在 ${j.existed}`); + say(`✓ ${bits.join(' · ') || '无变化'}`, 'ok'); + picked.forEach((i) => { if (i.cb) i.cb.checked = false; }); + refresh(); + }); + }; + + ping((ok) => { + online = ok; + dot.classList.toggle('on', ok); + say(ok ? '工具运行中' : 'EH Downloader 未运行', ok ? 'ok' : 'err'); + refresh(); + }); + + refresh(); + return true; + } + + // ============================================================ + // 入口 + // ============================================================ + + if (runGalleryPage()) return; + runListPage(); +})(); \ No newline at end of file diff --git a/wails.json b/wails.json new file mode 100644 index 0000000..15a0a9d --- /dev/null +++ b/wails.json @@ -0,0 +1,13 @@ +{ + "$schema": "https://wails.io/schemas/config.v2.json", + "name": "eh-downloader-wails", + "outputfilename": "eh-downloader-wails", + "frontend:install": "npm install", + "frontend:build": "npm run build", + "frontend:dev:watcher": "npm run dev", + "frontend:dev:serverUrl": "auto", + "author": { + "name": "", + "email": "" + } +}