121 Commits
3.0.2 ... 3.1.3

Author SHA1 Message Date
putyy
b562f76c69 feat: add url search, update version 2025-12-30 23:47:12 +08:00
putyy
8aaf95fd36 perf: domain rule 2025-12-30 23:47:12 +08:00
putyy
983d72d65a feat: add domain rule configuration 2025-12-30 23:47:12 +08:00
putyy
86378b9fba perf: domain rule 2025-12-27 11:32:00 +08:00
putyy
6b18e7fba1 feat: add domain rule configuration 2025-12-26 17:20:05 +08:00
putyy
ec11132240 perf: optimization type support 2025-12-26 17:20:05 +08:00
putyy
dc877bd634 perf: change icon color when filtering description field 2025-11-30 12:53:38 +08:00
putyy
00b4bf4068 feat: editable description field 2025-11-30 12:53:38 +08:00
putyy
51c43564b6 perf: save ongoing tasks when deleting records 2025-11-30 12:53:38 +08:00
putyy
820a2671cf fix: batch cancel 2025-10-16 12:24:40 +08:00
putyy
3b4443110e feat: update version 2025-10-15 21:07:00 +08:00
putyy
ffd5b29030 feat: add size sorting、remember clear list choice、reset App,optimize preview 2025-10-15 21:03:20 +08:00
putyy
779f56dd91 fix: batch download 2025-09-23 09:25:36 +08:00
putyy
2beecdade2 fix: windows file naming during download 2025-09-16 10:12:54 +08:00
putyy
bca2e110de feat: update version 2025-09-14 21:56:30 +08:00
putyy
8d55a86c06 feat: add loading check 2025-09-14 21:45:48 +08:00
putyy
f61199bed6 feat: add batch cancel, batch export link 2025-09-14 16:14:52 +08:00
putyy
2d75bbb5c3 feat: add cancel download 2025-09-13 22:25:14 +08:00
putyy
55d3f06cb6 perf: optimize file naming during download 2025-09-12 10:06:05 +08:00
putyy
1809847b8a perf: optimize file naming during download 2025-09-11 17:04:07 +08:00
putyy
da8e8d9641 perf: downloader cancel timeout 2025-09-11 17:04:07 +08:00
putyy
ead622d95e fix: qq plugin optimize 2025-09-11 17:04:07 +08:00
putyy
c47fcba36b fix: filter classify 2025-09-11 17:04:07 +08:00
putyy
54c0da081c fix: linux build 2025-07-29 17:40:16 +08:00
putyy
4bead0752d Merge branch 'master' into wails 2025-07-27 16:29:21 +08:00
putyy
bd7828b73f fix: version comparison 2025-07-27 16:26:40 +08:00
putyy
4706540475 docs: update document 2025-07-25 16:10:41 +08:00
putyy
0daec66fa6 docs: update document 2025-07-25 16:02:27 +08:00
putyy
379ae22db7 feat: update version to 3.1.0 2025-07-25 15:43:22 +08:00
putyy
2d1fc4273a feat: picture display optimize 2025-07-25 15:43:22 +08:00
putyy
55b67a0efa feat: insert mode setting 2025-07-25 15:43:22 +08:00
putyy
ace4625a27 perf: index page optimization 2025-07-25 15:43:22 +08:00
putyy
6fb0474154 doc: delete dartNode 2025-07-25 15:43:22 +08:00
putyy
86ef0d3331 perf: downloader optimization 2025-07-25 15:43:22 +08:00
putyy
cfa9d4929f fix: download action 2025-07-25 15:43:22 +08:00
putyy
3c40ada451 perf: Batch export、operation item style optimization 2025-07-25 15:43:22 +08:00
putyy
9ec4eca558 perf: setting optimization 2025-07-25 15:43:22 +08:00
putyy
f295fb6b64 perf: index height calculation optimization 2025-07-25 15:43:22 +08:00
putyy
3dc4322258 feat: update version to 3.1.0 2025-07-25 15:42:51 +08:00
putyy
3910d0ffb0 feat: picture display optimize 2025-07-25 15:37:10 +08:00
putyy
af75f1ce4f feat: insert mode setting 2025-07-25 11:46:07 +08:00
putyy
a016465bea Merge branch 'master' into wails 2025-07-25 10:32:33 +08:00
putyy
fd5e289c87 perf: index page optimization 2025-07-25 10:31:46 +08:00
putyy
2a2ca7eb4e doc: delete dartNode 2025-07-24 17:24:04 +08:00
putyy
a7ec61b8e2 perf: downloader optimization 2025-07-24 17:16:30 +08:00
putyy
84c882d573 fix: download action 2025-07-23 16:11:44 +08:00
putyy
567eb2903d perf: Batch export、operation item style optimization 2025-07-23 15:34:24 +08:00
putyy
31073eb57e perf: setting optimization 2025-07-23 10:42:12 +08:00
putyy
5613e21138 perf: index height calculation optimization 2025-07-23 09:41:20 +08:00
qiuzhiqian
0a516b2f3c 移除多余注释 2025-07-14 17:04:05 +08:00
xml
d3d8983307 fix: The certificate path in the ca-certificates.conf file on the deepin distribution is set incorrectly. 2025-07-14 17:04:05 +08:00
putyy
59e2b1b267 Merge branch 'wails' 2025-07-09 17:22:13 +08:00
putyy
4ebbc2347f perf: plugin optimize、add dartNode branding image 2025-07-09 17:16:56 +08:00
putyy
25ab8edd20 doc: delete DartNode 2025-07-09 17:16:56 +08:00
putyy
f4bc3c7b53 fix: Arch Linux certificate installation 2025-07-09 17:16:56 +08:00
putyy
ec89dc362f feat: Add version update detection 2025-07-09 17:16:56 +08:00
putyy
821d9949ab perf: Set up, download optimization, add description search... 2025-07-09 17:16:56 +08:00
putyy
e97120cf06 perf: Optimization of operation column 2025-07-09 17:16:56 +08:00
putyy
67f11d2b93 perf: intercept optimization 2025-07-09 17:16:56 +08:00
putyy
3be6b8cd91 doc: delete DartNode 2025-07-09 17:15:15 +08:00
putyy
db3ff8e0d2 fix: Arch Linux certificate installation 2025-07-07 15:22:06 +08:00
putyy
405d0bbdb2 feat: Add version update detection 2025-07-07 14:40:41 +08:00
putyy
6c21e37ce4 perf: Set up, download optimization, add description search... 2025-07-04 17:37:59 +08:00
putyy
b74e2a2bf6 perf: Optimization of operation column 2025-07-04 17:36:12 +08:00
putyy
deb3e83082 perf: intercept optimization 2025-06-08 11:20:56 +08:00
putyy
ff90c4ff03 perf: plugin optimize、add dartNode branding image 2025-06-08 11:07:53 +08:00
putyy
7f3d63532c perf: optimization 2025-06-08 11:07:53 +08:00
putyy
bd2fa75cde perf: Delete excess 2025-06-08 11:07:53 +08:00
putyy
27f9fb0def perf: install check 2025-06-08 11:07:53 +08:00
putyy
7a07456b2f Merge branch 'wails' of https://github.com/putyy/res-downloader into wails 2025-06-08 11:07:18 +08:00
putyy
3bce1f0332 perf: plugin optimize、add dartNode branding image 2025-06-08 11:07:11 +08:00
putyy
5a92d7beb7 perf: optimization 2025-05-27 17:20:01 +08:00
putyy
f28cb69826 perf: Delete excess 2025-05-21 17:59:35 +08:00
putyy
14d18ad310 perf: install check 2025-05-21 11:45:59 +08:00
putyy
ee6698a8e8 fix: upstream proxy settings 2025-05-20 17:17:08 +08:00
putyy
7f2b99b51f fix: upstream proxy settings 2025-05-20 17:16:05 +08:00
putyy
da9039ea9c Merge pull request #187 from putyy/wails
perf: update build readme
2025-05-20 14:28:39 +08:00
putyy
dc46668f0d perf: update build readme 2025-05-20 14:24:32 +08:00
putyy
142a2a84b4 Merge pull request #186 from putyy/wails
merge
2025-05-20 13:55:10 +08:00
putyy
84b6a142fc perf: update doc, core optimize 2025-05-20 13:53:58 +08:00
putyy
a853f1d991 perf: Optimize proxy settings 2025-05-19 18:03:35 +08:00
putyy
dc261bb6ce perf: Simulate Mac operation icons 2025-05-16 16:41:17 +08:00
putyy
d0ea8e4fab Merge branch 'wails' of https://github.com/putyy/res-downloader into wails 2025-05-16 15:24:47 +08:00
putyy
c1cce920a4 perf: core optimize(add plugins) 2025-05-14 17:51:36 +08:00
putyy
f0495c6858 feat: add i18n(zh、en) 2025-05-12 17:44:48 +08:00
putyy
a37bde428d perf: set page optimization、API request method(adaptation linux)、Linux certificate installation optimization 2025-05-10 16:56:54 +08:00
putyy
7793f83ea3 perf: Install certificates and optimize proxy settings 2025-05-09 17:56:11 +08:00
putyy
29cc879b85 perf: set page optimization 2025-05-09 10:26:30 +08:00
putyy
abfdb76589 perf: set page optimization 2025-05-09 10:26:26 +08:00
putyy
04e4f0e9cc docs: Update readme 2025-05-07 14:23:13 +08:00
putyy
625cfbc474 perf: set page optimization 2025-05-07 11:05:59 +08:00
putyy
d8857bd4a2 perf: interception type optimization 2025-05-06 18:06:42 +08:00
putyy
a247c708f6 perf: unified response 2025-04-29 11:31:38 +08:00
putyy
85781a150a perf: optimization download、Proxy settings, add batch export 2025-04-29 11:15:13 +08:00
putyy
3f07bae796 Merge pull request #176 from putyy/wails
feat: Added header cache, optional header settings...
2025-04-23 16:55:45 +08:00
putyy
6086bd7086 feat: Added header cache, optional header settings... 2025-04-23 16:55:24 +08:00
putyy
62bf0e2308 Merge pull request #175 from putyy/wails
优化拦截标识
2025-04-22 16:00:36 +08:00
putyy
0bb1a21a76 优化拦截标识 2025-04-22 15:59:59 +08:00
putyy
575e2d8904 Merge pull request #174 from putyy/wails
Wails
2025-04-22 14:54:11 +08:00
putyy
db21134550 优化下载、README等 2025-04-22 14:52:45 +08:00
putyy
3407490f82 Update README.md 2025-04-18 12:50:37 +08:00
putyy
dad06f6cd6 Merge pull request #158 from claviering/fix-NInput-disabled
input 组件disabled下内容看不清 #150
2025-03-13 16:11:58 +08:00
claviering
de70fc66b4 input 组件disabled下内容看不清 #150 2025-03-10 10:05:08 +08:00
putyy
2282f72b2f 更新readme 2025-03-06 22:05:53 +08:00
putyy
e00a7c9044 优化wx拦截 2025-02-13 09:24:00 +08:00
putyy
e79a7ba2fe 优化wx拦截 2025-02-13 09:23:39 +08:00
putyy
791e50411d Update issue templates 2025-02-11 21:45:52 +08:00
putyy
df8eb0e4cd Update issue templates 2025-02-11 21:44:28 +08:00
putyy
3e291171c2 Update issue templates 2025-02-11 21:43:48 +08:00
putyy
bd8f8e80c9 Merge pull request #143 from putyy/wails
Wails
2025-02-11 21:42:19 +08:00
putyy
8ed7e144e1 修改doc、优化Mac设置代理等 2025-02-11 21:40:29 +08:00
putyy
69f8224453 完善文档、新增一些功能 2025-02-10 15:40:32 +08:00
putyy
3c0e51a9e2 文档 2025-02-09 18:58:09 +08:00
putyy
0a6679b983 新增docsify 2025-02-08 17:29:36 +08:00
putyy
6d1024806c Merge pull request #131 from putyy/wails
Wails
2025-01-22 16:19:44 +08:00
putyy
388e3f46a4 更新md 2025-01-22 16:18:32 +08:00
putyy
f7c8e9f7db 更新host 2025-01-21 10:37:10 +08:00
putyy
7ca484f45d Merge pull request #130 from putyy/wails
up:wx rule、downloads
2025-01-14 14:16:21 +08:00
putyy
cecb13fa90 up:wx rule、downloads 2025-01-14 13:59:22 +08:00
putyy
331478d370 Merge pull request #127 from taotieren/update-aur
update README.md
2025-01-11 13:16:40 +08:00
taotieren
2481407093 update README.md 2025-01-11 11:45:37 +08:00
103 changed files with 4945 additions and 1985 deletions

BIN
.DS_Store vendored

Binary file not shown.

42
.github/ISSUE_TEMPLATE/bug_report.yml vendored Normal file
View File

@@ -0,0 +1,42 @@
name: "Bug Report \\ 问题反馈"
description: "Create a report to help us improve \\ 帮助改进"
labels: ["Bug"]
body:
- type: input
id: title
attributes:
label: "Title \\ 标题"
description: "A brief summary of the bug. \\ 对于该错误的简要总结。"
placeholder: "Enter the bug title here. \\ 在此输入错误标题。"
validations:
required: true
- type: textarea
id: reproduce
attributes:
label: "To Reproduce \\ 操作流程"
description: "Steps to reproduce the behaviour. \\ 重现该行为的步骤。"
placeholder: |
1. Go to '...' \ 进入 '...'
2. Click on '....' \ 点击 '....'
validations:
required: true
- type: textarea
id: expected-behaviour
attributes:
label: "Expected Behaviour \\ 预期结果"
description: "A clear and concise description of what you expected to happen. \\ 对您期望发生的事情的清晰简明描述。"
placeholder: "A clear and concise description of what you expected to happen. \\ 对您期望发生的事情的清晰简明描述。"
validations:
required: true
- type: textarea
id: software-version
attributes:
label: "Software Version \\ 软件版本"
description: "Please specify the version of the software you are using. \\ 请指定您使用的软件版本。"
placeholder: "Enter the software version here. \\ 在此输入软件版本。"
validations:
required: true

1
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View File

@@ -0,0 +1 @@
blank_issues_enabled: true

View File

@@ -0,0 +1,31 @@
name: "Feature Request \\ 功能建议"
description: "Suggest an idea for this project \\ 为这个项目提出一个新想法"
labels: ["Enhancement"]
body:
- type: input
id: title
attributes:
label: "Title \\ 标题"
description: "A brief summary of your feature request. \\ 对您功能建议的简要总结。"
placeholder: "Enter the feature title here. \\ 在此输入功能标题。 "
validations:
required: true
- type: textarea
id: feature-suggestion
attributes:
label: "Feature Suggestion \\ 功能建议"
description: "A clear and concise description of the feature you would like to suggest. \\ 对您想要建议的功能的清晰简明描述。"
placeholder: "Describe your feature suggestion here. \\ 在此描述您的功能建议。"
validations:
required: true
- type: textarea
id: proposed-solution
attributes:
label: "Proposed Solution \\ 你的方案"
description: "A clear and concise description of your proposed solution. \\ 对您提议的解决方案的清晰简明描述。"
placeholder: "Describe your proposed solution here. \\ 在此描述您的提议方案。"
validations:
required: true

2
.gitignore vendored
View File

@@ -1,5 +1,5 @@
.idea
test
build/bin
node_modules
frontend/dist
.DS_Store

View File

@@ -20,7 +20,7 @@
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,

104
README-EN.md Normal file
View File

@@ -0,0 +1,104 @@
<div align="center">
<a href="https://github.com/putyy/res-downloader"><img src="build/appicon.png" width="120"/></a>
<h1>res-downloader</h1>
<h4>📖 English | <a href="https://github.com/putyy/res-downloader/blob/master/README.md">中文</a></h4>
[![GitHub stars](https://img.shields.io/github/stars/putyy/res-downloader)](https://github.com/putyy/res-downloader/stargazers)
[![GitHub forks](https://img.shields.io/github/forks/putyy/res-downloader)](https://github.com/putyy/res-downloader/fork)
[![GitHub release](https://img.shields.io/github/release/putyy/res-downloader)](https://github.com/putyy/res-downloader/releases)
![GitHub All Releases](https://img.shields.io/github/downloads/putyy/res-downloader/total)
[![License](https://img.shields.io/github/license/putyy/res-downloader)](https://github.com/putyy/res-downloader/blob/master/LICENSE)
</div>
---
### 🎉 Aixiang Resource Downloader
> A cross-platform resource downloader built with Go + [Wails](https://github.com/wailsapp/wails).
Clean UI, easy to use, and supports a wide range of resource sniffing and downloading.
## ✨ Features
- 🚀 **User-Friendly**: Simple operation with an intuitive and beautiful UI
- 🖥️ **Cross-Platform**: Available on Windows / macOS / Linux
- 🌐 **Supports Multiple Resource Types**: Video / Audio / Images / m3u8 / Live streams, and more
- 📱 **Wide Platform Compatibility**: Works with WeChat Channels, Mini Programs, Douyin, Kuaishou, Xiaohongshu, KuGou Music, QQ Music, and more
- 🌍 **Proxy Capture**: Built-in proxy allows fetching resources behind network restrictions
## 📚 Docs & Versions
- 📘 [Online Documentation (Chinese)](https://res.putyy.com/)
- 🧩 [Mini Version Ui Display using default browser](https://github.com/putyy/res-downloader) [Old Electron Version Support Win7](https://github.com/putyy/res-downloader/tree/old)
- 💬 [Join the User Group (Chinese)](https://www.putyy.com/app/admin/upload/img/20250418/6801d9554dc7.webp)
> *If full, you can add WeChat `AmorousWorld` with a note “github”*
## 🧩 Download Links
- 🆕 [Download from GitHub](https://github.com/putyy/res-downloader/releases)
- 🆕 [Download via Lanzou Cloud (Password: 9vs5)](https://wwjv.lanzoum.com/b04wgtfyb)
- ⚠️ *Windows 7 users: Please use version `2.3.0`*
## 🖼️ Preview
![Preview](docs/images/show.webp)
## 🚀 How to Use
> Follow these steps to use the software correctly:
1. During installation, be sure to **allow certificate installation** and **grant network access**
2. Launch the software → Click **"Start Proxy"** at the top left
3. Choose the resource types to capture (default is all)
4. Open the target content externally (WeChat, Mini App, Browser, etc.)
5. Return to the homepage to view the captured resource list
---
## ❓ FAQ
### 📺 m3u8 Video Resources
- Online Preview: [m3u8play](https://m3u8play.com/)
- Download Tool: [m3u8-down](https://m3u8-down.gowas.cn/)
### 📡 Live Stream Resources
- We recommend [OBS](https://obsproject.com/) for recording (search for setup tutorials)
### 🐢 Slow Downloads or Large File Failures?
- Recommended download managers:
- [Neat Download Manager](https://www.neatdownloadmanager.com/index.php/en/)
- [Motrix](https://motrix.app/download)
- For WeChat videos, click `Decrypt Video` after download
### 🧩 Unable to Intercept Resources?
- Check your system proxy settings:
Address: 127.0.0.1
Port: 8899
### 🌐 Can't Access Internet After Closing the App?
- Manually disable the system proxy settings
### 🧠 More Questions?
- [GitHub Issues](https://github.com/putyy/res-downloader/issues)
- [Aixiang Forum Thread (Chinese)](https://s.gowas.cn/d/4089)
## 💡 Principles & Motivation
This tool captures traffic via a local proxy and filters useful resources.
Its working principle is similar to tools like Fiddler, Charles, or browser DevTools, but with a more user-friendly display and enhanced filtering, making it suitable for everyday users with minimal tech background.
---
## ⚠️ Disclaimer
> This software is for educational and research purposes only.
Commercial or illegal use is strictly prohibited.
The author is not responsible for any consequences arising from misuse.

126
README.md
View File

@@ -1,48 +1,100 @@
## res-downloader V3全新版来袭、全新实现支持更多设置视频号、直播流、m3u8预览等
### 爱享素材下载器【[加入群聊](https://qm.qq.com/q/HS8FdhpZCK)】
🎯 基于Go + [wails](https://github.com/wailsapp/wails)
📦 操作简单、可获取不同类型资源
🖥️ 支持Windows、Mac、Linux
🌐 支持视频、音频、图片、m3u8、直播流等常见网络资源
💪 支持微信视频号、小程序、抖音、快手、小红书、酷狗音乐、qq音乐等网络资源下载
👼 支持设置代理以获取特殊网络下的资源
<div align="center">
## 软件下载(Win7下载2.3.0版本)
🆕 [github下载](https://github.com/putyy/res-downloader/releases)
🆕 [蓝奏云下载 密码:9vs5](https://wwjv.lanzoum.com/b04wgtfyb)
<a href="https://github.com/putyy/res-downloader"><img src="build/appicon.png" width="120"/></a>
<h1>res-downloader</h1>
<h4>📖 中文 | <a href="https://github.com/putyy/res-downloader/blob/master/README-EN.md">English</a></h4>
## 使用方法
> 0. 安装时一定要同意安装证书文件、一定要允许网络访问
> 1. 打开本软件 软件首页左上角点击 “启动代理”
> 2. 软件首页选择要获取的资源类型(默认选中的全部)
> 3. 打开要捕获的源, 如:视频号、网页、小程序等等
> 4. 返回软件首页即可看到资源列表
[![GitHub stars](https://img.shields.io/github/stars/putyy/res-downloader)](https://github.com/putyy/res-downloader/stargazers)
[![GitHub forks](https://img.shields.io/github/forks/putyy/res-downloader)](https://github.com/putyy/res-downloader/fork)
[![GitHub release](https://img.shields.io/github/release/putyy/res-downloader)](https://github.com/putyy/res-downloader/releases)
![GitHub All Releases](https://img.shields.io/github/downloads/putyy/res-downloader/total)
[![License](https://img.shields.io/github/license/putyy/res-downloader)](https://github.com/putyy/res-downloader/blob/master/LICENSE)
## 软件截图
![](preview/show.webp)
</div>
## 常见问题
m3u8: 预览和下载:
> [下载](https://m3u8-down.gowas.cn/) [预览](https://m3u8play.com/)
---
直播流: 预览和录制:
> [使用obs进行预览和录制 使用教程自行百度]( https://obsproject.com/)
### 🎉 爱享素材下载器
下载慢、大视频下载失败
> 推荐使用如下工具加速下载,视频号可以下载完成后再到对应视频操作项选择 “视频解密(视频号)” 按钮
>> [Neat Download Manager](https://www.neatdownloadmanager.com/index.php/en/)、[Motrix](https://motrix.app/download)等软件进行下载
> 一款基于 Go + [Wails](https://github.com/wailsapp/wails) 的跨平台资源下载工具,简洁易用,支持多种资源嗅探与下载。
打开本软件,无法正常拦截获取
> 检查系统代理是否正确设置 代理地址127.0.0.1 端口8899
## ✨ 功能特色
关闭软件后无法正常上网
> 手动关闭系统代理设置
- 🚀 **简单易用**:操作简单,界面清晰美观
- 🖥️ **多平台支持**Windows / macOS / Linux
- 🌐 **多资源类型支持**:视频 / 音频 / 图片 / m3u8 / 直播流等
- 📱 **平台兼容广泛**支持微信视频号、小程序、抖音、快手、小红书、酷狗音乐、QQ音乐等
- 🌍 **代理抓包**:支持设置代理获取受限网络下的资源
其他问题
[github](https://github.com/putyy/res-downloader/issues) 、 [爱享论坛](https://s.gowas.cn/d/4089)
## 📚 文档 & 版本
## 实现 & 初衷
通过代理网络抓包拦截响应,筛选出有用的资源, 同fiddler、charles等抓包软件、浏览器F12打开控制也能达到目的只不过这些软件需要手动进行筛选对于小白用户上手还是有点难度本软件对部分资源做了特殊处理更适合大众用户所以就有了本项目。
- 📘 [在线文档](https://res.putyy.com/)
- 💬 [加入交流群](https://www.putyy.com/app/admin/upload/img/20250418/6801d9554dc7.webp)
- 🧩 [最新版](https://github.com/putyy/res-downloader/releases) [Mini版 使用默认浏览器展示UI](https://github.com/putyy/resd-mini) [Electron旧版 支持Win7](https://github.com/putyy/res-downloader/tree/old)
> *群满时可加微信 `AmorousWorld`请备注“github”*
## 免责声明
本软件用于学习研究使用,若因使用本软件造成的一切法律责任均与本人无关!
## 🧩 下载地址
- 🆕 [GitHub 下载](https://github.com/putyy/res-downloader/releases)
- 🆕 [蓝奏云下载密码9vs5](https://wwjv.lanzoum.com/b04wgtfyb)
- ⚠️ *Win7 用户请下载 `2.3.0` 版本*
## 🖼️ 预览
![预览](docs/images/show.webp)
---
## 🚀 使用方法
> 请按以下步骤操作以正确使用软件:
1. 安装时务必 **允许安装证书文件****允许网络访问**
2. 打开软件 → 首页左上角点击 **“启动代理”**
3. 选择要获取的资源类型(默认全部)
4. 在外部打开资源页面(如视频号、小程序、网页等)
5. 返回软件首页,即可看到资源列表
## ❓ 常见问题
### 📺 m3u8 视频资源
- 在线预览:[m3u8play](https://m3u8play.com/)
- 视频下载:[m3u8-down](https://m3u8-down.gowas.cn/)
### 📡 直播流资源
- 推荐使用 [OBS](https://obsproject.com/) 进行录制(教程请百度)
### 🐢 下载慢、大文件失败?
- 推荐工具:
- [Neat Download Manager](https://www.neatdownloadmanager.com/index.php/en/)
- [Motrix](https://motrix.app/download)
- 视频号资源下载后可在操作项点击 `视频解密(视频号)`
### 🧩 软件无法拦截资源?
- 检查是否正确设置系统代理:
地址127.0.0.1
端口8899
### 🌐 关闭软件后无法上网?
- 手动关闭系统代理设置
### 🧠 更多问题
- [GitHub Issues](https://github.com/putyy/res-downloader/issues)
- [爱享论坛讨论帖](https://s.gowas.cn/d/4089)
## 💡 实现原理 & 初衷
本工具通过代理方式实现网络抓包,并筛选可用资源。与 Fiddler、Charles、浏览器 DevTools 原理类似,但对资源进行了更友好的筛选、展示和处理,大幅度降低了使用门槛,更适合大众用户使用。
---
## ⚠️ 免责声明
> 本软件仅供学习与研究用途,禁止用于任何商业或违法用途。
如因此产生的任何法律责任,概与作者无关!

BIN
build/.DS_Store vendored

Binary file not shown.

View File

@@ -1,16 +1,14 @@
## Mac
```bash
wails build -platform "darwin/universal" --dmg-name
create-dmg 'build/bin/res-downloader.app' \
--overwrite --dmg-title="res-downloader" \
--dmg-name "res-downloader_$(jq -r '.info.productVersion' wails.json).dmg" \
./build/bin
wails build -platform "darwin/universal"
create-dmg 'build/bin/res-downloader.app' --overwrite ./build/bin
mv -f "build/bin/res-downloader $(jq -r '.info.productVersion' wails.json).dmg" "build/bin/res-downloader_$(jq -r '.info.productVersion' wails.json)_mac.dmg"
```
## Windows
```bash
wails build -f -nsis -platform "windows/amd64" -webview2 Embed
wails build -f -nsis -platform "windows/arm64" -webview2 Embed
wails build -f -nsis -platform "windows/amd64" -webview2 Embed -skipbindings && mv -f "build/bin/res-downloader-amd64-installer.exe" "build/bin/res-downloader_$(jq -r '.info.productVersion' wails.json)_win_amd64.exe"
wails build -f -nsis -platform "windows/arm64" -webview2 Embed -skipbindings && mv -f "build/bin/res-downloader-arm64-installer.exe" "build/bin/res-downloader_$(jq -r '.info.productVersion' wails.json)_win_arm64.exe"
```
## Linux
@@ -22,26 +20,37 @@ docker build --network host -f build/linux/dockerfile -t res-downloader-amd-linu
docker run -it --name res-downloader-amd-build --network host --privileged -v ./:/www/res-downloader res-downloader-amd-linux /bin/bash
# 容器内
cd /www/res-downloader
wails build
wails build -platform "linux/amd64" -s -skipbindings
# 打包debian
cp build/bin/res-downloader build/linux/Debian/usr/local/bin/
echo "$(cat build/linux/Debian/DEBIAN/.control | sed -e "s/{{Version}}/$(jq -r '.info.productVersion' wails.json)/g")" > build/linux/Debian/DEBIAN/control
dpkg-deb --build ./build/linux/Debian build/bin/res-downloader_$(jq -r '.info.productVersion' wails.json)_x64.deb
echo "$(cat build/linux/Debian/DEBIAN/.control | sed -e "s/{{Version}}/$(jq -r '.info.productVersion' wails.json)/g" -e "s/{{Architecture}}/amd64/g")" > build/linux/Debian/DEBIAN/control
dpkg-deb --build ./build/linux/Debian build/bin/res-downloader_$(jq -r '.info.productVersion' wails.json)_linux_amd64.deb
# 打包AppImage
cp build/bin/res-downloader build/linux/AppImage/usr/bin/
# 复制WebKit相关文件
pushd build/linux/AppImage
find /usr/lib* -name WebKitNetworkProcess -exec mkdir -p $(dirname '{}') \; -exec cp --parents '{}' "." \; || true
find /usr/lib* -name WebKitWebProcess -exec mkdir -p $(dirname '{}') \; -exec cp --parents '{}' "." \; || true
find /usr/lib* -name libwebkit2gtkinjectedbundle.so -exec mkdir -p $(dirname '{}') \; -exec cp --parents '{}' "." \; || true
for f in WebKitNetworkProcess WebKitWebProcess libwebkit2gtkinjectedbundle.so; do
path=$(find /usr/lib* -name "$f" 2>/dev/null | head -n 1)
if [ -n "$path" ]; then
mkdir -p ./$(dirname "$path")
cp --parents "$path" .
else
echo "⚠️ $f not found, you may need to install libwebkit2gtk"
fi
done
popd
# 下载appimagetool
wget -O ./build/bin/appimagetool-x86_64.AppImage https://github.com/AppImage/AppImageKit/releases/download/13/appimagetool-x86_64.AppImage
chmod +x ./build/bin/appimagetool-x86_64.AppImage
./build/bin/appimagetool-x86_64.AppImage build/linux/AppImage build/bin/res-downloader_$(jq -r '.info.productVersion' wails.json)_x64.AppImage
./build/bin/appimagetool-x86_64.AppImage build/linux/AppImage build/bin/res-downloader_$(jq -r '.info.productVersion' wails.json)_linux_amd64.AppImage
mv -f build/bin/res-downloader build/bin/res-downloader_$(jq -r '.info.productVersion' wails.json)_linux_amd64
```
> arm64
@@ -51,25 +60,36 @@ docker build --platform linux/arm64 --network host -f build/linux/dockerfile -t
docker run --platform linux/arm64 -it --name res-downloader-arm-build --network host --privileged -v ./:/www/res-downloader res-downloader-arm-linux /bin/bash
# 容器内
cd /www/res-downloader
wails build
wails build -platform "linux/arm64" -s -skipbindings
# 打包debian
cp build/bin/res-downloader build/linux/Debian/usr/local/bin/
echo "$(cat build/linux/Debian/DEBIAN/.control | sed -e "s/{{Version}}/$(jq -r '.info.productVersion' wails.json)/g")" > build/linux/Debian/DEBIAN/control
dpkg-deb --build ./build/linux/Debian build/bin/res-downloader_$(jq -r '.info.productVersion' wails.json)_arm.deb
echo "$(cat build/linux/Debian/DEBIAN/.control | sed -e "s/{{Version}}/$(jq -r '.info.productVersion' wails.json)/g" -e "s/{{Architecture}}/arm64/g")" > build/linux/Debian/DEBIAN/control
dpkg-deb --build ./build/linux/Debian build/bin/res-downloader_$(jq -r '.info.productVersion' wails.json)_linux_arm64.deb
mv -f build/bin/res-downloader build/bin/res-downloader_$(jq -r '.info.productVersion' wails.json)_linux_arm64
```
> ArchLinux环境
> 安装
> yay -Syu res-downloader
### Arch Linux
[![Packaging status](https://repology.org/badge/vertical-allrepos/res-downloader.svg)](https://repology.org/project/res-downloader/versions)
>
```bash
yay -Syu res-downloader
```
### Linux 本地编译
```bash
git clone https://github.com/putyy/res-downloader.git
cd res-downloader
# -- GO Proxy --
# 如果国内编译时 go 下载慢或报错,可以设置 go 国内代理加速,否则不用设置
export GO111MODULE=on
export GOPROXY=https://goproxy.cn,direct
# -- Go Proxy --
wails build
cd build
sudo install -Dvm755 bin/res-downloader -t /usr/bin
sudo install -Dvm644 appicon.png /usr/share/icons/hicolor/512x512/apps/res-downloader.png
sudo install -Dvm644 linux/res-downloader.desktop /usr/share/applications/res-downloader.desktop
```
sudo install -Dvm644 build/linux/Arch/res-downloader.desktop /usr/share/applications/res-downloader.desktop
```

View File

@@ -0,0 +1,8 @@
[Desktop Entry]
Type=Application
Name=res-downloader
Comment=This is a high-value and high-performance and diverse resource downloader called res-downloader
Exec=res-downloader
Icon=res-downloader.png
Terminal=false
Categories=Utility;

View File

@@ -2,7 +2,7 @@ Package: res-downloader
Version: {{Version}}
Section: utils
Priority: optional
Architecture: amd64
Architecture: {{Architecture}}
Depends: libwebkit2gtk-4.0-37
Maintainer: putyy@qq.com
Homepage: https://github.com/putyy/res-downloader

View File

@@ -1,4 +1,4 @@
FROM golang:1.23.4-bookworm
FROM golang:1.24.2-bookworm
WORKDIR /

View File

@@ -14,7 +14,7 @@
!define INFO_PRODUCTNAME "res-downloader"
!endif
!ifndef INFO_PRODUCTVERSION
!define INFO_PRODUCTVERSION "3.0.2"
!define INFO_PRODUCTVERSION "3.1.1"
!endif
!ifndef INFO_COPYRIGHT
!define INFO_COPYRIGHT "Copyright © 2023"

72
core/aes.go Normal file
View File

@@ -0,0 +1,72 @@
package core
import (
"bytes"
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/base64"
"errors"
"io"
)
type AESCipher struct {
key []byte
}
func NewAESCipher(key string) *AESCipher {
return &AESCipher{key: []byte(key)}
}
func (a *AESCipher) Encrypt(plainText string) (string, error) {
block, err := aes.NewCipher(a.key)
if err != nil {
return "", err
}
padding := block.BlockSize() - len(plainText)%block.BlockSize()
padText := bytes.Repeat([]byte{byte(padding)}, padding)
plainText = plainText + string(padText)
cipherText := make([]byte, aes.BlockSize+len(plainText))
iv := cipherText[:aes.BlockSize]
if _, err := io.ReadFull(rand.Reader, iv); err != nil {
return "", err
}
mode := cipher.NewCBCEncrypter(block, iv)
mode.CryptBlocks(cipherText[aes.BlockSize:], []byte(plainText))
return base64.StdEncoding.EncodeToString(cipherText), nil
}
func (a *AESCipher) Decrypt(cipherText string) (string, error) {
cipherTextBytes, err := base64.StdEncoding.DecodeString(cipherText)
if err != nil {
return "", err
}
block, err := aes.NewCipher(a.key)
if err != nil {
return "", err
}
if len(cipherTextBytes) < aes.BlockSize {
return "", errors.New("ciphertext too short")
}
iv := cipherTextBytes[:aes.BlockSize]
cipherTextBytes = cipherTextBytes[aes.BlockSize:]
mode := cipher.NewCBCDecrypter(block, iv)
mode.CryptBlocks(cipherTextBytes, cipherTextBytes)
padding := int(cipherTextBytes[len(cipherTextBytes)-1])
if padding > len(cipherTextBytes) || padding > aes.BlockSize {
return "", errors.New("padding size error")
}
plainText := cipherTextBytes[:len(cipherTextBytes)-padding]
return string(plainText), nil
}

View File

@@ -3,13 +3,14 @@ package core
import (
"context"
"embed"
"fmt"
"github.com/vrischmann/userdir"
"github.com/wailsapp/wails/v2/pkg/runtime"
"os"
"os/exec"
"path/filepath"
sysRuntime "runtime"
"regexp"
"res-downloader/core/shared"
"strconv"
"strings"
"time"
)
@@ -24,7 +25,8 @@ type App struct {
LockFile string `json:"-"`
PublicCrt []byte `json:"-"`
PrivateKey []byte `json:"-"`
IsProxy bool `json:"-"`
IsProxy bool `json:"IsProxy"`
IsReset bool `json:"-"`
}
var (
@@ -35,18 +37,25 @@ var (
systemOnce *SystemSetup
proxyOnce *Proxy
httpServerOnce *HttpServer
ruleOnce *RuleSet
)
func GetApp(assets embed.FS) *App {
func GetApp(assets embed.FS, wjs string) *App {
if appOnce == nil {
matches := regexp.MustCompile(`"productVersion":\s*"([\d.]+)"`).FindStringSubmatch(wjs)
version := "1.0.1"
if len(matches) > 0 {
version = matches[1]
}
appOnce = &App{
assets: assets,
AppName: "res-downloader",
Version: "3.0.2",
Version: version,
Description: "res-downloader是一款集网络资源嗅探 + 高速下载功能于一体的软件,高颜值、高性能和多样化,提供个人用户下载自己上传到各大平台的网络资源功能!",
Copyright: "Copyright © 2023~" + strconv.Itoa(time.Now().Year()),
PublicCrt: []byte(`
-----BEGIN CERTIFICATE-----
IsReset: false,
PublicCrt: []byte(`-----BEGIN CERTIFICATE-----
MIIDwzCCAqugAwIBAgIUFAnC6268dp/z1DR9E1UepiWgWzkwDQYJKoZIhvcNAQEL
BQAwcDELMAkGA1UEBhMCQ04xEjAQBgNVBAgMCUNob25ncWluZzESMBAGA1UEBwwJ
Q2hvbmdxaW5nMQ4wDAYDVQQKDAVnb3dhczEWMBQGA1UECwwNSVQgRGVwYXJ0bWVu
@@ -70,8 +79,7 @@ e3oowvgwikqm6XR6BEcRpPkztqcKST7jPFGHiXWsAqiibc+/plMW9qebhfMXEGhQ
D8HixYbEDg==
-----END CERTIFICATE-----
`),
PrivateKey: []byte(`
-----BEGIN PRIVATE KEY-----
PrivateKey: []byte(`-----BEGIN PRIVATE KEY-----
MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDcDt23t6ioBoHG
/Y2mOjxntWQa9dP3eNl+mAC6425DlEtyc6czNAIKuuM9wt+wAwDQAgrd5RaxdcpJ
H1JlMkEtBFkIkdn0Ag98D7nwlVA9ON3xQi5Bkl+sN/oWOE8lOwvNyNNT6ZPu3qUS
@@ -102,6 +110,10 @@ ILKEQKmPPzKs7kp/7Nz+2cT3
`),
}
appOnce.UserDir = filepath.Join(userdir.GetConfigHome(), appOnce.AppName)
err := os.MkdirAll(appOnce.UserDir, 0750)
if err != nil {
fmt.Println("Mkdir UserDir err: ", err.Error())
}
appOnce.LockFile = filepath.Join(appOnce.UserDir, "install.lock")
initLogger()
initConfig()
@@ -109,6 +121,7 @@ ILKEQKmPPzKs7kp/7Nz+2cT3
initResource()
initHttpServer()
initSystem()
initRule()
}
return appOnce
}
@@ -116,83 +129,83 @@ ILKEQKmPPzKs7kp/7Nz+2cT3
func (a *App) Startup(ctx context.Context) {
a.ctx = ctx
go httpServerOnce.run()
time.AfterFunc(200*time.Millisecond, func() {
if globalConfig.AutoProxy {
appOnce.OpenSystemProxy()
}
})
go func() {
if a.isInstall() {
return
}
err := os.MkdirAll(a.UserDir, os.ModePerm)
if err != nil {
return
}
a.installCert()
}()
}
func (a *App) OnExit() {
a.UnsetSystemProxy()
globalLogger.Close()
}
func (a *App) installCert() {
if res, err := systemOnce.installCert(); err != nil {
if sysRuntime.GOOS == "darwin" {
_ = runtime.ClipboardSetText(appOnce.ctx, `echo "输入本地登录密码" && sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain "`+systemOnce.CertFile+`" && touch `+a.LockFile+` && echo "安装完成"`)
DialogErr("证书安装失败,请打开终端执行安装(命令已复制到剪切板),err:" + err.Error() + ", " + res)
} else if sysRuntime.GOOS == "windows" && strings.Contains(err.Error(), "Access is denied.") {
DialogErr("首次启用本软件,请使用鼠标右键选择以管理员身份运行")
} else if sysRuntime.GOOS == "linux" && strings.Contains(err.Error(), "Access is denied.") {
DialogErr("证书路径: " + systemOnce.CertFile + ", 请手动安装,安装完成后请执行: touch" + a.LockFile + " err:" + err.Error() + ", " + res)
} else {
globalLogger.Esg(err, res)
DialogErr("err:" + err.Error() + ", " + res)
}
} else {
if err := a.lock(); err != nil {
globalLogger.err(err)
}
if appOnce.IsReset {
err := a.ResetApp()
fmt.Println("err:", err)
}
}
func (a *App) OpenSystemProxy() bool {
func (a *App) installCert() (string, error) {
out, err := systemOnce.installCert()
if err != nil {
globalLogger.Esg(err, out)
return out, err
} else {
if err := a.lock(); err != nil {
globalLogger.Err(err)
}
}
return out, nil
}
func (a *App) OpenSystemProxy() error {
if a.IsProxy {
return true
return nil
}
err := systemOnce.setProxy()
if err == nil {
a.IsProxy = true
return true
return nil
}
DialogErr("设置失败" + err.Error())
return false
return err
}
func (a *App) UnsetSystemProxy() bool {
func (a *App) UnsetSystemProxy() error {
if !a.IsProxy {
return true
return nil
}
err := systemOnce.unsetProxy()
if err == nil {
a.IsProxy = false
return true
return nil
}
DialogErr("设置失败")
return false
return err
}
func (a *App) isInstall() bool {
return FileExist(a.LockFile)
return shared.FileExist(a.LockFile)
}
func (a *App) lock() error {
err := os.WriteFile(a.LockFile, []byte("success"), 0777)
err := os.WriteFile(a.LockFile, []byte("success"), 0644)
if err != nil {
return err
}
return nil
}
func (a *App) ResetApp() error {
exePath, err := os.Executable()
if err != nil {
return err
}
exePath, err = filepath.Abs(exePath)
if err != nil {
return err
}
_ = os.Remove(filepath.Join(appOnce.UserDir, "install.lock"))
_ = os.Remove(filepath.Join(appOnce.UserDir, "pass.cache"))
_ = os.Remove(filepath.Join(appOnce.UserDir, "config.json"))
_ = os.Remove(filepath.Join(appOnce.UserDir, "cert.crt"))
cmd := exec.Command(exePath)
cmd.Start()
return nil
}

25
core/bind.go Normal file
View File

@@ -0,0 +1,25 @@
package core
import (
"github.com/wailsapp/wails/v2/pkg/runtime"
)
type Bind struct {
}
func NewBind() *Bind {
return &Bind{}
}
func (b *Bind) Config() *ResponseData {
return httpServerOnce.buildResp(1, "ok", globalConfig)
}
func (b *Bind) AppInfo() *ResponseData {
return httpServerOnce.buildResp(1, "ok", appOnce)
}
func (b *Bind) ResetApp() {
appOnce.IsReset = true
runtime.Quit(appOnce.ctx)
}

View File

@@ -2,80 +2,309 @@ package core
import (
"encoding/json"
"os"
"os/user"
"path/filepath"
"runtime"
"strconv"
"strings"
"sync"
)
type MimeInfo struct {
Type string `json:"Type"`
Suffix string `json:"Suffix"`
}
// Config struct
type Config struct {
storage *Storage
Theme string `json:"Theme"`
Host string `json:"Host"`
Port string `json:"Port"`
Quality int `json:"Quality"`
SaveDirectory string `json:"SaveDirectory"`
UpstreamProxy string `json:"UpstreamProxy"`
OpenProxy bool `json:"OpenProxy"`
DownloadProxy bool `json:"DownloadProxy"`
AutoProxy bool `json:"AutoProxy"`
WxAction bool `json:"WxAction"`
TaskNumber int `json:"TaskNumber"`
UserAgent string `json:"UserAgent"`
Theme string `json:"Theme"`
Locale string `json:"Locale"`
Host string `json:"Host"`
Port string `json:"Port"`
Quality int `json:"Quality"`
SaveDirectory string `json:"SaveDirectory"`
FilenameLen int `json:"FilenameLen"`
FilenameTime bool `json:"FilenameTime"`
UpstreamProxy string `json:"UpstreamProxy"`
OpenProxy bool `json:"OpenProxy"`
DownloadProxy bool `json:"DownloadProxy"`
AutoProxy bool `json:"AutoProxy"`
WxAction bool `json:"WxAction"`
TaskNumber int `json:"TaskNumber"`
DownNumber int `json:"DownNumber"`
UserAgent string `json:"UserAgent"`
UseHeaders string `json:"UseHeaders"`
InsertTail bool `json:"InsertTail"`
MimeMap map[string]MimeInfo `json:"MimeMap"`
Rule string `json:"Rule"`
}
var (
mimeMux sync.RWMutex
)
func initConfig() *Config {
if globalConfig == nil {
def := `
{
"Host": "0.0.0.0",
"Port": "8899",
"Theme": "lightTheme",
"Quality": 0,
"SaveDirectory": "",
"UpstreamProxy": "",
"OpenProxy": false,
"DownloadProxy": false,
"AutoProxy": false,
"WxAction": true,
"TaskNumber": __TaskNumber__,
"UserAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36"
}
`
def = strings.ReplaceAll(def, "__TaskNumber__", strconv.Itoa(runtime.NumCPU()*2))
globalConfig = &Config{
storage: NewStorage("config.json", []byte(def)),
}
if globalConfig != nil {
return globalConfig
}
data, err := globalConfig.storage.Load()
if err == nil {
_ = json.Unmarshal(data, &globalConfig)
} else {
globalLogger.Esg(err, "load config err")
defaultConfig := &Config{
Theme: "lightTheme",
Locale: "zh",
Host: "127.0.0.1",
Port: "8899",
Quality: 0,
SaveDirectory: getDefaultDownloadDir(),
FilenameLen: 0,
FilenameTime: true,
UpstreamProxy: "",
OpenProxy: false,
DownloadProxy: false,
AutoProxy: false,
WxAction: true,
TaskNumber: runtime.NumCPU() * 2,
DownNumber: 3,
UserAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36",
UseHeaders: "default",
InsertTail: true,
MimeMap: getDefaultMimeMap(),
Rule: "*",
}
rawDefaults, err := json.Marshal(defaultConfig)
if err != nil {
return globalConfig
}
storage := NewStorage("config.json", rawDefaults)
defaultConfig.storage = storage
globalConfig = defaultConfig
data, err := storage.Load()
if err != nil {
globalLogger.Esg(err, "load config failed, using defaults")
return globalConfig
}
var cacheMap map[string]interface{}
if err := json.Unmarshal(data, &cacheMap); err != nil {
globalLogger.Esg(err, "parse cached config failed, using defaults")
return globalConfig
}
var defaultMap map[string]interface{}
defaultBytes, _ := json.Marshal(defaultConfig)
_ = json.Unmarshal(defaultBytes, &defaultMap)
for k, v := range cacheMap {
if _, ok := defaultMap[k]; ok {
defaultMap[k] = v
}
}
finalBytes, err := json.Marshal(defaultMap)
if err != nil {
globalLogger.Esg(err, "marshal merged config failed")
return globalConfig
}
if err := json.Unmarshal(finalBytes, globalConfig); err != nil {
globalLogger.Esg(err, "unmarshal merged config to struct failed")
}
return globalConfig
}
func getDefaultMimeMap() map[string]MimeInfo {
return map[string]MimeInfo{
"image/png": {Type: "image", Suffix: ".png"},
"image/webp": {Type: "image", Suffix: ".webp"},
"image/jpeg": {Type: "image", Suffix: ".jpeg"},
"image/jpg": {Type: "image", Suffix: ".jpg"},
"image/gif": {Type: "image", Suffix: ".gif"},
"image/avif": {Type: "image", Suffix: ".avif"},
"image/bmp": {Type: "image", Suffix: ".bmp"},
"image/tiff": {Type: "image", Suffix: ".tiff"},
"image/heic": {Type: "image", Suffix: ".heic"},
"image/x-icon": {Type: "image", Suffix: ".ico"},
"image/svg+xml": {Type: "image", Suffix: ".svg"},
"image/vnd.adobe.photoshop": {Type: "image", Suffix: ".psd"},
"image/jp2": {Type: "image", Suffix: ".jp2"},
"image/jpeg2000": {Type: "image", Suffix: ".jp2"},
"image/apng": {Type: "image", Suffix: ".apng"},
"audio/mpeg": {Type: "audio", Suffix: ".mp3"},
"audio/mp3": {Type: "audio", Suffix: ".mp3"},
"audio/wav": {Type: "audio", Suffix: ".wav"},
"audio/aiff": {Type: "audio", Suffix: ".aiff"},
"audio/x-aiff": {Type: "audio", Suffix: ".aiff"},
"audio/aac": {Type: "audio", Suffix: ".aac"},
"audio/ogg": {Type: "audio", Suffix: ".ogg"},
"audio/flac": {Type: "audio", Suffix: ".flac"},
"audio/midi": {Type: "audio", Suffix: ".mid"},
"audio/x-midi": {Type: "audio", Suffix: ".mid"},
"audio/x-ms-wma": {Type: "audio", Suffix: ".wma"},
"audio/opus": {Type: "audio", Suffix: ".opus"},
"audio/webm": {Type: "audio", Suffix: ".webm"},
"audio/mp4": {Type: "audio", Suffix: ".m4a"},
"audio/amr": {Type: "audio", Suffix: ".amr"},
"video/mp4": {Type: "video", Suffix: ".mp4"},
"video/webm": {Type: "video", Suffix: ".webm"},
"video/ogg": {Type: "video", Suffix: ".ogv"},
"video/x-msvideo": {Type: "video", Suffix: ".avi"},
"video/mpeg": {Type: "video", Suffix: ".mpeg"},
"video/quicktime": {Type: "video", Suffix: ".mov"},
"video/x-ms-wmv": {Type: "video", Suffix: ".wmv"},
"video/3gpp": {Type: "video", Suffix: ".3gp"},
"video/x-matroska": {Type: "video", Suffix: ".mkv"},
"audio/video": {Type: "live", Suffix: ".flv"},
"video/x-flv": {Type: "live", Suffix: ".flv"},
"application/dash+xml": {Type: "live", Suffix: ".mpd"},
"application/vnd.apple.mpegurl": {Type: "m3u8", Suffix: ".m3u8"},
"application/x-mpegurl": {Type: "m3u8", Suffix: ".m3u8"},
"application/x-mpeg": {Type: "m3u8", Suffix: ".m3u8"},
"audio/x-mpegurl": {Type: "m3u8", Suffix: ".m3u8"},
"application/pdf": {Type: "pdf", Suffix: ".pdf"},
"application/vnd.ms-powerpoint": {Type: "ppt", Suffix: ".ppt"},
"application/vnd.openxmlformats-officedocument.presentationml.presentation": {Type: "ppt", Suffix: ".pptx"},
"application/vnd.ms-excel": {Type: "xls", Suffix: ".xls"},
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": {Type: "xls", Suffix: ".xlsx"},
"text/csv": {Type: "xls", Suffix: ".csv"},
"application/msword": {Type: "doc", Suffix: ".doc"},
"application/rtf": {Type: "doc", Suffix: ".rtf"},
"text/rtf": {Type: "doc", Suffix: ".rtf"},
"application/vnd.oasis.opendocument.text": {Type: "doc", Suffix: ".odt"},
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": {Type: "doc", Suffix: ".docx"},
"font/woff": {Type: "font", Suffix: ".woff"},
"application/octet-stream": {Type: "stream", Suffix: "default"},
}
}
func getDefaultDownloadDir() string {
usr, err := user.Current()
if err != nil {
return ""
}
homeDir := usr.HomeDir
var downloadDir string
switch runtime.GOOS {
case "windows", "darwin":
downloadDir = filepath.Join(homeDir, "Downloads")
case "linux":
downloadDir = filepath.Join(homeDir, "Downloads")
if xdgDir := os.Getenv("XDG_DOWNLOAD_DIR"); xdgDir != "" {
downloadDir = xdgDir
}
}
if stat, err := os.Stat(downloadDir); err == nil && stat.IsDir() {
return downloadDir
}
return ""
}
func (c *Config) setConfig(config Config) {
oldProxy := c.UpstreamProxy
openProxy := c.OpenProxy
oldRule := c.Rule
c.Host = config.Host
c.Port = config.Port
c.Theme = config.Theme
c.Locale = config.Locale
c.Quality = config.Quality
c.SaveDirectory = config.SaveDirectory
c.FilenameLen = config.FilenameLen
c.FilenameTime = config.FilenameTime
c.UpstreamProxy = config.UpstreamProxy
c.UserAgent = config.UserAgent
c.OpenProxy = config.OpenProxy
c.DownloadProxy = config.DownloadProxy
c.AutoProxy = config.AutoProxy
c.TaskNumber = config.TaskNumber
c.DownNumber = config.DownNumber
c.WxAction = config.WxAction
if oldProxy != c.UpstreamProxy {
c.UseHeaders = config.UseHeaders
c.InsertTail = config.InsertTail
c.Rule = config.Rule
if oldProxy != c.UpstreamProxy || openProxy != c.OpenProxy {
proxyOnce.setTransport()
}
if oldRule != c.Rule {
err := ruleOnce.Load(c.Rule)
if err != nil {
globalLogger.Esg(err, "set rule failed")
}
}
mimeMux.Lock()
c.MimeMap = config.MimeMap
mimeMux.Unlock()
jsonData, err := json.Marshal(c)
if err == nil {
_ = globalConfig.storage.Store(jsonData)
}
}
func (c *Config) getConfig(key string) interface{} {
switch key {
case "Host":
return c.Host
case "Port":
return c.Port
case "Theme":
return c.Theme
case "Locale":
return c.Locale
case "Quality":
return c.Quality
case "SaveDirectory":
return c.SaveDirectory
case "FilenameLen":
return c.FilenameLen
case "FilenameTime":
return c.FilenameTime
case "UpstreamProxy":
return c.UpstreamProxy
case "UserAgent":
return c.UserAgent
case "OpenProxy":
return c.OpenProxy
case "DownloadProxy":
return c.DownloadProxy
case "AutoProxy":
return c.AutoProxy
case "TaskNumber":
return c.TaskNumber
case "DownNumber":
return c.DownNumber
case "WxAction":
return c.WxAction
case "UseHeaders":
return c.UseHeaders
case "InsertTail":
return c.InsertTail
case "MimeMap":
mimeMux.RLock()
defer mimeMux.RUnlock()
return c.MimeMap
case "Rule":
return c.Rule
default:
return nil
}
}
func (c *Config) typeSuffix(mime string) (string, string) {
mimeMux.RLock()
defer mimeMux.RUnlock()
mime = strings.ToLower(strings.Split(mime, ";")[0])
if v, ok := c.MimeMap[mime]; ok {
return v.Type, v.Suffix
}
return "", ""
}

View File

@@ -1,19 +1,31 @@
package core
import (
"context"
"fmt"
"io"
"log"
"net/http"
"net/http/cookiejar"
"net/url"
"os"
"path/filepath"
"res-downloader/core/shared"
"strings"
"sync"
"time"
)
type ProgressCallback func(totalDownloaded float64)
const (
MaxRetries = 3 // 最大重试次数
RetryDelay = 3 * time.Second // 重试延迟
MinPartSize = 1 * 1024 * 1024 // 最小分片大小1MB
)
type ProgressCallback func(totalDownloaded float64, totalSize float64, taskID int, taskProgress float64)
type ProgressChan struct {
taskID int
bytes int64
}
type DownloadTask struct {
taskID int
@@ -21,6 +33,7 @@ type DownloadTask struct {
rangeEnd int64
downloadedSize int64
isCompleted bool
err error
}
type FileDownloader struct {
@@ -32,38 +45,88 @@ type FileDownloader struct {
totalTasks int
TotalSize int64
IsMultiPart bool
RetryOnError bool
Headers map[string]string
DownloadTaskList []*DownloadTask
progressCallback ProgressCallback
ctx context.Context
cancelFunc context.CancelFunc
}
func NewFileDownloader(url, filename string, totalTasks int) *FileDownloader {
func NewFileDownloader(url, filename string, totalTasks int, headers map[string]string) *FileDownloader {
ctx, cancelFunc := context.WithCancel(context.Background())
return &FileDownloader{
Url: url,
FileName: filename,
totalTasks: totalTasks,
IsMultiPart: false,
RetryOnError: false,
TotalSize: 0,
Headers: headers,
DownloadTaskList: make([]*DownloadTask, 0),
ctx: ctx,
cancelFunc: cancelFunc,
}
}
func (fd *FileDownloader) buildClient() *http.Client {
transport := &http.Transport{}
transport := &http.Transport{
MaxIdleConnsPerHost: 100,
IdleConnTimeout: 90 * time.Second,
}
if fd.ProxyUrl != nil {
transport.Proxy = http.ProxyURL(fd.ProxyUrl)
}
// Cookie handle
jar, _ := cookiejar.New(nil)
return &http.Client{
Transport: transport,
Jar: jar,
}
}
var forbiddenDownloadHeaders = map[string]struct{}{
"accept-encoding": {},
"content-length": {},
"host": {},
"connection": {},
"keep-alive": {},
"proxy-connection": {},
"transfer-encoding": {},
"sec-fetch-site": {},
"sec-fetch-mode": {},
"sec-fetch-dest": {},
"sec-fetch-user": {},
"sec-ch-ua": {},
"sec-ch-ua-mobile": {},
"sec-ch-ua-platform": {},
"if-none-match": {},
"if-modified-since": {},
"x-forwarded-for": {},
"x-real-ip": {},
}
func (fd *FileDownloader) setHeaders(request *http.Request) {
for key, value := range fd.Headers {
if globalConfig.UseHeaders == "default" {
lk := strings.ToLower(key)
if _, forbidden := forbiddenDownloadHeaders[lk]; forbidden {
continue
}
request.Header.Set(key, value)
continue
}
if strings.Contains(globalConfig.UseHeaders, key) {
request.Header.Set(key, value)
}
}
}
func (fd *FileDownloader) init() error {
parsedURL, err := url.Parse(fd.Url)
if err != nil {
return err
return fmt.Errorf("parse URL failed: %w", err)
}
if parsedURL.Scheme != "" && parsedURL.Host != "" {
fd.Referer = parsedURL.Scheme + "://" + parsedURL.Host + "/"
@@ -76,138 +139,306 @@ func (fd *FileDownloader) init() error {
}
}
resp, e := http.Head(fd.Url)
if e != nil {
return e
request, err := http.NewRequest("HEAD", fd.Url, nil)
if err != nil {
return fmt.Errorf("create HEAD request failed: %w", err)
}
fd.TotalSize = resp.ContentLength
if _, ok := fd.Headers["User-Agent"]; !ok {
fd.Headers["User-Agent"] = globalConfig.UserAgent
}
if _, ok := fd.Headers["Referer"]; !ok {
fd.Headers["Referer"] = fd.Referer
}
if resp.Header.Get("Accept-Ranges") == "bytes" && fd.TotalSize > 10485760 {
fd.setHeaders(request)
var resp *http.Response
for retries := 0; retries < MaxRetries; retries++ {
resp, err = fd.buildClient().Do(request)
if err == nil {
break
}
if retries < MaxRetries-1 {
time.Sleep(RetryDelay)
globalLogger.Warn().Msgf("HEAD request failed, retrying (%d/%d): %v", retries+1, MaxRetries, err)
}
}
if err != nil {
return fmt.Errorf("HEAD request failed after %d retries: %w", MaxRetries, err)
}
defer resp.Body.Close()
fd.TotalSize = resp.ContentLength
if fd.TotalSize <= 0 {
fd.IsMultiPart = false
fd.TotalSize = -1
} else if resp.Header.Get("Accept-Ranges") == "bytes" && fd.TotalSize > MinPartSize {
fd.IsMultiPart = true
}
resp.Body.Close()
dir := filepath.Dir(fd.FileName)
if err := os.MkdirAll(dir, os.ModePerm); err != nil {
return fmt.Errorf("create directory failed: %w", err)
}
fd.FileName = filepath.Clean(fd.FileName)
_, err = os.Stat(fd.FileName)
fd.FileName = shared.GetUniqueFileName(fd.FileName)
fd.File, err = os.OpenFile(fd.FileName, os.O_RDWR|os.O_CREATE, 0644)
if err != nil {
if os.IsNotExist(err) {
fd.File, err = os.Create(fd.FileName)
if err != nil && fd.TotalSize > 0 {
err = fd.File.Truncate(fd.TotalSize)
}
return fmt.Errorf("file open failed: %w", err)
}
if fd.TotalSize > 0 {
if err := fd.File.Truncate(fd.TotalSize); err != nil {
fd.File.Close()
return fmt.Errorf("file truncate failed: %w", err)
}
} else {
fd.File, err = os.OpenFile(fd.FileName, os.O_RDWR, os.ModeAppend)
}
if err != nil {
return err
}
return nil
}
func (fd *FileDownloader) createDownloadTasks() {
if fd.IsMultiPart {
if int64(fd.totalTasks) > fd.TotalSize {
fd.totalTasks = int(fd.TotalSize)
if fd.totalTasks <= 0 {
fd.totalTasks = 4
}
eachSize := fd.TotalSize / int64(fd.totalTasks)
if eachSize < MinPartSize {
fd.totalTasks = int(fd.TotalSize / MinPartSize)
if fd.totalTasks < 1 {
fd.totalTasks = 1
}
eachSize = fd.TotalSize / int64(fd.totalTasks)
}
for i := 0; i < fd.totalTasks; i++ {
start := eachSize * int64(i)
end := eachSize*int64(i+1) - 1
if i == fd.totalTasks-1 {
end = fd.TotalSize - 1
}
fd.DownloadTaskList = append(fd.DownloadTaskList, &DownloadTask{
taskID: i,
rangeStart: eachSize * int64(i),
rangeEnd: eachSize*int64(i+1) - 1,
downloadedSize: 0,
isCompleted: false,
taskID: i,
rangeStart: start,
rangeEnd: end,
})
}
fd.DownloadTaskList[len(fd.DownloadTaskList)-1].rangeEnd = fd.TotalSize - 1
} else {
fd.totalTasks = 1
rangeEnd := int64(-1)
if fd.TotalSize > 0 {
rangeEnd = fd.TotalSize - 1
}
fd.DownloadTaskList = append(fd.DownloadTaskList, &DownloadTask{
taskID: 0,
rangeStart: 0,
rangeEnd: 0,
downloadedSize: 0,
isCompleted: false,
taskID: 0,
rangeStart: 0,
rangeEnd: rangeEnd,
})
}
}
func (fd *FileDownloader) startDownload() {
waitGroup := &sync.WaitGroup{}
progressChan := make(chan int64)
func (fd *FileDownloader) startDownload() error {
wg := &sync.WaitGroup{}
progressChan := make(chan ProgressChan, len(fd.DownloadTaskList))
errorChan := make(chan error, len(fd.DownloadTaskList))
for _, task := range fd.DownloadTaskList {
go fd.startDownloadTask(waitGroup, progressChan, task)
waitGroup.Add(1)
wg.Add(1)
go fd.startDownloadTask(wg, progressChan, errorChan, task)
}
go func() {
waitGroup.Wait()
close(progressChan)
taskProgress := make([]int64, len(fd.DownloadTaskList))
totalDownloaded := int64(0)
for progress := range progressChan {
taskProgress[progress.taskID] += progress.bytes
totalDownloaded += progress.bytes
if fd.progressCallback != nil {
taskPercentage := float64(0)
if task := fd.DownloadTaskList[progress.taskID]; task != nil {
taskSize := task.rangeEnd - task.rangeStart + 1
if taskSize > 0 {
taskPercentage = float64(taskProgress[progress.taskID]) / float64(taskSize) * 100
}
}
fd.progressCallback(float64(totalDownloaded), float64(fd.TotalSize), progress.taskID, taskPercentage)
}
}
}()
if fd.progressCallback != nil {
totalDownloaded := int64(0)
for progress := range progressChan {
totalDownloaded += progress
fd.progressCallback(float64(totalDownloaded) * 100 / float64(fd.TotalSize))
go func() {
wg.Wait()
close(progressChan)
close(errorChan)
}()
var errArr []error
for err := range errorChan {
errArr = append(errArr, err)
}
if len(errArr) > 0 {
if !fd.RetryOnError && fd.IsMultiPart {
// 降级
fd.RetryOnError = true
fd.DownloadTaskList = []*DownloadTask{}
fd.totalTasks = 1
fd.IsMultiPart = false
fd.createDownloadTasks()
return fd.startDownload()
}
return fmt.Errorf("download failed with %d errors: %v", len(errArr), errArr[0])
}
if err := fd.verifyDownload(); err != nil {
return err
}
return nil
}
func (fd *FileDownloader) startDownloadTask(wg *sync.WaitGroup, progressChan chan ProgressChan, errorChan chan error, task *DownloadTask) {
defer wg.Done()
for retries := 0; retries < MaxRetries; retries++ {
err := fd.doDownloadTask(progressChan, task)
if err == nil {
task.isCompleted = true
return
}
if strings.Contains(err.Error(), "cancelled") {
errorChan <- err
return
}
task.err = err
globalLogger.Warn().Msgf("Task %d failed (attempt %d/%d): %v", task.taskID, retries+1, MaxRetries, err)
if retries < MaxRetries-1 {
select {
case <-fd.ctx.Done():
errorChan <- fmt.Errorf("task %d cancelled during retry", task.taskID)
return
case <-time.After(RetryDelay):
}
}
}
errorChan <- fmt.Errorf("task %d failed after %d attempts: %v", task.taskID, MaxRetries, task.err)
}
func (fd *FileDownloader) doDownloadTask(progressChan chan ProgressChan, task *DownloadTask) error {
select {
case <-fd.ctx.Done():
return fmt.Errorf("download cancelled")
default:
}
request, err := http.NewRequestWithContext(fd.ctx, "GET", fd.Url, nil)
if err != nil {
return fmt.Errorf("create request failed: %w", err)
}
fd.setHeaders(request)
if fd.IsMultiPart {
rangeStart := task.rangeStart + task.downloadedSize
rangeHeader := fmt.Sprintf("bytes=%d-%d", rangeStart, task.rangeEnd)
request.Header.Set("Range", rangeHeader)
}
client := fd.buildClient()
resp, err := client.Do(request)
if err != nil {
return fmt.Errorf("send request failed: %w", err)
}
defer resp.Body.Close()
if fd.IsMultiPart && resp.StatusCode != http.StatusPartialContent {
return fmt.Errorf("server does not support range requests, status: %d", resp.StatusCode)
} else if !fd.IsMultiPart && resp.StatusCode != http.StatusOK {
return fmt.Errorf("unexpected status code: %d", resp.StatusCode)
}
buf := make([]byte, 32*1024)
for {
select {
case <-fd.ctx.Done():
return fmt.Errorf("download cancelled")
default:
}
n, err := resp.Body.Read(buf)
if n > 0 {
writeSize := int64(n)
offset := task.rangeStart + task.downloadedSize
_, writeErr := fd.File.WriteAt(buf[:writeSize], offset)
if writeErr != nil {
return fmt.Errorf("write file failed at offset %d: %w", offset, writeErr)
}
task.downloadedSize += writeSize
progressChan <- ProgressChan{taskID: task.taskID, bytes: writeSize}
if fd.TotalSize > 0 && task.rangeStart+task.downloadedSize-1 >= task.rangeEnd {
return nil
}
}
if err != nil {
if err == io.EOF {
return nil
}
return fmt.Errorf("read response failed: %w", err)
}
}
}
func (fd *FileDownloader) startDownloadTask(waitGroup *sync.WaitGroup, progressChan chan int64, task *DownloadTask) {
defer waitGroup.Done()
request, err := http.NewRequest("GET", fd.Url, nil)
if err != nil {
globalLogger.Error().Stack().Err(err).Msgf("任务%d创建请求出错", task.taskID)
return
}
request.Header.Set("User-Agent", globalConfig.UserAgent)
request.Header.Set("Referer", fd.Referer)
if fd.IsMultiPart {
request.Header.Set("Range", fmt.Sprintf("bytes=%d-%d", task.rangeStart, task.rangeEnd))
func (fd *FileDownloader) verifyDownload() error {
for _, task := range fd.DownloadTaskList {
if !task.isCompleted {
return fmt.Errorf("task %d not completed", task.taskID)
}
}
resp, err := fd.buildClient().Do(request)
if err != nil {
log.Printf("任务%d发送下载请求出错%s", task.taskID, err)
return
}
defer resp.Body.Close()
buf := make([]byte, 8192)
for {
n, err := resp.Body.Read(buf)
if n > 0 {
_, err := fd.File.WriteAt(buf[:n], task.rangeStart+task.downloadedSize)
if err != nil {
log.Printf("任务%d写入文件时出现错误位置:%d, err: %s\n", task.taskID, task.rangeStart+task.downloadedSize, err)
return
}
downSize := int64(n)
task.downloadedSize += downSize
progressChan <- downSize
}
if fd.TotalSize > 0 {
_, err := fd.File.Stat()
if err != nil {
if err == io.EOF {
task.isCompleted = true
break
}
log.Printf("任务%d读取响应错误%s", task.taskID, err)
return
return fmt.Errorf("get file info failed: %w", err)
}
}
return nil
}
func (fd *FileDownloader) Start() error {
err := fd.init()
if err != nil {
if err := fd.init(); err != nil {
return err
}
fd.createDownloadTasks()
fd.startDownload()
defer fd.File.Close()
return nil
err := fd.startDownload()
if fd.File != nil {
fd.File.Close()
}
return err
}
func (fd *FileDownloader) Cancel() {
if fd.cancelFunc != nil {
fd.cancelFunc()
}
if fd.File != nil {
fd.File.Close()
}
if fd.FileName != "" {
_ = os.Remove(fd.FileName)
}
}

View File

@@ -4,17 +4,21 @@ import (
"bytes"
"encoding/json"
"fmt"
"github.com/wailsapp/wails/v2/pkg/runtime"
"io"
"log"
"net"
"net/http"
"net/url"
"os/exec"
sysRuntime "runtime"
"os"
"path/filepath"
"res-downloader/core/shared"
"strings"
"github.com/wailsapp/wails/v2/pkg/runtime"
)
type respData map[string]interface{}
type ResponseData struct {
Code int `json:"code"`
Message string `json:"message"`
@@ -33,25 +37,31 @@ func initHttpServer() *HttpServer {
func (h *HttpServer) run() {
listener, err := net.Listen("tcp", globalConfig.Host+":"+globalConfig.Port)
if err != nil {
log.Fatalf("无法启动监听: %v", err)
globalLogger.Err(err)
log.Fatalf("Service cannot start: %v", err)
}
fmt.Println("服务已启动,监听 http://" + globalConfig.Host + ":" + globalConfig.Port)
if err := http.Serve(listener, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Host == "127.0.0.1:"+globalConfig.Port && strings.Contains(r.URL.Path, "/cert") {
w.Header().Set("Content-Type", "application/x-x509-ca-data")
w.Header().Set("Content-Disposition", "attachment;filename=res-downloader-public.crt")
w.Header().Set("Content-Transfer-Encoding", "binary")
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(appOnce.PublicCrt)))
w.WriteHeader(http.StatusOK)
_, err = io.Copy(w, io.NopCloser(bytes.NewReader(appOnce.PublicCrt)))
fmt.Println("Service started, listening http://" + globalConfig.Host + ":" + globalConfig.Port)
if err1 := http.Serve(listener, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Host == "127.0.0.1:"+globalConfig.Port && HandleApi(w, r) {
} else {
proxyOnce.Proxy.ServeHTTP(w, r) // 代理
}
})); err != nil {
fmt.Printf("服务器异常: %v", err)
})); err1 != nil {
globalLogger.Err(err1)
fmt.Printf("Service startup exception: %v", err1)
}
}
func (h *HttpServer) downCert(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/x-x509-ca-data")
w.Header().Set("Content-Disposition", "attachment;filename=res-downloader-public.crt")
w.Header().Set("Content-Transfer-Encoding", "binary")
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(appOnce.PublicCrt)))
w.WriteHeader(http.StatusOK)
io.Copy(w, io.NopCloser(bytes.NewReader(appOnce.PublicCrt)))
}
func (h *HttpServer) preview(w http.ResponseWriter, r *http.Request) {
realURL := r.URL.Query().Get("url")
if realURL == "" {
@@ -74,8 +84,6 @@ func (h *HttpServer) preview(w http.ResponseWriter, r *http.Request) {
request.Header.Set("Range", rangeHeader)
}
//request.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36")
//request.Header.Set("Referer", parsedURL.Scheme+"://"+parsedURL.Host+"/")
resp, err := http.DefaultClient.Do(request)
if err != nil {
http.Error(w, "Failed to fetch the resource", http.StatusInternalServerError)
@@ -83,12 +91,15 @@ func (h *HttpServer) preview(w http.ResponseWriter, r *http.Request) {
}
defer resp.Body.Close()
w.Header().Set("Content-Type", resp.Header.Get("Content-Type"))
w.WriteHeader(resp.StatusCode)
if contentRange := resp.Header.Get("Content-Range"); contentRange != "" {
w.Header().Set("Content-Range", contentRange)
for k, v := range resp.Header {
if strings.ToLower(k) == "access-control-allow-origin" {
continue
}
for _, vv := range v {
w.Header().Add(k, vv)
}
}
w.WriteHeader(resp.StatusCode)
_, err = io.Copy(w, resp.Body)
if err != nil {
@@ -109,12 +120,47 @@ func (h *HttpServer) send(t string, data interface{}) {
runtime.EventsEmit(appOnce.ctx, "event", string(jsonData))
}
func (h *HttpServer) writeJson(w http.ResponseWriter, data ResponseData) {
func (h *HttpServer) writeJson(w http.ResponseWriter, data *ResponseData) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(200)
err := json.NewEncoder(w).Encode(data)
if err != nil {
globalLogger.err(err)
globalLogger.Err(err)
}
}
func (h *HttpServer) error(w http.ResponseWriter, args ...interface{}) {
message := "ok"
var data interface{}
if len(args) > 0 {
message = args[0].(string)
}
if len(args) > 1 {
data = args[1]
}
h.writeJson(w, h.buildResp(0, message, data))
}
func (h *HttpServer) success(w http.ResponseWriter, args ...interface{}) {
message := "ok"
var data interface{}
if len(args) > 0 {
data = args[0]
}
if len(args) > 1 {
message = args[1].(string)
}
h.writeJson(w, h.buildResp(1, message, data))
}
func (h *HttpServer) buildResp(code int, message string, data interface{}) *ResponseData {
return &ResponseData{
Code: code,
Message: message,
Data: data,
}
}
@@ -124,14 +170,11 @@ func (h *HttpServer) openDirectoryDialog(w http.ResponseWriter, r *http.Request)
Title: "Select a folder",
})
if err != nil {
h.writeJson(w, ResponseData{Code: 0, Message: err.Error()})
h.error(w, err.Error())
return
}
h.writeJson(w, ResponseData{
Code: 1,
Data: map[string]interface{}{
"folder": folder,
},
h.success(w, respData{
"folder": folder,
})
}
@@ -146,14 +189,11 @@ func (h *HttpServer) openFileDialog(w http.ResponseWriter, r *http.Request) {
Title: "Select a file",
})
if err != nil {
h.writeJson(w, ResponseData{Code: 0, Message: err.Error()})
h.error(w, err.Error())
return
}
h.writeJson(w, ResponseData{
Code: 1,
Data: map[string]interface{}{
"file": filePath,
},
h.success(w, respData{
"file": filePath,
})
}
@@ -166,99 +206,99 @@ func (h *HttpServer) openFolder(w http.ResponseWriter, r *http.Request) {
return
}
filePath := data.FilePath
var cmd *exec.Cmd
switch sysRuntime.GOOS {
case "darwin":
// macOS
cmd = exec.Command("open", "-R", filePath)
case "windows":
// Windows
cmd = exec.Command("explorer", "/select,", filePath)
case "linux":
// linux
// 尝试使用不同的文件管理器
cmd = exec.Command("nautilus", filePath)
if err := cmd.Start(); err != nil {
cmd = exec.Command("thunar", filePath)
if err := cmd.Start(); err != nil {
cmd = exec.Command("dolphin", filePath)
if err := cmd.Start(); err != nil {
cmd = exec.Command("pcmanfm", filePath)
if err := cmd.Start(); err != nil {
globalLogger.err(err)
h.writeJson(w, ResponseData{Code: 0, Message: err.Error()})
return
}
}
}
}
default:
h.writeJson(w, ResponseData{Code: 0, Message: "unsupported platform"})
return
}
err = cmd.Start()
err = shared.OpenFolder(data.FilePath)
if err != nil {
globalLogger.err(err)
h.writeJson(w, ResponseData{Code: 0, Message: err.Error()})
globalLogger.Err(err)
h.error(w, err.Error())
return
}
h.writeJson(w, ResponseData{Code: 1})
h.success(w)
return
}
func (h *HttpServer) install(w http.ResponseWriter, r *http.Request) {
if appOnce.isInstall() {
h.success(w, respData{
"isPass": systemOnce.Password == "",
})
return
}
out, err := appOnce.installCert()
if err != nil {
h.error(w, err.Error()+"\n"+out, respData{
"isPass": systemOnce.Password == "",
})
return
}
h.success(w, respData{
"isPass": systemOnce.Password == "",
})
}
func (h *HttpServer) setSystemPassword(w http.ResponseWriter, r *http.Request) {
var data struct {
Password string `json:"password"`
IsCache bool `json:"isCache"`
}
err := json.NewDecoder(r.Body).Decode(&data)
if err != nil {
h.error(w, err.Error())
return
}
systemOnce.SetPassword(data.Password, data.IsCache)
h.success(w)
}
func (h *HttpServer) openSystemProxy(w http.ResponseWriter, r *http.Request) {
appOnce.OpenSystemProxy()
h.writeJson(w, ResponseData{
Code: 1,
Data: map[string]bool{
"isProxy": appOnce.IsProxy,
},
err := appOnce.OpenSystemProxy()
if err != nil {
h.error(w, err.Error(), respData{
"value": appOnce.IsProxy,
})
return
}
h.success(w, respData{
"value": appOnce.IsProxy,
})
}
func (h *HttpServer) unsetSystemProxy(w http.ResponseWriter, r *http.Request) {
appOnce.UnsetSystemProxy()
h.writeJson(w, ResponseData{
Code: 1,
Data: map[string]bool{
"isProxy": appOnce.IsProxy,
},
err := appOnce.UnsetSystemProxy()
if err != nil {
h.error(w, err.Error(), respData{
"value": appOnce.IsProxy,
})
return
}
h.success(w, respData{
"value": appOnce.IsProxy,
})
}
func (h *HttpServer) isProxy(w http.ResponseWriter, r *http.Request) {
h.writeJson(w, ResponseData{
Code: 1,
Data: map[string]interface{}{
"isProxy": appOnce.IsProxy,
},
h.success(w, respData{
"value": appOnce.IsProxy,
})
}
func (h *HttpServer) appInfo(w http.ResponseWriter, r *http.Request) {
h.writeJson(w, ResponseData{
Code: 1,
Data: appOnce,
})
h.success(w, appOnce)
}
func (h *HttpServer) getConfig(w http.ResponseWriter, r *http.Request) {
h.writeJson(w, ResponseData{
Code: 1,
Data: globalConfig,
})
h.success(w, globalConfig)
}
func (h *HttpServer) setConfig(w http.ResponseWriter, r *http.Request) {
var data Config
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
h.writeJson(w, ResponseData{Code: 0, Message: err.Error()})
h.error(w, err.Error())
return
}
globalConfig.setConfig(data)
h.writeJson(w, ResponseData{Code: 1})
h.success(w)
}
func (h *HttpServer) setType(w http.ResponseWriter, r *http.Request) {
@@ -274,57 +314,95 @@ func (h *HttpServer) setType(w http.ResponseWriter, r *http.Request) {
}
}
h.writeJson(w, ResponseData{Code: 1})
h.success(w)
}
func (h *HttpServer) clear(w http.ResponseWriter, r *http.Request) {
resourceOnce.clear()
h.writeJson(w, ResponseData{Code: 1})
h.success(w)
}
func (h *HttpServer) delete(w http.ResponseWriter, r *http.Request) {
var data struct {
Sign string `json:"sign"`
Sign []string `json:"sign"`
}
err := json.NewDecoder(r.Body).Decode(&data)
if err == nil && data.Sign != "" {
resourceOnce.delete(data.Sign)
if err == nil && len(data.Sign) > 0 {
for _, v := range data.Sign {
resourceOnce.delete(v)
}
}
h.writeJson(w, ResponseData{Code: 1})
h.success(w)
}
func (h *HttpServer) download(w http.ResponseWriter, r *http.Request) {
var data struct {
MediaInfo
shared.MediaInfo
DecodeStr string `json:"decodeStr"`
}
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
h.writeJson(w, ResponseData{Code: 0, Message: err.Error()})
h.error(w, err.Error())
return
}
resourceOnce.download(data.MediaInfo, data.DecodeStr)
h.writeJson(w, ResponseData{Code: 1})
h.success(w)
}
func (h *HttpServer) cancel(w http.ResponseWriter, r *http.Request) {
var data struct {
shared.MediaInfo
}
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
h.error(w, err.Error())
return
}
err := resourceOnce.cancel(data.Id)
if err != nil {
h.error(w, err.Error())
return
}
h.success(w)
}
func (h *HttpServer) wxFileDecode(w http.ResponseWriter, r *http.Request) {
var data struct {
MediaInfo
shared.MediaInfo
Filename string `json:"filename"`
DecodeStr string `json:"decodeStr"`
}
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
h.writeJson(w, ResponseData{Code: 0, Message: err.Error()})
h.error(w, err.Error())
return
}
savePath, err := resourceOnce.wxFileDecode(data.MediaInfo, data.Filename, data.DecodeStr)
if err != nil {
h.writeJson(w, ResponseData{Code: 0, Message: err.Error()})
h.error(w, err.Error())
return
}
h.writeJson(w, ResponseData{
Code: 1,
Data: map[string]string{
"save_path": savePath,
},
h.success(w, respData{
"save_path": savePath,
})
}
func (h *HttpServer) batchExport(w http.ResponseWriter, r *http.Request) {
var data struct {
Content string `json:"content"`
}
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
h.error(w, err.Error())
return
}
fileName := filepath.Join(globalConfig.SaveDirectory, "res-downloader-"+shared.GetCurrentDateTimeFormatted()+".txt")
err := os.WriteFile(fileName, []byte(data.Content), 0644)
if err != nil {
h.error(w, err.Error())
return
}
_ = shared.OpenFolder(fileName)
h.success(w, respData{
"file_name": fileName,
})
}

View File

@@ -6,6 +6,7 @@ import (
"io"
"os"
"path/filepath"
"res-downloader/core/shared"
)
type Logger struct {
@@ -15,7 +16,7 @@ type Logger struct {
func initLogger() *Logger {
if globalLogger == nil {
globalLogger = NewLogger(!IsDevelopment(), filepath.Join(appOnce.UserDir, "logs", "app.log"))
globalLogger = NewLogger(!shared.IsDevelopment(), filepath.Join(appOnce.UserDir, "logs", "app.log"))
}
return globalLogger
}
@@ -24,7 +25,7 @@ func (l *Logger) Close() {
_ = l.logFile.Close()
}
func (l *Logger) err(err error) {
func (l *Logger) Err(err error) {
l.Error().Stack().Err(err)
}
@@ -38,14 +39,14 @@ func NewLogger(logFile bool, logPath string) *Logger {
if logFile {
// log to file
logDir := filepath.Dir(logPath)
if err := CreateDirIfNotExist(logDir); err != nil {
if err := shared.CreateDirIfNotExist(logDir); err != nil {
panic(err)
}
var (
logfile *os.File
err error
)
logfile, err = os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666)
logfile, err = os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
if err != nil {
panic(err)
}

View File

@@ -17,13 +17,19 @@ func Middleware(next http.Handler) http.Handler {
func HandleApi(w http.ResponseWriter, r *http.Request) bool {
if strings.HasPrefix(r.URL.Path, "/api") {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
if r.URL.Path != "/api/preview" {
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
}
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return true
}
switch r.URL.Path {
case "/api/install":
httpServerOnce.install(w, r)
case "/api/set-system-password":
httpServerOnce.setSystemPassword(w, r)
case "/api/preview":
httpServerOnce.preview(w, r)
case "/api/proxy-open":
@@ -52,8 +58,14 @@ func HandleApi(w http.ResponseWriter, r *http.Request) bool {
httpServerOnce.delete(w, r)
case "/api/download":
httpServerOnce.download(w, r)
case "/api/cancel":
httpServerOnce.cancel(w, r)
case "/api/wx-file-decode":
httpServerOnce.wxFileDecode(w, r)
case "/api/batch-export":
httpServerOnce.batchExport(w, r)
case "/api/cert":
httpServerOnce.downCert(w, r)
}
return true
}

View File

@@ -0,0 +1,87 @@
package plugins
import (
"encoding/json"
"github.com/elazarl/goproxy"
gonanoid "github.com/matoous/go-nanoid/v2"
"net/http"
"path/filepath"
"res-downloader/core/shared"
"strconv"
"strings"
)
type DefaultPlugin struct {
bridge *shared.Bridge
}
func (p *DefaultPlugin) SetBridge(bridge *shared.Bridge) {
p.bridge = bridge
}
func (p *DefaultPlugin) Domains() []string {
return []string{"default"}
}
func (p *DefaultPlugin) OnRequest(r *http.Request, ctx *goproxy.ProxyCtx) (*http.Request, *http.Response) {
return r, nil
}
func (p *DefaultPlugin) OnResponse(resp *http.Response, ctx *goproxy.ProxyCtx) *http.Response {
if resp == nil || resp.Request == nil || (resp.StatusCode != 200 && resp.StatusCode != 206 && resp.StatusCode != 304) {
return resp
}
classify, suffix := p.bridge.TypeSuffix(resp.Header.Get("Content-Type"))
if classify == "" {
return resp
}
rawUrl := resp.Request.URL.String()
isAll, _ := p.bridge.GetResType("all")
isClassify, _ := p.bridge.GetResType(classify)
if suffix == "default" {
ext := filepath.Ext(filepath.Base(strings.Split(strings.Split(rawUrl, "?")[0], "#")[0]))
if ext != "" {
suffix = ext
}
}
urlSign := shared.Md5(rawUrl)
if ok := p.bridge.MediaIsMarked(urlSign); !ok && (isAll || isClassify) {
value, _ := strconv.ParseFloat(resp.Header.Get("content-length"), 64)
id, err := gonanoid.New()
if err != nil {
id = urlSign
}
res := shared.MediaInfo{
Id: id,
Url: rawUrl,
UrlSign: urlSign,
CoverUrl: "",
Size: value,
Domain: shared.GetTopLevelDomain(rawUrl),
Classify: classify,
Suffix: suffix,
Status: shared.DownloadStatusReady,
SavePath: "",
DecodeKey: "",
OtherData: map[string]string{},
Description: "",
ContentType: resp.Header.Get("Content-Type"),
}
// Store entire request headers as JSON
if headers, err := json.Marshal(resp.Request.Header); err == nil {
res.OtherData["headers"] = string(headers)
}
p.bridge.MarkMedia(urlSign)
go func(res shared.MediaInfo) {
p.bridge.Send("newResources", res)
}(res)
}
return resp
}

View File

@@ -0,0 +1,272 @@
package plugins
import (
"bytes"
"encoding/json"
"fmt"
"github.com/elazarl/goproxy"
gonanoid "github.com/matoous/go-nanoid/v2"
"io"
"net/http"
"regexp"
"res-downloader/core/shared"
"strconv"
"strings"
)
var qqMediaRegex = regexp.MustCompile(`get\s*media\(\)\{`)
var qqCommentRegex = regexp.MustCompile(`async\s*finderGetCommentDetail\((\w+)\)\s*\{return(.*?)\s*}\s*async`)
type QqPlugin struct {
bridge *shared.Bridge
}
func (p *QqPlugin) SetBridge(bridge *shared.Bridge) {
p.bridge = bridge
}
func (p *QqPlugin) Domains() []string {
return []string{"qq.com"}
}
func (p *QqPlugin) OnRequest(r *http.Request, ctx *goproxy.ProxyCtx) (*http.Request, *http.Response) {
if strings.Contains(r.Host, "qq.com") && strings.Contains(r.URL.Path, "/res-downloader/wechat") {
if p.bridge.GetConfig("WxAction").(bool) && r.URL.Query().Get("type") == "1" {
return p.handleWechatRequest(r, ctx)
} else if !p.bridge.GetConfig("WxAction").(bool) && r.URL.Query().Get("type") == "2" {
return p.handleWechatRequest(r, ctx)
} else {
return r, p.buildEmptyResponse(r)
}
}
return nil, nil
}
func (p *QqPlugin) OnResponse(resp *http.Response, ctx *goproxy.ProxyCtx) *http.Response {
if resp.StatusCode != 200 && resp.StatusCode != 206 {
return nil
}
host := resp.Request.Host
Path := resp.Request.URL.Path
classify, _ := p.bridge.TypeSuffix(resp.Header.Get("Content-Type"))
if classify == "video" && strings.HasSuffix(host, "finder.video.qq.com") {
if strings.Contains(resp.Request.Header.Get("Origin"), "mp.weixin.qq.com") {
return nil
}
return resp
}
if strings.HasSuffix(host, "channels.weixin.qq.com") &&
(strings.Contains(Path, "/web/pages/feed") || strings.Contains(Path, "/web/pages/home")) {
return p.replaceWxJsContent(resp, ".js\"", ".js?v="+p.v()+"\"")
}
if strings.HasSuffix(host, "res.wx.qq.com") {
respTemp := resp
is := false
if strings.HasSuffix(respTemp.Request.URL.RequestURI(), ".js?v="+p.v()) {
respTemp = p.replaceWxJsContent(respTemp, ".js\"", ".js?v="+p.v()+"\"")
is = true
}
if strings.Contains(Path, "web/web-finder/res/js/virtual_svg-icons-register.publish") {
body, err := io.ReadAll(respTemp.Body)
if err != nil {
return respTemp
}
bodyStr := string(body)
newBody := qqMediaRegex.
ReplaceAllString(bodyStr, `
get media(){
if(this.objectDesc){
fetch("https://wxapp.tc.qq.com/res-downloader/wechat?type=1", {
method: "POST",
mode: "no-cors",
body: JSON.stringify(this.objectDesc),
});
};
`)
newBody = qqCommentRegex.
ReplaceAllString(newBody, `
async finderGetCommentDetail($1) {
var res = await$2;
if (res?.data?.object?.objectDesc) {
fetch("https://wxapp.tc.qq.com/res-downloader/wechat?type=2", {
method: "POST",
mode: "no-cors",
body: JSON.stringify(res.data.object.objectDesc),
});
}
return res;
}async
`)
newBodyBytes := []byte(newBody)
respTemp.Body = io.NopCloser(bytes.NewBuffer(newBodyBytes))
respTemp.ContentLength = int64(len(newBodyBytes))
respTemp.Header.Set("Content-Length", fmt.Sprintf("%d", len(newBodyBytes)))
return respTemp
}
if is {
return respTemp
}
}
return nil
}
func (p *QqPlugin) handleWechatRequest(r *http.Request, ctx *goproxy.ProxyCtx) (*http.Request, *http.Response) {
body, err := io.ReadAll(r.Body)
if err != nil {
return r, p.buildEmptyResponse(r)
}
go p.handleMedia(body)
return r, p.buildEmptyResponse(r)
}
func (p *QqPlugin) handleMedia(body []byte) {
var result map[string]interface{}
if err := json.Unmarshal(body, &result); err != nil {
return
}
mediaArr, ok := result["media"].([]interface{})
if !ok || len(mediaArr) == 0 {
return
}
firstMedia, ok := mediaArr[0].(map[string]interface{})
if !ok {
return
}
rawUrl, ok := firstMedia["url"].(string)
if !ok || rawUrl == "" {
return
}
urlSign := shared.Md5(rawUrl)
if p.bridge.MediaIsMarked(urlSign) {
return
}
id, err := gonanoid.New()
if err != nil {
id = urlSign
}
res := shared.MediaInfo{
Id: id,
Url: rawUrl,
UrlSign: urlSign,
CoverUrl: "",
Size: 0,
Domain: shared.GetTopLevelDomain(rawUrl),
Classify: "video",
Suffix: ".mp4",
Status: shared.DownloadStatusReady,
SavePath: "",
DecodeKey: "",
OtherData: map[string]string{},
Description: "",
ContentType: "video/mp4",
}
if mediaType, ok := firstMedia["mediaType"].(float64); ok && mediaType == 9 {
res.Classify = "image"
res.Suffix = ".png"
res.ContentType = "image/png"
}
isAll, _ := p.bridge.GetResType("all")
isImage, _ := p.bridge.GetResType("image")
if res.Classify == "image" && !isImage && !isAll {
return
}
isVideo, _ := p.bridge.GetResType("video")
if res.Classify == "video" && !isVideo && !isAll {
return
}
if urlToken, ok := firstMedia["urlToken"].(string); ok {
res.Url += urlToken
}
switch size := firstMedia["fileSize"].(type) {
case float64:
res.Size = size
case string:
if value, err := strconv.ParseFloat(size, 64); err == nil {
res.Size = value
}
}
if coverUrl, ok := firstMedia["coverUrl"].(string); ok {
res.CoverUrl = coverUrl
}
if decodeKey, ok := firstMedia["decodeKey"].(string); ok {
res.DecodeKey = decodeKey
}
if desc, ok := result["description"].(string); ok {
res.Description = desc
}
if spec, ok := firstMedia["spec"].([]interface{}); ok {
var fileFormats []string
for _, item := range spec {
if m, ok := item.(map[string]interface{}); ok {
if format, ok := m["fileFormat"].(string); ok {
fileFormats = append(fileFormats, format)
}
}
}
res.OtherData["wx_file_formats"] = strings.Join(fileFormats, "#")
}
p.bridge.MarkMedia(urlSign)
go func(res shared.MediaInfo) {
p.bridge.Send("newResources", res)
}(res)
}
func (p *QqPlugin) buildEmptyResponse(r *http.Request) *http.Response {
body := "The content does not exist"
resp := &http.Response{
Status: http.StatusText(http.StatusOK),
StatusCode: http.StatusOK,
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader(body)),
ContentLength: int64(len(body)),
Request: r,
}
resp.Header.Set("Content-Type", "text/plain; charset=utf-8")
return resp
}
func (p *QqPlugin) replaceWxJsContent(resp *http.Response, old, new string) *http.Response {
body, err := io.ReadAll(resp.Body)
if err != nil {
return resp
}
bodyString := string(body)
newBodyString := strings.ReplaceAll(bodyString, old, new)
newBodyBytes := []byte(newBodyString)
resp.Body = io.NopCloser(bytes.NewBuffer(newBodyBytes))
resp.ContentLength = int64(len(newBodyBytes))
resp.Header.Set("Content-Length", fmt.Sprintf("%d", len(newBodyBytes)))
return resp
}
func (p *QqPlugin) v() string {
return p.bridge.GetVersion()
}

View File

@@ -1,22 +1,18 @@
package core
import (
"bytes"
"context"
"crypto/tls"
"crypto/x509"
"encoding/json"
"fmt"
"github.com/elazarl/goproxy"
gonanoid "github.com/matoous/go-nanoid/v2"
"io"
"net"
"net/http"
"net/url"
"regexp"
"strconv"
"res-downloader/core/plugins"
"res-downloader/core/shared"
"strings"
"time"
"github.com/elazarl/goproxy"
)
type Proxy struct {
@@ -25,21 +21,44 @@ type Proxy struct {
Is bool
}
type MediaInfo struct {
Id string
Url string
UrlSign string
CoverUrl string
Size string
Domain string
Classify string
Suffix string
SavePath string
Status string
DecodeKey string
Description string
ContentType string
OtherData map[string]string
var pluginRegistry = make(map[string]shared.Plugin)
func init() {
ps := []shared.Plugin{
&plugins.QqPlugin{},
&plugins.DefaultPlugin{},
}
bridge := &shared.Bridge{
GetVersion: func() string {
return appOnce.Version
},
GetResType: func(key string) (bool, bool) {
return resourceOnce.getResType(key)
},
TypeSuffix: func(mine string) (string, string) {
return globalConfig.typeSuffix(mine)
},
MediaIsMarked: func(key string) bool {
return resourceOnce.mediaIsMarked(key)
},
MarkMedia: func(key string) {
resourceOnce.markMedia(key)
},
GetConfig: func(key string) interface{} {
return globalConfig.getConfig(key)
},
Send: func(t string, data interface{}) {
httpServerOnce.send(t, data)
},
}
for _, p := range ps {
p.SetBridge(bridge)
for _, domain := range p.Domains() {
pluginRegistry[domain] = p
}
}
}
func initProxy() *Proxy {
@@ -53,7 +72,7 @@ func initProxy() *Proxy {
func (p *Proxy) Startup() {
err := p.setCa()
if err != nil {
DialogErr("启动代理服务失败" + err.Error())
DialogErr("Failed to start proxy service" + err.Error())
return
}
@@ -61,7 +80,14 @@ func (p *Proxy) Startup() {
//p.Proxy.KeepDestinationHeaders = true
//p.Proxy.Verbose = false
p.setTransport()
p.Proxy.OnRequest().HandleConnect(goproxy.AlwaysMitm)
//p.Proxy.OnRequest().HandleConnect(goproxy.AlwaysMitm)
p.Proxy.OnRequest().HandleConnectFunc(func(host string, ctx *goproxy.ProxyCtx) (*goproxy.ConnectAction, string) {
if ruleOnce.shouldMitm(host) {
return goproxy.MitmConnect, host
}
return goproxy.OkConnect, host
})
p.Proxy.OnRequest().DoFunc(p.httpRequestEvent)
p.Proxy.OnResponse().DoFunc(p.httpResponseEvent)
}
@@ -69,7 +95,6 @@ func (p *Proxy) Startup() {
func (p *Proxy) setCa() error {
ca, err := tls.X509KeyPair(appOnce.PublicCrt, appOnce.PrivateKey)
if err != nil {
DialogErr("启动代理服务失败1")
return err
}
if ca.Leaf, err = x509.ParseCertificate(ca.Certificate[0]); err != nil {
@@ -104,251 +129,41 @@ func (p *Proxy) setTransport() {
p.Proxy.Tr = transport
}
func (p *Proxy) matchPlugin(host string) shared.Plugin {
domain := shared.GetTopLevelDomain(host)
if plugin, ok := pluginRegistry[domain]; ok {
return plugin
}
return nil
}
func (p *Proxy) httpRequestEvent(r *http.Request, ctx *goproxy.ProxyCtx) (*http.Request, *http.Response) {
if strings.Contains(r.Host, "res-downloader.666666.com") && strings.Contains(r.URL.Path, "/wechat") {
if globalConfig.WxAction && r.URL.Query().Get("type") == "1" {
return p.handleWechatRequest(r, ctx)
} else if !globalConfig.WxAction && r.URL.Query().Get("type") == "2" {
return p.handleWechatRequest(r, ctx)
} else {
return r, p.buildEmptyResponse(r)
plugin := p.matchPlugin(r.Host)
if plugin != nil {
newReq, newResp := plugin.OnRequest(r, ctx)
if newResp != nil {
return newReq, newResp
}
if newReq != nil {
return newReq, nil
}
}
return r, nil
}
func (p *Proxy) handleWechatRequest(r *http.Request, ctx *goproxy.ProxyCtx) (*http.Request, *http.Response) {
body, err := io.ReadAll(r.Body)
if err != nil {
fmt.Println(err)
return r, p.buildEmptyResponse(r)
}
isAll, _ := resourceOnce.getResType("all")
isClassify, _ := resourceOnce.getResType("video")
if !isAll && !isClassify {
return r, p.buildEmptyResponse(r)
}
go func(body []byte) {
var result map[string]interface{}
err = json.Unmarshal(body, &result)
if err != nil {
return
}
media, ok := result["media"].([]interface{})
if !ok || len(media) <= 0 {
return
}
firstMedia, ok := media[0].(map[string]interface{})
if !ok {
return
}
rowUrl, ok := firstMedia["url"]
if !ok {
return
}
resourceOnce.markMu.Lock()
defer resourceOnce.markMu.Unlock()
urlSign := Md5(rowUrl.(string))
if _, ok := resourceOnce.mark[urlSign]; ok {
return
}
id, err := gonanoid.New()
if err != nil {
id = urlSign
}
res := MediaInfo{
Id: id,
Url: rowUrl.(string),
UrlSign: urlSign,
CoverUrl: "",
Size: "0",
Domain: GetTopLevelDomain(rowUrl.(string)),
Classify: "video",
Suffix: ".mp4",
Status: DownloadStatusReady,
SavePath: "",
DecodeKey: "",
OtherData: map[string]string{},
Description: "",
ContentType: "video/mp4",
}
if urlToken, ok := firstMedia["urlToken"].(string); ok {
res.Url = res.Url + urlToken
}
if fileSize, ok := firstMedia["fileSize"].(float64); ok {
res.Size = FormatSize(fileSize)
}
if coverUrl, ok := firstMedia["coverUrl"].(string); ok {
res.CoverUrl = coverUrl
}
if fileSize, ok := firstMedia["fileSize"].(string); ok {
value, err := strconv.ParseFloat(fileSize, 64)
if err == nil {
res.Size = FormatSize(value)
}
}
if decodeKey, ok := firstMedia["decodeKey"].(string); ok {
res.DecodeKey = decodeKey
}
if desc, ok := result["description"].(string); ok {
res.Description = desc
}
if spec, ok := firstMedia["spec"].([]interface{}); ok {
var fileFormats []string
for _, item := range spec {
if itemMap, ok := item.(map[string]interface{}); ok {
if format, exists := itemMap["fileFormat"].(string); exists {
fileFormats = append(fileFormats, format)
}
}
}
res.OtherData["wx_file_formats"] = strings.Join(fileFormats, "#")
}
resourceOnce.mark[urlSign] = true
httpServerOnce.send("newResources", res)
}(body)
return r, p.buildEmptyResponse(r)
}
func (p *Proxy) buildEmptyResponse(r *http.Request) *http.Response {
body := "内容不存在"
resp := &http.Response{
Status: "200 OK",
StatusCode: http.StatusOK,
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader(body)),
ContentLength: int64(len(body)),
Request: r,
}
resp.Header.Set("Content-Type", "text/plain")
return resp
return pluginRegistry["default"].OnRequest(r, ctx)
}
func (p *Proxy) httpResponseEvent(resp *http.Response, ctx *goproxy.ProxyCtx) *http.Response {
if resp == nil || resp.Request == nil || (resp.StatusCode != 200 && resp.StatusCode != 206) {
if resp == nil || resp.Request == nil {
return resp
}
host := resp.Request.Host
Path := resp.Request.URL.Path
if strings.HasSuffix(host, "channels.weixin.qq.com") &&
(strings.Contains(Path, "/web/pages/feed") || strings.Contains(Path, "/web/pages/home")) {
return p.replaceWxJsContent(resp, ".js\"", ".js?v="+p.v()+"\"")
}
if strings.HasSuffix(host, "res.wx.qq.com") {
respTemp := resp
if strings.HasSuffix(respTemp.Request.URL.RequestURI(), ".js?v="+p.v()) {
respTemp = p.replaceWxJsContent(respTemp, ".js\"", ".js?v="+p.v()+"\"")
plugin := p.matchPlugin(resp.Request.Host)
if plugin != nil {
newResp := plugin.OnResponse(resp, ctx)
if newResp != nil {
return newResp
}
if strings.Contains(Path, "web/web-finder/res/js/virtual_svg-icons-register.publish") {
body, err := io.ReadAll(respTemp.Body)
if err != nil {
return respTemp
}
bodyStr := string(body)
newBody := regexp.MustCompile(`get\s*media\(\)\{`).
ReplaceAllString(bodyStr, `
get media(){
if(this.objectDesc){
fetch("https://res-downloader.666666.com/wechat?type=1", {
method: "POST",
mode: "no-cors",
body: JSON.stringify(this.objectDesc),
});
};
`)
newBody = regexp.MustCompile(`async\s*finderGetCommentDetail\((\w+)\)\s*\{return(.*?)\s*}\s*async`).
ReplaceAllString(newBody, `
async finderGetCommentDetail($1) {
var res = await$2;
if (res?.data?.object?.objectDesc) {
fetch("https://res-downloader.666666.com/wechat?type=2", {
method: "POST",
mode: "no-cors",
body: JSON.stringify(res.data.object.objectDesc),
});
}
return res;
}async
`)
newBodyBytes := []byte(newBody)
respTemp.Body = io.NopCloser(bytes.NewBuffer(newBodyBytes))
respTemp.ContentLength = int64(len(newBodyBytes))
respTemp.Header.Set("Content-Length", fmt.Sprintf("%d", len(newBodyBytes)))
return respTemp
}
return respTemp
}
classify, suffix := TypeSuffix(resp.Header.Get("Content-Type"))
if classify == "" {
return resp
}
if classify == "video" && strings.HasSuffix(host, "finder.video.qq.com") {
//if !globalConfig.WxAction && classify == "video" && strings.HasSuffix(host, "finder.video.qq.com") {
return resp
}
rawUrl := resp.Request.URL.String()
resourceOnce.markMu.Lock()
defer resourceOnce.markMu.Unlock()
isAll, _ := resourceOnce.getResType("all")
isClassify, _ := resourceOnce.getResType(classify)
urlSign := Md5(rawUrl)
if _, ok := resourceOnce.mark[urlSign]; !ok && (isAll || isClassify) {
value, _ := strconv.ParseFloat(resp.Header.Get("content-length"), 64)
id, err := gonanoid.New()
if err != nil {
id = urlSign
}
res := MediaInfo{
Id: id,
Url: rawUrl,
UrlSign: urlSign,
CoverUrl: "",
Size: FormatSize(value),
Domain: GetTopLevelDomain(rawUrl),
Classify: classify,
Suffix: suffix,
Status: DownloadStatusReady,
SavePath: "",
DecodeKey: "",
OtherData: map[string]string{},
Description: "",
ContentType: resp.Header.Get("Content-Type"),
}
resourceOnce.mark[urlSign] = true
httpServerOnce.send("newResources", res)
}
return resp
}
func (p *Proxy) replaceWxJsContent(resp *http.Response, old, new string) *http.Response {
body, err := io.ReadAll(resp.Body)
if err != nil {
return resp
}
bodyString := string(body)
newBodyString := strings.ReplaceAll(bodyString, old, new)
newBodyBytes := []byte(newBodyString)
resp.Body = io.NopCloser(bytes.NewBuffer(newBodyBytes))
resp.ContentLength = int64(len(newBodyBytes))
resp.Header.Set("Content-Length", fmt.Sprintf("%d", len(newBodyBytes)))
return resp
}
func (p *Proxy) v() string {
return appOnce.Version
return pluginRegistry["default"].OnResponse(resp, ctx)
}

View File

@@ -2,124 +2,135 @@ package core
import (
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net/url"
"os"
"path/filepath"
"regexp"
"res-downloader/core/shared"
"strconv"
"strings"
"sync"
)
const (
DownloadStatusReady string = "ready" // task create but not start
DownloadStatusRunning string = "running"
DownloadStatusError string = "error"
DownloadStatusDone string = "done"
DownloadStatusHandle string = "handle"
)
type WxFileDecodeResult struct {
SavePath string
Message string
}
type Resource struct {
mark map[string]bool
markMu sync.RWMutex
resType map[string]bool
resTypeMu sync.RWMutex
mediaMark sync.Map
tasks sync.Map
resType map[string]bool
resTypeMux sync.RWMutex
}
func initResource() *Resource {
if resourceOnce == nil {
resourceOnce = &Resource{
mark: make(map[string]bool),
resType: map[string]bool{
"all": true,
"image": true,
"audio": true,
"video": true,
"m3u8": true,
"live": true,
"xls": true,
"doc": true,
"pdf": true,
},
}
resourceOnce = &Resource{}
resourceOnce.resType = resourceOnce.buildResType(globalConfig.MimeMap)
}
return resourceOnce
}
func (r *Resource) getMark(key string) (bool, bool) {
r.markMu.RLock()
defer r.markMu.RUnlock()
value, ok := r.mark[key]
return value, ok
func (r *Resource) buildResType(mime map[string]MimeInfo) map[string]bool {
t := map[string]bool{
"all": true,
}
for _, item := range mime {
if _, ok := t[item.Type]; !ok {
t[item.Type] = true
}
}
return t
}
func (r *Resource) setMark(key string, value bool) {
r.markMu.Lock()
defer r.markMu.Unlock()
r.mark[key] = value
func (r *Resource) mediaIsMarked(key string) bool {
_, loaded := r.mediaMark.Load(key)
return loaded
}
func (r *Resource) markMedia(key string) {
r.mediaMark.Store(key, true)
}
func (r *Resource) getResType(key string) (bool, bool) {
r.resTypeMu.RLock()
defer r.resTypeMu.RUnlock()
r.resTypeMux.RLock()
value, ok := r.resType[key]
r.resTypeMux.RUnlock()
return value, ok
}
func (r *Resource) setResType(n []string) {
r.resTypeMu.Lock()
defer r.resTypeMu.Unlock()
r.resType = map[string]bool{
"all": false,
"image": false,
"audio": false,
"video": false,
"m3u8": false,
"live": false,
"xls": false,
"doc": false,
"pdf": false,
r.resTypeMux.Lock()
for key := range r.resType {
r.resType[key] = false
}
for _, value := range n {
r.resType[value] = true
if _, ok := r.resType[value]; ok {
r.resType[value] = true
}
}
r.resTypeMux.Unlock()
}
func (r *Resource) clear() {
r.markMu.Lock()
defer r.markMu.Unlock()
r.mark = make(map[string]bool)
r.mediaMark.Clear()
}
func (r *Resource) delete(sign string) {
r.markMu.Lock()
defer r.markMu.Unlock()
delete(r.mark, sign)
r.mediaMark.Delete(sign)
}
func (r *Resource) download(mediaInfo MediaInfo, decodeStr string) {
func (r *Resource) cancel(id string) error {
if d, ok := r.tasks.Load(id); ok {
d.(*FileDownloader).Cancel()
r.tasks.Delete(id) // 可选:取消后清理
return nil
}
return errors.New("task not found")
}
func (r *Resource) download(mediaInfo shared.MediaInfo, decodeStr string) {
if globalConfig.SaveDirectory == "" {
return
}
go func(mediaInfo MediaInfo) {
go func(mediaInfo shared.MediaInfo) {
rawUrl := mediaInfo.Url
fileName := Md5(rawUrl)
fileName := shared.Md5(rawUrl)
if v := shared.GetFileNameFromURL(rawUrl); v != "" {
fileName = v
}
if mediaInfo.Description != "" {
fileName = regexp.MustCompile(`[^\w\p{Han}]`).ReplaceAllString(mediaInfo.Description, "")
fileLen := globalConfig.FilenameLen
if fileLen <= 0 {
fileLen = 10
}
runes := []rune(fileName)
if len(runes) > 10 {
fileName = string(runes[:10])
if len(runes) > fileLen {
fileName = string(runes[:fileLen])
}
}
mediaInfo.SavePath = filepath.Join(globalConfig.SaveDirectory, fileName+"_"+GetCurrentDateTimeFormatted()+mediaInfo.Suffix)
if globalConfig.FilenameTime {
mediaInfo.SavePath = filepath.Join(globalConfig.SaveDirectory, fileName+"_"+shared.GetCurrentDateTimeFormatted())
} else {
mediaInfo.SavePath = filepath.Join(globalConfig.SaveDirectory, fileName)
}
if !strings.HasSuffix(mediaInfo.SavePath, mediaInfo.Suffix) {
mediaInfo.SavePath = mediaInfo.SavePath + mediaInfo.Suffix
}
if strings.Contains(rawUrl, "qq.com") {
if globalConfig.Quality == 1 &&
@@ -143,33 +154,58 @@ func (r *Resource) download(mediaInfo MediaInfo, decodeStr string) {
}
}
downloader := NewFileDownloader(rawUrl, mediaInfo.SavePath, globalConfig.TaskNumber)
downloader.progressCallback = func(totalDownloaded float64) {
r.progressEventsEmit(mediaInfo, strconv.Itoa(int(totalDownloaded))+"%", DownloadStatusRunning)
headers, _ := r.parseHeaders(mediaInfo)
downloader := NewFileDownloader(rawUrl, mediaInfo.SavePath, globalConfig.TaskNumber, headers)
downloader.progressCallback = func(totalDownloaded, totalSize float64, taskID int, taskProgress float64) {
r.progressEventsEmit(mediaInfo, strconv.Itoa(int(totalDownloaded*100/totalSize))+"%", shared.DownloadStatusRunning)
}
r.tasks.Store(mediaInfo.Id, downloader)
err := downloader.Start()
mediaInfo.SavePath = downloader.FileName
if err != nil {
r.progressEventsEmit(mediaInfo, err.Error())
if !strings.Contains(err.Error(), "cancelled") {
r.progressEventsEmit(mediaInfo, err.Error())
}
return
}
if decodeStr != "" {
r.progressEventsEmit(mediaInfo, "解密中", DownloadStatusRunning)
r.progressEventsEmit(mediaInfo, "decrypting in progress", shared.DownloadStatusRunning)
if err := r.decodeWxFile(mediaInfo.SavePath, decodeStr); err != nil {
r.progressEventsEmit(mediaInfo, "解密出错"+err.Error())
r.progressEventsEmit(mediaInfo, "decryption error: "+err.Error())
return
}
}
r.progressEventsEmit(mediaInfo, "完成", DownloadStatusDone)
r.progressEventsEmit(mediaInfo, "complete", shared.DownloadStatusDone)
}(mediaInfo)
}
func (r *Resource) wxFileDecode(mediaInfo MediaInfo, fileName, decodeStr string) (string, error) {
func (r *Resource) parseHeaders(mediaInfo shared.MediaInfo) (map[string]string, error) {
headers := make(map[string]string)
if hh, ok := mediaInfo.OtherData["headers"]; ok {
var tempHeaders map[string][]string
if err := json.Unmarshal([]byte(hh), &tempHeaders); err != nil {
return headers, fmt.Errorf("parse headers JSON err: %v", err)
}
for key, values := range tempHeaders {
if len(values) > 0 {
headers[key] = values[0]
}
}
}
return headers, nil
}
func (r *Resource) wxFileDecode(mediaInfo shared.MediaInfo, fileName, decodeStr string) (string, error) {
sourceFile, err := os.Open(fileName)
if err != nil {
return "", err
}
defer sourceFile.Close()
mediaInfo.SavePath = strings.ReplaceAll(fileName, ".mp4", "_解密.mp4")
mediaInfo.SavePath = strings.ReplaceAll(fileName, ".mp4", "_decrypt.mp4")
destinationFile, err := os.Create(mediaInfo.SavePath)
if err != nil {
@@ -188,8 +224,8 @@ func (r *Resource) wxFileDecode(mediaInfo MediaInfo, fileName, decodeStr string)
return mediaInfo.SavePath, nil
}
func (r *Resource) progressEventsEmit(mediaInfo MediaInfo, args ...string) {
Status := DownloadStatusError
func (r *Resource) progressEventsEmit(mediaInfo shared.MediaInfo, args ...string) {
Status := shared.DownloadStatusError
Message := "ok"
if len(args) > 0 {
@@ -221,10 +257,15 @@ func (r *Resource) decodeWxFile(fileName, decodeStr string) error {
byteCount := len(decodedBytes)
fileBytes := make([]byte, byteCount)
_, err = file.Read(fileBytes)
n, err := file.Read(fileBytes)
if err != nil && err != io.EOF {
return err
}
if n < byteCount {
byteCount = n
}
xorResult := make([]byte, byteCount)
for i := 0; i < byteCount; i++ {
xorResult[i] = decodedBytes[i] ^ fileBytes[i]

126
core/rule.go Normal file
View File

@@ -0,0 +1,126 @@
package core
import (
"bufio"
"net"
"strings"
"sync"
)
type Rule struct {
raw string
isNeg bool // 是否否定规则(以 ! 开头)
isWildcard bool // 是否为 *.domain 形式
isAll bool
domain string // 域名部分,不含 "*."
}
type RuleSet struct {
mu sync.RWMutex
rules []Rule
}
func initRule() *RuleSet {
if ruleOnce == nil {
ruleOnce = &RuleSet{}
err := ruleOnce.Load(globalConfig.Rule)
if err != nil {
globalLogger.Esg(err, "init rule failed")
return nil
}
}
return ruleOnce
}
func (r *RuleSet) Load(rs string) error {
reader := strings.NewReader(rs)
scanner := bufio.NewScanner(reader)
var rules []Rule
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
isNeg := false
if strings.HasPrefix(line, "!") {
isNeg = true
line = strings.TrimSpace(line[1:])
if line == "" {
continue
}
}
if line == "*" {
rules = append(rules, Rule{
raw: "*",
isAll: true,
isNeg: isNeg,
})
continue
}
isWildcard := false
domain := line
if strings.HasPrefix(line, "*.") {
isWildcard = true
domain = line[2:]
}
rules = append(rules, Rule{
raw: line,
isNeg: isNeg,
isWildcard: isWildcard,
domain: strings.ToLower(domain),
})
}
if err := scanner.Err(); err != nil {
return err
}
r.mu.Lock()
r.rules = rules
r.mu.Unlock()
return nil
}
// shouldMitm: 根据当前规则集判断是否对 host 做 MITM
// host 可能带端口example.com:443函数会只匹配 hostname 部分
// 返回 true => MITM解密false => 透传
func (r *RuleSet) shouldMitm(host string) bool {
h := host
if strings.HasPrefix(h, "[") {
if hostSplitIdx := strings.LastIndex(h, "]"); hostSplitIdx != -1 {
h = h[:hostSplitIdx+1]
}
}
if hp, _, err := net.SplitHostPort(host); err == nil {
h = hp
}
h = strings.ToLower(strings.Trim(h, "[]"))
r.mu.RLock()
defer r.mu.RUnlock()
action := false
for _, rule := range r.rules {
if rule.isAll {
action = !rule.isNeg
continue
}
if rule.isWildcard {
if h == rule.domain || strings.HasSuffix(h, "."+rule.domain) {
action = !rule.isNeg
}
continue
}
if h == rule.domain {
action = !rule.isNeg
}
}
return action
}

18
core/shared/base.go Normal file
View File

@@ -0,0 +1,18 @@
package shared
type MediaInfo struct {
Id string
Url string
UrlSign string
CoverUrl string
Size float64
Domain string
Classify string
Suffix string
SavePath string
Status string
DecodeKey string
Description string
ContentType string
OtherData map[string]string
}

9
core/shared/const.go Normal file
View File

@@ -0,0 +1,9 @@
package shared
const (
DownloadStatusReady string = "ready" // task create but not start
DownloadStatusRunning string = "running"
DownloadStatusError string = "error"
DownloadStatusDone string = "done"
DownloadStatusHandle string = "handle"
)

23
core/shared/plugin.go Normal file
View File

@@ -0,0 +1,23 @@
package shared
import (
"github.com/elazarl/goproxy"
"net/http"
)
type Bridge struct {
GetVersion func() string
GetResType func(key string) (bool, bool)
TypeSuffix func(mime string) (string, string)
MediaIsMarked func(key string) bool
MarkMedia func(key string)
GetConfig func(key string) interface{}
Send func(t string, data interface{})
}
type Plugin interface {
SetBridge(*Bridge)
Domains() []string
OnRequest(*http.Request, *goproxy.ProxyCtx) (*http.Request, *http.Response)
OnResponse(*http.Response, *goproxy.ProxyCtx) *http.Response
}

163
core/shared/utils.go Normal file
View File

@@ -0,0 +1,163 @@
package shared
import (
"crypto/md5"
"encoding/hex"
"errors"
"fmt"
"golang.org/x/net/publicsuffix"
"net/url"
"os"
"os/exec"
"path"
"path/filepath"
"regexp"
sysRuntime "runtime"
"strings"
"time"
)
func Md5(data string) string {
hashNew := md5.New()
hashNew.Write([]byte(data))
hash := hashNew.Sum(nil)
return hex.EncodeToString(hash)
}
func FormatSize(size float64) string {
if size > 1048576 {
return fmt.Sprintf("%.2fMB", float64(size)/1048576)
}
if size > 1024 {
return fmt.Sprintf("%.2fKB", float64(size)/1024)
}
return fmt.Sprintf("%.0fb", size)
}
func GetTopLevelDomain(rawURL string) string {
u, err := url.Parse(rawURL)
if err == nil && u.Host != "" {
rawURL = u.Host
}
domain, err := publicsuffix.EffectiveTLDPlusOne(rawURL)
if err != nil {
return rawURL
}
return domain
}
func FileExist(file string) bool {
info, err := os.Stat(file)
if err != nil {
return false
}
return !info.IsDir()
}
func CreateDirIfNotExist(dir string) error {
if _, err := os.Stat(dir); os.IsNotExist(err) {
return os.MkdirAll(dir, 0750)
}
return nil
}
func IsDevelopment() bool {
return os.Getenv("APP_ENV") == "development"
}
func GetFileNameFromURL(rawUrl string) string {
parsedURL, err := url.Parse(rawUrl)
if err != nil {
return ""
}
fileName := path.Base(parsedURL.Path)
if fileName == "" || fileName == "/" {
return ""
}
if decoded, err := url.QueryUnescape(fileName); err == nil {
fileName = decoded
}
re := regexp.MustCompile(`[<>:"/\\|?*]`)
fileName = re.ReplaceAllString(fileName, "_")
fileName = strings.TrimRightFunc(fileName, func(r rune) bool {
return r == '.' || r == ' '
})
const maxFileNameLen = 255
runes := []rune(fileName)
if len(runes) > maxFileNameLen {
ext := path.Ext(fileName)
name := strings.TrimSuffix(fileName, ext)
runes = []rune(name)
if len(runes) > maxFileNameLen-len(ext) {
runes = runes[:maxFileNameLen-len(ext)]
}
name = string(runes)
fileName = name + ext
}
return fileName
}
func GetCurrentDateTimeFormatted() string {
now := time.Now()
return fmt.Sprintf("%04d%02d%02d%02d%02d%02d",
now.Year(),
now.Month(),
now.Day(),
now.Hour(),
now.Minute(),
now.Second())
}
func GetUniqueFileName(filePath string) string {
if !FileExist(filePath) {
return filePath
}
ext := filepath.Ext(filePath)
baseName := strings.TrimSuffix(filePath, ext)
count := 1
for {
newFileName := fmt.Sprintf("%s(%d)%s", baseName, count, ext)
if !FileExist(newFileName) {
return newFileName
}
count++
}
}
func OpenFolder(filePath string) error {
var cmd *exec.Cmd
switch sysRuntime.GOOS {
case "darwin":
cmd = exec.Command("open", "-R", filePath)
case "windows":
cmd = exec.Command("explorer", "/select,", filePath)
case "linux":
cmd = exec.Command("nautilus", filePath)
if err := cmd.Start(); err != nil {
cmd = exec.Command("thunar", filePath)
if err := cmd.Start(); err != nil {
cmd = exec.Command("dolphin", filePath)
if err := cmd.Start(); err != nil {
cmd = exec.Command("pcmanfm", filePath)
if err := cmd.Start(); err != nil {
return err
}
}
}
}
default:
return errors.New("unsupported platform")
}
return cmd.Start()
}

View File

@@ -3,6 +3,7 @@ package core
import (
"os"
"path"
"res-downloader/core/shared"
)
type Storage struct {
@@ -18,8 +19,8 @@ func NewStorage(filename string, def []byte) *Storage {
}
func (l *Storage) Load() ([]byte, error) {
if !FileExist(l.fileName) {
err := os.WriteFile(l.fileName, l.def, 0777)
if !shared.FileExist(l.fileName) {
err := os.WriteFile(l.fileName, l.def, 0644)
if err != nil {
return nil, err
}
@@ -33,7 +34,7 @@ func (l *Storage) Load() ([]byte, error) {
}
func (l *Storage) Store(data []byte) error {
if err := os.WriteFile(l.fileName, data, 0777); err != nil {
if err := os.WriteFile(l.fileName, data, 0644); err != nil {
return err
}
return nil

View File

@@ -1,19 +1,27 @@
package core
import (
"fmt"
"os"
"path/filepath"
"time"
)
type SystemSetup struct {
CertFile string
CertFile string
CacheFile string
Password string
aesCipher *AESCipher
}
func initSystem() *SystemSetup {
if systemOnce == nil {
systemOnce = &SystemSetup{
CertFile: filepath.Join(appOnce.UserDir, "cert.crt"),
aesCipher: NewAESCipher("resd48w2d7er95627d447c490a8f02ff"),
CertFile: filepath.Join(appOnce.UserDir, "cert.crt"),
CacheFile: filepath.Join(appOnce.UserDir, "pass.cache"),
}
systemOnce.checkPasswordFile()
}
return systemOnce
}
@@ -24,7 +32,7 @@ func (s *SystemSetup) initCert() ([]byte, error) {
return content, nil
}
if os.IsNotExist(err) {
err = os.WriteFile(s.CertFile, appOnce.PublicCrt, 0777)
err = os.WriteFile(s.CertFile, appOnce.PublicCrt, 0750)
if err != nil {
return nil, err
}
@@ -33,3 +41,43 @@ func (s *SystemSetup) initCert() ([]byte, error) {
return nil, err
}
}
func (s *SystemSetup) SetPassword(password string, isCache bool) {
s.Password = password
if isCache {
encrypted, err := s.aesCipher.Encrypt(password)
if err == nil {
err1 := os.WriteFile(s.CacheFile, []byte(encrypted), 0750)
if err1 != nil {
fmt.Println("Failed to write password: ", err1.Error())
}
} else {
fmt.Println("Failed to Encrypt password: ", err.Error())
}
}
}
func (s *SystemSetup) checkPasswordFile() {
fileInfo, err := os.Stat(s.CacheFile)
if err != nil {
return
}
lastModified := fileInfo.ModTime()
oneMonthAgo := time.Now().AddDate(0, -1, 0)
if lastModified.Before(oneMonthAgo) {
os.Remove(s.CacheFile)
return
}
content, err := os.ReadFile(s.CacheFile)
if err != nil {
return
}
password, err := s.aesCipher.Decrypt(string(content))
if err != nil {
return
}
s.Password = password
}

View File

@@ -9,24 +9,53 @@ import (
"strings"
)
func (s *SystemSetup) runCommand(args []string) ([]byte, error) {
if len(args) == 0 {
return nil, fmt.Errorf("no command provided")
}
var cmd *exec.Cmd
if s.Password != "" {
cmd = exec.Command("sudo", append([]string{"-S"}, args...)...)
cmd.Stdin = bytes.NewReader([]byte(s.Password + "\n"))
} else {
cmd = exec.Command(args[0], args[1:]...)
}
output, err := cmd.CombinedOutput()
return output, err
}
func (s *SystemSetup) getNetworkServices() ([]string, error) {
cmd := exec.Command("networksetup", "-listallnetworkservices")
output, err := cmd.Output()
output, err := s.runCommand([]string{"networksetup", "-listallnetworkservices"})
if err != nil {
return nil, fmt.Errorf("failed to execute command: %v", err)
}
services := strings.Split(string(output), "\n")
var validServices []string
var activeServices []string
for _, service := range services {
service = strings.TrimSpace(service)
if service != "" && !strings.Contains(service, "*") && !strings.Contains(service, "Serial Port") {
validServices = append(validServices, service)
if service == "" || strings.Contains(service, "*") || strings.Contains(service, "Serial Port") {
continue
}
infoOutput, err := s.runCommand([]string{"networksetup", "-getinfo", service})
if err != nil {
fmt.Printf("failed to get info for service %s: %v\n", service, err)
continue
}
if strings.Contains(string(infoOutput), "IP address:") {
activeServices = append(activeServices, service)
}
}
return validServices, nil
if len(activeServices) == 0 {
return nil, fmt.Errorf("no active network services found")
}
return activeServices, nil
}
func (s *SystemSetup) setProxy() error {
@@ -34,29 +63,28 @@ func (s *SystemSetup) setProxy() error {
if err != nil {
return err
}
if len(services) == 0 {
return fmt.Errorf("find to Network failed")
}
is := false
isSuccess := false
var errs strings.Builder
for _, serviceName := range services {
if err := exec.Command("networksetup", "-setwebproxy", serviceName, "127.0.0.1", globalConfig.Port).Run(); err != nil {
fmt.Println(err)
} else {
is = true
commands := [][]string{
{"networksetup", "-setwebproxy", serviceName, "127.0.0.1", globalConfig.Port},
{"networksetup", "-setsecurewebproxy", serviceName, "127.0.0.1", globalConfig.Port},
}
if err := exec.Command("networksetup", "-setsecurewebproxy", serviceName, "127.0.0.1", globalConfig.Port).Run(); err != nil {
fmt.Println(err)
} else {
is = true
for _, cmd := range commands {
if output, err := s.runCommand(cmd); err != nil {
errs.WriteString(fmt.Sprintf("cmd: %v\noutput: %s\nerr: %s\n", cmd, output, err))
} else {
isSuccess = true
}
}
}
if is {
if isSuccess {
return nil
}
return fmt.Errorf("find to Network failed")
return fmt.Errorf("failed to set proxy for any active network service, errs:%s", errs)
}
func (s *SystemSetup) unsetProxy() error {
@@ -64,29 +92,28 @@ func (s *SystemSetup) unsetProxy() error {
if err != nil {
return err
}
if len(services) == 0 {
return fmt.Errorf("find to Network failed")
}
is := false
isSuccess := false
var errs strings.Builder
for _, serviceName := range services {
if err := exec.Command("networksetup", "-setwebproxystate", serviceName, "off").Run(); err != nil {
fmt.Println(err)
} else {
is = true
commands := [][]string{
{"networksetup", "-setwebproxystate", serviceName, "off"},
{"networksetup", "-setsecurewebproxystate", serviceName, "off"},
}
if err := exec.Command("networksetup", "-setsecurewebproxystate", serviceName, "off").Run(); err != nil {
fmt.Println(err)
} else {
is = true
for _, cmd := range commands {
if output, err := s.runCommand(cmd); err != nil {
errs.WriteString(fmt.Sprintf("cmd: %v\noutput: %s\nerr: %s\n", cmd, output, err))
} else {
isSuccess = true
}
}
}
if is {
if isSuccess {
return nil
}
return fmt.Errorf("find to Network failed")
return fmt.Errorf("failed to unset proxy for any active network service, errs:%s", errs)
}
func (s *SystemSetup) installCert() (string, error) {
@@ -94,18 +121,7 @@ func (s *SystemSetup) installCert() (string, error) {
if err != nil {
return "", err
}
getPasswordCmd := exec.Command("osascript", "-e", `tell app "System Events" to display dialog "请输入你的电脑密码,用于安装证书文件:" default answer "" with hidden answer`, "-e", `text returned of result`)
passwordOutput, err := getPasswordCmd.Output()
if err != nil {
return string(passwordOutput), err
}
password := bytes.TrimSpace(passwordOutput)
cmd := exec.Command("sudo", "-S", "security", "add-trusted-cert", "-d", "-r", "trustRoot", "-k", "/Library/Keychains/System.keychain", s.CertFile)
cmd.Stdin = bytes.NewReader(append(password, '\n'))
output, err := cmd.CombinedOutput()
output, err := s.runCommand([]string{"security", "add-trusted-cert", "-d", "-r", "trustRoot", "-k", "/Library/Keychains/System.keychain", s.CertFile})
if err != nil {
return string(output), err
}

View File

@@ -3,10 +3,43 @@
package core
import (
"bytes"
"fmt"
"os"
"os/exec"
"strings"
)
func (s *SystemSetup) getLinuxDistro() (string, error) {
data, err := os.ReadFile("/etc/os-release")
if err != nil {
return "", err
}
for _, line := range strings.Split(string(data), "\n") {
if strings.HasPrefix(line, "ID=") {
return strings.Trim(strings.TrimPrefix(line, "ID="), "\""), nil
}
}
return "", fmt.Errorf("could not determine linux distribution")
}
func (s *SystemSetup) runCommand(args []string, sudo bool) ([]byte, error) {
if len(args) == 0 {
return nil, fmt.Errorf("no command provided")
}
var cmd *exec.Cmd
if s.Password != "" && sudo {
cmd = exec.Command("sudo", append([]string{"-S"}, args...)...)
cmd.Stdin = bytes.NewReader([]byte(s.Password + "\n"))
} else {
cmd = exec.Command(args[0], args[1:]...)
}
output, err := cmd.CombinedOutput()
return output, err
}
func (s *SystemSetup) setProxy() error {
commands := [][]string{
{"gsettings", "set", "org.gnome.system.proxy", "mode", "manual"},
@@ -15,23 +48,32 @@ func (s *SystemSetup) setProxy() error {
{"gsettings", "set", "org.gnome.system.proxy.https", "host", "127.0.0.1"},
{"gsettings", "set", "org.gnome.system.proxy.https", "port", globalConfig.Port},
}
is := false
isSuccess := false
var errs strings.Builder
for _, cmd := range commands {
if err := exec.Command(cmd[0], cmd[1:]...).Run(); err != nil {
fmt.Println(err)
if output, err := s.runCommand(cmd, false); err != nil {
errs.WriteString(fmt.Sprintf("cmd: %v\noutput: %s\nerr: %s\n", cmd, output, err))
} else {
is = true
isSuccess = true
}
}
if is {
if isSuccess {
return nil
}
return fmt.Errorf("Failed to activate proxy")
return fmt.Errorf("failed to set proxy:\n%s", errs.String())
}
func (s *SystemSetup) unsetProxy() error {
cmd := []string{"gsettings", "set", "org.gnome.system.proxy", "mode", "none"}
return exec.Command(cmd[0], cmd[1:]...).Run()
output, err := s.runCommand(cmd, false)
if err != nil {
return fmt.Errorf("failed to unset proxy: %s\noutput: %s", err.Error(), string(output))
}
return nil
}
func (s *SystemSetup) installCert() (string, error) {
@@ -40,35 +82,61 @@ func (s *SystemSetup) installCert() (string, error) {
return "", err
}
actions := [][]string{
{"/usr/local/share/ca-certificates/", "update-ca-certificates"},
{"/usr/share/ca-certificates/trust-source/anchors/", "update-ca-trust"},
{"/usr/share/ca-certificates/trust-source/anchors/", "trust extract-compat"},
{"/etc/pki/ca-trust/source/anchors/", "update-ca-trust"},
{"/etc/ssl/ca-certificates/", "update-ca-certificates"},
distro, err := s.getLinuxDistro()
if err != nil {
return "", fmt.Errorf("detect distro failed: %w", err)
}
is := false
certName := appOnce.AppName + ".crt"
var certPath string
var updateCmd = []string{"update-ca-certificates"}
for _, action := range actions {
dir := action[0]
if err := exec.Command("sudo", "cp", "-f", s.CertFile, dir+appOnce.AppName+".crt").Run(); err != nil {
fmt.Printf("Failed to copy to %s: %v\n", dir, err)
continue
switch distro {
case "deepin":
certDir := "/usr/share/ca-certificates/" + appOnce.AppName
certPath = certDir + "/" + certName
s.runCommand([]string{"mkdir", "-p", certDir}, true)
case "arch":
certPath = "/usr/share/ca-certificates/trust-source/" + certName
updateCmd = []string{"update-ca-trust"}
default:
certPath = "/usr/local/share/ca-certificates/" + certName
}
var outs, errs strings.Builder
isSuccess := false
if output, err := s.runCommand([]string{"cp", "-f", s.CertFile, certPath}, true); err != nil {
errs.WriteString(fmt.Sprintf("copy cert failed: %s\n%s\n", err.Error(), output))
} else {
isSuccess = true
outs.Write(output)
}
if distro == "deepin" {
confPath := "/etc/ca-certificates.conf"
checkCmd := []string{"grep", "-qxF", certName, confPath}
if _, err := s.runCommand(checkCmd, true); err != nil {
echoCmd := []string{"bash", "-c", fmt.Sprintf("echo '%s/%s' >> %s", appOnce.AppName, certName, confPath)}
if output, err := s.runCommand(echoCmd, true); err != nil {
errs.WriteString(fmt.Sprintf("append conf failed: %s\n%s\n", err.Error(), output))
} else {
isSuccess = true
outs.Write(output)
}
}
cmd := action[1]
if err := exec.Command("sudo", cmd).Run(); err != nil {
fmt.Printf("Failed to refresh certificates using %s: %v\n", cmd, err)
continue
}
is = true
}
if !is {
return "", fmt.Errorf("Certificate installation failed")
if output, err := s.runCommand(updateCmd, true); err != nil {
errs.WriteString(fmt.Sprintf("update failed: %s\n%s\n", err.Error(), output))
} else {
isSuccess = true
outs.Write(output)
}
return "", nil
if isSuccess {
return "", nil
}
return outs.String(), fmt.Errorf("certificate installation failed:\n%s", errs.String())
}

View File

@@ -1,19 +1,9 @@
package core
import (
"crypto/md5"
"encoding/hex"
"fmt"
"github.com/wailsapp/wails/v2/pkg/runtime"
"net/url"
"os"
"strings"
"time"
)
func Empty(data interface{}) {
}
func DialogErr(message string) {
_, _ = runtime.MessageDialog(appOnce.ctx, runtime.MessageDialogOptions{
Type: runtime.ErrorDialog,
@@ -22,136 +12,3 @@ func DialogErr(message string) {
DefaultButton: "Cancel",
})
}
func IsDevelopment() bool {
return os.Getenv("APP_ENV") == "development"
}
func FileExist(file string) bool {
_, err := os.Stat(file)
if err != nil {
return false
}
if os.IsNotExist(err) {
return false
}
return true
}
func CreateDirIfNotExist(dir string) error {
if _, err := os.Stat(dir); os.IsNotExist(err) {
return os.MkdirAll(dir, 0777)
}
return nil
}
func TypeSuffix(mime string) (string, string) {
switch strings.ToLower(mime) {
case "image/png",
"image/webp",
"image/jpeg",
"image/jpg",
"image/gif",
"image/avif",
"image/bmp",
"image/tiff",
"image/heic",
"image/x-icon",
"image/svg+xml",
"image/vnd.adobe.photoshop":
return "image", ".png"
case "audio/mpeg",
"audio/wav",
"audio/aiff",
"audio/x-aiff",
"audio/aac",
"audio/ogg",
"audio/flac",
"audio/midi",
"audio/x-midi",
"audio/x-ms-wma",
"audio/opus",
"audio/webm",
"audio/mp4",
"audio/mp3":
return "audio", ".mp3"
case "video/mp4",
"video/webm",
"video/ogg",
"video/x-msvideo",
"video/mpeg",
"video/quicktime",
"video/x-ms-wmv",
"video/3gpp",
"video/x-matroska":
return "video", ".mp4"
case "audio/video",
"video/x-flv":
return "live", ".mp4"
case "application/vnd.apple.mpegurl",
"application/x-mpegurl":
return "m3u8", ".m3u8"
case "application/pdf":
return "pdf", ".pdf"
case "application/vnd.ms-powerpoint",
"application/vnd.openxmlformats-officedocument.presentationml.presentation":
return "ppt", ".ppt"
case "application/vnd.ms-excel",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet":
return "xls", ".xls"
case "application/msword",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document":
return "doc", ".doc"
}
return "", ""
}
func BuildReferer(rawURL string) string {
u, err := url.Parse(rawURL)
if err != nil {
return ""
}
return u.Scheme + "://" + u.Host + "/"
}
func Md5(data string) string {
hashNew := md5.New()
hashNew.Write([]byte(data))
hash := hashNew.Sum(nil)
return hex.EncodeToString(hash)
}
func FormatSize(size float64) string {
if size > 1048576 {
return fmt.Sprintf("%.2fMB", float64(size)/1048576)
}
if size > 1024 {
return fmt.Sprintf("%.2fKB", float64(size)/1024)
}
return fmt.Sprintf("%.0fb", size)
}
func GetTopLevelDomain(rawURL string) string {
parsedURL, err := url.Parse(rawURL)
if err != nil {
return ""
}
host := parsedURL.Hostname()
parts := strings.Split(host, ".")
if len(parts) < 2 {
return ""
}
return strings.Join(parts[len(parts)-2:], ".")
}
func GetCurrentDateTimeFormatted() string {
now := time.Now()
return fmt.Sprintf("%04d%02d%02d%02d%02d%02d",
now.Year(),
now.Month(),
now.Day(),
now.Hour(),
now.Minute(),
now.Second())
}

0
docs/.nojekyll Normal file
View File

12
docs/_coverpage.md Normal file
View File

@@ -0,0 +1,12 @@
<div align="center">
<a href="https://github.com/putyy/res-downloader"><img src="images/logo.png" width="120"/></a>
<h1><strong>res-downloader</strong></h1>
</div>
> 全新技术栈,更新、更小、更快、更稳
### 简单、高效、轻便 (仅 ~10M)
[开始使用 Let Go](/readme.md)
[下载](/getting-started.md)

4
docs/_navbar.md Normal file
View File

@@ -0,0 +1,4 @@
* [论坛](https://s.gowas.cn/d/4089)
* [反馈](https://github.com/putyy/res-downloader/issues)
* [日志](https://github.com/putyy/res-downloader/releases)
* [WX群](https://www.putyy.com/app/admin/upload/img/20250418/6801d9554dc7.webp)

6
docs/_sidebar.md Normal file
View File

@@ -0,0 +1,6 @@
* [简介](readme.md)
* [快速开始](getting-started.md)
* [安装指南](installation.md)
* [功能演示](examples.md)
* [更多说明](more.md)
* [常见问题](troubleshooting.md)

19
docs/examples.md Normal file
View File

@@ -0,0 +1,19 @@
## 开启代理
- 安装完成后开启代理 (最新版本为“开启抓取”),如图:
![](images/examples-1.png ':size=50%')
## 拦截资源
### 视频号
- 打开视频号即可看到本软件中拦截到的资源
![](images/examples-2.webp ':size=50%')
### 网页资源
- 浏览器打开或者其他软件内置浏览器打开的网页
- 这里演示打开百度这个网站https://www.baidu.com/
![](images/examples-4.png ':size=50%')
### 小程序、公众号、抖音、小红书、qq音乐、酷狗等应用内资源获取方式都差不多
## 下载资源到本地
- 选择你想下载的视频 点击下载即可
![](images/examples-3.png ':size=50%')

BIN
docs/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 264 KiB

18
docs/getting-started.md Normal file
View File

@@ -0,0 +1,18 @@
## 软件下载
🆕 [github下载](https://github.com/putyy/res-downloader/releases)
🆕 [蓝奏云下载 密码:9vs5](https://wwjv.lanzoum.com/b04wgtfyb)
!> Win7用户请使用2.3.0版本
## 使用方法
- 安装时一定要同意安装证书文件、一定要允许网络访问
- 打开本软件(win系统首次使用管理员打开-鼠标右键选择管理员打开)
- 打开后,左上角点击 “启动代理”
- 打开要捕获的源, 如:视频号、网页、小程序等等
- 返回软件首页即可看到资源列
!> windows安装先关闭所有安全管家之类的软件安装完成后首次使用需右键管理员打开
!> Mac如果无法拦截 请关闭防火墙
![](images/show.webp ':size=50%')

BIN
docs/images/config.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

BIN
docs/images/examples-1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

BIN
docs/images/examples-2.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

BIN
docs/images/examples-3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

BIN
docs/images/examples-4.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

BIN
docs/images/logo.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

BIN
docs/images/more-1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

BIN
docs/images/more-2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

BIN
docs/images/more-3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

BIN
docs/images/more-4.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

BIN
docs/images/show.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

50
docs/index.html Normal file
View File

@@ -0,0 +1,50 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>res-downloader</title>
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1" />
<meta name="keywords" content="res-downloader,视频号下载,抖音下载,快手下载,小红书下载,万能下载器,爱享素材,爱享素材下载器">
<meta name="description" content="res-downloader是一款集网络资源嗅探 + 高速下载功能于一体的软件,高颜值、高性能和多样化,提供个人用户下载自己上传到各大平台的网络资源功能!">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
<link rel="stylesheet" href="//cdn.jsdelivr.net/npm/docsify@4/lib/themes/vue.css">
</head>
<body>
<div id="app"></div>
<script>
window.$docsify = {
name: 'res-downloader',
repo: 'https://github.com/putyy/res-downloader',
// 侧边栏支持默认加载的是项目根目录下的_sidebar.md文件
loadSidebar: true,
// 导航栏支持默认加载的是项目根目录下的_navbar.md文件
loadNavbar: true,
// 封面支持默认加载的是项目根目录下的_coverpage.md文件
coverpage: true,
// 最大支持渲染的标题层级
maxLevel: 5,
// 自定义侧边栏后默认不会再生成目录设置生成目录的最大层级建议配置为2-4
subMaxLevel: 4,
// 小屏设备下合并导航栏到侧边栏
mergeNavbar: true,
basePath: '/',
homepage: 'readme.md',
search: {
maxAge: 86400000,// 过期时间,单位毫秒,默认一天
paths: 'auto',// 注意:仅适用于 paths: 'auto' 模式
// 支持本地化
placeholder: '搜索',
noData: '找不到结果',
depth: 4,
hideOtherSidebarContent: false,
namespace: 'Docsify-Guide',
}
}
</script>
<script src="//cdn.jsdelivr.net/npm/docsify@4"></script>
<script src="//cdn.jsdelivr.net/npm/docsify/lib/plugins/emoji.min.js"></script>
<script src="//cdn.jsdelivr.net/npm/docsify/lib/plugins/zoom-image.min.js"></script>
<script src="//cdn.jsdelivr.net/npm/docsify/lib/plugins/search.min.js"></script>
<script src="//cdn.jsdelivr.net/npm/docsify-copy-code/dist/docsify-copy-code.min.js"></script>
</body>
</html>

21
docs/installation.md Normal file
View File

@@ -0,0 +1,21 @@
## 下载安装文件
- windows下载.exe结尾的根据自己的系统架构下载合适的安装文件通常下载带有“win_amd64.exe”或“x64-installer.exe”结尾的文件
- Mac下载.dmg结尾即可
- Linux根据系统类型下载对应的执行文件或安装文件
## Windows安装过程
- 双击下载好的exe 正常安装即可,首次打开记得右键管理员运行
## Mac安装过程
- 双击下载好的dmg文件将res-downloader拖入应用即可如图:
![installation-mac-1.png](images/installation-mac-1.png ':size=50%')
## Linux安装过程(自行替换掉对应的安装文件目录)
- ubuntu安装deb文件
> sudo apt install res-downloader_3.0.2_linux_x64.deb
- 执行文件运行方式
> chmod +x ./res-downloader_3.0.2_linux_x64
> sudo ./res-downloader_3.0.2_linux_x64

20
docs/more.md Normal file
View File

@@ -0,0 +1,20 @@
## 清空列表、类型筛选
- 当资源列表过大时,无法快速找到需要的资源,这时可以先清空列表再去刷新需要的资源页面
- 资源列表过多,可以快速根据需要的资源类型进行筛选
![more-4.png](images/more-1.png ':size=30%')
## 拦截想要的资源类型、批量下载
- 比如只需要视频时就选择视频类型,可以多选
![more-1.png](images/more-2.png ':size=30%')
## 批量导出、批量导入使用场景
- 导出resd格式数据将txt文件发送到另外的电脑打开文件复制内容使用批量导入导入到新电脑
## 复制链接、视频解密
- 复制链接可用于第三方软件进行下载,下载完成后对该视频解密,点击“视频解密”选择用其他软件下载完成后的视频文件进行解密
![more-3.png](images/more-3.png ':size=30%')
## 设置说明
!> 修改完成后记得点保存
- 几乎每项配置在软件中都有说明(鼠标悬浮在?处即可查看),此处就不进行多余讲解
![config.png](images/config.png ':size=30%')

63
docs/readme.md Normal file
View File

@@ -0,0 +1,63 @@
<div align="center">
<a href="https://github.com/putyy/res-downloader"><img src="images/logo.png" width="120"/></a>
<h1>res-downloader</h1>
<h4>📖 中文 | <a href="https://github.com/putyy/res-downloader/blob/master/README-EN.md">English</a></h4>
[![GitHub stars](https://img.shields.io/github/stars/putyy/res-downloader)](https://github.com/putyy/res-downloader/stargazers)
[![GitHub forks](https://img.shields.io/github/forks/putyy/res-downloader)](https://github.com/putyy/res-downloader/fork)
[![GitHub release](https://img.shields.io/github/release/putyy/res-downloader)](https://github.com/putyy/res-downloader/releases)
![GitHub All Releases](https://img.shields.io/github/downloads/putyy/res-downloader/total)
[![License](https://img.shields.io/github/license/putyy/res-downloader)](https://github.com/putyy/res-downloader/blob/master/LICENSE)
</div>
---
### 🎉 爱享素材下载器
> 一款基于 Go + [Wails](https://github.com/wailsapp/wails) 的跨平台资源下载工具,简洁易用,支持多种资源嗅探与下载。
## ✨ 功能特色
- 🚀 **简单易用**:操作简单,界面清晰美观
- 🖥️ **多平台支持**Windows / macOS / Linux
- 🌐 **多资源类型支持**:视频 / 音频 / 图片 / m3u8 / 直播流等
- 📱 **平台兼容广泛**支持微信视频号、小程序、抖音、快手、小红书、酷狗音乐、QQ音乐等
- 🌍 **代理抓包**:支持设置代理获取受限网络下的资源
## 📚 文档 & 版本
- 📘 [在线文档](https://res.putyy.com/)
- 💬 [加入交流群](https://www.putyy.com/app/admin/upload/img/20250418/6801d9554dc7.webp)
- 🧩 [最新版](https://github.com/putyy/res-downloader/releases) [Mini版 使用默认浏览器展示UI](https://github.com/putyy/resd-mini) [Electron旧版 支持Win7](https://github.com/putyy/res-downloader/tree/old)
> *群满时可加微信 `AmorousWorld`,请备注“来源”*
## 🧩 下载地址
- 🆕 [GitHub 下载](https://github.com/putyy/res-downloader/releases)
- 🆕 [蓝奏云下载密码9vs5](https://wwjv.lanzoum.com/b04wgtfyb)
- ⚠️ *Win7 用户请下载 `2.3.0` 版本*
## 🖼️ 预览
![预览](images/show.webp)
---
### 🧠 更多问题
- [GitHub Issues](https://github.com/putyy/res-downloader/issues)
- [爱享论坛讨论帖](https://s.gowas.cn/d/4089)
## 💡 实现原理 & 初衷
本工具通过代理方式实现网络抓包,并筛选可用资源。与 Fiddler、Charles、浏览器 DevTools 原理类似,但对资源进行了更友好的筛选、展示和处理,大幅度降低了使用门槛,更适合大众用户使用。
---
## ⚠️ 免责声明
> 本软件仅供学习与研究用途,禁止用于任何商业或违法用途。
如因此产生的任何法律责任,概与作者无关!

78
docs/troubleshooting.md Normal file
View File

@@ -0,0 +1,78 @@
## 视频号拦截了一大堆 找不到想要的
> 设置里面关闭全量拦截,将视频转发好友后打开
## 某某网址拦截不了?
> 本软件并非万能的,所以有一些应用拦截不了很正常,实现原理 & 初衷如下,
```
本工具通过代理方式实现网络抓包,并筛选可用资源。与 Fiddler、Charles、浏览器 DevTools 原理类似,但对资源进行了更友好的筛选、展示和处理,大幅度降低了使用门槛,更适合大众用户使用。
```
## 软件打不开了?之前可以打开
> 删除对应目录, 然后重启
```
## Mac终端执行
rm -rf /Users/$(whoami)/Library/Preferences/res-downloader
## Windows手动删除以下目录Administrator为用户名 通常如下:
C:\Users\Administrator\AppData\Roaming\res-downloader
## Linux手动删除以下目录
/home/user/.config/res-downloader/home/user/.config/res-downloader
```
## 某应用只支持手机打开 如何拦截?
> 这里需要注意的是 应用使用http协议通讯才能拦截且安卓7.0以上系统不再信任用户CA证书 所以没法拦截,解决方案自行查找,
```
1. 将手机和电脑处于同一个网络
2. 在手机端安装res-downloader的证书
3. 将手机网络代理设置为res-downloader的代理
4. 正常使用
```
## Mac 提示“已损坏,无法打开”, 打开命令行执行如下命令:
> sudo xattr -d com.apple.quarantine /Applications/res-downloader.app
## 打开本软件,无法正常拦截获取
> 检查系统证书是否安装
> 关闭网络防火墙
> 系统代理是否正确设置(代理地址127.0.0.1 端口8899)
## 关闭软件后无法正常上网
> 手动关闭系统代理设置
## 链接不是私密链接
> 通常是证书未正确安装,最新版证书下载:软件左下角 ?点击后有下载地址
> 根据自己系统进行安装证书操作(不懂的自行百度),手动安装需安装到受信任的根证书
- Mac手动安装证书(V3+版本支持),打开终端复制以下命令 粘贴到终端回车 按照提示输入密码,完成后再打开软件:
```shell
sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain /Users/$(whoami)/Library/Preferences/res-downloader/cert.crt && touch /Users/$(whoami)/Library/Preferences/res-downloader/install.lock && echo "安装完成"
```
## 拦截不到小程序中的资源
清理微信缓存,删除小程序后,重新打开
> 1.设置->存储空间->缓存
> 2.删除小程序相关缓存目录(自行搜索)
## 只拦截打开的视频号视频
关闭全量拦截,打开视频号视频详情,通常分享好友后打开的页面属于详情页
## 拦截视频号账号视频
打开对应作者个人主页,浏览即可
## 下载慢、大视频下载失败
推荐使用如下工具加速下载,视频号可以下载完成后再到对应视频操作项选择 “视频解密” 按钮
> [Neat Download Manager](https://www.neatdownloadmanager.com/index.php/en/)、[Motrix](https://motrix.app/download)等软件进行下载
## 直播流: 预览和录制:
> [使用obs进行预览和录制 使用教程自行百度, 点击下载obs]( https://obsproject.com/)
## m3u8: 预览和下载:
> [在线下载](https://m3u8-down.gowas.cn/)、[在线预览](https://m3u8play.com/)
## 安装证书后还会提示安装
使用命令行打开本软件,查看 “lockfile:” 这串字符后面的锁文件路径,然后创建该文件即可
例如 mac系统下终端执行如下命令即可创建
> touch /Users/你的用户名/Library/Preferences/res-downloader/install.lock
## 更多问题 请前往github进行[反馈](https://github.com/putyy/res-downloader/issues)

View File

@@ -7,10 +7,15 @@ export {}
/* prettier-ignore */
declare module 'vue' {
export interface GlobalComponents {
Action: typeof import('./src/components/Action.vue')['default']
ActionDesc: typeof import('./src/components/ActionDesc.vue')['default']
Footer: typeof import('./src/components/Footer.vue')['default']
ImportJson: typeof import('./src/components/ImportJson.vue')['default']
Index: typeof import('./src/components/layout/Index.vue')['default']
NaiveProvider: typeof import('./src/components/NaiveProvider.vue')['default']
NButton: typeof import('naive-ui')['NButton']
NButtonGroup: typeof import('naive-ui')['NButtonGroup']
NCheckbox: typeof import('naive-ui')['NCheckbox']
NConfigProvider: typeof import('naive-ui')['NConfigProvider']
NDataTable: typeof import('naive-ui')['NDataTable']
NDialogProvider: typeof import('naive-ui')['NDialogProvider']
@@ -30,18 +35,21 @@ declare module 'vue' {
NModal: typeof import('naive-ui')['NModal']
NModalProvider: typeof import('naive-ui')['NModalProvider']
NNotificationProvider: typeof import('naive-ui')['NNotificationProvider']
NRadio: typeof import('naive-ui')['NRadio']
NPopconfirm: typeof import('naive-ui')['NPopconfirm']
NPopover: typeof import('naive-ui')['NPopover']
NScrollbar: typeof import('naive-ui')['NScrollbar']
NSelect: typeof import('naive-ui')['NSelect']
NSpace: typeof import('naive-ui')['NSpace']
NSwitch: typeof import('naive-ui')['NSwitch']
NTabPane: typeof import('naive-ui')['NTabPane']
NTabs: typeof import('naive-ui')['NTabs']
NTooltip: typeof import('naive-ui')['NTooltip']
Password: typeof import('./src/components/Password.vue')['default']
Preview: typeof import('./src/components/Preview.vue')['default']
ResAction: typeof import('./src/components/ResAction.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
Screen: typeof import('./src/components/Screen.vue')['default']
ShowLoading: typeof import('./src/components/ShowLoading.vue')['default']
ShowOrEdit: typeof import('./src/components/ShowOrEdit.vue')['default']
Sider: typeof import('./src/components/layout/Sider.vue')['default']
}
}

View File

@@ -13,7 +13,9 @@
"flv.js": "^1.6.2",
"naive-ui": "^2.38.2",
"pinia": "^2.1.7",
"video.js": "^8.22.0",
"vue": "^3.2.37",
"vue-i18n": "^11.1.3",
"vue-router": "^4.3.3"
},
"devDependencies": {
@@ -23,14 +25,12 @@
"@vitejs/plugin-vue": "^3.0.3",
"autoprefixer": "^10.4.19",
"postcss": "^8.4.38",
"pug": "^3.0.3",
"sass": "^1.77.6",
"sass-loader": "^14.2.1",
"tailwindcss": "^3.4.4",
"typescript": "^4.6.4",
"unplugin-auto-import": "^0.18.3",
"unplugin-vue-components": "^0.27.4",
"video.js": "^8.19.1",
"vite": "^3.0.7",
"vue-tsc": "^1.8.27"
}
@@ -95,7 +95,6 @@
"version": "7.26.0",
"resolved": "https://registry.npmmirror.com/@babel/runtime/-/runtime-7.26.0.tgz",
"integrity": "sha512-FDSOghenHTiToteC/QRlv2q3DhPZ/oOXTBoirfWNx1Cx3TMVcGWQtMMmQcSvb/JjpNeGzx8Pq/b4fKEJuWm1sw==",
"dev": true,
"license": "MIT",
"dependencies": {
"regenerator-runtime": "^0.14.0"
@@ -198,6 +197,50 @@
"vue": ">=3"
}
},
"node_modules/@intlify/core-base": {
"version": "11.1.3",
"resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-11.1.3.tgz",
"integrity": "sha512-cMuHunYO7LE80azTitcvEbs1KJmtd6g7I5pxlApV3Jo547zdO3h31/0uXpqHc+Y3RKt1wo2y68RGSx77Z1klyA==",
"license": "MIT",
"dependencies": {
"@intlify/message-compiler": "11.1.3",
"@intlify/shared": "11.1.3"
},
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://github.com/sponsors/kazupon"
}
},
"node_modules/@intlify/message-compiler": {
"version": "11.1.3",
"resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-11.1.3.tgz",
"integrity": "sha512-7rbqqpo2f5+tIcwZTAG/Ooy9C8NDVwfDkvSeDPWUPQW+Dyzfw2o9H103N5lKBxO7wxX9dgCDjQ8Umz73uYw3hw==",
"license": "MIT",
"dependencies": {
"@intlify/shared": "11.1.3",
"source-map-js": "^1.0.2"
},
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://github.com/sponsors/kazupon"
}
},
"node_modules/@intlify/shared": {
"version": "11.1.3",
"resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-11.1.3.tgz",
"integrity": "sha512-pTFBgqa/99JRA2H1qfyqv97MKWJrYngXBA/I0elZcYxvJgcCw3mApAoPW3mJ7vx3j+Ti0FyKUFZ4hWxdjKaxvA==",
"license": "MIT",
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://github.com/sponsors/kazupon"
}
},
"node_modules/@isaacs/cliui": {
"version": "8.0.2",
"resolved": "https://registry.npmmirror.com/@isaacs/cliui/-/cliui-8.0.2.tgz",
@@ -714,10 +757,9 @@
"license": "MIT"
},
"node_modules/@videojs/http-streaming": {
"version": "3.16.2",
"resolved": "https://registry.npmmirror.com/@videojs/http-streaming/-/http-streaming-3.16.2.tgz",
"integrity": "sha512-fvt4ko7FknxiT9FnjyNQt6q2px+awrkM+Orv7IB/4gldvj94u4fowGfmNHynnvNTPgPkdxHklGmFLGfclYw8HA==",
"dev": true,
"version": "3.17.0",
"resolved": "https://registry.npmjs.org/@videojs/http-streaming/-/http-streaming-3.17.0.tgz",
"integrity": "sha512-Ch1P3tvvIEezeZXyK11UfWgp4cWKX4vIhZ30baN/lRinqdbakZ5hiAI3pGjRy3d+q/Epyc8Csz5xMdKNNGYpcw==",
"license": "Apache-2.0",
"dependencies": {
"@babel/runtime": "^7.12.5",
@@ -739,9 +781,8 @@
},
"node_modules/@videojs/vhs-utils": {
"version": "4.1.1",
"resolved": "https://registry.npmmirror.com/@videojs/vhs-utils/-/vhs-utils-4.1.1.tgz",
"resolved": "https://registry.npmjs.org/@videojs/vhs-utils/-/vhs-utils-4.1.1.tgz",
"integrity": "sha512-5iLX6sR2ownbv4Mtejw6Ax+naosGvoT9kY+gcuHzANyUZZ+4NpeNdKMUhb6ag0acYej1Y7cmr/F2+4PrggMiVA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.12.5",
@@ -756,7 +797,6 @@
"version": "2.7.0",
"resolved": "https://registry.npmmirror.com/@videojs/xhr/-/xhr-2.7.0.tgz",
"integrity": "sha512-giab+EVRanChIupZK7gXjHy90y3nncA2phIOyG3Ne5fvpiMJzvqYwiTOnEVW2S4CoYcuKJkomat7bMXA/UoUZQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.5.5",
@@ -943,32 +983,17 @@
},
"node_modules/@xmldom/xmldom": {
"version": "0.8.10",
"resolved": "https://registry.npmmirror.com/@xmldom/xmldom/-/xmldom-0.8.10.tgz",
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.10.tgz",
"integrity": "sha512-2WALfTl4xo2SkGCYRt6rDTFfk9R1czmBvUQy12gK2KuRKIpWEhcbbzy8EZXtz/jkRqHX8bFEc6FC1HjX4TUWYw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/acorn": {
"version": "7.4.1",
"resolved": "https://registry.npmmirror.com/acorn/-/acorn-7.4.1.tgz",
"integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==",
"dev": true,
"license": "MIT",
"bin": {
"acorn": "bin/acorn"
},
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/aes-decrypter": {
"version": "4.0.2",
"resolved": "https://registry.npmmirror.com/aes-decrypter/-/aes-decrypter-4.0.2.tgz",
"resolved": "https://registry.npmjs.org/aes-decrypter/-/aes-decrypter-4.0.2.tgz",
"integrity": "sha512-lc+/9s6iJvuaRe5qDlMTpCFjnwpkeOXp8qP3oiZ5jsj1MRg+SBVUmmICrhxHvc8OELSmc+fEyyxAuppY6hrWzw==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@babel/runtime": "^7.12.5",
@@ -1031,20 +1056,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/asap": {
"version": "2.0.6",
"resolved": "https://registry.npmmirror.com/asap/-/asap-2.0.6.tgz",
"integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==",
"dev": true,
"license": "MIT"
},
"node_modules/assert-never": {
"version": "1.4.0",
"resolved": "https://registry.npmmirror.com/assert-never/-/assert-never-1.4.0.tgz",
"integrity": "sha512-5oJg84os6NMQNl27T9LnZkvvqzvAnHu03ShCnoj6bsJwS7L8AO4lf+C/XjK/nvzEqQB744moC6V128RucQd1jA==",
"dev": true,
"license": "MIT"
},
"node_modules/async-validator": {
"version": "4.2.5",
"resolved": "https://registry.npmmirror.com/async-validator/-/async-validator-4.2.5.tgz",
@@ -1106,19 +1117,6 @@
"proxy-from-env": "^1.1.0"
}
},
"node_modules/babel-walk": {
"version": "3.0.0-canary-5",
"resolved": "https://registry.npmmirror.com/babel-walk/-/babel-walk-3.0.0-canary-5.tgz",
"integrity": "sha512-GAwkz0AihzY5bkwIY5QDR+LvsRQgB/B+1foMPvi0FZPMl5fjD7ICiznUiBdLYMH1QYe6vqu4gWYytZOccLouFw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/types": "^7.9.6"
},
"engines": {
"node": ">= 10.0.0"
}
},
"node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmmirror.com/balanced-match/-/balanced-match-1.0.2.tgz",
@@ -1195,37 +1193,6 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
}
},
"node_modules/call-bind-apply-helpers": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.1.tgz",
"integrity": "sha512-BhYE+WDaywFg2TBWYNXAE+8B1ATnThNBqXHP5nQu0jWJdVvY2hvkpyB3qOmtmDePiS5/BDQ8wASEWGMWRG148g==",
"dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/call-bound": {
"version": "1.0.3",
"resolved": "https://registry.npmmirror.com/call-bound/-/call-bound-1.0.3.tgz",
"integrity": "sha512-YTd+6wGlNlPxSuri7Y6X8tY2dmm12UMH66RpKMhiX6rsk5wXXnYgbUcOt8kiS31/AjfoTOvCsE+w8nZQLQnzHA==",
"dev": true,
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
"get-intrinsic": "^1.2.6"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/camelcase-css": {
"version": "2.0.1",
"resolved": "https://registry.npmmirror.com/camelcase-css/-/camelcase-css-2.0.1.tgz",
@@ -1257,16 +1224,6 @@
],
"license": "CC-BY-4.0"
},
"node_modules/character-parser": {
"version": "2.2.0",
"resolved": "https://registry.npmmirror.com/character-parser/-/character-parser-2.2.0.tgz",
"integrity": "sha512-+UqJQjFEFaTAs3bNsF2j2kEN1baG/zghZbdqoYEDxGZtJo9LBzl1A+m0D4n3qKx8N2FNv8/Xp6yV9mQmBuptaw==",
"dev": true,
"license": "MIT",
"dependencies": {
"is-regex": "^1.0.3"
}
},
"node_modules/chokidar": {
"version": "4.0.3",
"resolved": "https://registry.npmmirror.com/chokidar/-/chokidar-4.0.3.tgz",
@@ -1339,17 +1296,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/constantinople": {
"version": "4.0.1",
"resolved": "https://registry.npmmirror.com/constantinople/-/constantinople-4.0.1.tgz",
"integrity": "sha512-vCrqcSIq4//Gx74TXXCGnHpulY1dskqLTFGDmhrGxzeXL8lF8kvXv6mpNWlJj1uD4DW23D4ljAqbY4RRaaUZIw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.6.0",
"@babel/types": "^7.6.1"
}
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmmirror.com/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -1481,33 +1427,10 @@
"dev": true,
"license": "MIT"
},
"node_modules/doctypes": {
"version": "1.1.0",
"resolved": "https://registry.npmmirror.com/doctypes/-/doctypes-1.1.0.tgz",
"integrity": "sha512-LLBi6pEqS6Do3EKQ3J0NqHWV5hhb78Pi8vvESYwyOy2c31ZEZVdtitdzsQsKb7878PEERhzUk0ftqGhG6Mz+pQ==",
"dev": true,
"license": "MIT"
},
"node_modules/dom-walk": {
"version": "0.1.2",
"resolved": "https://registry.npmmirror.com/dom-walk/-/dom-walk-0.1.2.tgz",
"integrity": "sha512-6QvTW9mrGeIegrFXdtQi9pk7O/nSK6lSdXW2eqUspN5LWD7UTji2Fqw5V2YLjBpHEoU9Xl/eUWNpDeZvoyOv2w==",
"dev": true
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"dev": true,
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
"es-errors": "^1.3.0",
"gopd": "^1.2.0"
},
"engines": {
"node": ">= 0.4"
}
"integrity": "sha512-6QvTW9mrGeIegrFXdtQi9pk7O/nSK6lSdXW2eqUspN5LWD7UTji2Fqw5V2YLjBpHEoU9Xl/eUWNpDeZvoyOv2w=="
},
"node_modules/eastasianwidth": {
"version": "0.2.0",
@@ -1542,39 +1465,6 @@
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-errors": {
"version": "1.3.0",
"resolved": "https://registry.npmmirror.com/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-object-atoms": {
"version": "1.0.0",
"resolved": "https://registry.npmmirror.com/es-object-atoms/-/es-object-atoms-1.0.0.tgz",
"integrity": "sha512-MZ4iQ6JwHOBQjahnjwaC1ZtIBH+2ohjamzAO3oaHcXYup7qxjF2fixyH+Q71voWHeOkI2q/TnJao/KfXYIZWbw==",
"dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es6-promise": {
"version": "4.2.8",
"resolved": "https://registry.npmmirror.com/es6-promise/-/es6-promise-4.2.8.tgz",
@@ -2147,31 +2037,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-intrinsic": {
"version": "1.2.6",
"resolved": "https://registry.npmmirror.com/get-intrinsic/-/get-intrinsic-1.2.6.tgz",
"integrity": "sha512-qxsEs+9A+u85HhllWJJFicJfPDhRmjzoYdl64aMWW9yRIJmSyxdn8IEkuIM530/7T+lv0TIHd8L6Q/ra0tEoeA==",
"dev": true,
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
"dunder-proto": "^1.0.0",
"es-define-property": "^1.0.1",
"es-errors": "^1.3.0",
"es-object-atoms": "^1.0.0",
"function-bind": "^1.1.2",
"gopd": "^1.2.0",
"has-symbols": "^1.1.0",
"hasown": "^2.0.2",
"math-intrinsics": "^1.0.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/glob": {
"version": "10.4.5",
"resolved": "https://registry.npmmirror.com/glob/-/glob-10.4.5.tgz",
@@ -2210,55 +2075,12 @@
"version": "4.4.0",
"resolved": "https://registry.npmmirror.com/global/-/global-4.4.0.tgz",
"integrity": "sha512-wv/LAoHdRE3BeTGz53FAamhGlPLhlssK45usmGFThIi4XqnBmjKQ16u+RNbP7WvigRZDxUsM0J3gcQ5yicaL0w==",
"dev": true,
"license": "MIT",
"dependencies": {
"min-document": "^2.19.0",
"process": "^0.11.10"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmmirror.com/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmmirror.com/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-tostringtag": {
"version": "1.0.2",
"resolved": "https://registry.npmmirror.com/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
"dev": true,
"license": "MIT",
"dependencies": {
"has-symbols": "^1.0.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": {
"version": "2.0.2",
"resolved": "https://registry.npmmirror.com/hasown/-/hasown-2.0.2.tgz",
@@ -2327,17 +2149,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-expression": {
"version": "4.0.0",
"resolved": "https://registry.npmmirror.com/is-expression/-/is-expression-4.0.0.tgz",
"integrity": "sha512-zMIXX63sxzG3XrkHkrAPvm/OVZVSCPNkwMHU8oTX7/U3AL78I0QXCEICXUM13BIa8TYGZ68PiTKfQz3yaTNr4A==",
"dev": true,
"license": "MIT",
"dependencies": {
"acorn": "^7.1.1",
"object-assign": "^4.1.1"
}
},
"node_modules/is-extglob": {
"version": "2.1.1",
"resolved": "https://registry.npmmirror.com/is-extglob/-/is-extglob-2.1.1.tgz",
@@ -2362,7 +2173,6 @@
"version": "1.0.2",
"resolved": "https://registry.npmmirror.com/is-function/-/is-function-1.0.2.tgz",
"integrity": "sha512-lw7DUp0aWXYg+CBCN+JKkcE0Q2RayZnSvnZBlwgxHBQhqt5pZNVy4Ri7H9GmmXkdu7LUthszM+Tor1u/2iBcpQ==",
"dev": true,
"license": "MIT"
},
"node_modules/is-glob": {
@@ -2388,32 +2198,6 @@
"node": ">=0.12.0"
}
},
"node_modules/is-promise": {
"version": "2.2.2",
"resolved": "https://registry.npmmirror.com/is-promise/-/is-promise-2.2.2.tgz",
"integrity": "sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ==",
"dev": true,
"license": "MIT"
},
"node_modules/is-regex": {
"version": "1.2.1",
"resolved": "https://registry.npmmirror.com/is-regex/-/is-regex-1.2.1.tgz",
"integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==",
"dev": true,
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"gopd": "^1.2.0",
"has-tostringtag": "^1.0.2",
"hasown": "^2.0.2"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmmirror.com/isexe/-/isexe-2.0.0.tgz",
@@ -2447,13 +2231,6 @@
"jiti": "bin/jiti.js"
}
},
"node_modules/js-stringify": {
"version": "1.0.2",
"resolved": "https://registry.npmmirror.com/js-stringify/-/js-stringify-1.0.2.tgz",
"integrity": "sha512-rtS5ATOo2Q5k1G+DADISilDA6lv79zIiwFd6CcjuIxGKLFm5C+RLImRscVap9k55i+MOZwgliw+NejvkLuGD5g==",
"dev": true,
"license": "MIT"
},
"node_modules/js-tokens": {
"version": "9.0.1",
"resolved": "https://registry.npmmirror.com/js-tokens/-/js-tokens-9.0.1.tgz",
@@ -2461,17 +2238,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/jstransformer": {
"version": "1.0.0",
"resolved": "https://registry.npmmirror.com/jstransformer/-/jstransformer-1.0.0.tgz",
"integrity": "sha512-C9YK3Rf8q6VAPDCCU9fnqo3mAfOH6vUGnMcP4AQAYIEpWtfGLpwOTmZ+igtdK5y+VvI2n3CyYSzy4Qh34eq24A==",
"dev": true,
"license": "MIT",
"dependencies": {
"is-promise": "^2.0.0",
"promise": "^7.0.1"
}
},
"node_modules/lilconfig": {
"version": "3.1.3",
"resolved": "https://registry.npmmirror.com/lilconfig/-/lilconfig-3.1.3.tgz",
@@ -2530,9 +2296,8 @@
},
"node_modules/m3u8-parser": {
"version": "7.2.0",
"resolved": "https://registry.npmmirror.com/m3u8-parser/-/m3u8-parser-7.2.0.tgz",
"resolved": "https://registry.npmjs.org/m3u8-parser/-/m3u8-parser-7.2.0.tgz",
"integrity": "sha512-CRatFqpjVtMiMaKXxNvuI3I++vUumIXVVT/JpCpdU/FynV/ceVw1qpPyyBNindL+JlPMSesx+WX1QJaZEJSaMQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@babel/runtime": "^7.12.5",
@@ -2549,16 +2314,6 @@
"@jridgewell/sourcemap-codec": "^1.5.0"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/merge2": {
"version": "1.4.1",
"resolved": "https://registry.npmmirror.com/merge2/-/merge2-1.4.1.tgz",
@@ -2608,7 +2363,6 @@
"version": "2.19.0",
"resolved": "https://registry.npmmirror.com/min-document/-/min-document-2.19.0.tgz",
"integrity": "sha512-9Wy1B3m3f66bPPmU5hdA4DR4PB2OfDU/+GS3yAB7IQozE3tqXaVv2zOjgla7MEGSRv95+ILmOuvhLkOK6wJtCQ==",
"dev": true,
"dependencies": {
"dom-walk": "^0.1.0"
}
@@ -2667,9 +2421,8 @@
},
"node_modules/mpd-parser": {
"version": "1.3.1",
"resolved": "https://registry.npmmirror.com/mpd-parser/-/mpd-parser-1.3.1.tgz",
"resolved": "https://registry.npmjs.org/mpd-parser/-/mpd-parser-1.3.1.tgz",
"integrity": "sha512-1FuyEWI5k2HcmhS1HkKnUAQV7yFPfXPht2DnRRGtoiiAAW+ESTbtEXIDpRkwdU+XyrQuwrIym7UkoPKsZ0SyFw==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@babel/runtime": "^7.12.5",
@@ -2697,9 +2450,8 @@
},
"node_modules/mux.js": {
"version": "7.1.0",
"resolved": "https://registry.npmmirror.com/mux.js/-/mux.js-7.1.0.tgz",
"resolved": "https://registry.npmjs.org/mux.js/-/mux.js-7.1.0.tgz",
"integrity": "sha512-NTxawK/BBELJrYsZThEulyUMDVlLizKdxyAsMuzoCD1eFj97BVaA8D/CvKsKu6FOLYkFojN5CbM9h++ZTZtknA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@babel/runtime": "^7.11.2",
@@ -2953,9 +2705,8 @@
},
"node_modules/pkcs7": {
"version": "1.0.4",
"resolved": "https://registry.npmmirror.com/pkcs7/-/pkcs7-1.0.4.tgz",
"resolved": "https://registry.npmjs.org/pkcs7/-/pkcs7-1.0.4.tgz",
"integrity": "sha512-afRERtHn54AlwaF2/+LFszyAANTCggGilmcmILUzEjvs3XgFZT+xE6+QWQcAGmu4xajy+Xtj7acLOPdx5/eXWQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@babel/runtime": "^7.5.5"
@@ -3129,164 +2880,17 @@
"version": "0.11.10",
"resolved": "https://registry.npmmirror.com/process/-/process-0.11.10.tgz",
"integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.6.0"
}
},
"node_modules/promise": {
"version": "7.3.1",
"resolved": "https://registry.npmmirror.com/promise/-/promise-7.3.1.tgz",
"integrity": "sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg==",
"dev": true,
"license": "MIT",
"dependencies": {
"asap": "~2.0.3"
}
},
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmmirror.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
"license": "MIT"
},
"node_modules/pug": {
"version": "3.0.3",
"resolved": "https://registry.npmmirror.com/pug/-/pug-3.0.3.tgz",
"integrity": "sha512-uBi6kmc9f3SZ3PXxqcHiUZLmIXgfgWooKWXcwSGwQd2Zi5Rb0bT14+8CJjJgI8AB+nndLaNgHGrcc6bPIB665g==",
"dev": true,
"license": "MIT",
"dependencies": {
"pug-code-gen": "^3.0.3",
"pug-filters": "^4.0.0",
"pug-lexer": "^5.0.1",
"pug-linker": "^4.0.0",
"pug-load": "^3.0.0",
"pug-parser": "^6.0.0",
"pug-runtime": "^3.0.1",
"pug-strip-comments": "^2.0.0"
}
},
"node_modules/pug-attrs": {
"version": "3.0.0",
"resolved": "https://registry.npmmirror.com/pug-attrs/-/pug-attrs-3.0.0.tgz",
"integrity": "sha512-azINV9dUtzPMFQktvTXciNAfAuVh/L/JCl0vtPCwvOA21uZrC08K/UnmrL+SXGEVc1FwzjW62+xw5S/uaLj6cA==",
"dev": true,
"license": "MIT",
"dependencies": {
"constantinople": "^4.0.1",
"js-stringify": "^1.0.2",
"pug-runtime": "^3.0.0"
}
},
"node_modules/pug-code-gen": {
"version": "3.0.3",
"resolved": "https://registry.npmmirror.com/pug-code-gen/-/pug-code-gen-3.0.3.tgz",
"integrity": "sha512-cYQg0JW0w32Ux+XTeZnBEeuWrAY7/HNE6TWnhiHGnnRYlCgyAUPoyh9KzCMa9WhcJlJ1AtQqpEYHc+vbCzA+Aw==",
"dev": true,
"license": "MIT",
"dependencies": {
"constantinople": "^4.0.1",
"doctypes": "^1.1.0",
"js-stringify": "^1.0.2",
"pug-attrs": "^3.0.0",
"pug-error": "^2.1.0",
"pug-runtime": "^3.0.1",
"void-elements": "^3.1.0",
"with": "^7.0.0"
}
},
"node_modules/pug-error": {
"version": "2.1.0",
"resolved": "https://registry.npmmirror.com/pug-error/-/pug-error-2.1.0.tgz",
"integrity": "sha512-lv7sU9e5Jk8IeUheHata6/UThZ7RK2jnaaNztxfPYUY+VxZyk/ePVaNZ/vwmH8WqGvDz3LrNYt/+gA55NDg6Pg==",
"dev": true,
"license": "MIT"
},
"node_modules/pug-filters": {
"version": "4.0.0",
"resolved": "https://registry.npmmirror.com/pug-filters/-/pug-filters-4.0.0.tgz",
"integrity": "sha512-yeNFtq5Yxmfz0f9z2rMXGw/8/4i1cCFecw/Q7+D0V2DdtII5UvqE12VaZ2AY7ri6o5RNXiweGH79OCq+2RQU4A==",
"dev": true,
"license": "MIT",
"dependencies": {
"constantinople": "^4.0.1",
"jstransformer": "1.0.0",
"pug-error": "^2.0.0",
"pug-walk": "^2.0.0",
"resolve": "^1.15.1"
}
},
"node_modules/pug-lexer": {
"version": "5.0.1",
"resolved": "https://registry.npmmirror.com/pug-lexer/-/pug-lexer-5.0.1.tgz",
"integrity": "sha512-0I6C62+keXlZPZkOJeVam9aBLVP2EnbeDw3An+k0/QlqdwH6rv8284nko14Na7c0TtqtogfWXcRoFE4O4Ff20w==",
"dev": true,
"license": "MIT",
"dependencies": {
"character-parser": "^2.2.0",
"is-expression": "^4.0.0",
"pug-error": "^2.0.0"
}
},
"node_modules/pug-linker": {
"version": "4.0.0",
"resolved": "https://registry.npmmirror.com/pug-linker/-/pug-linker-4.0.0.tgz",
"integrity": "sha512-gjD1yzp0yxbQqnzBAdlhbgoJL5qIFJw78juN1NpTLt/mfPJ5VgC4BvkoD3G23qKzJtIIXBbcCt6FioLSFLOHdw==",
"dev": true,
"license": "MIT",
"dependencies": {
"pug-error": "^2.0.0",
"pug-walk": "^2.0.0"
}
},
"node_modules/pug-load": {
"version": "3.0.0",
"resolved": "https://registry.npmmirror.com/pug-load/-/pug-load-3.0.0.tgz",
"integrity": "sha512-OCjTEnhLWZBvS4zni/WUMjH2YSUosnsmjGBB1An7CsKQarYSWQ0GCVyd4eQPMFJqZ8w9xgs01QdiZXKVjk92EQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"object-assign": "^4.1.1",
"pug-walk": "^2.0.0"
}
},
"node_modules/pug-parser": {
"version": "6.0.0",
"resolved": "https://registry.npmmirror.com/pug-parser/-/pug-parser-6.0.0.tgz",
"integrity": "sha512-ukiYM/9cH6Cml+AOl5kETtM9NR3WulyVP2y4HOU45DyMim1IeP/OOiyEWRr6qk5I5klpsBnbuHpwKmTx6WURnw==",
"dev": true,
"license": "MIT",
"dependencies": {
"pug-error": "^2.0.0",
"token-stream": "1.0.0"
}
},
"node_modules/pug-runtime": {
"version": "3.0.1",
"resolved": "https://registry.npmmirror.com/pug-runtime/-/pug-runtime-3.0.1.tgz",
"integrity": "sha512-L50zbvrQ35TkpHwv0G6aLSuueDRwc/97XdY8kL3tOT0FmhgG7UypU3VztfV/LATAvmUfYi4wNxSajhSAeNN+Kg==",
"dev": true,
"license": "MIT"
},
"node_modules/pug-strip-comments": {
"version": "2.0.0",
"resolved": "https://registry.npmmirror.com/pug-strip-comments/-/pug-strip-comments-2.0.0.tgz",
"integrity": "sha512-zo8DsDpH7eTkPHCXFeAk1xZXJbyoTfdPlNR0bK7rpOMuhBYb0f5qUVCO1xlsitYd3w5FQTK7zpNVKb3rZoUrrQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"pug-error": "^2.0.0"
}
},
"node_modules/pug-walk": {
"version": "2.0.0",
"resolved": "https://registry.npmmirror.com/pug-walk/-/pug-walk-2.0.0.tgz",
"integrity": "sha512-yYELe9Q5q9IQhuvqsZNwA5hfPkMJ8u92bQLIMcsMxf/VADjNtEYptU+inlufAFYcWdHlwNfZOEnOOQrZrcyJCQ==",
"dev": true,
"license": "MIT"
},
"node_modules/queue-microtask": {
"version": "1.2.3",
"resolved": "https://registry.npmmirror.com/queue-microtask/-/queue-microtask-1.2.3.tgz",
@@ -3336,7 +2940,6 @@
"version": "0.14.1",
"resolved": "https://registry.npmmirror.com/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz",
"integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==",
"dev": true,
"license": "MIT"
},
"node_modules/resolve": {
@@ -3822,13 +3425,6 @@
"node": ">=8.0"
}
},
"node_modules/token-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmmirror.com/token-stream/-/token-stream-1.0.0.tgz",
"integrity": "sha512-VSsyNPPW74RpHwR8Fc21uubwHY7wMDeJLys2IX5zJNih+OnAnaifKHo+1LHT7DAdloQ7apeaaWg8l7qnf/TnEg==",
"dev": true,
"license": "MIT"
},
"node_modules/treemate": {
"version": "0.3.11",
"resolved": "https://registry.npmmirror.com/treemate/-/treemate-0.3.11.tgz",
@@ -4131,14 +3727,13 @@
}
},
"node_modules/video.js": {
"version": "8.21.0",
"resolved": "https://registry.npmmirror.com/video.js/-/video.js-8.21.0.tgz",
"integrity": "sha512-zcwerRb257QAuWfi8NH9yEX7vrGKFthjfcONmOQ4lxFRpDAbAi+u5LAjCjMWqhJda6zEmxkgdDpOMW3Y21QpXA==",
"dev": true,
"version": "8.22.0",
"resolved": "https://registry.npmjs.org/video.js/-/video.js-8.22.0.tgz",
"integrity": "sha512-xge2kpjsvC0zgFJ1cqt+wTqsi21+huFswlonPFh7qiplypsb4FN/D2Rz6bWdG/S9eQaPHfWHsarmJL/7D3DHoA==",
"license": "Apache-2.0",
"dependencies": {
"@babel/runtime": "^7.12.5",
"@videojs/http-streaming": "^3.16.2",
"@videojs/http-streaming": "^3.17.0",
"@videojs/vhs-utils": "^4.1.1",
"@videojs/xhr": "2.7.0",
"aes-decrypter": "^4.0.2",
@@ -4155,7 +3750,6 @@
"version": "4.1.0",
"resolved": "https://registry.npmmirror.com/videojs-contrib-quality-levels/-/videojs-contrib-quality-levels-4.1.0.tgz",
"integrity": "sha512-TfrXJJg1Bv4t6TOCMEVMwF/CoS8iENYsWNKip8zfhB5kTcegiFYezEA0eHAJPU64ZC8NQbxQgOwAsYU8VXbOWA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"global": "^4.4.0"
@@ -4172,14 +3766,12 @@
"version": "4.2.0",
"resolved": "https://registry.npmmirror.com/videojs-font/-/videojs-font-4.2.0.tgz",
"integrity": "sha512-YPq+wiKoGy2/M7ccjmlvwi58z2xsykkkfNMyIg4xb7EZQQNwB71hcSsB3o75CqQV7/y5lXkXhI/rsGAS7jfEmQ==",
"dev": true,
"license": "Apache-2.0"
},
"node_modules/videojs-vtt.js": {
"version": "0.15.5",
"resolved": "https://registry.npmmirror.com/videojs-vtt.js/-/videojs-vtt.js-0.15.5.tgz",
"integrity": "sha512-yZbBxvA7QMYn15Lr/ZfhhLPrNpI/RmCSCqgIff57GC2gIrV5YfyzLfLyZMj0NnZSAz8syB4N0nHXpZg9MyrMOQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"global": "^4.3.1"
@@ -4235,16 +3827,6 @@
}
}
},
"node_modules/void-elements": {
"version": "3.1.0",
"resolved": "https://registry.npmmirror.com/void-elements/-/void-elements-3.1.0.tgz",
"integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/vooks": {
"version": "0.2.12",
"resolved": "https://registry.npmmirror.com/vooks/-/vooks-0.2.12.tgz",
@@ -4304,6 +3886,26 @@
}
}
},
"node_modules/vue-i18n": {
"version": "11.1.3",
"resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-11.1.3.tgz",
"integrity": "sha512-Pcylh9z9S5+CJAqgbRZ3EKxFIBIrtY5YUppU722GIT65+Nukm0TCqiQegZnNLCZkXGthxe0cpqj0AoM51H+6Gw==",
"license": "MIT",
"dependencies": {
"@intlify/core-base": "11.1.3",
"@intlify/shared": "11.1.3",
"@vue/devtools-api": "^6.5.0"
},
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://github.com/sponsors/kazupon"
},
"peerDependencies": {
"vue": "^3.0.0"
}
},
"node_modules/vue-router": {
"version": "4.5.0",
"resolved": "https://registry.npmmirror.com/vue-router/-/vue-router-4.5.0.tgz",
@@ -4395,22 +3997,6 @@
"node": ">= 8"
}
},
"node_modules/with": {
"version": "7.0.2",
"resolved": "https://registry.npmmirror.com/with/-/with-7.0.2.tgz",
"integrity": "sha512-RNGKj82nUPg3g5ygxkQl0R937xLyho1J24ItRCBTr/m1YnZkzJy1hUiHUJrc/VlsDQzsCnInEGSg3bci0Lmd4w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.9.6",
"@babel/types": "^7.9.6",
"assert-never": "^1.2.1",
"babel-walk": "3.0.0-canary-5"
},
"engines": {
"node": ">= 10.0.0"
}
},
"node_modules/wrap-ansi": {
"version": "8.1.0",
"resolved": "https://registry.npmmirror.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz",

View File

@@ -14,7 +14,9 @@
"flv.js": "^1.6.2",
"naive-ui": "^2.38.2",
"pinia": "^2.1.7",
"video.js": "^8.22.0",
"vue": "^3.2.37",
"vue-i18n": "^11.1.3",
"vue-router": "^4.3.3"
},
"devDependencies": {
@@ -24,14 +26,12 @@
"@vitejs/plugin-vue": "^3.0.3",
"autoprefixer": "^10.4.19",
"postcss": "^8.4.38",
"pug": "^3.0.3",
"sass": "^1.77.6",
"sass-loader": "^14.2.1",
"tailwindcss": "^3.4.4",
"typescript": "^4.6.4",
"unplugin-auto-import": "^0.18.3",
"unplugin-vue-components": "^0.27.4",
"video.js": "^8.19.1",
"vite": "^3.0.7",
"vue-tsc": "^1.8.27"
}

View File

@@ -1 +1 @@
4b201370b63f9befd97a0e875507a444
853e4a476a4f41b58875469cfb541133

View File

@@ -1,23 +1,25 @@
<template>
<NConfigProvider class="h-full" :theme="theme" :locale="zhCN">
<NConfigProvider class="h-full" :theme="theme" :locale="uiLocale">
<NaiveProvider>
<RouterView />
<RouterView/>
</NaiveProvider>
<NGlobalStyle />
<NModalProvider />
<NGlobalStyle/>
<NModalProvider/>
</NConfigProvider>
</template>
<script setup lang="ts">
import NaiveProvider from '@/components/NaiveProvider.vue'
import {darkTheme, lightTheme, zhCN} from 'naive-ui'
import {darkTheme, lightTheme, zhCN, enUS} from 'naive-ui'
import {useIndexStore} from "@/stores"
import {computed, onMounted} from "vue"
import {useEventStore} from "@/stores/event"
import {appType} from "@/types/app";
import type {appType} from "@/types/app"
import {useI18n} from 'vue-i18n'
const store = useIndexStore()
const eventStore = useEventStore()
const {locale} = useI18n()
const theme = computed(() => {
if (store.globalConfig.Theme === "darkTheme") {
@@ -28,31 +30,30 @@ const theme = computed(() => {
return lightTheme
})
const uiLocale = computed(() => {
locale.value = store.globalConfig.Locale
if (store.globalConfig.Locale === "zh") {
return zhCN
}
return enUS
})
onMounted(async () => {
await store.init()
eventStore.init()
eventStore.addHandle({
type: "message",
event: (res: appType.Message)=>{
event: (res: appType.Message) => {
switch (res?.code) {
case 0:
window?.$message?.error(res.message)
window.$message?.error(res.message)
break
case 1:
window?.$message?.success(res.message)
window.$message?.success(res.message)
break
}
}
})
eventStore.addHandle({
type: "updateProxyStatus",
event: (res: any)=>{
store.updateProxyStatus(res)
}
})
})
</script>
<style scoped>
</style>
</script>

View File

@@ -1,6 +1,19 @@
import request from '@/api/request'
export default {
install() {
return request({
url: '/api/install',
method: 'post'
})
},
setSystemPassword(data: object) {
return request({
url: 'api/set-system-password',
method: 'post',
data: data
})
},
openSystemProxy() {
return request({
url: 'api/proxy-open',
@@ -79,6 +92,13 @@ export default {
data: data
})
},
cancel(data: object) {
return request({
url: 'api/cancel',
method: 'post',
data: data
})
},
download(data: object) {
return request({
url: 'api/download',
@@ -93,4 +113,11 @@ export default {
data: data
})
},
batchExport(data: object) {
return request({
url: 'api/batch-export',
method: 'post',
data: data
})
},
}

View File

@@ -1,37 +1,38 @@
import axios from 'axios';
import type {AxiosResponse, InternalAxiosRequestConfig} from 'axios';
import type {AxiosResponse, InternalAxiosRequestConfig} from 'axios'
import axios from 'axios'
interface RequestOptions {
url: string;
method: 'get' | 'post' | 'put' | 'delete'; // 根据需要扩展
params?: Record<string, any>;
data?: Record<string, any>;
url: string
method: 'get' | 'post' | 'put' | 'delete'
params?: Record<string, any>
data?: Record<string, any>
}
const instance = axios.create({
baseURL: "/",
});
timeout: 180000
})
instance.interceptors.request.use(
(config: InternalAxiosRequestConfig<any>) => {
return config;
return config
},
(error) => {
return Promise.reject(error);
return Promise.reject(error)
}
);
)
instance.interceptors.response.use(
(response: AxiosResponse) => {
return response.data;
return response.data
},
(error) => {
return Promise.reject(error);
}
);
)
const request = ({url, method, params, data}: RequestOptions): Promise<any> => {
return instance({url, method, params, data});
};
return instance({url, method, params, data, baseURL: window.$baseUrl})
}
export default request;
export default request

View File

@@ -7,4 +7,12 @@
#app {
width: 100vw;
height: 100vh;
}
.ellipsis-2 {
display: -webkit-box;
overflow: hidden;
text-overflow: ellipsis;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}

View File

@@ -0,0 +1,109 @@
<template>
<div style="--wails-draggable:no-drag" class="grid grid-cols-3 gap-1.5">
<n-icon
size="30"
class="text-emerald-600 dark:text-emerald-400 bg-emerald-500/20 dark:bg-emerald-500/30 rounded-full flex items-center justify-center p-1.5 cursor-pointer hover:bg-emerald-500/40 transition-colors"
@click="action('down')"
>
<DownloadOutline/>
</n-icon>
<n-icon
size="28"
class="text-red-500 dark:text-red-300 bg-red-500/20 dark:bg-red-500/30 rounded-full flex items-center justify-center p-1.5 cursor-pointer hover:bg-red-500/40 transition-colors"
@click="action('delete')"
>
<TrashOutline/>
</n-icon>
<NPopover placement="bottom" trigger="hover">
<template #trigger>
<NIcon size="30" class="text-sky-500 dark:text-sky-300 bg-sky-500/20 dark:bg-sky-200/30 rounded-full flex items-center justify-center p-2 cursor-pointer hover:bg-sky-200/40 transition-colors">
<GridSharp/>
</NIcon>
</template>
<div class="flex flex-col">
<div class="flex items-center justify-start p-1.5 cursor-pointer" v-if="row.Status === 'running' || row.Status === 'pending'" @click="action('cancel')">
<n-icon
size="28"
class="text-red-500 dark:text-red-300 bg-red-500/20 dark:bg-red-500/30 rounded-full flex items-center justify-center p-1.5 cursor-pointer hover:bg-red-500/40 transition-colors"
>
<CloseOutline/>
</n-icon>
<span class="ml-1">{{ t("index.cancel_down") }}</span>
</div>
<div class="flex items-center justify-start p-1.5 cursor-pointer" @click="action('copy')">
<n-icon
size="28"
class="text-blue-300 dark:text-blue-300 bg-blue-300/20 dark:bg-blue-500/30 rounded-full flex items-center justify-center p-1.5 cursor-pointer hover:bg-blue-300/40 transition-colors"
>
<LinkOutline/>
</n-icon>
<span class="ml-1">{{ t("index.copy_link") }}</span>
</div>
<div class="flex items-center justify-start p-1.5 cursor-pointer" v-if="row.Classify !== 'live' && row.Classify !== 'm3u8'" @click="action('open')">
<n-icon
size="28"
class="text-blue-500 dark:text-blue-200 bg-blue-400/20 dark:bg-blue-400/30 rounded-full flex items-center justify-center p-1.5 cursor-pointer hover:bg-blue-400/40 transition-colors"
>
<GlobeOutline/>
</n-icon>
<span class="ml-1">{{ t("index.open_link") }}</span>
</div>
<div class="flex items-center justify-start p-1.5 cursor-pointer" v-if="row.DecodeKey" @click="action('decode')">
<n-icon
size="28"
class="text-orange-400 dark:text-red-300 bg-orange-500/20 dark:bg-orange-200/30 rounded-full flex items-center justify-center p-1.5 cursor-pointer hover:bg-orange-200/40 transition-colors"
>
<LockOpenSharp/>
</n-icon>
<span class="ml-1">{{ t("index.video_decode") }}</span>
</div>
<div class="flex items-center justify-start p-1.5 cursor-pointer" @click="action('json')">
<n-icon
size="28"
class="text-sky-400 dark:text-sky-200 bg-sky-500/20 dark:bg-sky-500/30 rounded-full flex items-center justify-center p-1.5 cursor-pointer hover:bg-sky-500/40 transition-colors"
>
<CopyOutline/>
</n-icon>
<span class="ml-1">{{ t("index.copy_data") }}</span>
</div>
</div>
</NPopover>
</div>
</template>
<script setup lang="ts">
import {useI18n} from 'vue-i18n'
import {
DownloadOutline,
CopyOutline,
GlobeOutline,
LockOpenSharp,
LinkOutline,
GridSharp,
CloseOutline,
TrashOutline
} from "@vicons/ionicons5"
const {t} = useI18n()
const props = defineProps<{
row: any,
index: number,
}>()
const emits = defineEmits(["action"])
const action = (type: string) => {
if (type === 'down' && (props.row.Classify === 'live' || props.row.Classify === 'm3u8')) {
window?.$message?.error(t("index.download_no_tip"))
return
}
emits('action', props.row, props.index, type)
}
</script>

View File

@@ -0,0 +1,107 @@
<template>
<div class="flex items-center">
<span>
{{ t('index.operation') }}
</span>
<NPopover trigger="hover">
<template #trigger>
<NIcon size="18" class="ml-1 text-gray-500">
<HelpCircleOutline/>
</NIcon>
</template>
<div class="flex flex-col">
<div class="flex items-center justify-start p-1.5">
<n-icon size="28"
class="text-emerald-600 dark:text-emerald-400 bg-emerald-500/20 dark:bg-emerald-500/30 rounded-full flex items-center justify-center p-1.5 cursor-pointer hover:bg-emerald-500/40 transition-colors">
<DownloadOutline/>
</n-icon>
<span class="ml-1">{{ t("index.direct_download") }}</span>
</div>
<div class="flex items-center justify-start p-1.5">
<n-icon size="28"
class="text-red-500 dark:text-red-300 bg-red-500/20 dark:bg-red-500/30 rounded-full flex items-center justify-center p-1.5 cursor-pointer hover:bg-red-500/40 transition-colors">
<CloseOutline/>
</n-icon>
<span class="ml-1">{{ t("index.cancel_down") }}</span>
</div>
<div class="flex items-center justify-start p-1.5">
<n-icon
size="28"
class="text-blue-600 dark:text-blue-300 bg-blue-500/20 dark:bg-blue-500/30 rounded-full flex items-center justify-center p-1.5 cursor-pointer hover:bg-blue-500/40 transition-colors"
>
<LinkOutline/>
</n-icon>
<span class="ml-1">{{ t("index.copy_link") }}</span>
</div>
<div class="flex items-center justify-start p-1.5">
<n-icon
size="28"
class="text-blue-500 dark:text-blue-200 bg-blue-400/20 dark:bg-blue-400/30 rounded-full flex items-center justify-center p-1.5 cursor-pointer hover:bg-blue-400/40 transition-colors"
>
<GlobeOutline/>
</n-icon>
<span class="ml-1">{{ t("index.open_link") }}</span>
</div>
<div class="flex items-center justify-start p-1.5">
<n-icon
size="28"
class="text-orange-400 dark:text-red-300 bg-orange-500/20 dark:bg-orange-200/30 rounded-full flex items-center justify-center p-1.5 cursor-pointer hover:bg-orange-200/40 transition-colors"
>
<LockOpenSharp/>
</n-icon>
<span class="ml-1">{{ t("index.video_decode") }}</span>
</div>
<div class="flex items-center justify-start p-1.5">
<n-icon
size="28"
class="text-sky-400 dark:text-sky-200 bg-sky-500/20 dark:bg-sky-500/30 rounded-full flex items-center justify-center p-1.5 cursor-pointer hover:bg-sky-500/40 transition-colors"
>
<CopyOutline/>
</n-icon>
<span class="ml-1">{{ t("index.copy_data") }}</span>
</div>
<div class="flex items-center justify-start p-1.5">
<n-icon
size="28"
class="text-red-500 dark:text-red-300 bg-red-500/20 dark:bg-red-500/30 rounded-full flex items-center justify-center p-1.5 cursor-pointer hover:bg-red-500/40 transition-colors"
>
<TrashOutline/>
</n-icon>
<span class="ml-1">{{ t("index.delete_row") }}</span>
</div>
<div class="flex items-center justify-start p-1.5">
<n-icon
size="28"
class="text-sky-500 dark:text-sky-300 bg-sky-500/20 dark:bg-sky-200/30 rounded-full flex items-center justify-center p-2 cursor-pointer hover:bg-sky-200/40 transition-colors"
>
<GridSharp/>
</n-icon>
<span class="ml-1">{{ t("index.more_operation") }}</span>
</div>
</div>
</NPopover>
</div>
</template>
<script setup lang="ts">
import {useI18n} from "vue-i18n"
import {
CopyOutline,
DownloadOutline,
GlobeOutline,
HelpCircleOutline,
LinkOutline,
LockOpenSharp,
GridSharp,
CloseOutline,
TrashOutline
} from "@vicons/ionicons5"
const {t} = useI18n()
</script>

View File

@@ -5,20 +5,20 @@
style="--wails-draggable:no-drag"
preset="card"
class="w-[640px]"
title="关于"
:title="t('footer.title')"
>
<div class="rounded p-5">
<div class="flex flex-col">
<div class="flex flex-row">
<div>
<div class="flex flex-row items-center">
<div class="flex flex-row items-end">
<div class="text-4xl font-bold">{{ store.appInfo.AppName }}</div>
<div class="text-xs pl-5 text-slate-400">
Version {{ store.appInfo.Version }}
</div>
</div>
<div class="text-slate-400 w-80 pt-2 pb-4">
{{ store.appInfo.Description }}
{{ t('footer.description') }}
</div>
</div>
<div class="pl-8">
@@ -28,29 +28,20 @@
</div>
<div class="flex flex-col">
<div class="text-2xl font-bold text-emerald-600">
支持市面上几乎所有的网络应用
{{ t('footer.support') }}
</div>
<div class="grid grid-cols-5 gap-2 text-sm m-4 text-slate-400">
<span>抖音</span>
<span>快手</span>
<span>小红书</span>
<span>视频号</span>
<span>小程序</span>
<span>公众号</span>
<span>酷狗音乐</span>
<span>QQ音乐</span>
<span>QQ微视</span>
<span>......</span>
<span v-for="item in t('footer.application').split(',')">{{ item }}</span>
</div>
</div>
<div class="flex w-full text-sm justify-between pt-8 text-slate-400">
<div>{{ store.appInfo.Copyright }}</div>
<div class="flex">
<button class="pl-4" @click="toWebsite('https://s.gowas.cn/d/4089')">论坛</button>
<button class="pl-4" @click="toWebsite('http://127.0.0.1:8899/cert')">证书</button>
<button class="pl-4" @click="toWebsite('https://github.com/putyy/res-downloader')">软件源码</button>
<button class="pl-4" @click="toWebsite('https://github.com/putyy/res-downloader/issues')">帮助支持</button>
<button class="pl-4" @click="toWebsite('https://github.com/putyy/res-downloader/releases')">更新日志</button>
<button class="pl-4" @click="toWebsite('https://s.gowas.cn/d/4089')">{{ t('footer.forum') }}</button>
<button class="pl-4" @click="toWebsite(certUrl)">{{ t('footer.cert_download') }}</button>
<button class="pl-4" @click="toWebsite('https://github.com/putyy/res-downloader')">{{ t('footer.source_code') }}</button>
<button class="pl-4" @click="toWebsite('https://github.com/putyy/res-downloader/issues')">{{ t('footer.help') }}</button>
<button class="pl-4" @click="toWebsite('https://github.com/putyy/res-downloader/releases')">{{ t('footer.update_log') }}</button>
</div>
</div>
</div>
@@ -60,10 +51,16 @@
<script lang="ts" setup>
import {useIndexStore} from "@/stores"
import {BrowserOpenURL} from "../../wailsjs/runtime"
import {computed} from "vue"
import {useI18n} from 'vue-i18n'
const {t} = useI18n()
const store = useIndexStore()
const props = defineProps(["showModal"])
const emits = defineEmits(["update:showModal"])
const certUrl = computed(()=>{
return store.baseUrl + "/api/cert"
})
const changeShow = (value: boolean) => {
emits('update:showModal', value)
}

View File

@@ -0,0 +1,38 @@
<template>
<NModal
:show="showModal"
:on-update:show="changeShow"
style="--wails-draggable:no-drag"
preset="card"
class="w-[640px]"
:title="t('index.batch_import')"
>
<NForm
size="medium"
label-placement="left"
label-width="auto"
require-mark-placement="right-hanging"
style="--wails-draggable:no-drag"
>
<NFormItem>
<NInput type="textarea" v-model:value="content" rows="8" :autosize="false" :placeholder="t('index.import_placeholder')"></NInput>
</NFormItem>
<NFormItem>
<NButton strong secondary type="success" @click="emits('submit', content)" class="w-20">{{ t('common.submit') }}</NButton>
</NFormItem>
</NForm>
</NModal>
</template>
<script setup lang="ts">
import {ref} from "vue"
import {useI18n} from 'vue-i18n'
const {t} = useI18n()
const content = ref("")
const props = defineProps<{
showModal: boolean
}>()
const emits = defineEmits(["update:showModal", "submit"])
const changeShow = (value: boolean) => emits("update:showModal", value)
</script>

View File

@@ -19,7 +19,6 @@ import {
useNotification,
} from "naive-ui"
function registerNaiveTools() {
window.$loadingBar = useLoadingBar()
window.$dialog = useDialog()

View File

@@ -0,0 +1,63 @@
<template>
<n-modal
:show="showModal"
:on-update:show="changeShow"
style="--wails-draggable:no-drag"
preset="dialog"
:title="t('components.password_title')"
content=""
:show-icon="false"
:mask-closable="false"
:close-on-esc="false"
class="rounded-lg"
>
<div>
<div class="text-red-500 text-base">
{{ t("components.password_tip") }}
</div>
<div class="mt-3">
<n-input
v-model:value="formValue.password"
type="password"
:placeholder="t('components.password_placeholder')"
class="w-full"
/>
</div>
<div class="mt-3 text-base">
<label>{{ t("components.password_cache") }}</label>
<NSwitch class="pl-1" v-model:value="formValue.cache" :aria-placeholder="t('components.password_cache')"/>
</div>
</div>
<template #action>
<n-button type="primary" @click="submit">{{ t("common.submit") }}</n-button>
</template>
</n-modal>
</template>
<script setup lang="ts">
import {reactive} from 'vue'
import {NButton, NInput, NModal} from 'naive-ui'
import {useI18n} from 'vue-i18n'
const {t} = useI18n()
defineProps({
showModal: Boolean,
})
const formValue = reactive({
password: "",
cache: false,
})
const emits = defineEmits(["update:showModal", "submit"])
const changeShow = (value: boolean) => emits("update:showModal", value)
const submit = () => {
if (!formValue.password) {
window.$message?.error(t("components.password_empty"))
return
}
emits('submit', formValue.password, formValue.cache)
}
</script>

View File

@@ -5,7 +5,7 @@
:on-update:show="changeShow"
preset="card"
class="w-[540px] h-auto"
title="预览"
:title="t('index.preview')"
display-directive="show"
:on-after-enter="onAfterEnter"
:on-after-leave="onAfterLeave"
@@ -30,7 +30,9 @@ import axios from "axios"
// @ts-ignore
import { getDecryptionArray } from '@/assets/js/decrypt.js'
import type Player from "video.js/dist/types/player"
import {useI18n} from 'vue-i18n'
const {t} = useI18n()
const videoPlayer = ref<HTMLElement | any>(null)
let player: Player | null = null
let flvPlayer: flvjs.Player | null = null
@@ -82,7 +84,7 @@ const playFlvStream = () => {
try {
if (!flvjs.isSupported() || !videoPlayer.value) return
flvPlayer = flvjs.createPlayer({ type: "flv", url: props.previewRow.Url })
flvPlayer = flvjs.createPlayer({ type: "flv", url: window?.$baseUrl + "/api/preview?url=" + encodeURIComponent(props.previewRow.Url) })
flvPlayer.attachMediaElement(videoPlayer.value)
flvPlayer.load()
flvPlayer.play()
@@ -103,7 +105,7 @@ const setupVideoJsPlayer = () => {
}
player.src({
src: props.previewRow.Url,
src: window?.$baseUrl + "/api/preview?url=" + encodeURIComponent(props.previewRow.Url),
type: props.previewRow.ContentType,
withCredentials: true,
})
@@ -111,7 +113,7 @@ const setupVideoJsPlayer = () => {
}
const playVideoWithoutTotalLength = () => {
rowUrl = buildUrlWithParams(props.previewRow.Url)
rowUrl = window?.$baseUrl + "/api/preview?url=" + encodeURIComponent(buildUrlWithParams(props.previewRow.Url))
mediaSource = new MediaSource()
videoPlayer.value.src = URL.createObjectURL(mediaSource)
videoPlayer.value.play()
@@ -139,7 +141,6 @@ const buildUrlWithParams = (url: string) => {
}
const handleSeeking = () => {
console.log('handleSeeking')
const currentTime = videoPlayer.value.currentTime
const bufferedEnd = videoPlayer.value.buffered.end(videoPlayer.value.buffered.length - 1)

View File

@@ -1,34 +0,0 @@
<template>
<NSpace style="--wails-draggable:no-drag">
<NButton v-if="row.Classify != 'live' && row.Classify != 'm3u8'" type="success" :tertiary="true" size="small" @click="action('down')">
直接下载
</NButton>
<NButton type="info" :tertiary="true" size="small" @click="action('copy')">
复制链接
</NButton>
<NButton v-if="row.Classify != 'live' && row.Classify != 'm3u8'" type="info" :tertiary="true" size="small" @click="action('open')">
打开浏览
</NButton>
<NButton v-if="row.DecodeKey" type="warning" :tertiary="true" size="small" @click="action('decode')">
视频解密
</NButton>
<NButton type="error" :tertiary="true" size="small" @click="action('delete')">
删除
</NButton>
</NSpace>
</template>
<script setup lang="ts">
const props = defineProps<{
row: any,
index: number,
}>()
const emits = defineEmits(["action"])
const action = (type: string) => {
emits('action', props.row, props.index, type)
}
</script>

View File

@@ -1,21 +1,32 @@
<template>
<div class="flex justify-around px-2 pt-3">
<button class="w-3 h-3 rounded-full flex items-center justify-center p-[2px] bg-[#fd5b5b]" @click="closeWindow">
<svg t="1729232659670" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="11784" width="200" height="200">
<path d="M617.92 516.096l272 272-101.824 101.824-272-272-272 272-101.856-101.824 272-272-275.008-275.04L241.056 139.2l275.04 275.04 275.04-275.04 101.824 101.824-275.04 275.04z" fill="#2c2c2c" p-id="11785"/>
<button
class="w-4 h-4 rounded-full bg-[#ff5c57] flex items-center justify-center transition-all duration-200 hover:bg-[#e0443e] group"
@click="closeWindow"
:title="t('components.screen_close')"
>
<svg class="w-[0.7rem] h-[0.7rem] text-[#2c2c2c] opacity-0 group-hover:opacity-100 transition-opacity duration-150" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6 6L18 18M6 18L18 6" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
</button>
<button class="w-3 h-3 rounded-full bg-yellow-500 flex items-center justify-center p-[2px]" @click="minimizeWindow">
<svg t="1729223652686" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="9954" width="200" height="200">
<path d="M892 472c22.1 0 40 17.9 40 40 0 21.9-17.6 39.6-39.3 40H132c-22.1 0-40-17.9-40-40 0-21.9 17.6-39.6 39.3-40H892z" fill="#111111" p-id="9955"/>
<path d="M892 472c22.1 0 40 17.9 40 40 0 21.9-17.6 39.6-39.3 40H132c-22.1 0-40-17.9-40-40 0-21.9 17.6-39.6 39.3-40H892z" fill="#111111" p-id="9956"/>
<button
class="w-4 h-4 rounded-full bg-[#ffbc38] flex items-center justify-center transition-all duration-200 hover:bg-[#e0a824] group"
@click="minimizeWindow"
:title="t('components.screen_minimize')"
>
<svg class="w-[0.7rem] h-[0.7rem] text-[#2c2c2c] opacity-0 group-hover:opacity-100 transition-opacity duration-150" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4 12H20" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
</button>
<button class="w-3 h-3 rounded-full bg-green-500 flex items-center justify-center" :class="isMaximized ? 'p-[2px]' : 'p-[3px]'" @click="maximizeWindow">
<svg v-if="isMaximized" t="1729223337944" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="2168" width="200" height="200">
<button
class="w-4 h-4 rounded-full bg-[#28c840] flex items-center justify-center transition-all duration-200 hover:bg-[#1ea230] group"
@click="maximizeWindow"
:title="isMaximized ? t('components.screen_restore') : t('components.screen_maximize')"
>
<svg v-if="isMaximized" class="w-[0.5rem] h-[0.5rem] text-[#2c2c2c] opacity-0 group-hover:opacity-100 transition-opacity duration-150" t="1729223337944" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="2168">
<path d="M965.6 512.8L564 512c-28.8 0-52 23.2-52 51.2l1.6 401.6c0 28.8 16.8 35.2 36.8 15.2l430.4-431.2c20-20 12.8-36-15.2-36zM510.4 58.4c0-28.8-16.8-35.2-36.8-15.2L44 474.4c-20 20-12.8 36.8 15.2 36.8l401.6 0.8c28.8 0 51.2-23.2 51.2-51.2l-1.6-402.4z" fill="#2c2c2c" p-id="2169"/>
</svg>
<svg v-else t="1729223289826" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="1881" width="200" height="200">
<svg v-else class="w-[0.5rem] h-[0.5rem] text-[#2c2c2c] opacity-0 group-hover:opacity-100 transition-opacity duration-150" t="1729223289826" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="1881">
<path d="M966.2236448 410.53297813l0 496.32142187a56.61582187 56.61582187 0 0 1-56.59306667 56.61582187l-496.32142293-1e-8a56.59306667 56.59306667 0 0 1-39.9815104-96.68835519L869.53528854 370.57422187a56.61582187 56.61582187 0 0 1 96.71111146 39.95875626z m-905.67111147 200.15786666L60.55253333 114.39217812a56.61582187 56.61582187 0 0 1 56.59306667-56.61582292l496.29866667 0a56.59306667 56.59306667 0 0 1 39.98151146 96.68835626l-496.2076448 496.20764374a56.61582187 56.61582187 0 0 1-96.68835519-39.9815104z" fill="#373C43" p-id="1882"/>
</svg>
</button>
@@ -25,8 +36,10 @@
<script lang="ts" setup>
import {ref} from "vue"
import {Quit, WindowFullscreen, WindowMinimise, WindowUnfullscreen} from "../../wailsjs/runtime"
import {useI18n} from 'vue-i18n'
const isMaximized = ref(false);
const {t} = useI18n()
const isMaximized = ref(false)
const closeWindow = () => {
Quit()

View File

@@ -1,15 +1,19 @@
<template>
<div class="fixed inset-0 flex items-center justify-center bg-black bg-opacity-50 z-50" v-if="isLoading">
<div class="flex flex-col items-center">
<svg class="animate-spin h-10 w-10 text-white mb-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<svg class="animate-spin h-10 w-10 text-white mb-4" xmlns="http://www.w3.org/2000/svg" fill="none"
viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z"></path>
</svg>
<span class="text-white">{{ loadingText }}</span>
<span class="text-white" v-if="loadingText">{{ loadingText }}</span>
</div>
</div>
</template>
<script lang="ts" setup>
const props = defineProps(["isLoading", "loadingText"])
defineProps<{
isLoading: boolean
loadingText?: string
}>()
</script>

View File

@@ -0,0 +1,59 @@
<template>
<div
class="min-h-6"
@click="handleOnClick"
>
<n-input
v-if="isEdit"
ref="inputRef"
:value="inputValue"
@update:value="v => inputValue = v"
@change="handleChange"
@blur="handleChange"
/>
<n-tooltip
v-else
trigger="hover"
placement="top"
>
<template #trigger>
<div class="ellipsis-2">{{ inputValue }}</div>
</template>
<div class="ellipsis-2">{{ inputValue }}</div>
</n-tooltip>
</div>
</template>
<script setup lang="ts">
import { ref, nextTick, watch } from 'vue'
import type { InputInst } from 'naive-ui'
interface OnUpdateValue {
(value: string): void
}
const props = defineProps<{
value: string | number
onUpdateValue?: OnUpdateValue
}>()
const isEdit = ref(false)
const inputRef = ref<InputInst | null>(null)
const inputValue = ref(String(props.value))
watch(
() => props.value,
v => inputValue.value = String(v)
)
function handleOnClick() {
isEdit.value = true
nextTick(() => inputRef.value?.focus())
}
function handleChange() {
props.onUpdateValue?.(String(inputValue.value))
isEdit.value = false
}
</script>

View File

@@ -1,10 +1,15 @@
<template>
<div class="flex pb-2 flex-col h-full min-w-[80px] border-r border-slate-100 dark:border-slate-900">
<Screen v-if="envInfo.platform!=='darwin'"></Screen>
<div class="w-full flex flex-row items-center justify-center pt-5 ml-[-5px]" :class="envInfo.platform==='darwin' ? 'pt-8' : 'pt-2'">
<img class="w-12 h-12 cursor-pointer" src="@/assets/image/logo.png" alt="res-downloader logo"/>
<div class="w-full flex flex-row items-center justify-center pt-5" :class="envInfo.platform==='darwin' ? 'pt-8' : 'pt-2'">
<div class="relative flex items-center justify-center cursor-pointer" @click="handleFooterUpdate('github')">
<img class="w-12 h-12 rounded-full transition-transform duration-300 hover:scale-105 dark" src="@/assets/image/logo.png" alt="res-downloader logo"/>
<span class="absolute right-[-25px] top-0 font-semibold rounded-full bg-red-500 text-white dark:bg-red-600 dark:text-gray-100 text-[10px] px-1.5 py-0.5 animate-pulse" v-if="showUpdate">
New
</span>
</div>
</div>
<main class="flex-1 flex-grow-1 mb-5 overflow-auto flex flex-col pt-1 items-center h-full">
<main class="flex-1 flex-grow-1 mb-5 overflow-auto flex flex-col pt-1 items-center h-full" v-if="is">
<NScrollbar :size="1">
<NLayout has-sider>
<NLayoutSider
@@ -14,10 +19,11 @@
:on-after-enter="() => { showAppName = true }"
:on-after-leave="() => { showAppName = false }"
:collapsed-width="70"
:default-collapsed="true"
:width="120"
:collapsed="collapsed"
:width="envInfo.platform==='linux' ? 160 : 140"
:native-scrollbar="false"
:inverted="inverted"
:on-update:collapsed="collapsedChange"
class="bg-inherit"
>
<NMenu
@@ -43,36 +49,46 @@
</NScrollbar>
</main>
</div>
<Footer v-model:showModal="showAppInfo" />
<Footer v-model:showModal="showAppInfo"/>
</template>
<script lang="ts" setup>
import {MenuOption} from "naive-ui"
import {NIcon} from "naive-ui"
import {computed, h, ref, watch} from "vue"
import {computed, h, onMounted, ref, watch} from "vue"
import {useRoute, useRouter} from "vue-router"
import {
CloudOutline,
SettingsOutline,
HelpCircleOutline,
MoonOutline, SunnyOutline, LogoGithub
MoonOutline,
SunnyOutline,
LanguageSharp,
LogoGithub
} from "@vicons/ionicons5"
import {useIndexStore} from "@/stores"
import Footer from "@/components/Footer.vue"
import Screen from "@/components/Screen.vue";
import {BrowserOpenURL} from "../../../wailsjs/runtime";
import Screen from "@/components/Screen.vue"
import {BrowserOpenURL} from "../../../wailsjs/runtime"
import {useI18n} from "vue-i18n"
import request from "@/api/request"
import {compareVersions} from "@/func"
const {t} = useI18n()
const route = useRoute()
const router = useRouter()
const inverted = ref(false)
const collapsed = ref(false)
const showAppName = ref(false)
const showAppInfo = ref(false)
const menuValue = ref(route.fullPath.substring(1))
const store = useIndexStore()
const is = ref(false)
const showUpdate = ref(false)
const envInfo = store.envInfo
const globalConfig = computed(()=>{
const globalConfig = computed(() => {
return store.globalConfig
})
@@ -82,7 +98,22 @@ const theme = computed(() => {
watch(() => route.path, (newPath, oldPath) => {
menuValue.value = route.fullPath.substring(1)
});
})
onMounted(()=>{
const collapsedCache = localStorage.getItem("collapsed");
if (collapsedCache) {
collapsed.value = JSON.parse(collapsedCache).collapsed
}
is.value = true
request({
url: 'https://res.putyy.com/version.json?v=' + Date.now(),
method: 'get',
}).then((res)=>{
showUpdate.value = compareVersions(res.version, store.appInfo.Version) === 1
})
})
const renderIcon = (icon: any) => {
return () => h(NIcon, null, {default: () => h(icon)})
@@ -90,40 +121,45 @@ const renderIcon = (icon: any) => {
const menuOptions = ref([
{
label: "拦截",
label: computed(() => t("menu.index")),
key: 'index',
icon: renderIcon(CloudOutline),
},
{
label: "设置",
label: computed(() => t("menu.setting")),
key: 'setting',
icon: renderIcon(SettingsOutline),
},
])
const footerOptions = ref([
{
label: "主题",
key: 'theme',
icon: theme,
},
{
label: "github",
key: 'github',
icon: renderIcon(LogoGithub),
},
{
label: "关于",
label: computed(() => t("menu.locale")),
key: 'locale',
icon: renderIcon(LanguageSharp),
},
{
label: computed(() => t("menu.theme")),
key: 'theme',
icon: theme,
},
{
label: computed(() => t("menu.about")),
key: 'about',
icon: renderIcon(HelpCircleOutline),
},
])
const handleUpdateValue = (key: string, item: MenuOption) => {
const handleUpdateValue = (key: string, item?: MenuOption) => {
menuValue.value = key
return router.push({path: "/" + key})
}
const handleFooterUpdate = (key: string, item: MenuOption) => {
const handleFooterUpdate = (key: string, item?: MenuOption) => {
if (key === "about") {
showAppInfo.value = true
return
@@ -133,17 +169,49 @@ const handleFooterUpdate = (key: string, item: MenuOption) => {
BrowserOpenURL("https://github.com/putyy/res-downloader")
return
}
if (key === "theme") {
if (globalConfig.value.Theme === "darkTheme") {
store.setConfig(Object.assign({}, globalConfig.value, {Theme: "lightTheme"}))
store.setConfig({Theme: "lightTheme"})
return
}
store.setConfig(Object.assign({}, globalConfig.value, {Theme: "darkTheme"}))
store.setConfig({Theme: "darkTheme"})
return
}
if (key === "locale") {
if (globalConfig.value.Locale === "zh") {
store.setConfig({Locale: "en"})
return
}
store.setConfig({Locale: "zh"})
return
}
menuValue.value = key
return router.push({path: "/" + key})
}
</script>
const collapsedChange = (value: boolean)=>{
collapsed.value = value
localStorage.setItem("collapsed", JSON.stringify({collapsed: value}))
}
</script>
<style scoped>
@keyframes pulse {
0% {
transform: scale(0.9);
}
50% {
transform: scale(1);
}
100% {
transform: scale(0.9);
}
}
.animate-pulse {
animation: pulse 2s infinite;
}
</style>

View File

@@ -1,7 +0,0 @@
export const DwStatus = {
ready: "就绪",
running: "运行中",
error: "错误",
done: "完成",
handle: "已下载,后续处理",
}

40
frontend/src/func.ts Normal file
View File

@@ -0,0 +1,40 @@
const ipv4Regex = /^(25[0-5]|2[0-4]\d|1\d{2}|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d{2}|[1-9]?\d)){3}$/
const domainRegex = /^(?!:\/\/)([a-zA-Z0-9-_]+\.)*[a-zA-Z0-9][a-zA-Z0-9-_]+\.[a-zA-Z]{2,11}?$/
const localhostRegex = /^localhost$/
export const compareVersions = (v1: string, v2: string) => {
const parts1 = v1.split('.').map(Number)
const parts2 = v2.split('.').map(Number)
const maxLength = Math.max(parts1.length, parts2.length)
for (let i = 0; i < maxLength; i++) {
const num1 = parts1[i] || 0
const num2 = parts2[i] || 0
if (num1 < num2) return -1
if (num1 > num2) return 1
}
return 0
}
export const isValidHost = (host: string) => {
return ipv4Regex.test(host) || domainRegex.test(host) || localhostRegex.test(host)
}
export const isValidPort = (port: number) => {
const portNumber = Number(port)
return Number.isInteger(portNumber) && portNumber > 1024 && portNumber < 65535
}
export const formatSize = (size: number | string) => {
if (typeof size === "string") return size
if (size > 1048576) {
return (size / 1048576).toFixed(2) + 'MB';
}
if (size > 1024) {
return (size / 1024).toFixed(2) + 'KB';
}
return Math.floor(size) + 'b';
}

14
frontend/src/i18n.ts Normal file
View File

@@ -0,0 +1,14 @@
import {createI18n} from 'vue-i18n'
import en from './locales/en.json'
import zh from './locales/zh.json'
const i18n = createI18n({
locale: 'zh',
fallbackLocale: 'en',
messages: {
en,
zh
}
})
export default i18n

View File

@@ -0,0 +1,144 @@
{
"common": {
"copy_success": "Copy Success",
"copy_fail": "Copy Failed",
"file_path": "File Path",
"loading": "Loading",
"select": "Select",
"submit": "Submit",
"delete": "Delete",
"yes": "Yes",
"no": "No"
},
"components": {
"password_title": "Admin Authorization",
"password_tip": "The password entered this time is only valid during this session, used for installing certificates or setting system proxy!",
"password_placeholder": "Please enter your computer password",
"password_cache": "Cache Password",
"password_empty": "Password cannot be empty",
"screen_minimize": "Minimize",
"screen_restore": "Restore",
"screen_maximize": "Maximize",
"screen_close": "Close"
},
"menu": {
"index": "Intercept",
"setting": "Setting",
"locale": "中文",
"theme": "Theme",
"about": "About"
},
"index": {
"open_grab": "Start Grabbing",
"close_grab": "Stop Grabbing",
"grab_type": "Grab Type",
"clear_list": "Clear List",
"clear_list_tip": "Clear all records?",
"remember_clear_choice": "Remember this selection and clear it next time",
"batch_download": "Batch Download",
"batch_export": "Batch Export",
"batch_import": "Batch Import",
"export_url": "Export Url",
"import_success": "Export Success",
"total_resources": "total of {count} resources",
"all": "All",
"image": "Image",
"audio": "Audio",
"video": "Video",
"m3u8": "M3U8",
"live": "Live Stream",
"stream": "Data Stream",
"xls": "Spreadsheet",
"doc": "Document",
"pdf": "PDF",
"font": "Font",
"domain": "Domain",
"choice": "choice",
"type": "Type",
"preview": "Preview",
"preview_tip": "Cannot preview",
"status": "Status",
"description": "Description",
"resource_size": "Resource Size",
"save_path": "Save Path",
"save_path_empty": "Please set save location",
"operation": "Operation",
"ready": "Ready",
"pending": "Pending",
"running": "Running",
"error": "Error",
"done": "Done",
"handle": "Post Processing",
"direct_download": "Download",
"download_success": "Download Success",
"download_no_tip": "This type of download is not supported yet. Please copy the link and use other tools to download.",
"copy_link": "Copy Link",
"copy_data": "Copy Data",
"open_link": "Open Link",
"open_file": "Open File",
"delete_row": "Delete Row",
"delete_tip": "Running tasks cannot be deleted",
"cancel_down": "Cancel Download",
"more_operation": "More Operations",
"video_decode": "WxDecrypt",
"video_decode_loading": "Decrypting",
"video_decode_no": "Cannot Decrypt",
"video_decode_success": "Decrypt Success",
"use_data": "Please select required data",
"import_placeholder": "When adding multiple items, ensure each line contains only one (each link on a new line)",
"import_empty": "Please enter data to import",
"win_install_tip": "For the first time using this software, please right-click and select 'Run as administrator'",
"download_queued": "has been added to the queue, current queue length{count}",
"search": "Search",
"search_description": "Keyword Search...",
"start_err_tip": "Error Message",
"start_err_content": "The current startup process has encountered an issue. Do you want to reset the application?",
"start_err_positiveText": "Clear cache and restart",
"start_err_negativeText": "Close the software",
"reset_app_tip": "This operation will delete intercepted data and data related to this application. Please proceed with caution!"
},
"setting": {
"restart_tip": "Keep default if unsure, please restart software after modification",
"save_dir": "Save Directory",
"filename_rules": "Filename Rules",
"filename_rules_tip": "Input controls filename length (excluding timestamp, 0 means invalid, effective when description exists), switch controls whether to add timestamp at filename end",
"auto_proxy": "Auto Intercept",
"auto_proxy_tip": "Enable intercept when software starts",
"quality": "Quality",
"quality_value": "Default(Recommended),Ultra HD,High Quality,Medium Quality,Low Quality",
"quality_tip": "Effective for video accounts",
"full_intercept": "Full Intercept",
"full_intercept_tip": "Whether to fully intercept WeChat video accounts, No: only intercept video details",
"insert_tail": "Insert tail",
"insert_tail_tip": "Intercept whether new data is added to the end of the list",
"upstream_proxy": "Upstream Proxy",
"upstream_proxy_tip": "For combining with other proxy tools, format: http://username:password@your.proxy.server:port",
"download_proxy": "Download Proxy",
"download_proxy_tip": "Whether to use upstream proxy for downloads",
"user_agent_tip": "Keep default if unsure",
"connections": "Connections",
"connections_tip": "Keep default if unsure, usually CPU cores * 2, for faster downloads",
"down_number": "Download Number",
"down_number_tip": "Number of downloads executed simultaneously",
"use_headers_tip": "Default system filtering, Define headers for downloads, comma separated",
"mime_map": "Intercept Rules",
"mime_map_tip": "JSON format, keep default if unsure, please restart software after modification",
"domain_rule": "Domain Rule",
"domain_rule_tip": "Default * matches all domains, One line for each rulesupports the following: \n*.qq.com\nvideo.qq.com\nexample.com\n\n# Exclude\n!static.qq.com",
"port_format_error": "port format error",
"host_format_error": "host format error",
"basic_setting": "Basic Setting",
"advanced_setting": "Advanced Setting"
},
"footer": {
"title": "About Us",
"description": "A software combining network resource sniffing + high-speed download functions, with high aesthetics, performance and diversity, providing personal users with the ability to download their own resources uploaded to various platforms!",
"support": "Supports almost all network applications on the market",
"application": "Douyin,Kuaishou,Xiaohongshu,Wechat,Mini Programs,Youtube,Kugou Music,QQ Music,QQ Weishi,......",
"forum": "Forum",
"cert_download": "Certificate Download",
"source_code": "Source Code",
"help": "Issues",
"update_log": "Update Log"
}
}

View File

@@ -0,0 +1,144 @@
{
"common": {
"copy_success": "复制成功",
"copy_fail": "复制失败",
"file_path": "文件路径",
"loading": "loading",
"select": "选择",
"submit": "提交",
"delete": "删除",
"yes": "是",
"no": "否"
},
"components": {
"password_title": "管理员授权",
"password_tip": "本次输入的密码仅在本次运行期间有效,用于安装证书或设置系统代理!",
"password_placeholder": "请输入你的电脑密码",
"password_cache": "缓存密码",
"password_empty": "密码不能为空",
"screen_minimize": "最小化",
"screen_restore": "还原",
"screen_maximize": "最大化",
"screen_close": "关闭"
},
"menu": {
"index": "获取资源",
"setting": "系统设置",
"locale": "English",
"theme": "主题更换",
"about": "关于我们"
},
"index": {
"open_grab": "开启抓取",
"close_grab": "关闭抓取",
"grab_type": "抓取类型",
"clear_list": "清空列表",
"clear_list_tip": "清空所有记录?",
"remember_clear_choice": "记住此选择,下次直接清除",
"batch_download": "批量下载",
"batch_export": "批量导出",
"batch_import": "批量导入",
"export_url": "导出链接",
"import_success": "导出成功",
"total_resources": "共{count}个资源",
"all": "全部",
"image": "图片",
"audio": "音频",
"video": "视频",
"m3u8": "m3u8",
"live": "直播流",
"stream": "流数据",
"xls": "表格",
"doc": "文档",
"pdf": "pdf",
"font": "字体",
"domain": "域",
"choice": "已选",
"type": "类型",
"preview": "预览",
"preview_tip": "无法预览",
"status": "状态",
"description": "描述",
"resource_size": "资源大小",
"save_path": "保存路径",
"save_path_empty": "请设置保存位置",
"operation": "操作",
"ready": "就绪",
"pending": "待处理",
"running": "运行中",
"error": "错误",
"done": "完成",
"handle": "后续处理",
"direct_download": "直接下载",
"download_success": "下载成功",
"download_no_tip": "该类型暂不支持下载,请复制链接后使用其他工具下载",
"copy_link": "复制链接",
"copy_data": "复制数据",
"open_link": "打开链接",
"open_file": "打开文件",
"delete_row": "删除记录",
"delete_tip": "运行中任务无法删除",
"cancel_down": "取消下载",
"more_operation": "更多操作",
"video_decode": "视频解密",
"video_decode_loading": "解密中",
"video_decode_no": "无法解密",
"video_decode_success": "解密成功",
"use_data": "请选择需要的数据",
"import_placeholder": "添加多个时,请确保每行只有一个(每个链接回车换行)",
"import_empty": "请输入需要导入的数据",
"win_install_tip": "首次启用本软件,请使用鼠标右键选择以管理员身份运行",
"download_queued": "已加入队列,当前队列长度:{count}",
"search": "搜索",
"search_description": "关键字搜索...",
"start_err_tip": "错误提示",
"start_err_content": "当前启动过程遇到了问题,是否重置应用?",
"start_err_positiveText": "清理缓存并重启",
"start_err_negativeText": "关闭软件",
"reset_app_tip": "此操作会删除已拦截数据以及本应用相关数据,请谨慎操作!"
},
"setting": {
"restart_tip": "如果不清楚保持默认就行,修改后请重启软件",
"save_dir": "保存目录",
"filename_rules": "文件命名",
"filename_rules_tip": "输入框控制文件命名的长度(不含时间、0为无效此选项有描述信息时有效),开关控制文件末尾是否添加时间标识",
"auto_proxy": "自动拦截",
"auto_proxy_tip": "打开软件时自动启用拦截",
"quality": "清晰度",
"quality_value": "默认(推荐),超清,高画质,中画质,低画质",
"quality_tip": "视频号有效",
"full_intercept": "全量拦截",
"full_intercept_tip": "微信视频号是否全量拦截,否:只拦截视频详情",
"insert_tail": "添入尾部",
"insert_tail_tip": "拦截到新数据是否添加到列表尾部",
"upstream_proxy": "上游代理",
"upstream_proxy_tip": "用于结合其他代理工具,格式: http://username:password@your.proxy.server:port",
"download_proxy": "下载代理",
"download_proxy_tip": "下载操作时是否使用上游代理",
"user_agent_tip": "如不清楚请保持默认",
"connections": "连接数",
"connections_tip": "如不清楚请保持默认通常CPU核心数*2用于加速下载",
"down_number": "下载数",
"down_number_tip": "同时进行的下载数量",
"use_headers_tip": "默认系统过滤定义下载时可使用的header参数逗号分割",
"mime_map": "拦截规则",
"mime_map_tip": "json格式如果不清楚保持默认就行修改后请重启软件",
"domain_rule": "域名规则",
"domain_rule_tip": "默认*匹配所有域,每个规则一行,支持如下: \n*.qq.com\nvideo.qq.com\nexample.com\n\n# 排除\n!static.qq.com",
"port_format_error": "port 格式错误",
"host_format_error": "host 格式错误",
"basic_setting": "基础设置",
"advanced_setting": "高级设置"
},
"footer": {
"title": "关于我们",
"description": "一款集网络资源嗅探 + 高速下载功能于一体的软件,高颜值、高性能和多样化,提供个人用户下载自己上传到各大平台的网络资源功能!",
"support": "支持市面上几乎所有的网络应用",
"application": "抖音,快手,小红书,视频号,小程序,公众号,酷狗音乐,QQ音乐,QQ微视,......",
"forum": "论坛",
"cert_download": "证书下载",
"source_code": "软件源码",
"help": "帮助支持",
"update_log": "更新日志"
}
}

View File

@@ -1,14 +1,14 @@
import './assets/css/main.css'
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import {createApp} from 'vue'
import {createPinia} from 'pinia'
import i18n from './i18n'
import App from './App.vue'
import router from './router'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.mount('#app')
createApp(App)
.use(router)
.use(i18n)
.use(createPinia())
.mount('#app')

View File

@@ -16,7 +16,7 @@ const routes = [
{
path: "/setting",
name: "setting",
meta: {keepAlive: true},
meta: {keepAlive: false},
component: () => import("@/views/setting.vue"),
},
]

View File

@@ -6,7 +6,7 @@ import {appType} from "@/types/app"
export const useEventStore = defineStore('ws-store', () => {
const handles = ref<any>({})
const init = ()=>{
const init = () => {
EventsOn("event", (res: any) => {
const data = JSON.parse(res)
if (handles.value.hasOwnProperty(data.type)) {

View File

@@ -3,6 +3,8 @@ import {ref} from "vue"
import type {appType} from "@/types/app"
import appApi from "@/api/app"
import {Environment} from "../../wailsjs/runtime"
import * as bind from "../../wailsjs/go/core/Bind"
import {core} from "../../wailsjs/go/models"
export const useIndexStore = defineStore("index-store", () => {
const appInfo = ref<appType.App>({
@@ -14,17 +16,25 @@ export const useIndexStore = defineStore("index-store", () => {
const globalConfig = ref<appType.Config>({
Theme: "lightTheme",
Locale: "zh",
Host: "0.0.0.0",
Port: "8899",
Quality: 0,
SaveDirectory: "",
UpstreamProxy: "",
FilenameLen: 0,
FilenameTime: false,
OpenProxy: false,
DownloadProxy: false,
AutoProxy: false,
WxAction: false,
TaskNumber: 8,
DownNumber: 3,
UserAgent: "",
UseHeaders: "",
InsertTail: true,
MimeMap: {},
Rule: "*"
})
const envInfo = ref({
@@ -33,45 +43,57 @@ export const useIndexStore = defineStore("index-store", () => {
arch: "",
});
const tableHeight = ref(800)
const isProxy = ref(false)
const baseUrl = ref("")
const init = async () => {
Environment().then((res) => {
envInfo.value = res
})
await getAppInfo()
await appApi.getConfig().then((res) => {
await bind.AppInfo().then((res: core.ResponseData)=>{
appInfo.value = Object.assign({}, appInfo.value, res.data)
isProxy.value = res.data.IsProxy
})
await bind.Config().then((res: core.ResponseData)=>{
globalConfig.value = Object.assign({}, globalConfig.value, res.data)
})
setTimeout(() => {
appApi.isProxy().then((res: any) => {
isProxy.value = res.data.isProxy
})
}, 150)
window.addEventListener("resize", handleResize);
handleResize()
baseUrl.value = "http://127.0.0.1:" +globalConfig.value.Port
window.$baseUrl = baseUrl.value
}
const getAppInfo = async () => {
await appApi.appInfo().then((res) => {
appInfo.value = Object.assign({}, appInfo.value, res.data)
})
}
const setConfig = (formValue: appType.Config) => {
const setConfig = (formValue: Object) => {
globalConfig.value = Object.assign({}, globalConfig.value, formValue)
appApi.setConfig(globalConfig.value)
}
const handleResize = () => {
tableHeight.value = document.documentElement.clientHeight || window.innerHeight
const openProxy = async () => {
return appApi.openSystemProxy().then(handleProxy)
}
const updateProxyStatus = (res: any) => {
isProxy.value = res.isProxy
const unsetProxy = async () => {
return appApi.unsetSystemProxy().then(handleProxy)
}
return {appInfo, globalConfig, tableHeight, isProxy, envInfo, init, getAppInfo, setConfig, updateProxyStatus}
const handleProxy = (res: appType.Res) => {
isProxy.value = res.data.value
if (res.code === 0) {
window?.$message?.error(res.message)
}
return res
}
return {
appInfo,
globalConfig,
isProxy,
envInfo,
baseUrl,
init,
setConfig,
openProxy,
unsetProxy
}
})

View File

@@ -6,19 +6,32 @@ export namespace appType {
Copyright: string
}
interface MimeMap {
Type: string
Suffix: string
}
interface Config {
Theme: string
Locale: string
Host: string
Port: string
Quality: number
SaveDirectory: string
FilenameLen: number
FilenameTime: boolean
UpstreamProxy: string
OpenProxy: boolean
DownloadProxy: boolean
AutoProxy: boolean
WxAction: boolean
TaskNumber: number
DownNumber: number
UserAgent: string
UseHeaders: string
InsertTail: boolean
MimeMap: { [key: string]: MimeMap }
Rule: string
}
interface MediaInfo {
@@ -26,7 +39,7 @@ export namespace appType {
Url: string
UrlSign: string
CoverUrl: string
Size: string
Size: number
Domain: string
Classify: string
Suffix: string
@@ -35,7 +48,7 @@ export namespace appType {
DecodeKey: string
Description: string
ContentType: string
OtherData: {[key: string]: string}
OtherData: { [key: string]: string }
}
interface DownloadProgress {
@@ -54,4 +67,10 @@ export namespace appType {
type: string
event: any
}
interface Res<T = any> {
code: number;
message: string;
data: T; // T will be the specific type of your data
}
}

View File

@@ -1,9 +1,9 @@
interface Window {
$loadingBar?: import('naive-ui').LoadingBarProviderInst;
$dialog?: import('naive-ui').DialogProviderInst;
$message?: import('naive-ui').MessageProviderInst;
$notification?: import('naive-ui').NotificationProviderInst;
$ws?: WebSocket;
$loadingBar?: import('naive-ui').LoadingBarProviderInst
$dialog?: import('naive-ui').DialogProviderInst
$message?: import('naive-ui').MessageProviderInst
$notification?: import('naive-ui').NotificationProviderInst
$baseUrl?: string
}
declare module '*.vue' {

File diff suppressed because it is too large Load Diff

View File

@@ -1,130 +1,237 @@
<template>
<div class="h-full relative">
<NForm
:model="formValue"
size="medium"
label-placement="left"
label-width="auto"
require-mark-placement="right-hanging"
style="--wails-draggable:no-drag"
class="px-5 py-5"
>
<NFormItem label="代理Host" path="Port" size="small">
<NInput v-model:value="formValue.Host" placeholder="0.0.0.0" style="width:256px"/>
<NTooltip trigger="hover">
<template #trigger>
<NIcon size="20" class="pl-1">
<HelpCircleOutline />
</NIcon>
</template>
<span>如果不清楚保持默认就行修改后请重启软件</span>
</NTooltip>
</NFormItem>
<NFormItem label="代理端口" path="Port" size="small">
<NInput v-model:value="formValue.Port" placeholder="8899" style="width:256px"/>
<NTooltip trigger="hover">
<template #trigger>
<NIcon size="20" class="pl-1">
<HelpCircleOutline />
</NIcon>
</template>
<span>如果不清楚保持默认就行修改后请重启软件</span>
</NTooltip>
</NFormItem>
<NFormItem label="保存位置" path="SaveDirectory" size="small">
<NSpace>
<NInput v-model:value="formValue.SaveDirectory" disabled placeholder="保存位置" style="width:256px"/>
<NButton strong secondary type="success" @click="selectDir">选择</NButton>
</NSpace>
</NFormItem>
<NFormItem label="主题" path="theme" size="small">
<NRadio :checked="formValue.Theme === 'lightTheme'" value="lightTheme" name="theme" @change="handleChange">浅色主题</NRadio>
<NRadio :checked="formValue.Theme === 'darkTheme'" value="darkTheme" name="theme" @change="handleChange">深色主题</NRadio>
</NFormItem>
<NFormItem label="自动拦截" path="AutoProxy" size="small">
<NSwitch v-model:value="formValue.AutoProxy" />
<NTooltip trigger="hover">
<template #trigger>
<NIcon size="20" class="pl-1">
<HelpCircleOutline />
</NIcon>
</template>
<span>打开软件时动启用拦截</span>
</NTooltip>
</NFormItem>
<NFormItem label="清晰度" path="Quality" size="small">
<NSelect v-model:value="formValue.Quality" :options="options" class="w-64" />
<NTooltip trigger="hover">
<template #trigger>
<NIcon size="20" class="pl-1">
<HelpCircleOutline />
</NIcon>
</template>
<span>视频号有效</span>
</NTooltip>
</NFormItem>
<NFormItem label="全量拦截" path="Quality" size="small">
<NSwitch v-model:value="formValue.WxAction" />
<NTooltip trigger="hover">
<template #trigger>
<NIcon size="20" class="pl-1">
<HelpCircleOutline />
</NIcon>
</template>
<span>微信视频号是否全量拦截只拦截视频详情</span>
</NTooltip>
</NFormItem>
<NFormItem label="上游代理" path="UpstreamProxy" size="small">
<NInput v-model:value="formValue.UpstreamProxy" placeholder="例如: http://127.0.0.1:7890" style="width:256px"/>
<NTooltip trigger="hover">
<template #trigger>
<NIcon size="20" class="pl-1">
<HelpCircleOutline />
</NIcon>
</template>
<span>可结合其他代理工具用于访问国外网站以及正常网络无法访问的资源(格式http://username:password@your.proxy.server:port)</span>
</NTooltip>
</NFormItem>
<NFormItem label="开启代理" path="OpenProxy" size="small">
<NSwitch v-model:value="formValue.OpenProxy" />
</NFormItem>
<NFormItem label="下载代理" path="DownloadProxy" size="small">
<NSwitch v-model:value="formValue.DownloadProxy" />
<NTooltip trigger="hover">
<template #trigger>
<NIcon size="20" class="pl-1">
<HelpCircleOutline />
</NIcon>
</template>
<span>进行下载时使用代理请求</span>
</NTooltip>
</NFormItem>
<NFormItem label="连接数" path="TaskNumber" size="small">
<NInputNumber v-model:value="formValue.TaskNumber" :min="2" :max="64" class="w-64"/>
<NTooltip trigger="hover">
<template #trigger>
<NIcon size="20" class="pl-1">
<HelpCircleOutline />
</NIcon>
</template>
<span>如不清楚请保持默认通常CPU核心数*2用于分片下载</span>
</NTooltip>
</NFormItem>
<NFormItem label="UserAgent" path="UserAgent" size="small">
<NInput v-model:value="formValue.UserAgent" style="width:256px" placeholder=""/>
<NTooltip trigger="hover">
<template #trigger>
<NIcon size="20" class="pl-1">
<HelpCircleOutline />
</NIcon>
</template>
<span>如不清楚请保持默认</span>
</NTooltip>
</NFormItem>
<NFormItem label=" " path="UserAgent" size="small">
<NButton strong secondary type="success" @click="save" class="w-20">保存</NButton>
</NFormItem>
</NForm>
<div class="h-full relative p-5 overflow-y-auto [&::-webkit-scrollbar]:hidden" :key="renderKey">
<NTabs type="line" animated>
<NTabPane name="basic" :tab="t('setting.basic_setting')">
<NForm
:model="formValue"
size="medium"
label-placement="left"
label-width="auto"
require-mark-placement="right-hanging"
style="--wails-draggable:no-drag"
class="w-[700px]"
>
<NFormItem :label="t('setting.save_dir')" path="SaveDirectory">
<NInput :value="formValue.SaveDirectory" :placeholder="t('setting.save_dir')"/>
<NButton strong secondary type="primary" @click="selectDir" class="ml-1">{{ t('common.select') }}</NButton>
</NFormItem>
<NFormItem :label="t('setting.filename_rules')" path="FilenameLen">
<NInputNumber v-model:value="formValue.FilenameLen" :min="0" :max="9999" placeholder="0"/>
<NSwitch v-model:value="formValue.FilenameTime" class="ml-1"></NSwitch>
<NTooltip trigger="hover">
<template #trigger>
<NIcon size="18" class="ml-1 text-gray-500">
<HelpCircleOutline/>
</NIcon>
</template>
{{ t("setting.filename_rules_tip") }}
</NTooltip>
</NFormItem>
<NFormItem :label="t('setting.quality')" path="Quality">
<NSelect v-model:value="formValue.Quality" :options="options"/>
<NTooltip trigger="hover">
<template #trigger>
<NIcon size="18" class="ml-1 text-gray-500">
<HelpCircleOutline/>
</NIcon>
</template>
{{ t("setting.quality_tip") }}
</NTooltip>
</NFormItem>
<NFormItem :label="t('setting.auto_proxy')" path="AutoProxy">
<NSwitch v-model:value="formValue.AutoProxy"/>
<NTooltip trigger="hover">
<template #trigger>
<NIcon size="18" class="ml-1 text-gray-500">
<HelpCircleOutline/>
</NIcon>
</template>
{{ t("setting.auto_proxy_tip") }}
</NTooltip>
</NFormItem>
<NFormItem :label="t('setting.full_intercept')" path="WxAction">
<NSwitch v-model:value="formValue.WxAction"/>
<NTooltip trigger="hover">
<template #trigger>
<NIcon size="18" class="ml-1 text-gray-500">
<HelpCircleOutline/>
</NIcon>
</template>
{{ t("setting.full_intercept_tip") }}
</NTooltip>
</NFormItem>
<NFormItem :label="t('setting.insert_tail')" path="InsertTail">
<NSwitch v-model:value="formValue.InsertTail"/>
<NTooltip trigger="hover">
<template #trigger>
<NIcon size="18" class="ml-1 text-gray-500">
<HelpCircleOutline/>
</NIcon>
</template>
{{ t("setting.insert_tail_tip") }}
</NTooltip>
</NFormItem>
<NFormItem >
<n-popconfirm @positive-click="resetHandle">
<template #trigger>
<NButton tertiary type="error" style="--wails-draggable:no-drag">
{{ t("index.start_err_positiveText") }}
</NButton>
</template>
{{t("index.reset_app_tip")}}
</n-popconfirm>
</NFormItem>
</NForm>
</NTabPane>
<NTabPane name="advanced" :tab="t('setting.advanced_setting')">
<NForm
:model="formValue"
size="medium"
label-placement="left"
label-width="auto"
require-mark-placement="right-hanging"
style="--wails-draggable:no-drag"
class="w-[700px]"
>
<NFormItem label="Host" path="Host" :validation-status="hostValidationFeedback==='' ? undefined : 'error'" :feedback="hostValidationFeedback">
<NInput v-model:value="formValue.Host" placeholder="127.0.0.1"/>
<NTooltip trigger="hover">
<template #trigger>
<NIcon size="18" class="ml-1 text-gray-500">
<HelpCircleOutline/>
</NIcon>
</template>
{{ t("setting.restart_tip") }}
</NTooltip>
</NFormItem>
<NFormItem label="Port" path="Port" :validation-status="portValidationFeedback==='' ? undefined : 'error'" :feedback="portValidationFeedback">
<NInput v-model:value="formValue.Port" placeholder="8899"/>
<NTooltip trigger="hover">
<template #trigger>
<NIcon size="18" class="ml-1 text-gray-500">
<HelpCircleOutline/>
</NIcon>
</template>
{{ t("setting.restart_tip") }}
</NTooltip>
</NFormItem>
<NFormItem :label="t('setting.upstream_proxy')" path="UpstreamProxy">
<NInput v-model:value="formValue.UpstreamProxy" placeholder="http://127.0.0.1:7890"/>
<NSwitch v-model:value="formValue.OpenProxy" class="ml-1"/>
<NTooltip trigger="hover">
<template #trigger>
<NIcon size="18" class="ml-1 text-gray-500">
<HelpCircleOutline/>
</NIcon>
</template>
{{ t("setting.upstream_proxy_tip") }}
</NTooltip>
</NFormItem>
<NFormItem :label="t('setting.download_proxy')" path="DownloadProxy">
<NSwitch v-model:value="formValue.DownloadProxy"/>
<NTooltip trigger="hover">
<template #trigger>
<NIcon size="18" class="ml-1 text-gray-500">
<HelpCircleOutline/>
</NIcon>
</template>
{{ t("setting.download_proxy_tip") }}
</NTooltip>
</NFormItem>
<NFormItem :label="t('setting.connections')" path="TaskNumber">
<NInputNumber v-model:value="formValue.TaskNumber" :min="2" :max="64"/>
<NTooltip trigger="hover">
<template #trigger>
<NIcon size="18" class="ml-1 text-gray-500">
<HelpCircleOutline/>
</NIcon>
</template>
{{ t("setting.connections_tip") }}
</NTooltip>
</NFormItem>
<NFormItem :label="t('setting.down_number')" path="DownNumber">
<NInputNumber v-model:value="formValue.DownNumber" :min="1" :max="10"/>
<NTooltip trigger="hover">
<template #trigger>
<NIcon size="18" class="ml-1 text-gray-500">
<HelpCircleOutline/>
</NIcon>
</template>
{{ t("setting.down_number_tip") }}
</NTooltip>
</NFormItem>
<NFormItem label="UserAgent" path="UserAgent">
<NInput v-model:value="formValue.UserAgent" placeholder="UserAgent"/>
<NTooltip trigger="hover">
<template #trigger>
<NIcon size="18" class="ml-1 text-gray-500">
<HelpCircleOutline/>
</NIcon>
</template>
{{ t("setting.user_agent_tip") }}
</NTooltip>
</NFormItem>
<NFormItem label="Headers" path="Headers">
<NInput v-model:value="formValue.UseHeaders" placeholder="User-Agent,Referer,Authorization,Cookie"/>
<NTooltip trigger="hover">
<template #trigger>
<NIcon size="18" class="ml-1 text-gray-500">
<HelpCircleOutline/>
</NIcon>
</template>
{{ t("setting.use_headers_tip") }}
</NTooltip>
</NFormItem>
<NFormItem :label="t('setting.domain_rule')" path="DomainRule">
<NInput
v-model:value="formValue.Rule"
type="textarea"
rows="5"
:placeholder="t('setting.domain_rule_tip')"
/>
<NTooltip trigger="hover">
<template #trigger>
<NIcon size="18" class="ml-1 text-gray-500">
<HelpCircleOutline/>
</NIcon>
</template>
{{ t("setting.domain_rule_tip") }}
</NTooltip>
</NFormItem>
<NFormItem :label="t('setting.mime_map')" path="MimeMap">
<NInput
v-model:value="MimeMap"
type="textarea"
rows="11"
placeholder='{"video/mp4": { "Type": "video","Suffix": ".mp4"}}'
/>
<NTooltip trigger="hover">
<template #trigger>
<NIcon size="18" class="ml-1 text-gray-500">
<HelpCircleOutline/>
</NIcon>
</template>
{{ t("setting.mime_map_tip") }}
</NTooltip>
</NFormItem>
</NForm>
</NTabPane>
</NTabs>
</div>
</template>
@@ -134,39 +241,65 @@ import {ref, watch} from "vue"
import {useIndexStore} from "@/stores"
import type {appType} from "@/types/app"
import appApi from "@/api/app"
import {computed} from "vue"
import {useI18n} from 'vue-i18n'
import {isValidHost, isValidPort} from '@/func'
import {NButton, NIcon} from "naive-ui"
import * as bind from "../../wailsjs/go/core/Bind"
const {t} = useI18n()
const store = useIndexStore()
const options = [
{
value: 0,
label: "默认(推荐)"
}, {
value: 1,
label: "超清"
}, {
value: 2,
label: "高画质"
}, {
value: 3,
label: "中画质"
}, {
value: 4,
label: "低画质"
}
]
const options = computed(() =>
t("setting.quality_value")
.split(",")
.map((value, index) => ({ value: index, label: value }))
)
const formValue = ref<appType.Config>(Object.assign({}, store.globalConfig))
watch(()=>{
const MimeMap = ref(formValue.value.MimeMap ? JSON.stringify(formValue.value.MimeMap, null, 2) : "")
const renderKey = ref(999)
const hostValidationFeedback = ref("")
const portValidationFeedback = ref("")
watch(formValue.value, () => {
formValue.value.Port = formValue.value.Port.trim()
formValue.value.Host = formValue.value.Host.trim()
if (!isValidHost(formValue.value.Host)) {
hostValidationFeedback.value = t("setting.host_format_error")
return
} else {
hostValidationFeedback.value = ''
}
if (!isValidPort(parseInt(formValue.value.Port))) {
portValidationFeedback.value = t("setting.port_format_error")
return
} else {
portValidationFeedback.value = ''
}
store.setConfig(formValue.value)
}, {deep: true})
watch(MimeMap, () => {
store.setConfig({
MimeMap: JSON.parse(MimeMap.value)
})
})
watch(() => {
return store.globalConfig.Theme
}, ()=>{
}, () => {
formValue.value.Theme = store.globalConfig.Theme
})
const handleChange = (e: Event)=>{
formValue.value.Theme = (e.target as HTMLInputElement).value
}
watch(() => store.globalConfig.Locale, () => {
formValue.value.Locale = store.globalConfig.Locale
renderKey.value++
})
const selectDir = () => {
appApi.openDirectoryDialog().then((res: any) => {
@@ -175,11 +308,17 @@ const selectDir = () => {
}
}).catch((err: any) => {
window?.$message?.error(err)
});
})
}
const save = () => {
store.setConfig(formValue.value)
window?.$message?.success("保存成功")
const resetHandle = ()=>{
localStorage.clear()
bind.ResetApp()
}
</script>
</script>
<style lang="scss">
.n-tabs-nav--top{
@apply sticky top-0 z-10;
background-color: var(--n-color);
}
</style>

View File

@@ -6,12 +6,7 @@ import Components from 'unplugin-vue-components/vite'
import {NaiveUiResolver} from 'unplugin-vue-components/resolvers'
// https://vitejs.dev/config/
// export default defineConfig({
// plugins: [vue()]
// })
export default defineConfig((env) => {
const viteEnv = loadEnv(env.mode, process.cwd())
return {
plugins: [
vue(),

9
frontend/wailsjs/go/core/Bind.d.ts vendored Executable file
View File

@@ -0,0 +1,9 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
import {core} from '../models';
export function AppInfo():Promise<core.ResponseData>;
export function Config():Promise<core.ResponseData>;
export function ResetApp():Promise<void>;

View File

@@ -0,0 +1,15 @@
// @ts-check
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
export function AppInfo() {
return window['go']['core']['Bind']['AppInfo']();
}
export function Config() {
return window['go']['core']['Bind']['Config']();
}
export function ResetApp() {
return window['go']['core']['Bind']['ResetApp']();
}

21
frontend/wailsjs/go/models.ts Executable file
View File

@@ -0,0 +1,21 @@
export namespace core {
export class ResponseData {
code: number;
message: string;
data: any;
static createFrom(source: any = {}) {
return new ResponseData(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.code = source["code"];
this.message = source["message"];
this.data = source["data"];
}
}
}

View File

@@ -134,7 +134,7 @@ export function WindowIsFullscreen(): Promise<boolean>;
// [WindowSetSize](https://wails.io/docs/reference/runtime/window#windowsetsize)
// Sets the width and height of the window.
export function WindowSetSize(width: number, height: number): Promise<Size>;
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.

16
go.mod
View File

@@ -5,12 +5,13 @@ go 1.22.0
toolchain go1.23.2
require (
github.com/elazarl/goproxy v0.0.0-20241223171911-d5978cb8c956
github.com/elazarl/goproxy v1.7.2
github.com/matoous/go-nanoid/v2 v2.1.0
github.com/rs/zerolog v1.33.0
github.com/vrischmann/userdir v0.0.0-20151206171402-20f291cebd68
github.com/wailsapp/wails/v2 v2.9.2
golang.org/x/sys v0.28.0
github.com/wailsapp/wails/v2 v2.10.1
golang.org/x/net v0.35.0
golang.org/x/sys v0.30.0
)
require (
@@ -30,13 +31,12 @@ require (
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.47.0 // 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.18 // indirect
github.com/wailsapp/go-webview2 v1.0.19 // indirect
github.com/wailsapp/mimetype v1.4.1 // indirect
golang.org/x/crypto v0.31.0 // indirect
golang.org/x/net v0.33.0 // indirect
golang.org/x/text v0.21.0 // indirect
golang.org/x/crypto v0.33.0 // indirect
golang.org/x/text v0.22.0 // indirect
)

32
go.sum
View File

@@ -3,8 +3,8 @@ github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3IS
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
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/elazarl/goproxy v0.0.0-20241223171911-d5978cb8c956 h1:HyPt0ZkHkpke+HFl/4dDMz55A/AjFn7ZnLSm8GfdnwU=
github.com/elazarl/goproxy v0.0.0-20241223171911-d5978cb8c956/go.mod h1:YfEbZtqP4AetfO6d40vWchF3znWX7C7Vd6ZMfdL8z64=
github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o=
github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE=
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.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
@@ -51,8 +51,8 @@ github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUc
github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8=
github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss=
github.com/samber/lo v1.47.0 h1:z7RynLwP5nbyRscyvcD043DWYoOcYRv3mV8lBeqOCLc=
github.com/samber/lo v1.47.0/go.mod h1:RmDH9Ct32Qy3gduHQuKJ3gW1fMHAnE/fAzQuf6He5cU=
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.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/tkrajina/go-reflector v0.5.8 h1:yPADHrwmUbMq4RGEyaOUpz2H90sRsETNVpjzo3DLVQQ=
@@ -63,17 +63,17 @@ github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQ
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
github.com/vrischmann/userdir v0.0.0-20151206171402-20f291cebd68 h1:Ah2/69Z24rwD6OByyOdpJDmttftz0FTF8Q4QZ/SF1E4=
github.com/vrischmann/userdir v0.0.0-20151206171402-20f291cebd68/go.mod h1:EqKqAeKddSL9XSGnfXd/7iLncccKhR16HBKVva7ENw8=
github.com/wailsapp/go-webview2 v1.0.18 h1:SSSCoLA+MYikSp1U0WmvELF/4c3x5kH8Vi31TKyZ4yk=
github.com/wailsapp/go-webview2 v1.0.18/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc=
github.com/wailsapp/go-webview2 v1.0.19 h1:7U3QcDj1PrBPaxJNCui2k1SkWml+Q5kvFUFyTImA6NU=
github.com/wailsapp/go-webview2 v1.0.19/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.9.2 h1:Xb5YRTos1w5N7DTMyYegWaGukCP2fIaX9WF21kPPF2k=
github.com/wailsapp/wails/v2 v2.9.2/go.mod h1:uehvlCwJSFcBq7rMCGfk4rxca67QQGsbg5Nm4m9UnBs=
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
github.com/wailsapp/wails/v2 v2.10.1 h1:QWHvWMXII2nI/nXz77gpPG8P3ehl6zKe+u4su5BWIns=
github.com/wailsapp/wails/v2 v2.10.1/go.mod h1:zrebnFV6MQf9kx8HI4iAv63vsR5v67oS7GTEZ7Pz1TY=
golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
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=
@@ -81,12 +81,12 @@ golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBc
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.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
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.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
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=

Some files were not shown because too many files have changed in this diff Show More