Compare commits
157 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bb6a71f6b6 | ||
|
|
b562f76c69 | ||
|
|
8aaf95fd36 | ||
|
|
983d72d65a | ||
|
|
86378b9fba | ||
|
|
6b18e7fba1 | ||
|
|
ec11132240 | ||
|
|
dc877bd634 | ||
|
|
00b4bf4068 | ||
|
|
51c43564b6 | ||
|
|
820a2671cf | ||
|
|
3b4443110e | ||
|
|
ffd5b29030 | ||
|
|
779f56dd91 | ||
|
|
2beecdade2 | ||
|
|
bca2e110de | ||
|
|
8d55a86c06 | ||
|
|
f61199bed6 | ||
|
|
2d75bbb5c3 | ||
|
|
55d3f06cb6 | ||
|
|
1809847b8a | ||
|
|
da8e8d9641 | ||
|
|
ead622d95e | ||
|
|
c47fcba36b | ||
|
|
54c0da081c | ||
|
|
4bead0752d | ||
|
|
bd7828b73f | ||
|
|
4706540475 | ||
|
|
0daec66fa6 | ||
|
|
379ae22db7 | ||
|
|
2d1fc4273a | ||
|
|
55b67a0efa | ||
|
|
ace4625a27 | ||
|
|
6fb0474154 | ||
|
|
86ef0d3331 | ||
|
|
cfa9d4929f | ||
|
|
3c40ada451 | ||
|
|
9ec4eca558 | ||
|
|
f295fb6b64 | ||
|
|
3dc4322258 | ||
|
|
3910d0ffb0 | ||
|
|
af75f1ce4f | ||
|
|
a016465bea | ||
|
|
fd5e289c87 | ||
|
|
2a2ca7eb4e | ||
|
|
a7ec61b8e2 | ||
|
|
84c882d573 | ||
|
|
567eb2903d | ||
|
|
31073eb57e | ||
|
|
5613e21138 | ||
|
|
0a516b2f3c | ||
|
|
d3d8983307 | ||
|
|
59e2b1b267 | ||
|
|
4ebbc2347f | ||
|
|
25ab8edd20 | ||
|
|
f4bc3c7b53 | ||
|
|
ec89dc362f | ||
|
|
821d9949ab | ||
|
|
e97120cf06 | ||
|
|
67f11d2b93 | ||
|
|
3be6b8cd91 | ||
|
|
db3ff8e0d2 | ||
|
|
405d0bbdb2 | ||
|
|
6c21e37ce4 | ||
|
|
b74e2a2bf6 | ||
|
|
deb3e83082 | ||
|
|
ff90c4ff03 | ||
|
|
7f3d63532c | ||
|
|
bd2fa75cde | ||
|
|
27f9fb0def | ||
|
|
7a07456b2f | ||
|
|
3bce1f0332 | ||
|
|
5a92d7beb7 | ||
|
|
f28cb69826 | ||
|
|
14d18ad310 | ||
|
|
ee6698a8e8 | ||
|
|
7f2b99b51f | ||
|
|
da9039ea9c | ||
|
|
dc46668f0d | ||
|
|
142a2a84b4 | ||
|
|
84b6a142fc | ||
|
|
a853f1d991 | ||
|
|
dc261bb6ce | ||
|
|
d0ea8e4fab | ||
|
|
c1cce920a4 | ||
|
|
f0495c6858 | ||
|
|
a37bde428d | ||
|
|
7793f83ea3 | ||
|
|
29cc879b85 | ||
|
|
abfdb76589 | ||
|
|
04e4f0e9cc | ||
|
|
625cfbc474 | ||
|
|
d8857bd4a2 | ||
|
|
a247c708f6 | ||
|
|
85781a150a | ||
|
|
3f07bae796 | ||
|
|
6086bd7086 | ||
|
|
62bf0e2308 | ||
|
|
0bb1a21a76 | ||
|
|
575e2d8904 | ||
|
|
db21134550 | ||
|
|
3407490f82 | ||
|
|
dad06f6cd6 | ||
|
|
de70fc66b4 | ||
|
|
2282f72b2f | ||
|
|
e00a7c9044 | ||
|
|
e79a7ba2fe | ||
|
|
791e50411d | ||
|
|
df8eb0e4cd | ||
|
|
3e291171c2 | ||
|
|
bd8f8e80c9 | ||
|
|
8ed7e144e1 | ||
|
|
69f8224453 | ||
|
|
3c0e51a9e2 | ||
|
|
0a6679b983 | ||
|
|
6d1024806c | ||
|
|
388e3f46a4 | ||
|
|
f7c8e9f7db | ||
|
|
7ca484f45d | ||
|
|
cecb13fa90 | ||
|
|
331478d370 | ||
|
|
2481407093 | ||
|
|
e024a812d0 | ||
|
|
f5b5767997 | ||
|
|
5e7022ec81 | ||
|
|
e54177e8bf | ||
|
|
69cc5383d1 | ||
|
|
11f88e86e3 | ||
|
|
9e87e64223 | ||
|
|
9ffef9db8e | ||
|
|
6d2705112d | ||
|
|
f92d898136 | ||
|
|
d6b4c37138 | ||
|
|
b87ff38353 | ||
|
|
d9259bb259 | ||
|
|
8756e5357a | ||
|
|
4c09b8f3be | ||
|
|
6f3e361ee6 | ||
|
|
93d9dbe32b | ||
|
|
fd89b7125c | ||
|
|
bb0e97c402 | ||
|
|
edd097855b | ||
|
|
4d35c44247 | ||
|
|
d7e34d9c21 | ||
|
|
fc06c29759 | ||
|
|
c65702e215 | ||
|
|
96300164da | ||
|
|
1b35475302 | ||
|
|
240bae9be9 | ||
|
|
46b1592a7f | ||
|
|
1cae714fd2 | ||
|
|
5e63624955 | ||
|
|
7584156262 | ||
|
|
d23c09748b | ||
|
|
936adca3f2 | ||
|
|
37424da698 | ||
|
|
7fd4877087 |
@@ -1 +0,0 @@
|
||||
VITE_APP_API=""
|
||||
@@ -1 +0,0 @@
|
||||
VITE_APP_API=""
|
||||
42
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal 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
@@ -0,0 +1 @@
|
||||
blank_issues_enabled: true
|
||||
31
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal 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
|
||||
36
.gitignore
vendored
@@ -1,33 +1,5 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
temp
|
||||
test
|
||||
dist-ssr
|
||||
dist-electron
|
||||
release
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/.debug.env
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
.env
|
||||
|
||||
# lockfile
|
||||
package-lock.json
|
||||
yarn.lock
|
||||
pnpm-lock.yaml
|
||||
build/bin
|
||||
node_modules
|
||||
frontend/dist
|
||||
.DS_Store
|
||||
214
LICENSE
@@ -1,21 +1,201 @@
|
||||
MIT License
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
Copyright (c) 2023 草鞋没号
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
1. Definitions.
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
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
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
104
README-EN.md
Normal 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>
|
||||
|
||||
[](https://github.com/putyy/res-downloader/stargazers)
|
||||
[](https://github.com/putyy/res-downloader/fork)
|
||||
[](https://github.com/putyy/res-downloader/releases)
|
||||

|
||||
[](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
|
||||
|
||||

|
||||
|
||||
## 🚀 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.
|
||||
131
README.md
@@ -1,63 +1,100 @@
|
||||
## res-downloader(爱享素材下载器) 【[点击加入群聊](https://qm.qq.com/q/W8mVeZideE)】
|
||||
🎯 基于 [electron-vite-vue](https://github.com/electron-vite/electron-vite-vue.git)
|
||||
📦 操作简单、可获取不同类型的资源
|
||||
🖥️ 支持Win10、Win11、Mac
|
||||
🌐 支持视频、音频、图片、m3u8等网络资源下载
|
||||
💪 支持微信视频号、小程序、抖音、快手、小红书、酷狗音乐、qq音乐等网络资源下载
|
||||
👼 支持设置代理以获取特殊网络下的资源
|
||||
<div align="center">
|
||||
|
||||
## 软件下载
|
||||
🆕 [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. 返回软件首页即可看到资源列表
|
||||
[](https://github.com/putyy/res-downloader/stargazers)
|
||||
[](https://github.com/putyy/res-downloader/fork)
|
||||
[](https://github.com/putyy/res-downloader/releases)
|
||||

|
||||
[](https://github.com/putyy/res-downloader/blob/master/LICENSE)
|
||||
|
||||
## 软件截图
|
||||

|
||||
</div>
|
||||
|
||||
## 常见问题
|
||||
下载慢、大视频下载失败
|
||||
> 推荐使用如下工具加速下载,视频号可以下载完成后再到对应视频操作项选择 “视频解密(视频号)” 按钮
|
||||
>> [Neat Download Manager](https://www.neatdownloadmanager.com/index.php/en/)、[Motrix](https://motrix.app/download)等软件进行下载
|
||||
---
|
||||
|
||||
Win7无法使用
|
||||
> 软件不支持,也无计划支持
|
||||
### 🎉 爱享素材下载器
|
||||
|
||||
打开本软件,无法正常拦截获取
|
||||
> 检查系统代理是否正确设置 代理地址:127.0.0.1 端口:8899
|
||||
> 一款基于 Go + [Wails](https://github.com/wailsapp/wails) 的跨平台资源下载工具,简洁易用,支持多种资源嗅探与下载。
|
||||
|
||||
关闭软件后无法正常上网
|
||||
> 手动关闭系统代理设置
|
||||
## ✨ 功能特色
|
||||
|
||||
打开本软件后无法上网
|
||||
> 手动删除安装标识锁文件,之后再打开软件会进行检查证书是否正确安装
|
||||
>> MAC: /Users/你的用户名称/.res-downloader@putyy/res-downloader-installed.lock
|
||||
>> Win: C:\Users\Admin\.res-downloader@putyy/res-downloader-installed.lock
|
||||
- 🚀 **简单易用**:操作简单,界面清晰美观
|
||||
- 🖥️ **多平台支持**:Windows / macOS / Linux
|
||||
- 🌐 **多资源类型支持**:视频 / 音频 / 图片 / m3u8 / 直播流等
|
||||
- 📱 **平台兼容广泛**:支持微信视频号、小程序、抖音、快手、小红书、酷狗音乐、QQ音乐等
|
||||
- 🌍 **代理抓包**:支持设置代理获取受限网络下的资源
|
||||
|
||||
其他问题请留言 https://github.com/putyy/res-downloader/issues
|
||||
## 📚 文档 & 版本
|
||||
|
||||
## 二次开发
|
||||
> ps: 打包慢的问题可以参考 https://www.putyy.com/articles/87
|
||||
```sh
|
||||
git clone https://github.com/putyy/res-downloader
|
||||
- 📘 [在线文档](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”*
|
||||
|
||||
cd res-downloader
|
||||
## 🧩 下载地址
|
||||
|
||||
yarn install
|
||||
- 🆕 [GitHub 下载](https://github.com/putyy/res-downloader/releases)
|
||||
- 🆕 [蓝奏云下载(密码:9vs5)](https://wwjv.lanzoum.com/b04wgtfyb)
|
||||
- ⚠️ *Win7 用户请下载 `2.3.0` 版本*
|
||||
|
||||
yarn run dev
|
||||
|
||||
# 打包mac
|
||||
yarn run build --universal --mac
|
||||
## 🖼️ 预览
|
||||
|
||||
# 打包win
|
||||
yarn run build --win
|
||||
```
|
||||

|
||||
---
|
||||
|
||||
## 免责声明
|
||||
本软件用于学习研究使用,若因使用本软件造成的一切法律责任均与本人无关!
|
||||
## 🚀 使用方法
|
||||
|
||||
> 请按以下步骤操作以正确使用软件:
|
||||
|
||||
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 原理类似,但对资源进行了更友好的筛选、展示和处理,大幅度降低了使用门槛,更适合大众用户使用。
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 免责声明
|
||||
|
||||
> 本软件仅供学习与研究用途,禁止用于任何商业或违法用途。
|
||||
如因此产生的任何法律责任,概与作者无关!
|
||||
|
||||
9
auto-imports.d.ts
vendored
@@ -1,9 +0,0 @@
|
||||
/* eslint-disable */
|
||||
/* prettier-ignore */
|
||||
// @ts-nocheck
|
||||
// noinspection JSUnusedGlobalSymbols
|
||||
// Generated by unplugin-auto-import
|
||||
export {}
|
||||
declare global {
|
||||
|
||||
}
|
||||
95
build/README.md
Normal file
@@ -0,0 +1,95 @@
|
||||
## Mac
|
||||
```bash
|
||||
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 -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
|
||||
|
||||
### docker方式
|
||||
> x86_64
|
||||
```bash
|
||||
docker build --network host -f build/linux/dockerfile -t res-downloader-amd-linux .
|
||||
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 -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" -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
|
||||
|
||||
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)_linux_amd64.AppImage
|
||||
|
||||
mv -f build/bin/res-downloader build/bin/res-downloader_$(jq -r '.info.productVersion' wails.json)_linux_amd64
|
||||
```
|
||||
|
||||
> arm64
|
||||
```bash
|
||||
# arm
|
||||
docker build --platform linux/arm64 --network host -f build/linux/dockerfile -t res-downloader-arm-linux .
|
||||
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 -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" -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
|
||||
```
|
||||
|
||||
### Arch Linux
|
||||
|
||||
[](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 build/linux/Arch/res-downloader.desktop /usr/share/applications/res-downloader.desktop
|
||||
```
|
||||
|
Before Width: | Height: | Size: 120 KiB After Width: | Height: | Size: 120 KiB |
68
build/darwin/Info.dev.plist
Normal file
@@ -0,0 +1,68 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>{{.Info.ProductName}}</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>{{.Name}}</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>com.wails.{{.Name}}</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>{{.Info.ProductVersion}}</string>
|
||||
<key>CFBundleGetInfoString</key>
|
||||
<string>{{.Info.Comments}}</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>{{.Info.ProductVersion}}</string>
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>iconfile</string>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>10.13.0</string>
|
||||
<key>NSHighResolutionCapable</key>
|
||||
<string>true</string>
|
||||
<key>NSHumanReadableCopyright</key>
|
||||
<string>{{.Info.Copyright}}</string>
|
||||
{{if .Info.FileAssociations}}
|
||||
<key>CFBundleDocumentTypes</key>
|
||||
<array>
|
||||
{{range .Info.FileAssociations}}
|
||||
<dict>
|
||||
<key>CFBundleTypeExtensions</key>
|
||||
<array>
|
||||
<string>{{.Ext}}</string>
|
||||
</array>
|
||||
<key>CFBundleTypeName</key>
|
||||
<string>{{.Name}}</string>
|
||||
<key>CFBundleTypeRole</key>
|
||||
<string>{{.Role}}</string>
|
||||
<key>CFBundleTypeIconFile</key>
|
||||
<string>{{.IconName}}</string>
|
||||
</dict>
|
||||
{{end}}
|
||||
</array>
|
||||
{{end}}
|
||||
{{if .Info.Protocols}}
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
{{range .Info.Protocols}}
|
||||
<dict>
|
||||
<key>CFBundleURLName</key>
|
||||
<string>com.wails.{{.Scheme}}</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>{{.Scheme}}</string>
|
||||
</array>
|
||||
<key>CFBundleTypeRole</key>
|
||||
<string>{{.Role}}</string>
|
||||
</dict>
|
||||
{{end}}
|
||||
</array>
|
||||
{{end}}
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsLocalNetworking</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
63
build/darwin/Info.plist
Normal file
@@ -0,0 +1,63 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>{{.Info.ProductName}}</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>{{.Name}}</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>com.wails.{{.Name}}</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>{{.Info.ProductVersion}}</string>
|
||||
<key>CFBundleGetInfoString</key>
|
||||
<string>{{.Info.Comments}}</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>{{.Info.ProductVersion}}</string>
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>iconfile</string>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>10.13.0</string>
|
||||
<key>NSHighResolutionCapable</key>
|
||||
<string>true</string>
|
||||
<key>NSHumanReadableCopyright</key>
|
||||
<string>{{.Info.Copyright}}</string>
|
||||
{{if .Info.FileAssociations}}
|
||||
<key>CFBundleDocumentTypes</key>
|
||||
<array>
|
||||
{{range .Info.FileAssociations}}
|
||||
<dict>
|
||||
<key>CFBundleTypeExtensions</key>
|
||||
<array>
|
||||
<string>{{.Ext}}</string>
|
||||
</array>
|
||||
<key>CFBundleTypeName</key>
|
||||
<string>{{.Name}}</string>
|
||||
<key>CFBundleTypeRole</key>
|
||||
<string>{{.Role}}</string>
|
||||
<key>CFBundleTypeIconFile</key>
|
||||
<string>{{.IconName}}</string>
|
||||
</dict>
|
||||
{{end}}
|
||||
</array>
|
||||
{{end}}
|
||||
{{if .Info.Protocols}}
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
{{range .Info.Protocols}}
|
||||
<dict>
|
||||
<key>CFBundleURLName</key>
|
||||
<string>com.wails.{{.Scheme}}</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>{{.Scheme}}</string>
|
||||
</array>
|
||||
<key>CFBundleTypeRole</key>
|
||||
<string>{{.Role}}</string>
|
||||
</dict>
|
||||
{{end}}
|
||||
</array>
|
||||
{{end}}
|
||||
</dict>
|
||||
</plist>
|
||||
5
build/linux/.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
!.gitkeep
|
||||
debian/usr/local/bin/*
|
||||
debian/DEBIAN/control
|
||||
AppImage/usr/bin/*
|
||||
AppImage/usr/lib/*
|
||||
1
build/linux/AppImage/.DirIcon
Symbolic link
@@ -0,0 +1 @@
|
||||
res-downloader.png
|
||||
8
build/linux/AppImage/res-downloader.desktop
Normal 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=/usr/bin/res-downloader
|
||||
Icon=/usr/share/icons/hicolor/256x256/apps/res-downloader
|
||||
Terminal=false
|
||||
Categories=Utility;
|
||||
BIN
build/linux/AppImage/usr/.DS_Store
vendored
Normal file
0
build/linux/AppImage/usr/bin/.gitkeep
Normal 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=/usr/bin/res-downloader
|
||||
Icon=/usr/share/icons/hicolor/256x256/apps/res-downloader
|
||||
Terminal=false
|
||||
Categories=Utility;
|
||||
|
After Width: | Height: | Size: 45 KiB |
8
build/linux/Arch/res-downloader.desktop
Normal 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;
|
||||
BIN
build/linux/Debian/.DS_Store
vendored
Normal file
BIN
build/linux/Debian/DEBIAN/.DS_Store
vendored
Normal file
9
build/linux/Debian/DEBIAN/.control
Normal file
@@ -0,0 +1,9 @@
|
||||
Package: res-downloader
|
||||
Version: {{Version}}
|
||||
Section: utils
|
||||
Priority: optional
|
||||
Architecture: {{Architecture}}
|
||||
Depends: libwebkit2gtk-4.0-37
|
||||
Maintainer: putyy@qq.com
|
||||
Homepage: https://github.com/putyy/res-downloader
|
||||
Description: This is a high-value and high-performance and diverse resource downloader called res-downloader
|
||||
BIN
build/linux/Debian/usr/local/.DS_Store
vendored
Normal file
0
build/linux/Debian/usr/local/bin/.gitkeep
Normal 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=/usr/local/bin/res-downloader
|
||||
Icon=/usr/share/icons/hicolor/256x256/apps/res-downloader.png
|
||||
Terminal=false
|
||||
Categories=Utility;
|
||||
|
After Width: | Height: | Size: 45 KiB |
29
build/linux/dockerfile
Normal file
@@ -0,0 +1,29 @@
|
||||
FROM golang:1.24.2-bookworm
|
||||
|
||||
WORKDIR /
|
||||
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --fix-missing \
|
||||
build-essential \
|
||||
git \
|
||||
jq \
|
||||
kmod \
|
||||
fuse \
|
||||
libgtk-3-dev \
|
||||
libwebkit2gtk-4.0-dev \
|
||||
nsis \
|
||||
wget \
|
||||
curl \
|
||||
gnupg2 \
|
||||
lsb-release \
|
||||
libfuse-dev \
|
||||
libfuse2 \
|
||||
file \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN curl -sL https://deb.nodesource.com/setup_20.x | bash - \
|
||||
&& apt-get install -y nodejs
|
||||
|
||||
RUN go install github.com/wailsapp/wails/v2/cmd/wails@latest
|
||||
|
||||
RUN wails doctor
|
||||
BIN
build/windows/icon.ico
Normal file
|
After Width: | Height: | Size: 264 KiB |
15
build/windows/info.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"fixed": {
|
||||
"file_version": "{{.Info.ProductVersion}}"
|
||||
},
|
||||
"info": {
|
||||
"0000": {
|
||||
"ProductVersion": "{{.Info.ProductVersion}}",
|
||||
"CompanyName": "{{.Info.CompanyName}}",
|
||||
"FileDescription": "{{.Info.ProductName}}",
|
||||
"LegalCopyright": "{{.Info.Copyright}}",
|
||||
"ProductName": "{{.Info.ProductName}}",
|
||||
"Comments": "{{.Info.Comments}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
114
build/windows/installer/project.nsi
Normal file
@@ -0,0 +1,114 @@
|
||||
Unicode true
|
||||
|
||||
####
|
||||
## Please note: Template replacements don't work in this file. They are provided with default defines like
|
||||
## mentioned underneath.
|
||||
## If the keyword is not defined, "wails_tools.nsh" will populate them with the values from ProjectInfo.
|
||||
## If they are defined here, "wails_tools.nsh" will not touch them. This allows to use this project.nsi manually
|
||||
## from outside of Wails for debugging and development of the installer.
|
||||
##
|
||||
## For development first make a wails nsis build to populate the "wails_tools.nsh":
|
||||
## > wails build --target windows/amd64 --nsis
|
||||
## Then you can call makensis on this file with specifying the path to your binary:
|
||||
## For a AMD64 only installer:
|
||||
## > makensis -DARG_WAILS_AMD64_BINARY=..\..\bin\app.exe
|
||||
## For a ARM64 only installer:
|
||||
## > makensis -DARG_WAILS_ARM64_BINARY=..\..\bin\app.exe
|
||||
## For a installer with both architectures:
|
||||
## > makensis -DARG_WAILS_AMD64_BINARY=..\..\bin\app-amd64.exe -DARG_WAILS_ARM64_BINARY=..\..\bin\app-arm64.exe
|
||||
####
|
||||
## The following information is taken from the ProjectInfo file, but they can be overwritten here.
|
||||
####
|
||||
## !define INFO_PROJECTNAME "MyProject" # Default "{{.Name}}"
|
||||
## !define INFO_COMPANYNAME "MyCompany" # Default "{{.Info.CompanyName}}"
|
||||
## !define INFO_PRODUCTNAME "MyProduct" # Default "{{.Info.ProductName}}"
|
||||
## !define INFO_PRODUCTVERSION "1.0.0" # Default "{{.Info.ProductVersion}}"
|
||||
## !define INFO_COPYRIGHT "Copyright" # Default "{{.Info.Copyright}}"
|
||||
###
|
||||
## !define PRODUCT_EXECUTABLE "Application.exe" # Default "${INFO_PROJECTNAME}.exe"
|
||||
## !define UNINST_KEY_NAME "UninstKeyInRegistry" # Default "${INFO_COMPANYNAME}${INFO_PRODUCTNAME}"
|
||||
####
|
||||
## !define REQUEST_EXECUTION_LEVEL "admin" # Default "admin" see also https://nsis.sourceforge.io/Docs/Chapter4.html
|
||||
####
|
||||
## Include the wails tools
|
||||
####
|
||||
!include "wails_tools.nsh"
|
||||
|
||||
# The version information for this two must consist of 4 parts
|
||||
VIProductVersion "${INFO_PRODUCTVERSION}.0"
|
||||
VIFileVersion "${INFO_PRODUCTVERSION}.0"
|
||||
|
||||
VIAddVersionKey "CompanyName" "${INFO_COMPANYNAME}"
|
||||
VIAddVersionKey "FileDescription" "${INFO_PRODUCTNAME} Installer"
|
||||
VIAddVersionKey "ProductVersion" "${INFO_PRODUCTVERSION}"
|
||||
VIAddVersionKey "FileVersion" "${INFO_PRODUCTVERSION}"
|
||||
VIAddVersionKey "LegalCopyright" "${INFO_COPYRIGHT}"
|
||||
VIAddVersionKey "ProductName" "${INFO_PRODUCTNAME}"
|
||||
|
||||
# Enable HiDPI support. https://nsis.sourceforge.io/Reference/ManifestDPIAware
|
||||
ManifestDPIAware true
|
||||
|
||||
!include "MUI.nsh"
|
||||
|
||||
!define MUI_ICON "..\icon.ico"
|
||||
!define MUI_UNICON "..\icon.ico"
|
||||
# !define MUI_WELCOMEFINISHPAGE_BITMAP "resources\leftimage.bmp" #Include this to add a bitmap on the left side of the Welcome Page. Must be a size of 164x314
|
||||
!define MUI_FINISHPAGE_NOAUTOCLOSE # Wait on the INSTFILES page so the user can take a look into the details of the installation steps
|
||||
!define MUI_ABORTWARNING # This will warn the user if they exit from the installer.
|
||||
|
||||
!insertmacro MUI_PAGE_WELCOME # Welcome to the installer page.
|
||||
# !insertmacro MUI_PAGE_LICENSE "resources\eula.txt" # Adds a EULA page to the installer
|
||||
!insertmacro MUI_PAGE_DIRECTORY # In which folder install page.
|
||||
!insertmacro MUI_PAGE_INSTFILES # Installing page.
|
||||
!insertmacro MUI_PAGE_FINISH # Finished installation page.
|
||||
|
||||
!insertmacro MUI_UNPAGE_INSTFILES # Uinstalling page
|
||||
|
||||
!insertmacro MUI_LANGUAGE "English" # Set the Language of the installer
|
||||
|
||||
## The following two statements can be used to sign the installer and the uninstaller. The path to the binaries are provided in %1
|
||||
#!uninstfinalize 'signtool --file "%1"'
|
||||
#!finalize 'signtool --file "%1"'
|
||||
|
||||
Name "${INFO_PRODUCTNAME}"
|
||||
OutFile "..\..\bin\${INFO_PROJECTNAME}-${ARCH}-installer.exe" # Name of the installer's file.
|
||||
InstallDir "$PROGRAMFILES64\${INFO_COMPANYNAME}\${INFO_PRODUCTNAME}" # Default installing folder ($PROGRAMFILES is Program Files folder).
|
||||
ShowInstDetails show # This will always show the installation details.
|
||||
|
||||
Function .onInit
|
||||
!insertmacro wails.checkArchitecture
|
||||
FunctionEnd
|
||||
|
||||
Section
|
||||
!insertmacro wails.setShellContext
|
||||
|
||||
!insertmacro wails.webview2runtime
|
||||
|
||||
SetOutPath $INSTDIR
|
||||
|
||||
!insertmacro wails.files
|
||||
|
||||
CreateShortcut "$SMPROGRAMS\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${PRODUCT_EXECUTABLE}"
|
||||
CreateShortCut "$DESKTOP\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${PRODUCT_EXECUTABLE}"
|
||||
|
||||
!insertmacro wails.associateFiles
|
||||
!insertmacro wails.associateCustomProtocols
|
||||
|
||||
!insertmacro wails.writeUninstaller
|
||||
SectionEnd
|
||||
|
||||
Section "uninstall"
|
||||
!insertmacro wails.setShellContext
|
||||
|
||||
RMDir /r "$AppData\${PRODUCT_EXECUTABLE}" # Remove the WebView2 DataPath
|
||||
|
||||
RMDir /r $INSTDIR
|
||||
|
||||
Delete "$SMPROGRAMS\${INFO_PRODUCTNAME}.lnk"
|
||||
Delete "$DESKTOP\${INFO_PRODUCTNAME}.lnk"
|
||||
|
||||
!insertmacro wails.unassociateFiles
|
||||
!insertmacro wails.unassociateCustomProtocols
|
||||
|
||||
!insertmacro wails.deleteUninstaller
|
||||
SectionEnd
|
||||
BIN
build/windows/installer/tmp/MicrosoftEdgeWebview2Setup.exe
Executable file
236
build/windows/installer/wails_tools.nsh
Normal file
@@ -0,0 +1,236 @@
|
||||
# DO NOT EDIT - Generated automatically by `wails build`
|
||||
|
||||
!include "x64.nsh"
|
||||
!include "WinVer.nsh"
|
||||
!include "FileFunc.nsh"
|
||||
|
||||
!ifndef INFO_PROJECTNAME
|
||||
!define INFO_PROJECTNAME "res-downloader"
|
||||
!endif
|
||||
!ifndef INFO_COMPANYNAME
|
||||
!define INFO_COMPANYNAME "res-downloader"
|
||||
!endif
|
||||
!ifndef INFO_PRODUCTNAME
|
||||
!define INFO_PRODUCTNAME "res-downloader"
|
||||
!endif
|
||||
!ifndef INFO_PRODUCTVERSION
|
||||
!define INFO_PRODUCTVERSION "3.1.3"
|
||||
!endif
|
||||
!ifndef INFO_COPYRIGHT
|
||||
!define INFO_COPYRIGHT "Copyright © 2023"
|
||||
!endif
|
||||
!ifndef PRODUCT_EXECUTABLE
|
||||
!define PRODUCT_EXECUTABLE "${INFO_PROJECTNAME}.exe"
|
||||
!endif
|
||||
!ifndef UNINST_KEY_NAME
|
||||
!define UNINST_KEY_NAME "${INFO_COMPANYNAME}${INFO_PRODUCTNAME}"
|
||||
!endif
|
||||
!define UNINST_KEY "Software\Microsoft\Windows\CurrentVersion\Uninstall\${UNINST_KEY_NAME}"
|
||||
|
||||
!ifndef REQUEST_EXECUTION_LEVEL
|
||||
!define REQUEST_EXECUTION_LEVEL "admin"
|
||||
!endif
|
||||
|
||||
RequestExecutionLevel "${REQUEST_EXECUTION_LEVEL}"
|
||||
|
||||
!ifdef ARG_WAILS_AMD64_BINARY
|
||||
!define SUPPORTS_AMD64
|
||||
!endif
|
||||
|
||||
!ifdef ARG_WAILS_ARM64_BINARY
|
||||
!define SUPPORTS_ARM64
|
||||
!endif
|
||||
|
||||
!ifdef SUPPORTS_AMD64
|
||||
!ifdef SUPPORTS_ARM64
|
||||
!define ARCH "amd64_arm64"
|
||||
!else
|
||||
!define ARCH "amd64"
|
||||
!endif
|
||||
!else
|
||||
!ifdef SUPPORTS_ARM64
|
||||
!define ARCH "arm64"
|
||||
!else
|
||||
!error "Wails: Undefined ARCH, please provide at least one of ARG_WAILS_AMD64_BINARY or ARG_WAILS_ARM64_BINARY"
|
||||
!endif
|
||||
!endif
|
||||
|
||||
!macro wails.checkArchitecture
|
||||
!ifndef WAILS_WIN10_REQUIRED
|
||||
!define WAILS_WIN10_REQUIRED "This product is only supported on Windows 10 (Server 2016) and later."
|
||||
!endif
|
||||
|
||||
!ifndef WAILS_ARCHITECTURE_NOT_SUPPORTED
|
||||
!define WAILS_ARCHITECTURE_NOT_SUPPORTED "This product can't be installed on the current Windows architecture. Supports: ${ARCH}"
|
||||
!endif
|
||||
|
||||
${If} ${AtLeastWin10}
|
||||
!ifdef SUPPORTS_AMD64
|
||||
${if} ${IsNativeAMD64}
|
||||
Goto ok
|
||||
${EndIf}
|
||||
!endif
|
||||
|
||||
!ifdef SUPPORTS_ARM64
|
||||
${if} ${IsNativeARM64}
|
||||
Goto ok
|
||||
${EndIf}
|
||||
!endif
|
||||
|
||||
IfSilent silentArch notSilentArch
|
||||
silentArch:
|
||||
SetErrorLevel 65
|
||||
Abort
|
||||
notSilentArch:
|
||||
MessageBox MB_OK "${WAILS_ARCHITECTURE_NOT_SUPPORTED}"
|
||||
Quit
|
||||
${else}
|
||||
IfSilent silentWin notSilentWin
|
||||
silentWin:
|
||||
SetErrorLevel 64
|
||||
Abort
|
||||
notSilentWin:
|
||||
MessageBox MB_OK "${WAILS_WIN10_REQUIRED}"
|
||||
Quit
|
||||
${EndIf}
|
||||
|
||||
ok:
|
||||
!macroend
|
||||
|
||||
!macro wails.files
|
||||
!ifdef SUPPORTS_AMD64
|
||||
${if} ${IsNativeAMD64}
|
||||
File "/oname=${PRODUCT_EXECUTABLE}" "${ARG_WAILS_AMD64_BINARY}"
|
||||
${EndIf}
|
||||
!endif
|
||||
|
||||
!ifdef SUPPORTS_ARM64
|
||||
${if} ${IsNativeARM64}
|
||||
File "/oname=${PRODUCT_EXECUTABLE}" "${ARG_WAILS_ARM64_BINARY}"
|
||||
${EndIf}
|
||||
!endif
|
||||
!macroend
|
||||
|
||||
!macro wails.writeUninstaller
|
||||
WriteUninstaller "$INSTDIR\uninstall.exe"
|
||||
|
||||
SetRegView 64
|
||||
WriteRegStr HKLM "${UNINST_KEY}" "Publisher" "${INFO_COMPANYNAME}"
|
||||
WriteRegStr HKLM "${UNINST_KEY}" "DisplayName" "${INFO_PRODUCTNAME}"
|
||||
WriteRegStr HKLM "${UNINST_KEY}" "DisplayVersion" "${INFO_PRODUCTVERSION}"
|
||||
WriteRegStr HKLM "${UNINST_KEY}" "DisplayIcon" "$INSTDIR\${PRODUCT_EXECUTABLE}"
|
||||
WriteRegStr HKLM "${UNINST_KEY}" "UninstallString" "$\"$INSTDIR\uninstall.exe$\""
|
||||
WriteRegStr HKLM "${UNINST_KEY}" "QuietUninstallString" "$\"$INSTDIR\uninstall.exe$\" /S"
|
||||
|
||||
${GetSize} "$INSTDIR" "/S=0K" $0 $1 $2
|
||||
IntFmt $0 "0x%08X" $0
|
||||
WriteRegDWORD HKLM "${UNINST_KEY}" "EstimatedSize" "$0"
|
||||
!macroend
|
||||
|
||||
!macro wails.deleteUninstaller
|
||||
Delete "$INSTDIR\uninstall.exe"
|
||||
|
||||
SetRegView 64
|
||||
DeleteRegKey HKLM "${UNINST_KEY}"
|
||||
!macroend
|
||||
|
||||
!macro wails.setShellContext
|
||||
${If} ${REQUEST_EXECUTION_LEVEL} == "admin"
|
||||
SetShellVarContext all
|
||||
${else}
|
||||
SetShellVarContext current
|
||||
${EndIf}
|
||||
!macroend
|
||||
|
||||
# Install webview2 by launching the bootstrapper
|
||||
# See https://docs.microsoft.com/en-us/microsoft-edge/webview2/concepts/distribution#online-only-deployment
|
||||
!macro wails.webview2runtime
|
||||
!ifndef WAILS_INSTALL_WEBVIEW_DETAILPRINT
|
||||
!define WAILS_INSTALL_WEBVIEW_DETAILPRINT "Installing: WebView2 Runtime"
|
||||
!endif
|
||||
|
||||
SetRegView 64
|
||||
# If the admin key exists and is not empty then webview2 is already installed
|
||||
ReadRegStr $0 HKLM "SOFTWARE\WOW6432Node\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" "pv"
|
||||
${If} $0 != ""
|
||||
Goto ok
|
||||
${EndIf}
|
||||
|
||||
${If} ${REQUEST_EXECUTION_LEVEL} == "user"
|
||||
# If the installer is run in user level, check the user specific key exists and is not empty then webview2 is already installed
|
||||
ReadRegStr $0 HKCU "Software\Microsoft\EdgeUpdate\Clients{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" "pv"
|
||||
${If} $0 != ""
|
||||
Goto ok
|
||||
${EndIf}
|
||||
${EndIf}
|
||||
|
||||
SetDetailsPrint both
|
||||
DetailPrint "${WAILS_INSTALL_WEBVIEW_DETAILPRINT}"
|
||||
SetDetailsPrint listonly
|
||||
|
||||
InitPluginsDir
|
||||
CreateDirectory "$pluginsdir\webview2bootstrapper"
|
||||
SetOutPath "$pluginsdir\webview2bootstrapper"
|
||||
File "tmp\MicrosoftEdgeWebview2Setup.exe"
|
||||
ExecWait '"$pluginsdir\webview2bootstrapper\MicrosoftEdgeWebview2Setup.exe" /silent /install'
|
||||
|
||||
SetDetailsPrint both
|
||||
ok:
|
||||
!macroend
|
||||
|
||||
# Copy of APP_ASSOCIATE and APP_UNASSOCIATE macros from here https://gist.github.com/nikku/281d0ef126dbc215dd58bfd5b3a5cd5b
|
||||
!macro APP_ASSOCIATE EXT FILECLASS DESCRIPTION ICON COMMANDTEXT COMMAND
|
||||
; Backup the previously associated file class
|
||||
ReadRegStr $R0 SHELL_CONTEXT "Software\Classes\.${EXT}" ""
|
||||
WriteRegStr SHELL_CONTEXT "Software\Classes\.${EXT}" "${FILECLASS}_backup" "$R0"
|
||||
|
||||
WriteRegStr SHELL_CONTEXT "Software\Classes\.${EXT}" "" "${FILECLASS}"
|
||||
|
||||
WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}" "" `${DESCRIPTION}`
|
||||
WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\DefaultIcon" "" `${ICON}`
|
||||
WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\shell" "" "open"
|
||||
WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\shell\open" "" `${COMMANDTEXT}`
|
||||
WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\shell\open\command" "" `${COMMAND}`
|
||||
!macroend
|
||||
|
||||
!macro APP_UNASSOCIATE EXT FILECLASS
|
||||
; Backup the previously associated file class
|
||||
ReadRegStr $R0 SHELL_CONTEXT "Software\Classes\.${EXT}" `${FILECLASS}_backup`
|
||||
WriteRegStr SHELL_CONTEXT "Software\Classes\.${EXT}" "" "$R0"
|
||||
|
||||
DeleteRegKey SHELL_CONTEXT `Software\Classes\${FILECLASS}`
|
||||
!macroend
|
||||
|
||||
!macro wails.associateFiles
|
||||
; Create file associations
|
||||
|
||||
!macroend
|
||||
|
||||
!macro wails.unassociateFiles
|
||||
; Delete app associations
|
||||
|
||||
!macroend
|
||||
|
||||
!macro CUSTOM_PROTOCOL_ASSOCIATE PROTOCOL DESCRIPTION ICON COMMAND
|
||||
DeleteRegKey SHELL_CONTEXT "Software\Classes\${PROTOCOL}"
|
||||
WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}" "" "${DESCRIPTION}"
|
||||
WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}" "URL Protocol" ""
|
||||
WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}\DefaultIcon" "" "${ICON}"
|
||||
WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}\shell" "" ""
|
||||
WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}\shell\open" "" ""
|
||||
WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}\shell\open\command" "" "${COMMAND}"
|
||||
!macroend
|
||||
|
||||
!macro CUSTOM_PROTOCOL_UNASSOCIATE PROTOCOL
|
||||
DeleteRegKey SHELL_CONTEXT "Software\Classes\${PROTOCOL}"
|
||||
!macroend
|
||||
|
||||
!macro wails.associateCustomProtocols
|
||||
; Create custom protocols associations
|
||||
|
||||
!macroend
|
||||
|
||||
!macro wails.unassociateCustomProtocols
|
||||
; Delete app custom protocol associations
|
||||
|
||||
!macroend
|
||||
15
build/windows/wails.exe.manifest
Normal file
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1" xmlns:asmv3="urn:schemas-microsoft-com:asm.v3">
|
||||
<assemblyIdentity type="win32" name="com.wails.{{.Name}}" version="{{.Info.ProductVersion}}.0" processorArchitecture="*"/>
|
||||
<dependency>
|
||||
<dependentAssembly>
|
||||
<assemblyIdentity type="win32" name="Microsoft.Windows.Common-Controls" version="6.0.0.0" processorArchitecture="*" publicKeyToken="6595b64144ccf1df" language="*"/>
|
||||
</dependentAssembly>
|
||||
</dependency>
|
||||
<asmv3:application>
|
||||
<asmv3:windowsSettings>
|
||||
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/pm</dpiAware> <!-- fallback for Windows 7 and 8 -->
|
||||
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">permonitorv2,permonitor</dpiAwareness> <!-- falls back to per-monitor if per-monitor v2 is not supported -->
|
||||
</asmv3:windowsSettings>
|
||||
</asmv3:application>
|
||||
</assembly>
|
||||
32
components.d.ts
vendored
@@ -1,32 +0,0 @@
|
||||
/* eslint-disable */
|
||||
/* prettier-ignore */
|
||||
// @ts-nocheck
|
||||
// Generated by unplugin-vue-components
|
||||
// Read more: https://github.com/vuejs/core/pull/3399
|
||||
export {}
|
||||
|
||||
declare module 'vue' {
|
||||
export interface GlobalComponents {
|
||||
ElButton: typeof import('element-plus/es')['ElButton']
|
||||
ElContainer: typeof import('element-plus/es')['ElContainer']
|
||||
ElFooter: typeof import('element-plus/es')['ElFooter']
|
||||
ElForm: typeof import('element-plus/es')['ElForm']
|
||||
ElFormItem: typeof import('element-plus/es')['ElFormItem']
|
||||
ElHeader: typeof import('element-plus/es')['ElHeader']
|
||||
ElIcon: typeof import('element-plus/es')['ElIcon']
|
||||
ElInput: typeof import('element-plus/es')['ElInput']
|
||||
ElLink: typeof import('element-plus/es')['ElLink']
|
||||
ElMain: typeof import('element-plus/es')['ElMain']
|
||||
ElMenu: typeof import('element-plus/es')['ElMenu']
|
||||
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
|
||||
ElOption: typeof import('element-plus/es')['ElOption']
|
||||
ElRow: typeof import('element-plus/es')['ElRow']
|
||||
ElSelect: typeof import('element-plus/es')['ElSelect']
|
||||
ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
|
||||
Footer: typeof import('./src/components/layout/Footer.vue')['default']
|
||||
Index: typeof import('./src/components/layout/Index.vue')['default']
|
||||
RouterLink: typeof import('vue-router')['RouterLink']
|
||||
RouterView: typeof import('vue-router')['RouterView']
|
||||
Sidebar: typeof import('./src/components/layout/Sidebar.vue')['default']
|
||||
}
|
||||
}
|
||||
72
core/aes.go
Normal 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
|
||||
}
|
||||
211
core/app.go
Normal file
@@ -0,0 +1,211 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"context"
|
||||
"embed"
|
||||
"fmt"
|
||||
"github.com/vrischmann/userdir"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"res-downloader/core/shared"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
type App struct {
|
||||
ctx context.Context
|
||||
assets embed.FS
|
||||
AppName string `json:"AppName"`
|
||||
Version string `json:"Version"`
|
||||
Description string `json:"Description"`
|
||||
Copyright string `json:"Copyright"`
|
||||
UserDir string `json:"-"`
|
||||
LockFile string `json:"-"`
|
||||
PublicCrt []byte `json:"-"`
|
||||
PrivateKey []byte `json:"-"`
|
||||
IsProxy bool `json:"IsProxy"`
|
||||
IsReset bool `json:"-"`
|
||||
}
|
||||
|
||||
var (
|
||||
appOnce *App
|
||||
globalConfig *Config
|
||||
globalLogger *Logger
|
||||
resourceOnce *Resource
|
||||
systemOnce *SystemSetup
|
||||
proxyOnce *Proxy
|
||||
httpServerOnce *HttpServer
|
||||
ruleOnce *RuleSet
|
||||
)
|
||||
|
||||
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: version,
|
||||
Description: "res-downloader是一款集网络资源嗅探 + 高速下载功能于一体的软件,高颜值、高性能和多样化,提供个人用户下载自己上传到各大平台的网络资源功能!",
|
||||
Copyright: "Copyright © 2023~" + strconv.Itoa(time.Now().Year()),
|
||||
IsReset: false,
|
||||
PublicCrt: []byte(`-----BEGIN CERTIFICATE-----
|
||||
MIIDwzCCAqugAwIBAgIUFAnC6268dp/z1DR9E1UepiWgWzkwDQYJKoZIhvcNAQEL
|
||||
BQAwcDELMAkGA1UEBhMCQ04xEjAQBgNVBAgMCUNob25ncWluZzESMBAGA1UEBwwJ
|
||||
Q2hvbmdxaW5nMQ4wDAYDVQQKDAVnb3dhczEWMBQGA1UECwwNSVQgRGVwYXJ0bWVu
|
||||
dDERMA8GA1UEAwwIZ293YXMuY24wIBcNMjQwMjE4MDIwOTI2WhgPMjEyNDAxMjUw
|
||||
MjA5MjZaMHAxCzAJBgNVBAYTAkNOMRIwEAYDVQQIDAlDaG9uZ3FpbmcxEjAQBgNV
|
||||
BAcMCUNob25ncWluZzEOMAwGA1UECgwFZ293YXMxFjAUBgNVBAsMDUlUIERlcGFy
|
||||
dG1lbnQxETAPBgNVBAMMCGdvd2FzLmNuMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A
|
||||
MIIBCgKCAQEA3A7dt7eoqAaBxv2Npjo8Z7VkGvXT93jZfpgAuuNuQ5RLcnOnMzQC
|
||||
CrrjPcLfsAMA0AIK3eUWsXXKSR9SZTJBLQRZCJHZ9AIPfA+58JVQPTjd8UIuQZJf
|
||||
rDf6FjhPJTsLzcjTU+mT7t6lEimPEl2VWN9eXWqs9nkVrJtqLao6m1hoYfXOxRh6
|
||||
96/WgBtPHcmjujryteBiSITVflDjx+YQzDGsbqw7fM52klMPd2+w/vmhJ4pxq6P7
|
||||
Ni2OBvdXYDPIuLfPFFqG16arORjBkyNCJy19iOuh5LXh+EUX11wvbLwNgsTd8j9v
|
||||
eBSD+4HUUNQhiXiXJbs7I7cdFYthvb609QIDAQABo1MwUTAdBgNVHQ4EFgQUdI8p
|
||||
aY1A47rWCRvQKSTRCCk6FoMwHwYDVR0jBBgwFoAUdI8paY1A47rWCRvQKSTRCCk6
|
||||
FoMwDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEArMCAfqidgXL7
|
||||
cW5TAZTCqnUeKzbbqMJgk6iFsma8scMRsUXz9ZhF0UVf98376KvoJpy4vd81afbi
|
||||
TehQ8wVBuKTtkHeh/MkXMWC/FU4HqSjtvxpic2+Or5dMjIrfa5VYPgzfqNaBIUh4
|
||||
InD5lo8b/n5V+jdwX7RX9VYAKug6QZlCg5YSKIvgNRChb36JmrGcvsp5R0Vejnii
|
||||
e3oowvgwikqm6XR6BEcRpPkztqcKST7jPFGHiXWsAqiibc+/plMW9qebhfMXEGhQ
|
||||
5yVNeSxX2zqasZvP/fRy+3I5iVilxtKvJuVpPZ0UZzGS0CJ/lF67ntibktiPa3sR
|
||||
D8HixYbEDg==
|
||||
-----END CERTIFICATE-----
|
||||
`),
|
||||
PrivateKey: []byte(`-----BEGIN PRIVATE KEY-----
|
||||
MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDcDt23t6ioBoHG
|
||||
/Y2mOjxntWQa9dP3eNl+mAC6425DlEtyc6czNAIKuuM9wt+wAwDQAgrd5RaxdcpJ
|
||||
H1JlMkEtBFkIkdn0Ag98D7nwlVA9ON3xQi5Bkl+sN/oWOE8lOwvNyNNT6ZPu3qUS
|
||||
KY8SXZVY315daqz2eRWsm2otqjqbWGhh9c7FGHr3r9aAG08dyaO6OvK14GJIhNV+
|
||||
UOPH5hDMMaxurDt8znaSUw93b7D++aEninGro/s2LY4G91dgM8i4t88UWobXpqs5
|
||||
GMGTI0InLX2I66HkteH4RRfXXC9svA2CxN3yP294FIP7gdRQ1CGJeJcluzsjtx0V
|
||||
i2G9vrT1AgMBAAECggEAF0obfQ4a82183qqHC0iui+tOpOvPeyl3G0bLDPx09wIC
|
||||
2iITV//xF2GgGzE8q0wmEd2leMZ+GFn3BrYh6kPfUfxbz+RfxMtTCDZB34xt6YzT
|
||||
MG1op9ft+DQUa7WZ6r7NCQJwGzllRqqZncp4MeFlpPo+6nQXyh4WhSYNnredbENE
|
||||
uPZ63Kme4RZfMvtVso+XgAQM3oDih0onv1YitmNQpL9rRzlthTfybAT4737DBINq
|
||||
zsmBNE6QIsXnSKpzo11OtDgof2QM9ac6eAXf73oTpDxfodwCotILytKn+8WYvlR+
|
||||
T15uuknb4M3XI1FPVolkF4qtK5SLAAbVzV4DsCmuIQKBgQD6bTKKbL2huvU6dEKx
|
||||
bgS079LfQUxxOTClgwkhVsMxRtvcPBnHYMAsPK4mnMhEh9x+TF6wxMx0pmhQluPI
|
||||
ZULNBj/qdoiBL0RwVLA+9jgE0NeWB3XXFDsEavQBr9Q8CC0uzrsgsxFcvHpqqs2Q
|
||||
RtngxRWtJP06D6mKC23s4YjDHwKBgQDg9KUCFqOmWcRXyeg9gYMC4jFFQw4lUQBd
|
||||
sYpqSMHDw1b+T1W/dCPbwbxZL/+d8y930BYy9QYDtQwHdLyXCH0pHM7S6rfgr5xk
|
||||
2Szd8xBUIqmeV/zcR00mTeQHJ1M50VHfclAVgZgkpWSoLwbX+bXyx/mfqLAtynZ5
|
||||
yU9RfrT5awKBgQC0uJ8TlFvZXjFgyMvkfY/5/2R/ZwFCaFI573FkVNeyNP+vVNQJ
|
||||
tUGZ6wSGqvg/tIgjwPtIuA0QVZLMLcgeMy1dBhiUHIxwJetO4V77YPaWSxx5kdKx
|
||||
r1DT5FdI7FnOJNxufhQ/CdsKwJ3bYn3Mk8TiV3hIJnx0LR9dltfybeQjYwKBgDOY
|
||||
6aApATBOtrJMJXC2HA61QwfX8Y6tnZ/f8RefyJHWZEXAfLKFORRWw5TRZZgdB247
|
||||
1Furx81h4Xh0Vi1uTQb5DJdkLvjiTsTy60+dSMmDidQ/6ke8Mv3uL7dUVcqVMGpI
|
||||
FgZYy0TcitHot3EiXZFqPN9aGc7m+XXFruPKZEgxAoGBAMA96jsow7CzulU+GRW8
|
||||
Njg4zWuAEVErgPoNBcOXAVWLCTU/qGIEMNpZL6Ok34kf13pJDMjQ8eDuQHu5CSqf
|
||||
0ul5Zy85fwfVq2IvNAyYT8eflQprTejFw22CHhfPBfADVW9ro8dK/Jw+J/31Vh7V
|
||||
ILKEQKmPPzKs7kp/7Nz+2cT3
|
||||
-----END PRIVATE KEY-----
|
||||
`),
|
||||
}
|
||||
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()
|
||||
initProxy()
|
||||
initResource()
|
||||
initHttpServer()
|
||||
initSystem()
|
||||
initRule()
|
||||
}
|
||||
return appOnce
|
||||
}
|
||||
|
||||
func (a *App) Startup(ctx context.Context) {
|
||||
a.ctx = ctx
|
||||
go httpServerOnce.run()
|
||||
}
|
||||
|
||||
func (a *App) OnExit() {
|
||||
a.UnsetSystemProxy()
|
||||
globalLogger.Close()
|
||||
if appOnce.IsReset {
|
||||
err := a.ResetApp()
|
||||
fmt.Println("err:", err)
|
||||
}
|
||||
}
|
||||
|
||||
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 nil
|
||||
}
|
||||
err := systemOnce.setProxy()
|
||||
if err == nil {
|
||||
a.IsProxy = true
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (a *App) UnsetSystemProxy() error {
|
||||
if !a.IsProxy {
|
||||
return nil
|
||||
}
|
||||
err := systemOnce.unsetProxy()
|
||||
if err == nil {
|
||||
a.IsProxy = false
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (a *App) isInstall() bool {
|
||||
return shared.FileExist(a.LockFile)
|
||||
}
|
||||
|
||||
func (a *App) lock() error {
|
||||
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
@@ -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)
|
||||
}
|
||||
310
core/config.go
Normal file
@@ -0,0 +1,310 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type MimeInfo struct {
|
||||
Type string `json:"Type"`
|
||||
Suffix string `json:"Suffix"`
|
||||
}
|
||||
|
||||
// Config struct
|
||||
type Config struct {
|
||||
storage *Storage
|
||||
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 {
|
||||
return globalConfig
|
||||
}
|
||||
|
||||
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
|
||||
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 "", ""
|
||||
}
|
||||
444
core/downloader.go
Normal file
@@ -0,0 +1,444 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"res-downloader/core/shared"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
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
|
||||
rangeStart int64
|
||||
rangeEnd int64
|
||||
downloadedSize int64
|
||||
isCompleted bool
|
||||
err error
|
||||
}
|
||||
|
||||
type FileDownloader struct {
|
||||
Url string
|
||||
Referer string
|
||||
ProxyUrl *url.URL
|
||||
FileName string
|
||||
File *os.File
|
||||
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, 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{
|
||||
MaxIdleConnsPerHost: 100,
|
||||
IdleConnTimeout: 90 * time.Second,
|
||||
}
|
||||
if fd.ProxyUrl != nil {
|
||||
transport.Proxy = http.ProxyURL(fd.ProxyUrl)
|
||||
}
|
||||
return &http.Client{
|
||||
Transport: transport,
|
||||
}
|
||||
}
|
||||
|
||||
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 fmt.Errorf("parse URL failed: %w", err)
|
||||
}
|
||||
if parsedURL.Scheme != "" && parsedURL.Host != "" {
|
||||
fd.Referer = parsedURL.Scheme + "://" + parsedURL.Host + "/"
|
||||
}
|
||||
|
||||
if globalConfig.DownloadProxy && globalConfig.UpstreamProxy != "" && !strings.Contains(globalConfig.UpstreamProxy, globalConfig.Port) {
|
||||
proxyURL, err := url.Parse(globalConfig.UpstreamProxy)
|
||||
if err == nil {
|
||||
fd.ProxyUrl = proxyURL
|
||||
}
|
||||
}
|
||||
|
||||
request, err := http.NewRequest("HEAD", fd.Url, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create HEAD request failed: %w", err)
|
||||
}
|
||||
|
||||
if _, ok := fd.Headers["User-Agent"]; !ok {
|
||||
fd.Headers["User-Agent"] = globalConfig.UserAgent
|
||||
}
|
||||
if _, ok := fd.Headers["Referer"]; !ok {
|
||||
fd.Headers["Referer"] = fd.Referer
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
dir := filepath.Dir(fd.FileName)
|
||||
if err := os.MkdirAll(dir, os.ModePerm); err != nil {
|
||||
return fmt.Errorf("create directory failed: %w", err)
|
||||
}
|
||||
|
||||
fd.FileName = shared.GetUniqueFileName(fd.FileName)
|
||||
|
||||
fd.File, err = os.OpenFile(fd.FileName, os.O_RDWR|os.O_CREATE, 0644)
|
||||
if err != nil {
|
||||
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)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (fd *FileDownloader) createDownloadTasks() {
|
||||
if fd.IsMultiPart {
|
||||
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: start,
|
||||
rangeEnd: end,
|
||||
})
|
||||
}
|
||||
} 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: rangeEnd,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
wg.Add(1)
|
||||
go fd.startDownloadTask(wg, progressChan, errorChan, task)
|
||||
}
|
||||
|
||||
go func() {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
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) verifyDownload() error {
|
||||
for _, task := range fd.DownloadTaskList {
|
||||
if !task.isCompleted {
|
||||
return fmt.Errorf("task %d not completed", task.taskID)
|
||||
}
|
||||
}
|
||||
|
||||
if fd.TotalSize > 0 {
|
||||
_, err := fd.File.Stat()
|
||||
if err != nil {
|
||||
return fmt.Errorf("get file info failed: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (fd *FileDownloader) Start() error {
|
||||
if err := fd.init(); err != nil {
|
||||
return err
|
||||
}
|
||||
fd.createDownloadTasks()
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
408
core/http.go
Normal file
@@ -0,0 +1,408 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"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"`
|
||||
Data interface{} `json:"data"`
|
||||
}
|
||||
|
||||
type HttpServer struct{}
|
||||
|
||||
func initHttpServer() *HttpServer {
|
||||
if httpServerOnce == nil {
|
||||
httpServerOnce = &HttpServer{}
|
||||
}
|
||||
return httpServerOnce
|
||||
}
|
||||
|
||||
func (h *HttpServer) run() {
|
||||
listener, err := net.Listen("tcp", globalConfig.Host+":"+globalConfig.Port)
|
||||
if err != nil {
|
||||
globalLogger.Err(err)
|
||||
log.Fatalf("Service cannot start: %v", err)
|
||||
}
|
||||
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) // 代理
|
||||
}
|
||||
})); 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 == "" {
|
||||
http.Error(w, "Missing 'url' parameter", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
realURL, _ = url.QueryUnescape(realURL)
|
||||
parsedURL, err := url.Parse(realURL)
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid URL", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
request, err := http.NewRequest("GET", parsedURL.String(), nil)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to fetch the resource", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if rangeHeader := r.Header.Get("Range"); rangeHeader != "" {
|
||||
request.Header.Set("Range", rangeHeader)
|
||||
}
|
||||
|
||||
resp, err := http.DefaultClient.Do(request)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to fetch the resource", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
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 {
|
||||
http.Error(w, "Failed to serve the resource", http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (h *HttpServer) send(t string, data interface{}) {
|
||||
jsonData, err := json.Marshal(map[string]interface{}{
|
||||
"type": t,
|
||||
"data": data,
|
||||
})
|
||||
if err != nil {
|
||||
fmt.Println("Error converting map to JSON:", err)
|
||||
return
|
||||
}
|
||||
runtime.EventsEmit(appOnce.ctx, "event", string(jsonData))
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *HttpServer) openDirectoryDialog(w http.ResponseWriter, r *http.Request) {
|
||||
folder, err := runtime.OpenDirectoryDialog(appOnce.ctx, runtime.OpenDialogOptions{
|
||||
DefaultDirectory: "",
|
||||
Title: "Select a folder",
|
||||
})
|
||||
if err != nil {
|
||||
h.error(w, err.Error())
|
||||
return
|
||||
}
|
||||
h.success(w, respData{
|
||||
"folder": folder,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *HttpServer) openFileDialog(w http.ResponseWriter, r *http.Request) {
|
||||
filePath, err := runtime.OpenFileDialog(appOnce.ctx, runtime.OpenDialogOptions{
|
||||
Filters: []runtime.FileFilter{
|
||||
{
|
||||
DisplayName: "Videos (*.mov;*.mp4)",
|
||||
Pattern: "*.mp4",
|
||||
},
|
||||
},
|
||||
Title: "Select a file",
|
||||
})
|
||||
if err != nil {
|
||||
h.error(w, err.Error())
|
||||
return
|
||||
}
|
||||
h.success(w, respData{
|
||||
"file": filePath,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *HttpServer) openFolder(w http.ResponseWriter, r *http.Request) {
|
||||
var data struct {
|
||||
FilePath string `json:"filePath"`
|
||||
}
|
||||
err := json.NewDecoder(r.Body).Decode(&data)
|
||||
if err == nil && data.FilePath == "" {
|
||||
return
|
||||
}
|
||||
|
||||
err = shared.OpenFolder(data.FilePath)
|
||||
if err != nil {
|
||||
globalLogger.Err(err)
|
||||
h.error(w, err.Error())
|
||||
return
|
||||
}
|
||||
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) {
|
||||
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) {
|
||||
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.success(w, respData{
|
||||
"value": appOnce.IsProxy,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *HttpServer) appInfo(w http.ResponseWriter, r *http.Request) {
|
||||
h.success(w, appOnce)
|
||||
}
|
||||
|
||||
func (h *HttpServer) getConfig(w http.ResponseWriter, r *http.Request) {
|
||||
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.error(w, err.Error())
|
||||
return
|
||||
}
|
||||
globalConfig.setConfig(data)
|
||||
h.success(w)
|
||||
}
|
||||
|
||||
func (h *HttpServer) setType(w http.ResponseWriter, r *http.Request) {
|
||||
var data struct {
|
||||
Type string `json:"type"`
|
||||
}
|
||||
err := json.NewDecoder(r.Body).Decode(&data)
|
||||
if err == nil {
|
||||
if data.Type != "" {
|
||||
resourceOnce.setResType(strings.Split(data.Type, ","))
|
||||
} else {
|
||||
resourceOnce.setResType([]string{})
|
||||
}
|
||||
}
|
||||
|
||||
h.success(w)
|
||||
}
|
||||
|
||||
func (h *HttpServer) clear(w http.ResponseWriter, r *http.Request) {
|
||||
resourceOnce.clear()
|
||||
h.success(w)
|
||||
}
|
||||
|
||||
func (h *HttpServer) delete(w http.ResponseWriter, r *http.Request) {
|
||||
var data struct {
|
||||
Sign []string `json:"sign"`
|
||||
}
|
||||
err := json.NewDecoder(r.Body).Decode(&data)
|
||||
if err == nil && len(data.Sign) > 0 {
|
||||
for _, v := range data.Sign {
|
||||
resourceOnce.delete(v)
|
||||
}
|
||||
}
|
||||
h.success(w)
|
||||
}
|
||||
|
||||
func (h *HttpServer) download(w http.ResponseWriter, r *http.Request) {
|
||||
var data struct {
|
||||
shared.MediaInfo
|
||||
DecodeStr string `json:"decodeStr"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
|
||||
h.error(w, err.Error())
|
||||
return
|
||||
}
|
||||
resourceOnce.download(data.MediaInfo, data.DecodeStr)
|
||||
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 {
|
||||
shared.MediaInfo
|
||||
Filename string `json:"filename"`
|
||||
DecodeStr string `json:"decodeStr"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
|
||||
h.error(w, err.Error())
|
||||
return
|
||||
}
|
||||
savePath, err := resourceOnce.wxFileDecode(data.MediaInfo, data.Filename, data.DecodeStr)
|
||||
if err != nil {
|
||||
h.error(w, err.Error())
|
||||
return
|
||||
}
|
||||
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,
|
||||
})
|
||||
}
|
||||
68
core/logger.go
Normal file
@@ -0,0 +1,68 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/rs/zerolog"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"res-downloader/core/shared"
|
||||
)
|
||||
|
||||
type Logger struct {
|
||||
zerolog.Logger
|
||||
logFile *os.File
|
||||
}
|
||||
|
||||
func initLogger() *Logger {
|
||||
if globalLogger == nil {
|
||||
globalLogger = NewLogger(!shared.IsDevelopment(), filepath.Join(appOnce.UserDir, "logs", "app.log"))
|
||||
}
|
||||
return globalLogger
|
||||
}
|
||||
|
||||
func (l *Logger) Close() {
|
||||
_ = l.logFile.Close()
|
||||
}
|
||||
|
||||
func (l *Logger) Err(err error) {
|
||||
l.Error().Stack().Err(err)
|
||||
}
|
||||
|
||||
func (l *Logger) Esg(err error, format string, v ...interface{}) {
|
||||
l.Error().Stack().Err(err).Msgf(fmt.Sprintf(format, v...))
|
||||
}
|
||||
|
||||
// NewLogger create a new logger
|
||||
func NewLogger(logFile bool, logPath string) *Logger {
|
||||
var out io.Writer
|
||||
if logFile {
|
||||
// log to file
|
||||
logDir := filepath.Dir(logPath)
|
||||
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, 0644)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
out = logfile
|
||||
} else {
|
||||
out = os.Stdout
|
||||
}
|
||||
|
||||
logger := &Logger{}
|
||||
if logFile {
|
||||
logger.logFile = out.(*os.File)
|
||||
}
|
||||
logger.Logger = zerolog.New(zerolog.ConsoleWriter{
|
||||
NoColor: true,
|
||||
Out: out,
|
||||
TimeFormat: "2006-01-02 15:04:05",
|
||||
}).With().Timestamp().Logger()
|
||||
return logger
|
||||
}
|
||||
73
core/middleware.go
Normal file
@@ -0,0 +1,73 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func Middleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if HandleApi(w, r) {
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func HandleApi(w http.ResponseWriter, r *http.Request) bool {
|
||||
if strings.HasPrefix(r.URL.Path, "/api") {
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
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":
|
||||
httpServerOnce.openSystemProxy(w, r)
|
||||
case "/api/proxy-unset":
|
||||
httpServerOnce.unsetSystemProxy(w, r)
|
||||
case "/api/open-directory":
|
||||
httpServerOnce.openDirectoryDialog(w, r)
|
||||
case "/api/open-file":
|
||||
httpServerOnce.openFileDialog(w, r)
|
||||
case "/api/open-folder":
|
||||
httpServerOnce.openFolder(w, r)
|
||||
case "/api/is-proxy":
|
||||
httpServerOnce.isProxy(w, r)
|
||||
case "/api/app-info":
|
||||
httpServerOnce.appInfo(w, r)
|
||||
case "/api/set-config":
|
||||
httpServerOnce.setConfig(w, r)
|
||||
case "/api/get-config":
|
||||
httpServerOnce.getConfig(w, r)
|
||||
case "/api/set-type":
|
||||
httpServerOnce.setType(w, r)
|
||||
case "/api/clear":
|
||||
httpServerOnce.clear(w, r)
|
||||
case "/api/delete":
|
||||
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
|
||||
}
|
||||
return false
|
||||
}
|
||||
87
core/plugins/plugin.default.go
Normal 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
|
||||
}
|
||||
272
core/plugins/plugin.qq.com.go
Normal 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()
|
||||
}
|
||||
173
core/proxy.go
Normal file
@@ -0,0 +1,173 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"res-downloader/core/plugins"
|
||||
"res-downloader/core/shared"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/elazarl/goproxy"
|
||||
)
|
||||
|
||||
type Proxy struct {
|
||||
ctx context.Context
|
||||
Proxy *goproxy.ProxyHttpServer
|
||||
Is bool
|
||||
}
|
||||
|
||||
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 {
|
||||
if proxyOnce == nil {
|
||||
proxyOnce = &Proxy{}
|
||||
proxyOnce.Startup()
|
||||
}
|
||||
return proxyOnce
|
||||
}
|
||||
|
||||
func (p *Proxy) Startup() {
|
||||
err := p.setCa()
|
||||
if err != nil {
|
||||
DialogErr("Failed to start proxy service:" + err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
p.Proxy = goproxy.NewProxyHttpServer()
|
||||
//p.Proxy.KeepDestinationHeaders = true
|
||||
//p.Proxy.Verbose = false
|
||||
p.setTransport()
|
||||
//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)
|
||||
}
|
||||
|
||||
func (p *Proxy) setCa() error {
|
||||
ca, err := tls.X509KeyPair(appOnce.PublicCrt, appOnce.PrivateKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if ca.Leaf, err = x509.ParseCertificate(ca.Certificate[0]); err != nil {
|
||||
return err
|
||||
}
|
||||
goproxy.GoproxyCa = ca
|
||||
goproxy.OkConnect = &goproxy.ConnectAction{Action: goproxy.ConnectAccept, TLSConfig: goproxy.TLSConfigFromCA(&ca)}
|
||||
goproxy.MitmConnect = &goproxy.ConnectAction{Action: goproxy.ConnectMitm, TLSConfig: goproxy.TLSConfigFromCA(&ca)}
|
||||
goproxy.HTTPMitmConnect = &goproxy.ConnectAction{Action: goproxy.ConnectHTTPMitm, TLSConfig: goproxy.TLSConfigFromCA(&ca)}
|
||||
goproxy.RejectConnect = &goproxy.ConnectAction{Action: goproxy.ConnectReject, TLSConfig: goproxy.TLSConfigFromCA(&ca)}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Proxy) setTransport() {
|
||||
transport := &http.Transport{
|
||||
DisableKeepAlives: false,
|
||||
// MaxIdleConnsPerHost: 10,
|
||||
DialContext: (&net.Dialer{
|
||||
Timeout: 60 * time.Second,
|
||||
}).DialContext,
|
||||
TLSHandshakeTimeout: 60 * time.Second,
|
||||
ResponseHeaderTimeout: 60 * time.Second,
|
||||
IdleConnTimeout: 30 * time.Second,
|
||||
}
|
||||
|
||||
p.Proxy.ConnectDial = nil
|
||||
p.Proxy.ConnectDialWithReq = nil
|
||||
|
||||
if globalConfig.UpstreamProxy != "" && globalConfig.OpenProxy && !strings.Contains(globalConfig.UpstreamProxy, globalConfig.Port) {
|
||||
proxyURL, err := url.Parse(globalConfig.UpstreamProxy)
|
||||
if err == nil {
|
||||
transport.Proxy = http.ProxyURL(proxyURL)
|
||||
p.Proxy.ConnectDial = p.Proxy.NewConnectDialToProxy(globalConfig.UpstreamProxy)
|
||||
}
|
||||
}
|
||||
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) {
|
||||
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 pluginRegistry["default"].OnRequest(r, ctx)
|
||||
}
|
||||
|
||||
func (p *Proxy) httpResponseEvent(resp *http.Response, ctx *goproxy.ProxyCtx) *http.Response {
|
||||
if resp == nil || resp.Request == nil {
|
||||
return resp
|
||||
}
|
||||
|
||||
plugin := p.matchPlugin(resp.Request.Host)
|
||||
if plugin != nil {
|
||||
newResp := plugin.OnResponse(resp, ctx)
|
||||
if newResp != nil {
|
||||
return newResp
|
||||
}
|
||||
}
|
||||
|
||||
return pluginRegistry["default"].OnResponse(resp, ctx)
|
||||
}
|
||||
283
core/resource.go
Normal file
@@ -0,0 +1,283 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"res-downloader/core/shared"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type WxFileDecodeResult struct {
|
||||
SavePath string
|
||||
Message string
|
||||
}
|
||||
|
||||
type Resource struct {
|
||||
mediaMark sync.Map
|
||||
tasks sync.Map
|
||||
resType map[string]bool
|
||||
resTypeMux sync.RWMutex
|
||||
}
|
||||
|
||||
func initResource() *Resource {
|
||||
if resourceOnce == nil {
|
||||
resourceOnce = &Resource{}
|
||||
resourceOnce.resType = resourceOnce.buildResType(globalConfig.MimeMap)
|
||||
}
|
||||
return resourceOnce
|
||||
}
|
||||
|
||||
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) 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.resTypeMux.RLock()
|
||||
value, ok := r.resType[key]
|
||||
r.resTypeMux.RUnlock()
|
||||
return value, ok
|
||||
}
|
||||
|
||||
func (r *Resource) setResType(n []string) {
|
||||
r.resTypeMux.Lock()
|
||||
for key := range r.resType {
|
||||
r.resType[key] = false
|
||||
}
|
||||
|
||||
for _, value := range n {
|
||||
if _, ok := r.resType[value]; ok {
|
||||
r.resType[value] = true
|
||||
}
|
||||
}
|
||||
r.resTypeMux.Unlock()
|
||||
}
|
||||
|
||||
func (r *Resource) clear() {
|
||||
r.mediaMark.Clear()
|
||||
}
|
||||
|
||||
func (r *Resource) delete(sign string) {
|
||||
r.mediaMark.Delete(sign)
|
||||
}
|
||||
|
||||
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 shared.MediaInfo) {
|
||||
rawUrl := mediaInfo.Url
|
||||
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) > fileLen {
|
||||
fileName = string(runes[:fileLen])
|
||||
}
|
||||
}
|
||||
|
||||
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 &&
|
||||
strings.Contains(rawUrl, "encfilekey=") &&
|
||||
strings.Contains(rawUrl, "token=") {
|
||||
parseUrl, err := url.Parse(rawUrl)
|
||||
queryParams := parseUrl.Query()
|
||||
if err == nil && queryParams.Has("encfilekey") && queryParams.Has("token") {
|
||||
rawUrl = parseUrl.Scheme + "://" + parseUrl.Host + "/" + parseUrl.Path +
|
||||
"?encfilekey=" + queryParams.Get("encfilekey") +
|
||||
"&token=" + queryParams.Get("token")
|
||||
}
|
||||
} else if globalConfig.Quality > 1 && mediaInfo.OtherData["wx_file_formats"] != "" {
|
||||
format := strings.Split(mediaInfo.OtherData["wx_file_formats"], "#")
|
||||
qualityMap := []string{
|
||||
format[0],
|
||||
format[len(format)/2],
|
||||
format[len(format)-1],
|
||||
}
|
||||
rawUrl += "&X-snsvideoflag=" + qualityMap[globalConfig.Quality-2]
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
if !strings.Contains(err.Error(), "cancelled") {
|
||||
r.progressEventsEmit(mediaInfo, err.Error())
|
||||
}
|
||||
return
|
||||
}
|
||||
if decodeStr != "" {
|
||||
r.progressEventsEmit(mediaInfo, "decrypting in progress", shared.DownloadStatusRunning)
|
||||
if err := r.decodeWxFile(mediaInfo.SavePath, decodeStr); err != nil {
|
||||
r.progressEventsEmit(mediaInfo, "decryption error: "+err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
r.progressEventsEmit(mediaInfo, "complete", shared.DownloadStatusDone)
|
||||
}(mediaInfo)
|
||||
}
|
||||
|
||||
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", "_decrypt.mp4")
|
||||
|
||||
destinationFile, err := os.Create(mediaInfo.SavePath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer destinationFile.Close()
|
||||
|
||||
_, err = io.Copy(destinationFile, sourceFile)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
err = r.decodeWxFile(mediaInfo.SavePath, decodeStr)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return mediaInfo.SavePath, nil
|
||||
}
|
||||
|
||||
func (r *Resource) progressEventsEmit(mediaInfo shared.MediaInfo, args ...string) {
|
||||
Status := shared.DownloadStatusError
|
||||
Message := "ok"
|
||||
|
||||
if len(args) > 0 {
|
||||
Message = args[0]
|
||||
}
|
||||
if len(args) > 1 {
|
||||
Status = args[1]
|
||||
}
|
||||
|
||||
httpServerOnce.send("downloadProgress", map[string]interface{}{
|
||||
"Id": mediaInfo.Id,
|
||||
"Status": Status,
|
||||
"SavePath": mediaInfo.SavePath,
|
||||
"Message": Message,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
func (r *Resource) decodeWxFile(fileName, decodeStr string) error {
|
||||
decodedBytes, err := base64.StdEncoding.DecodeString(decodeStr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
file, err := os.OpenFile(fileName, os.O_RDWR, 0644)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
byteCount := len(decodedBytes)
|
||||
fileBytes := make([]byte, byteCount)
|
||||
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]
|
||||
}
|
||||
_, err = file.Seek(0, 0)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = file.Write(xorResult)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
126
core/rule.go
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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()
|
||||
}
|
||||
41
core/storage.go
Normal file
@@ -0,0 +1,41 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path"
|
||||
"res-downloader/core/shared"
|
||||
)
|
||||
|
||||
type Storage struct {
|
||||
fileName string
|
||||
def []byte
|
||||
}
|
||||
|
||||
func NewStorage(filename string, def []byte) *Storage {
|
||||
return &Storage{
|
||||
fileName: path.Join(appOnce.UserDir, filename),
|
||||
def: def,
|
||||
}
|
||||
}
|
||||
|
||||
func (l *Storage) Load() ([]byte, error) {
|
||||
if !shared.FileExist(l.fileName) {
|
||||
err := os.WriteFile(l.fileName, l.def, 0644)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return l.def, nil
|
||||
}
|
||||
d, err := os.ReadFile(l.fileName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return d, err
|
||||
}
|
||||
|
||||
func (l *Storage) Store(data []byte) error {
|
||||
if err := os.WriteFile(l.fileName, data, 0644); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
83
core/system.go
Normal file
@@ -0,0 +1,83 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
)
|
||||
|
||||
type SystemSetup struct {
|
||||
CertFile string
|
||||
CacheFile string
|
||||
Password string
|
||||
aesCipher *AESCipher
|
||||
}
|
||||
|
||||
func initSystem() *SystemSetup {
|
||||
if systemOnce == nil {
|
||||
systemOnce = &SystemSetup{
|
||||
aesCipher: NewAESCipher("resd48w2d7er95627d447c490a8f02ff"),
|
||||
CertFile: filepath.Join(appOnce.UserDir, "cert.crt"),
|
||||
CacheFile: filepath.Join(appOnce.UserDir, "pass.cache"),
|
||||
}
|
||||
systemOnce.checkPasswordFile()
|
||||
}
|
||||
return systemOnce
|
||||
}
|
||||
|
||||
func (s *SystemSetup) initCert() ([]byte, error) {
|
||||
content, err := os.ReadFile(s.CertFile)
|
||||
if err == nil {
|
||||
return content, nil
|
||||
}
|
||||
if os.IsNotExist(err) {
|
||||
err = os.WriteFile(s.CertFile, appOnce.PublicCrt, 0750)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return appOnce.PublicCrt, nil
|
||||
} else {
|
||||
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
|
||||
}
|
||||
129
core/system_darwin.go
Normal file
@@ -0,0 +1,129 @@
|
||||
//go:build darwin
|
||||
|
||||
package core
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"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) {
|
||||
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 activeServices []string
|
||||
for _, service := range services {
|
||||
service = strings.TrimSpace(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)
|
||||
}
|
||||
}
|
||||
|
||||
if len(activeServices) == 0 {
|
||||
return nil, fmt.Errorf("no active network services found")
|
||||
}
|
||||
|
||||
return activeServices, nil
|
||||
}
|
||||
|
||||
func (s *SystemSetup) setProxy() error {
|
||||
services, err := s.getNetworkServices()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
isSuccess := false
|
||||
var errs strings.Builder
|
||||
for _, serviceName := range services {
|
||||
commands := [][]string{
|
||||
{"networksetup", "-setwebproxy", serviceName, "127.0.0.1", globalConfig.Port},
|
||||
{"networksetup", "-setsecurewebproxy", serviceName, "127.0.0.1", globalConfig.Port},
|
||||
}
|
||||
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 isSuccess {
|
||||
return nil
|
||||
}
|
||||
|
||||
return fmt.Errorf("failed to set proxy for any active network service, errs:%s", errs)
|
||||
}
|
||||
|
||||
func (s *SystemSetup) unsetProxy() error {
|
||||
services, err := s.getNetworkServices()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
isSuccess := false
|
||||
var errs strings.Builder
|
||||
for _, serviceName := range services {
|
||||
commands := [][]string{
|
||||
{"networksetup", "-setwebproxystate", serviceName, "off"},
|
||||
{"networksetup", "-setsecurewebproxystate", serviceName, "off"},
|
||||
}
|
||||
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 isSuccess {
|
||||
return nil
|
||||
}
|
||||
|
||||
return fmt.Errorf("failed to unset proxy for any active network service, errs:%s", errs)
|
||||
}
|
||||
|
||||
func (s *SystemSetup) installCert() (string, error) {
|
||||
_, err := s.initCert()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
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
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
142
core/system_linux.go
Normal file
@@ -0,0 +1,142 @@
|
||||
//go:build linux
|
||||
|
||||
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"},
|
||||
{"gsettings", "set", "org.gnome.system.proxy.http", "host", "127.0.0.1"},
|
||||
{"gsettings", "set", "org.gnome.system.proxy.http", "port", globalConfig.Port},
|
||||
{"gsettings", "set", "org.gnome.system.proxy.https", "host", "127.0.0.1"},
|
||||
{"gsettings", "set", "org.gnome.system.proxy.https", "port", globalConfig.Port},
|
||||
}
|
||||
|
||||
isSuccess := false
|
||||
var errs strings.Builder
|
||||
|
||||
for _, cmd := range commands {
|
||||
if output, err := s.runCommand(cmd, false); err != nil {
|
||||
errs.WriteString(fmt.Sprintf("cmd: %v\noutput: %s\nerr: %s\n", cmd, output, err))
|
||||
} else {
|
||||
isSuccess = true
|
||||
}
|
||||
}
|
||||
|
||||
if isSuccess {
|
||||
return nil
|
||||
}
|
||||
|
||||
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"}
|
||||
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) {
|
||||
_, err := s.initCert()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
distro, err := s.getLinuxDistro()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("detect distro failed: %w", err)
|
||||
}
|
||||
|
||||
certName := appOnce.AppName + ".crt"
|
||||
var certPath string
|
||||
var updateCmd = []string{"update-ca-certificates"}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
if isSuccess {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
return outs.String(), fmt.Errorf("certificate installation failed:\n%s", errs.String())
|
||||
}
|
||||
83
core/system_windows.go
Normal file
@@ -0,0 +1,83 @@
|
||||
//go:build windows
|
||||
|
||||
package core
|
||||
|
||||
import (
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"golang.org/x/sys/windows"
|
||||
"golang.org/x/sys/windows/registry"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
func (s *SystemSetup) setProxy() error {
|
||||
key, err := registry.OpenKey(registry.CURRENT_USER, `Software\Microsoft\Windows\CurrentVersion\Internet Settings`, registry.SET_VALUE)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer key.Close()
|
||||
|
||||
err = key.SetStringValue("ProxyServer", "127.0.0.1:"+globalConfig.Port)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = key.SetDWordValue("ProxyEnable", 1)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SystemSetup) unsetProxy() error {
|
||||
key, err := registry.OpenKey(registry.CURRENT_USER, `Software\Microsoft\Windows\CurrentVersion\Internet Settings`, registry.SET_VALUE)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer key.Close()
|
||||
err = key.SetDWordValue("ProxyEnable", 0)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SystemSetup) installCert() (string, error) {
|
||||
certData, err := s.initCert()
|
||||
if err != nil {
|
||||
return "", errors.New("installCert1:" + err.Error())
|
||||
}
|
||||
|
||||
block, _ := pem.Decode(certData)
|
||||
if block == nil {
|
||||
return "", errors.New("Failed to parse certificate PEM" + err.Error())
|
||||
}
|
||||
|
||||
cert, err := x509.ParseCertificate(block.Bytes)
|
||||
if err != nil {
|
||||
return "", errors.New("installCert3:" + err.Error())
|
||||
}
|
||||
|
||||
rootStorePtr, err := windows.UTF16PtrFromString("ROOT")
|
||||
if err != nil {
|
||||
return "", errors.New("installCert4:" + err.Error())
|
||||
}
|
||||
|
||||
store, err := windows.CertOpenStore(windows.CERT_STORE_PROV_SYSTEM, 0, 0, windows.CERT_SYSTEM_STORE_LOCAL_MACHINE, uintptr(unsafe.Pointer(rootStorePtr)))
|
||||
if err != nil {
|
||||
return "", errors.New("installCert5:" + err.Error())
|
||||
}
|
||||
defer windows.CertCloseStore(store, 0)
|
||||
|
||||
certContext, err := windows.CertCreateCertificateContext(windows.X509_ASN_ENCODING|windows.PKCS_7_ASN_ENCODING, &cert.Raw[0], uint32(len(cert.Raw)))
|
||||
if err != nil {
|
||||
return "", errors.New("installCert6:" + err.Error())
|
||||
}
|
||||
defer windows.CertFreeCertificateContext(certContext)
|
||||
err = windows.CertAddCertificateContextToStore(store, certContext, windows.CERT_STORE_ADD_REPLACE_EXISTING, nil)
|
||||
if err != nil {
|
||||
return "", errors.New("installCert7:" + err.Error())
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
14
core/utils.go
Normal file
@@ -0,0 +1,14 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"github.com/wailsapp/wails/v2/pkg/runtime"
|
||||
)
|
||||
|
||||
func DialogErr(message string) {
|
||||
_, _ = runtime.MessageDialog(appOnce.ctx, runtime.MessageDialogOptions{
|
||||
Type: runtime.ErrorDialog,
|
||||
Title: "Error",
|
||||
Message: message,
|
||||
DefaultButton: "Cancel",
|
||||
})
|
||||
}
|
||||
0
docs/.nojekyll
Normal file
12
docs/_coverpage.md
Normal 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
@@ -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
@@ -0,0 +1,6 @@
|
||||
* [简介](readme.md)
|
||||
* [快速开始](getting-started.md)
|
||||
* [安装指南](installation.md)
|
||||
* [功能演示](examples.md)
|
||||
* [更多说明](more.md)
|
||||
* [常见问题](troubleshooting.md)
|
||||
19
docs/examples.md
Normal file
@@ -0,0 +1,19 @@
|
||||
## 开启代理
|
||||
- 安装完成后开启代理 (最新版本为“开启抓取”),如图:
|
||||

|
||||
|
||||
## 拦截资源
|
||||
### 视频号
|
||||
- 打开视频号即可看到本软件中拦截到的资源
|
||||

|
||||
|
||||
### 网页资源
|
||||
- 浏览器打开或者其他软件内置浏览器打开的网页
|
||||
- 这里演示打开百度这个网站:https://www.baidu.com/
|
||||

|
||||
|
||||
### 小程序、公众号、抖音、小红书、qq音乐、酷狗等应用内资源获取方式都差不多!
|
||||
|
||||
## 下载资源到本地
|
||||
- 选择你想下载的视频 点击下载即可
|
||||

|
||||
BIN
docs/favicon.ico
Normal file
|
After Width: | Height: | Size: 264 KiB |
18
docs/getting-started.md
Normal 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如果无法拦截 请关闭防火墙
|
||||
|
||||

|
||||
BIN
docs/images/config.png
Normal file
|
After Width: | Height: | Size: 58 KiB |
BIN
docs/images/examples-1.png
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
docs/images/examples-2.webp
Normal file
|
After Width: | Height: | Size: 48 KiB |
BIN
docs/images/examples-3.png
Normal file
|
After Width: | Height: | Size: 88 KiB |
BIN
docs/images/examples-4.png
Normal file
|
After Width: | Height: | Size: 84 KiB |
BIN
docs/images/installation-mac-1.png
Normal file
|
After Width: | Height: | Size: 65 KiB |
|
Before Width: | Height: | Size: 120 KiB After Width: | Height: | Size: 120 KiB |
BIN
docs/images/more-1.png
Normal file
|
After Width: | Height: | Size: 87 KiB |
BIN
docs/images/more-2.png
Normal file
|
After Width: | Height: | Size: 87 KiB |
BIN
docs/images/more-3.png
Normal file
|
After Width: | Height: | Size: 79 KiB |
BIN
docs/images/more-4.png
Normal file
|
After Width: | Height: | Size: 81 KiB |
BIN
docs/images/show.webp
Normal file
|
After Width: | Height: | Size: 30 KiB |
50
docs/index.html
Normal 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
@@ -0,0 +1,21 @@
|
||||
## 下载安装文件
|
||||
- windows下载.exe结尾的,根据自己的系统架构下载合适的安装文件,通常下载带有“win_amd64.exe”或“x64-installer.exe”结尾的文件
|
||||
- Mac下载.dmg结尾即可
|
||||
- Linux根据系统类型下载对应的执行文件或安装文件
|
||||
|
||||
## Windows安装过程
|
||||
- 双击下载好的exe 正常安装即可,首次打开记得右键管理员运行
|
||||
|
||||
## Mac安装过程
|
||||
- 双击下载好的dmg文件,将res-downloader拖入应用即可,如图:
|
||||

|
||||
|
||||
## 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
@@ -0,0 +1,20 @@
|
||||
## 清空列表、类型筛选
|
||||
- 当资源列表过大时,无法快速找到需要的资源,这时可以先清空列表再去刷新需要的资源页面
|
||||
- 资源列表过多,可以快速根据需要的资源类型进行筛选
|
||||

|
||||
|
||||
## 拦截想要的资源类型、批量下载
|
||||
- 比如只需要视频时就选择视频类型,可以多选
|
||||

|
||||
|
||||
## 批量导出、批量导入使用场景
|
||||
- 导出resd格式数据,将txt文件发送到另外的电脑,打开文件复制内容,使用批量导入导入到新电脑
|
||||
|
||||
## 复制链接、视频解密
|
||||
- 复制链接可用于第三方软件进行下载,下载完成后对该视频解密,点击“视频解密”选择用其他软件下载完成后的视频文件进行解密
|
||||

|
||||
|
||||
## 设置说明
|
||||
!> 修改完成后记得点保存
|
||||
- 几乎每项配置在软件中都有说明(鼠标悬浮在?处即可查看),此处就不进行多余讲解
|
||||

|
||||
63
docs/readme.md
Normal 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>
|
||||
|
||||
[](https://github.com/putyy/res-downloader/stargazers)
|
||||
[](https://github.com/putyy/res-downloader/fork)
|
||||
[](https://github.com/putyy/res-downloader/releases)
|
||||

|
||||
[](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` 版本*
|
||||
|
||||
|
||||
## 🖼️ 预览
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
### 🧠 更多问题
|
||||
|
||||
- [GitHub Issues](https://github.com/putyy/res-downloader/issues)
|
||||
- [爱享论坛讨论帖](https://s.gowas.cn/d/4089)
|
||||
|
||||
## 💡 实现原理 & 初衷
|
||||
|
||||
本工具通过代理方式实现网络抓包,并筛选可用资源。与 Fiddler、Charles、浏览器 DevTools 原理类似,但对资源进行了更友好的筛选、展示和处理,大幅度降低了使用门槛,更适合大众用户使用。
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 免责声明
|
||||
|
||||
> 本软件仅供学习与研究用途,禁止用于任何商业或违法用途。
|
||||
如因此产生的任何法律责任,概与作者无关!
|
||||
78
docs/troubleshooting.md
Normal 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)
|
||||
@@ -1,73 +0,0 @@
|
||||
/**
|
||||
* @see https://www.electron.build/configuration/configuration
|
||||
*/
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/electron-userland/electron-builder/master/packages/app-builder-lib/scheme.json",
|
||||
"appId": "com.putyy.res-downloader",
|
||||
"asar": true,
|
||||
"directories": {
|
||||
"output": "release/${version}"
|
||||
},
|
||||
"files": [
|
||||
"dist-electron",
|
||||
"dist",
|
||||
"electron/res/**/*"
|
||||
],
|
||||
"mac": {
|
||||
"icon": "electron/res/icon/mac.icns",
|
||||
"artifactName": "${productName}_${version}.${arch}.${ext}",
|
||||
"singleArchFiles": "*",
|
||||
"target": [
|
||||
{
|
||||
"target": "dmg",
|
||||
"arch": [
|
||||
'x64',
|
||||
'arm64',
|
||||
'universal'
|
||||
]
|
||||
}
|
||||
],
|
||||
"extraResources": [
|
||||
{
|
||||
"from": "electron/res/mac/aria2/aria2.conf",
|
||||
"to": "electron/res/mac/aria2/aria2.conf",
|
||||
"filter": ["**/*"]
|
||||
},
|
||||
{
|
||||
"from": "electron/res/mac/aria2/${arch}/aria2c",
|
||||
"to": "electron/res/mac/aria2/aria2c",
|
||||
"filter": ["**/*"],
|
||||
}
|
||||
]
|
||||
},
|
||||
"win": {
|
||||
"icon": "electron/res/icon/win.ico",
|
||||
"artifactName": "${productName}_${version}.${ext}",
|
||||
"target": [
|
||||
{
|
||||
"target": "nsis",
|
||||
"arch": [
|
||||
"x64"
|
||||
]
|
||||
}
|
||||
],
|
||||
"extraResources": [
|
||||
{
|
||||
"from": "electron/res/win",
|
||||
"to": "electron/res/win",
|
||||
"filter": ["**/*"],
|
||||
}
|
||||
]
|
||||
},
|
||||
"nsis": {
|
||||
"oneClick": false,
|
||||
"perMachine": false,
|
||||
"allowElevation": true,
|
||||
"allowToChangeInstallationDirectory": true,
|
||||
"deleteAppDataOnUninstall": false
|
||||
},
|
||||
"extraResources": [
|
||||
"electron/res/icon",
|
||||
"electron/res/keys",
|
||||
]
|
||||
}
|
||||
11
electron/electron-env.d.ts
vendored
@@ -1,11 +0,0 @@
|
||||
/// <reference types="vite-plugin-electron/electron-env" />
|
||||
|
||||
declare namespace NodeJS {
|
||||
interface ProcessEnv {
|
||||
VSCODE_DEBUG?: 'true'
|
||||
DIST_ELECTRON: string
|
||||
DIST: string
|
||||
/** /dist/ or /public/ */
|
||||
VITE_PUBLIC: string
|
||||
}
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
const axios = require('axios')
|
||||
import CONFIG from './const'
|
||||
|
||||
export class Aria2RPC {
|
||||
constructor() {
|
||||
this.url = `http://127.0.0.1:${CONFIG.ARIA_PORT}/jsonrpc`
|
||||
this.id = 1
|
||||
}
|
||||
|
||||
call(method, params) {
|
||||
const requestData = {
|
||||
jsonrpc: "2.0",
|
||||
method: method,
|
||||
params: params,
|
||||
id: this.id++
|
||||
};
|
||||
return axios.post(this.url, requestData, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
}).then((response)=>{
|
||||
return response.data
|
||||
})
|
||||
}
|
||||
|
||||
addUri(uri, dir, filename, headers = {}) {
|
||||
return this.call('aria2.addUri', [uri, {
|
||||
dir: dir,
|
||||
out: filename,
|
||||
headers: headers,
|
||||
}]);
|
||||
}
|
||||
|
||||
tellStatus(gid) {
|
||||
return this.call('aria2.tellStatus', [gid]);
|
||||
}
|
||||
|
||||
calculateDownloadProgress(bitfield) {
|
||||
// 将十六进制的 bitfield 转换为二进制字符串
|
||||
const totalPieces = bitfield.length * 4; // 每个十六进制字符对应 4 位
|
||||
const binaryString = bitfield.split('').map(hex => parseInt(hex, 16).toString(2).padStart(4, '0')).join('');
|
||||
|
||||
// 计算已下载的部分数
|
||||
const downloadedPieces = binaryString.split('').filter(bit => bit === '1').length;
|
||||
|
||||
// 计算进度百分比
|
||||
const progressPercentage = (downloadedPieces / totalPieces) * 100;
|
||||
|
||||
return progressPercentage.toFixed(2); // 保留两位小数
|
||||
}
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
import CONFIG from './const'
|
||||
import {mkdirp} from 'mkdirp'
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import {clipboard, dialog} from 'electron'
|
||||
import spawn from 'cross-spawn'
|
||||
|
||||
export function checkCertInstalled() {
|
||||
return fs.existsSync(CONFIG.INSTALL_CERT_FLAG)
|
||||
}
|
||||
|
||||
export async function installCert(checkInstalled = true) {
|
||||
if (checkInstalled && checkCertInstalled()) {
|
||||
return;
|
||||
}
|
||||
mkdirp.sync(path.dirname(CONFIG.INSTALL_CERT_FLAG))
|
||||
|
||||
if (process.platform === 'darwin') {
|
||||
return new Promise((resolve, reject) => {
|
||||
clipboard.writeText(
|
||||
`echo "输入本地登录密码" && sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain "${CONFIG.CERT_PUBLIC_PATH}" && touch ${CONFIG.INSTALL_CERT_FLAG} && echo "安装完成"`,
|
||||
)
|
||||
dialog.showMessageBoxSync({
|
||||
type: "info",
|
||||
message: `命令已复制到剪贴板,粘贴命令到终端并运行以安装并信任证书`,
|
||||
});
|
||||
|
||||
reject()
|
||||
});
|
||||
} else {
|
||||
return new Promise((resolve: any, reject) => {
|
||||
const result = spawn.sync(CONFIG.WIN_CERT_INSTALL_HELPER, [
|
||||
'-c',
|
||||
'-add',
|
||||
CONFIG.CERT_PUBLIC_PATH,
|
||||
'-s',
|
||||
'root',
|
||||
]);
|
||||
if (result.stdout.toString().indexOf('Succeeded') > -1) {
|
||||
fs.writeFileSync(CONFIG.INSTALL_CERT_FLAG, '')
|
||||
resolve()
|
||||
} else {
|
||||
reject()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import path from 'path'
|
||||
import isDev from 'electron-is-dev'
|
||||
import os from 'os'
|
||||
import {app} from 'electron'
|
||||
|
||||
const APP_PATH = app.getAppPath();
|
||||
// 对于一些 shell 去执行的文件,asar 目录下无法使用。配合 extraResources
|
||||
const EXECUTABLE_PATH = path.join(
|
||||
APP_PATH.indexOf('app.asar') > -1
|
||||
? APP_PATH.substring(0, APP_PATH.indexOf('app.asar'))
|
||||
: APP_PATH,
|
||||
'electron/res',
|
||||
)
|
||||
|
||||
const HOME_PATH = path.join(os.homedir(), '.res-downloader@putyy')
|
||||
export default {
|
||||
IS_DEV: isDev,
|
||||
EXECUTABLE_PATH,
|
||||
HOME_PATH,
|
||||
APP_CN_NAME: '爱享素材下载器',
|
||||
APP_EN_NAME: 'ResDownloader',
|
||||
CERT_PRIVATE_PATH: path.join(EXECUTABLE_PATH, './keys/private.pem'),
|
||||
CERT_PUBLIC_PATH: path.join(EXECUTABLE_PATH, './keys/public.pem'),
|
||||
INSTALL_CERT_FLAG: path.join(HOME_PATH, './res-downloader-installed.lock'),
|
||||
WIN_CERT_INSTALL_HELPER: path.join(EXECUTABLE_PATH, './win/w_c.exe'),
|
||||
REGEDIT_VBS_PATH: path.join(EXECUTABLE_PATH, './win/regedit-vbs'),
|
||||
OPEN_SSL_BIN_PATH: path.join(EXECUTABLE_PATH, './win/openssl/openssl.exe'),
|
||||
OPEN_SSL_CNF_PATH: path.join(EXECUTABLE_PATH, './win/openssl/openssl.cnf'),
|
||||
ARIA_PORT: "18899",
|
||||
};
|
||||
@@ -1,213 +0,0 @@
|
||||
import {app, BrowserWindow, shell, ipcMain, Menu} from 'electron'
|
||||
import {release} from 'node:os'
|
||||
import {join} from 'node:path'
|
||||
import CONFIG from './const'
|
||||
import initIPC, {setWin} from './ipc'
|
||||
import {closeProxy} from "./setProxy"
|
||||
import log from "electron-log"
|
||||
import path from 'path'
|
||||
import {spawn} from 'child_process'
|
||||
|
||||
// The built directory structure
|
||||
//
|
||||
// ├─┬ dist-electron
|
||||
// │ ├─┬ main
|
||||
// │ │ └── index.js > Electron-Main
|
||||
// │ └─┬ preload
|
||||
// │ └── index.js > Preload-Scripts
|
||||
// ├─┬ dist
|
||||
// │ └── index.html > Electron-Renderer
|
||||
//
|
||||
process.env.DIST_ELECTRON = join(__dirname, '..')
|
||||
process.env.DIST = join(process.env.DIST_ELECTRON, '../dist')
|
||||
process.env.VITE_PUBLIC = process.env.VITE_DEV_SERVER_URL
|
||||
? join(process.env.DIST_ELECTRON, '../public')
|
||||
: process.env.DIST
|
||||
|
||||
// Disable GPU Acceleration for Windows 7
|
||||
if (release().startsWith('6.1')) app.disableHardwareAcceleration()
|
||||
|
||||
// Set application name for Windows 10+ notifications
|
||||
if (process.platform === 'win32') app.setAppUserModelId(app.getName())
|
||||
|
||||
if (!app.requestSingleInstanceLock()) {
|
||||
app.quit()
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
// Remove electron security warnings
|
||||
// This warning only shows in development mode
|
||||
// Read more on https://www.electronjs.org/docs/latest/tutorial/security
|
||||
// process.env['ELECTRON_DISABLE_SECURITY_WARNINGS'] = 'true'
|
||||
|
||||
app.commandLine.appendSwitch('--no-proxy-server')
|
||||
process.on('uncaughtException', () => {
|
||||
});
|
||||
process.on('unhandledRejection', () => {
|
||||
});
|
||||
|
||||
|
||||
let mainWindow: BrowserWindow | null = null
|
||||
let previewWin: BrowserWindow | null = null
|
||||
let aria2Process
|
||||
|
||||
// Here, you can also use other preload
|
||||
const preload = join(__dirname, '../preload/index.js')
|
||||
const url = process.env.VITE_DEV_SERVER_URL
|
||||
const indexHtml = join(process.env.DIST, 'index.html')
|
||||
|
||||
// app.whenReady().then(createWindow)
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
mainWindow = null
|
||||
if (process.platform !== 'darwin') app.quit()
|
||||
})
|
||||
|
||||
app.on('second-instance', () => {
|
||||
if (mainWindow) {
|
||||
// Focus on the main window if the user tried to open another
|
||||
if (mainWindow.isMinimized()) mainWindow.restore()
|
||||
mainWindow.focus()
|
||||
}
|
||||
})
|
||||
|
||||
app.on('activate', () => {
|
||||
const allWindows = BrowserWindow.getAllWindows()
|
||||
if (allWindows.length) {
|
||||
allWindows[0].focus()
|
||||
} else {
|
||||
createWindow()
|
||||
createPreviewWindow(mainWindow)
|
||||
setWin(mainWindow, previewWin)
|
||||
}
|
||||
})
|
||||
|
||||
app.on('before-quit', async e => {
|
||||
e.preventDefault()
|
||||
try {
|
||||
await closeProxy()
|
||||
aria2Process && aria2Process.kill();
|
||||
log.log("--------------closeProxy success--------------")
|
||||
} catch (error) {
|
||||
log.log("--------------proxy catch err--------------", error)
|
||||
}
|
||||
app.exit()
|
||||
})
|
||||
|
||||
function createWindow() {
|
||||
Menu.setApplicationMenu(null);
|
||||
|
||||
mainWindow = new BrowserWindow({
|
||||
title: 'Main window',
|
||||
icon: join(process.env.VITE_PUBLIC, 'favicon.ico'),
|
||||
width: 800,
|
||||
height: 600,
|
||||
webPreferences: {
|
||||
preload,
|
||||
// Warning: Enable nodeIntegration and disable contextIsolation is not secure in production
|
||||
// Consider using contextBridge.exposeInMainWorld
|
||||
// Read more on https://www.electronjs.org/docs/latest/tutorial/context-isolation
|
||||
nodeIntegration: true,
|
||||
contextIsolation: false,
|
||||
webSecurity: false,
|
||||
},
|
||||
})
|
||||
|
||||
if (process.env.VITE_DEV_SERVER_URL) { // electron-vite-vue#298
|
||||
mainWindow.loadURL(url).then(r => {
|
||||
})
|
||||
// Open devTool if the app is not packaged
|
||||
// mainWindow.webContents.openDevTools()
|
||||
} else {
|
||||
mainWindow.loadFile(indexHtml).then(r => {
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
CONFIG.IS_DEV && mainWindow.webContents.openDevTools()
|
||||
|
||||
// Test actively push message to the Electron-Renderer
|
||||
mainWindow.webContents.on('did-finish-load', () => {
|
||||
mainWindow?.webContents.send('main-process-message', new Date().toLocaleString())
|
||||
})
|
||||
|
||||
// Make all links open with the browser, not with the application
|
||||
mainWindow.webContents.setWindowOpenHandler(({url}) => {
|
||||
if (url.startsWith('https:')) shell.openExternal(url)
|
||||
return {action: 'deny'}
|
||||
})
|
||||
// win.webContents.on('will-navigate', (event, url) => { }) #344
|
||||
}
|
||||
|
||||
function createPreviewWindow(parent: BrowserWindow) {
|
||||
previewWin = new BrowserWindow({
|
||||
parent: parent,
|
||||
width: 600,
|
||||
height: 400,
|
||||
show: false,
|
||||
// paintWhenInitiallyHidden: false,
|
||||
webPreferences: {
|
||||
webSecurity: false,
|
||||
nodeIntegration: true,
|
||||
contextIsolation: false,
|
||||
},
|
||||
})
|
||||
|
||||
// previewWin.hide()
|
||||
previewWin.setTitle("预览")
|
||||
|
||||
previewWin.on("page-title-updated", (event) => {
|
||||
// 阻止该事件
|
||||
event.preventDefault()
|
||||
})
|
||||
|
||||
previewWin.on("close", (event) => {
|
||||
// 不关闭窗口
|
||||
event.preventDefault()
|
||||
// 影藏窗口
|
||||
previewWin.hide()
|
||||
})
|
||||
}
|
||||
|
||||
function createArua2Process() {
|
||||
// 根据操作系统选择 aria2 的路径
|
||||
try {
|
||||
let aria2Path, aria2Conf
|
||||
if (process.platform === 'win32') {
|
||||
// Windows
|
||||
aria2Path = path.join(CONFIG.EXECUTABLE_PATH, "./win/aria2/aria2c.exe")
|
||||
aria2Conf = path.join(CONFIG.EXECUTABLE_PATH, "./win/aria2/aria2.conf")
|
||||
} else {
|
||||
aria2Path = path.join(CONFIG.EXECUTABLE_PATH, "./mac/aria2" + (CONFIG.IS_DEV ? `/${process.arch}` : '/') + "/aria2c");
|
||||
aria2Conf = path.join(CONFIG.EXECUTABLE_PATH, "./mac/aria2/aria2.conf")
|
||||
}
|
||||
// 启动 aria2
|
||||
console.log("启动 aria2")
|
||||
aria2Process = spawn(aria2Path, [`--conf-path=${aria2Conf}`, `--rpc-listen-port=${CONFIG.ARIA_PORT}`], {
|
||||
windowsHide: false,
|
||||
stdio: CONFIG.IS_DEV ? 'pipe' : 'ignore'
|
||||
});
|
||||
if(!aria2Process){
|
||||
console.log("启动 aria2 失败")
|
||||
}
|
||||
if (CONFIG.IS_DEV) {
|
||||
aria2Process.stdout.on('data', (data) => {
|
||||
console.log(`aria2: ${data}`);
|
||||
});
|
||||
aria2Process.stderr.on('data', (data) => {
|
||||
console.log(`aria2 error: ${data}`);
|
||||
});
|
||||
}
|
||||
console.log("aria2 成功启动")
|
||||
} catch (e) {
|
||||
console.log(`aria2 process start err`, e);
|
||||
}
|
||||
}
|
||||
|
||||
app.whenReady().then(() => {
|
||||
initIPC()
|
||||
createWindow()
|
||||
createPreviewWindow(mainWindow)
|
||||
createArua2Process()
|
||||
setWin(mainWindow, previewWin)
|
||||
})
|
||||
@@ -1,182 +0,0 @@
|
||||
import {ipcMain, dialog, BrowserWindow, app, shell} from 'electron'
|
||||
import {startServer} from './proxyServer'
|
||||
import {installCert, checkCertInstalled} from './cert'
|
||||
import {decodeWxFile, suffix, getCurrentDateTimeFormatted} from './utils'
|
||||
// @ts-ignore
|
||||
import {hexMD5} from '../../src/common/md5'
|
||||
import {Aria2RPC} from './aria2Rpc'
|
||||
import fs from "fs"
|
||||
|
||||
let win: BrowserWindow
|
||||
let previewWin: BrowserWindow
|
||||
let isStartProxy = false
|
||||
const aria2RpcClient = new Aria2RPC()
|
||||
|
||||
export default function initIPC() {
|
||||
ipcMain.handle('invoke_app_is_init', async (event, arg) => {
|
||||
// 初始化应用 安装证书相关
|
||||
return checkCertInstalled()
|
||||
})
|
||||
|
||||
ipcMain.handle('invoke_init_app', (event, arg) => {
|
||||
// 开始 初始化应用 安装证书相关
|
||||
installCert(false).then(r => {
|
||||
})
|
||||
})
|
||||
|
||||
ipcMain.handle('invoke_start_proxy', (event, arg) => {
|
||||
// 启动代理服务
|
||||
if (isStartProxy) {
|
||||
return
|
||||
}
|
||||
isStartProxy = true
|
||||
return startServer({
|
||||
win: win,
|
||||
upstreamProxy: arg.upstream_proxy ? arg.upstream_proxy : "",
|
||||
setProxyErrorCallback: err => {
|
||||
console.log('setProxyErrorCallback', err)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
ipcMain.handle('invoke_select_down_dir', async (event, arg) => {
|
||||
// 选择下载位置
|
||||
const result = dialog.showOpenDialogSync({title: '保存', properties: ['openDirectory']})
|
||||
if (!result?.[0]) {
|
||||
return false
|
||||
}
|
||||
|
||||
return result?.[0]
|
||||
})
|
||||
|
||||
ipcMain.handle('invoke_select_wx_file', async (event, {index, data}) => {
|
||||
// 选择下载位置
|
||||
const result = dialog.showOpenDialogSync({title: '保存', properties: ['openFile']})
|
||||
if (!result?.[0]) {
|
||||
return false
|
||||
}
|
||||
return decodeWxFile(result?.[0], data.decode_key, result?.[0].replace(".mp4", "_解密.mp4"))
|
||||
})
|
||||
|
||||
ipcMain.handle('invoke_file_exists', async (event, {save_path, url, description}) => {
|
||||
let fileName = description ? description.replace(/[^a-zA-Z\u4e00-\u9fa5]/g, '') : hexMD5(url);
|
||||
let res = fs.existsSync(`${save_path}/${fileName}.mp4`)
|
||||
return {is_file: res, fileName: `${save_path}/${fileName}.mp4`}
|
||||
})
|
||||
|
||||
ipcMain.handle('invoke_down_file', async (event, {data, save_path, quality}) => {
|
||||
let down_url = data.url
|
||||
if (!down_url) {
|
||||
return new Promise((resolve, reject) => {
|
||||
resolve(false);
|
||||
});
|
||||
}
|
||||
if (quality !== "-1" && data.decode_key && data.file_format) {
|
||||
const format = data.file_format.split('#');
|
||||
const qualityMap = [
|
||||
format[0],
|
||||
format[Math.floor(format.length / 2)],
|
||||
format[format.length - 1]
|
||||
];
|
||||
down_url += "&X-snsvideoflag=" + qualityMap[quality];
|
||||
}
|
||||
let fileName = data?.description ? data.description.replace(/[^a-zA-Z0-9\u4e00-\u9fa5]/g, '') : hexMD5(down_url);
|
||||
fileName = fileName + "_" + getCurrentDateTimeFormatted() + suffix(data.type)
|
||||
let save_path_file = `${save_path}/${fileName}`
|
||||
if (process.platform === 'win32') {
|
||||
save_path_file = `${save_path}\\${fileName}`
|
||||
}
|
||||
|
||||
let headers = {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/100.0.4896.127 Safari/537.36',
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
|
||||
if (down_url.includes("douyin")) {
|
||||
headers['Referer'] = down_url
|
||||
}
|
||||
|
||||
aria2RpcClient.addUri([down_url], save_path, fileName, headers).then((response) => {
|
||||
let currentGid = response.result // 保存当前下载的 gid
|
||||
let progressIntervalId = null
|
||||
// // 开始定时查询下载进度
|
||||
progressIntervalId = setInterval(() => {
|
||||
aria2RpcClient.tellStatus(currentGid).then((status) => {
|
||||
if (status.result.status !== "complete") {
|
||||
const progress = aria2RpcClient.calculateDownloadProgress(status.result.bitfield);
|
||||
win?.webContents.send('on_down_file_schedule', {schedule: `已下载${progress}%`})
|
||||
} else {
|
||||
clearInterval(progressIntervalId);
|
||||
if (data.decode_key) {
|
||||
win?.webContents.send('on_down_file_schedule', {schedule: `开始解密`})
|
||||
decodeWxFile(save_path_file, data.decode_key, save_path_file.replace(".mp4", "_wx.mp4")).then((res) => {
|
||||
fs.unlink(save_path_file, (err) => {
|
||||
})
|
||||
resolve(res);
|
||||
}).catch((error) => {
|
||||
console.log("err:", error)
|
||||
resolve(false);
|
||||
});
|
||||
} else {
|
||||
resolve({
|
||||
fullFileName: save_path_file,
|
||||
});
|
||||
}
|
||||
}
|
||||
}).catch((error) => {
|
||||
console.error(error);
|
||||
clearInterval(progressIntervalId);
|
||||
resolve(false);
|
||||
});
|
||||
}, 1000);
|
||||
}).catch((error) => {
|
||||
console.log("err:", error)
|
||||
resolve(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
ipcMain.handle('invoke_resources_preview', async (event, {url}) => {
|
||||
if (!url) {
|
||||
return
|
||||
}
|
||||
|
||||
previewWin.loadURL(url).then(r => {
|
||||
return
|
||||
}).catch(res => {
|
||||
})
|
||||
previewWin.show()
|
||||
return
|
||||
})
|
||||
|
||||
ipcMain.handle('invoke_open_default_browser', (event, {url}) => {
|
||||
shell.openExternal(url).then(r => {
|
||||
})
|
||||
})
|
||||
|
||||
ipcMain.handle('invoke_open_file_dir', (event, {save_path}) => {
|
||||
shell.showItemInFolder(save_path)
|
||||
})
|
||||
|
||||
ipcMain.handle('invoke_file_del', (event, {url_sign}) => {
|
||||
if (url_sign === "all") {
|
||||
global.videoList = {}
|
||||
return
|
||||
}
|
||||
if (url_sign) {
|
||||
delete global.videoList[url_sign]
|
||||
return
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle('invoke_window_restart', (event) => {
|
||||
app.relaunch()
|
||||
app.exit()
|
||||
})
|
||||
}
|
||||
|
||||
export function setWin(w, p) {
|
||||
win = w
|
||||
previewWin = p
|
||||
}
|
||||
@@ -1,241 +0,0 @@
|
||||
import fs from 'fs'
|
||||
import log from 'electron-log'
|
||||
import CONFIG from './const'
|
||||
import {closeProxy, setProxy} from './setProxy'
|
||||
import {app} from "electron"
|
||||
import * as urlTool from "url"
|
||||
import {toSize} from "./utils"
|
||||
// @ts-ignore
|
||||
import {hexMD5} from '../../src/common/md5'
|
||||
import pkg from '../../package.json'
|
||||
|
||||
const hoXy = require('hoxy')
|
||||
|
||||
const port = 8899
|
||||
|
||||
global.videoList = {}
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
process.env.OPENSSL_BIN = CONFIG.OPEN_SSL_BIN_PATH
|
||||
process.env.OPENSSL_CONF = CONFIG.OPEN_SSL_CNF_PATH
|
||||
}
|
||||
|
||||
const resObject = {
|
||||
url: "",
|
||||
url_sign: "",
|
||||
cover_url: "",
|
||||
file_format: "",
|
||||
platform: "",
|
||||
size: "",
|
||||
type: "video/mp4",
|
||||
type_str: 'video',
|
||||
progress_bar: "",
|
||||
save_path: "",
|
||||
decode_key: "",
|
||||
description: ""
|
||||
}
|
||||
|
||||
const vv = hexMD5(pkg.version) + (CONFIG.IS_DEV ? Math.random() :"")
|
||||
|
||||
export async function startServer({win, upstreamProxy, setProxyErrorCallback = f => f,}) {
|
||||
return new Promise(async (resolve: any, reject) => {
|
||||
try {
|
||||
const proxy = hoXy.createServer({
|
||||
upstreamProxy: upstreamProxy,
|
||||
certAuthority: {
|
||||
key: fs.readFileSync(CONFIG.CERT_PRIVATE_PATH),
|
||||
cert: fs.readFileSync(CONFIG.CERT_PUBLIC_PATH),
|
||||
},
|
||||
})
|
||||
.listen(port, () => {
|
||||
setProxy('127.0.0.1', port)
|
||||
.then((res) => {
|
||||
resolve()
|
||||
})
|
||||
.catch((err) => {
|
||||
setProxyErrorCallback(err)
|
||||
reject('setting proxy err: ' + err.toString())
|
||||
});
|
||||
})
|
||||
.on('error', err => {
|
||||
setProxyErrorCallback(err)
|
||||
reject('proxy service err: ' + err.toString())
|
||||
})
|
||||
|
||||
|
||||
proxy.intercept(
|
||||
{
|
||||
phase: 'request',
|
||||
hostname: 'res-downloader.666666.com',
|
||||
as: 'json',
|
||||
},
|
||||
(req, res) => {
|
||||
res.string = 'ok'
|
||||
res.statusCode = 200
|
||||
try {
|
||||
if (req.json?.media?.length <= 0) {
|
||||
return
|
||||
}
|
||||
const media = req.json?.media[0]
|
||||
const url_sign: string = hexMD5(media.url)
|
||||
if (!media?.decodeKey || global.videoList.hasOwnProperty(url_sign) === true) {
|
||||
return
|
||||
}
|
||||
const urlInfo = urlTool.parse(media.url, true)
|
||||
global.videoList[url_sign] = media.url
|
||||
win.webContents.send('on_get_queue', Object.assign({}, resObject, {
|
||||
url_sign: url_sign,
|
||||
url: media.url + media.urlToken,
|
||||
cover_url: media.coverUrl,
|
||||
file_format: media.spec.map((res)=> res.fileFormat).join('#'),
|
||||
platform: urlInfo.hostname,
|
||||
size: toSize(media.fileSize),
|
||||
type: "video/mp4",
|
||||
type_str: 'video',
|
||||
decode_key: media.decodeKey,
|
||||
description: req.json.description,
|
||||
}))
|
||||
} catch (e) {
|
||||
log.log(e.toString())
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
proxy.intercept(
|
||||
{
|
||||
phase: 'response',
|
||||
hostname: 'channels.weixin.qq.com',
|
||||
as: 'string',
|
||||
},
|
||||
async (req, res) => {
|
||||
if (req.url.includes('/web/pages/feed') || req.url.includes('/web/pages/home')) {
|
||||
res.string = res.string.replaceAll('.js"', '.js?v=' + vv + '"')
|
||||
res.statusCode = 200
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
proxy.intercept(
|
||||
{
|
||||
phase: 'response',
|
||||
hostname: 'res.wx.qq.com',
|
||||
as: 'string',
|
||||
},
|
||||
async (req, res) => {
|
||||
if (req.url.endsWith('.js?v=' + vv)) {
|
||||
res.string = res.string.replaceAll('.js"', '.js?v=' + vv + '"');
|
||||
}
|
||||
if (req.url.includes("web/web-finder/res/js/virtual_svg-icons-register.publish")) {
|
||||
res.string = res.string.replace(/get\s*media\s*\(\)\s*\{/, `
|
||||
get media(){
|
||||
if(this.objectDesc){
|
||||
fetch("https://res-downloader.666666.com", {
|
||||
method: "POST",
|
||||
mode: "no-cors",
|
||||
body: JSON.stringify(this.objectDesc),
|
||||
});
|
||||
};
|
||||
`)
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
proxy.intercept(
|
||||
{
|
||||
phase: 'response',
|
||||
},
|
||||
async (req, res) => {
|
||||
try {
|
||||
// 拦截响应
|
||||
const ctype = res?._data?.headers?.['content-type']
|
||||
const url_sign: string = hexMD5(req.fullUrl())
|
||||
const res_url = req.fullUrl()
|
||||
const urlInfo = urlTool.parse(res_url, true)
|
||||
switch (ctype) {
|
||||
case "video/mp4":
|
||||
case "video/webm":
|
||||
case "video/ogg":
|
||||
case "video/x-msvideo":
|
||||
case "video/mpeg":
|
||||
case "video/quicktime":
|
||||
case "video/x-ms-wmv":
|
||||
case "video/x-flv":
|
||||
case "video/3gpp":
|
||||
case "video/x-matroska":
|
||||
if (global.videoList.hasOwnProperty(url_sign) === false) {
|
||||
global.videoList[url_sign] = res_url
|
||||
win.webContents.send('on_get_queue', Object.assign({}, resObject, {
|
||||
url: res_url,
|
||||
url_sign: url_sign,
|
||||
platform: urlInfo.hostname,
|
||||
size: toSize(res?._data?.headers?.['content-length'] ?? 0),
|
||||
type: ctype,
|
||||
type_str: 'video',
|
||||
}))
|
||||
}
|
||||
break;
|
||||
case "image/png":
|
||||
case "image/webp":
|
||||
case "image/jpeg":
|
||||
case "image/jpg":
|
||||
case "image/svg+xml":
|
||||
case "image/gif":
|
||||
case "image/avif":
|
||||
case "image/bmp":
|
||||
case "image/tiff":
|
||||
case "image/x-icon":
|
||||
case "image/heic":
|
||||
case "image/vnd.adobe.photoshop":
|
||||
win.webContents.send('on_get_queue', Object.assign({}, resObject, {
|
||||
url: res_url,
|
||||
url_sign: url_sign,
|
||||
platform: urlInfo.hostname,
|
||||
size: res?._data?.headers?.['content-length'] ? toSize(res?._data?.headers?.['content-length']) : 0,
|
||||
type: ctype,
|
||||
type_str: 'image',
|
||||
}))
|
||||
break
|
||||
case "audio/mpeg":
|
||||
case "audio/wav":
|
||||
case "audio/aiff":
|
||||
case "audio/x-aiff":
|
||||
case "audio/aac":
|
||||
case "audio/ogg":
|
||||
case "audio/flac":
|
||||
case "audio/midi":
|
||||
case "audio/x-midi":
|
||||
case "audio/x-ms-wma":
|
||||
case "audio/opus":
|
||||
case "audio/webm":
|
||||
case "audio/mp4":
|
||||
win.webContents.send('on_get_queue', Object.assign({}, resObject, {
|
||||
url: res_url,
|
||||
url_sign: url_sign,
|
||||
platform: urlInfo.hostname,
|
||||
size: res?._data?.headers?.['content-length'] ? toSize(res?._data?.headers?.['content-length']) : 0,
|
||||
type: ctype,
|
||||
type_str: 'audio',
|
||||
}))
|
||||
break
|
||||
case "application/vnd.apple.mpegurl":
|
||||
case "application/x-mpegURL":
|
||||
win.webContents.send('on_get_queue', Object.assign({}, resObject, {
|
||||
url: res_url,
|
||||
url_sign: url_sign,
|
||||
platform: urlInfo.hostname,
|
||||
size: res?._data?.headers?.['content-length'] ? toSize(res?._data?.headers?.['content-length']) : 0,
|
||||
type: ctype,
|
||||
type_str: 'm3u8',
|
||||
}))
|
||||
break
|
||||
}
|
||||
} catch (e) {
|
||||
log.log(e.toString())
|
||||
}
|
||||
},
|
||||
)
|
||||
} catch (e) {
|
||||
log.log("--------------proxy catch err--------------", e)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
import {exec} from 'child_process'
|
||||
// @ts-ignore
|
||||
import regedit from 'regedit'
|
||||
import CONFIG from './const'
|
||||
|
||||
regedit.setExternalVBSLocation(CONFIG.REGEDIT_VBS_PATH)
|
||||
|
||||
export async function setProxy(host, port) {
|
||||
if (process.platform === 'darwin') {
|
||||
const networks = await getMacAvailableNetworks()
|
||||
|
||||
// @ts-ignore
|
||||
if (networks.length === 0) {
|
||||
throw 'no network'
|
||||
}
|
||||
|
||||
return Promise.all(
|
||||
// @ts-ignore
|
||||
networks.map(network => {
|
||||
return new Promise((resolve, reject) => {
|
||||
exec(`networksetup -setsecurewebproxy "${network}" ${host} ${port}`, error => {
|
||||
if (error) {
|
||||
reject(null)
|
||||
} else {
|
||||
exec(`networksetup -setwebproxy "${network}" ${host} ${port}`, error => {
|
||||
if (error) {
|
||||
reject(null)
|
||||
} else {
|
||||
resolve(network)
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
const valuesToPut = {
|
||||
'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings': {
|
||||
ProxyServer: {
|
||||
value: `${host}:${port}`,
|
||||
type: 'REG_SZ',
|
||||
},
|
||||
ProxyEnable: {
|
||||
value: 1,
|
||||
type: 'REG_DWORD',
|
||||
},
|
||||
},
|
||||
};
|
||||
// @ts-ignore
|
||||
return regedit.promisified.putValue(valuesToPut)
|
||||
}
|
||||
}
|
||||
|
||||
export async function closeProxy() {
|
||||
if (process.platform === 'darwin') {
|
||||
const networks = await getMacAvailableNetworks()
|
||||
|
||||
// @ts-ignore
|
||||
if (networks.length === 0) {
|
||||
throw 'no network'
|
||||
}
|
||||
|
||||
return Promise.all(
|
||||
// @ts-ignore
|
||||
networks.map(network => {
|
||||
return new Promise((resolve, reject) => {
|
||||
exec(`networksetup -setsecurewebproxystate "${network}" off`, error => {
|
||||
if (error) {
|
||||
reject(null)
|
||||
} else {
|
||||
exec(`networksetup -setwebproxystate "${network}" off`, error => {
|
||||
if (error) {
|
||||
reject(null)
|
||||
} else {
|
||||
resolve(network)
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
const valuesToPut = {
|
||||
'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings': {
|
||||
ProxyEnable: {
|
||||
value: 0,
|
||||
type: 'REG_DWORD',
|
||||
},
|
||||
},
|
||||
};
|
||||
// @ts-ignore
|
||||
return regedit.promisified.putValue(valuesToPut)
|
||||
}
|
||||
}
|
||||
|
||||
function getMacAvailableNetworks() {
|
||||
return new Promise((resolve, reject) => {
|
||||
exec('networksetup -listallnetworkservices', (error, stdout) => {
|
||||
if (error) {
|
||||
reject(error)
|
||||
} else {
|
||||
Promise.all(
|
||||
stdout
|
||||
.toString()
|
||||
.split('\n')
|
||||
.map(network => {
|
||||
return new Promise(resolve => {
|
||||
exec(
|
||||
`networksetup getinfo "${network}" | grep "^IP address:\\s\\d"`,
|
||||
(error, stdout) => {
|
||||
if (error) {
|
||||
resolve(null)
|
||||
} else {
|
||||
resolve(stdout ? network : null)
|
||||
}
|
||||
},
|
||||
)
|
||||
})
|
||||
}),
|
||||
).then(networks => {
|
||||
resolve(networks.filter(Boolean))
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -1,160 +0,0 @@
|
||||
import fs from 'fs'
|
||||
import {Transform } from 'stream'
|
||||
import {getDecryptionArray} from '../wxjs/decrypt.js'
|
||||
|
||||
const axios = require('axios')
|
||||
function xorTransform(decryptionArray) {
|
||||
let processedBytes = 0;
|
||||
return new Transform({
|
||||
transform(chunk, encoding, callback) {
|
||||
if (processedBytes < decryptionArray.length) {
|
||||
let remaining = Math.min(decryptionArray.length - processedBytes, chunk.length);
|
||||
for (let i = 0; i < remaining; i++) {
|
||||
chunk[i] = chunk[i] ^ decryptionArray[processedBytes + i];
|
||||
}
|
||||
processedBytes += remaining;
|
||||
}
|
||||
this.push(chunk);
|
||||
callback();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function downloadFile(url, decodeKey, fullFileName, progressCallback) {
|
||||
let xorStream = null
|
||||
if (decodeKey) {
|
||||
xorStream = xorTransform(getDecryptionArray(decodeKey));
|
||||
}
|
||||
let config = {
|
||||
responseType: 'stream',
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/100.0.4896.127 Safari/537.36',
|
||||
},
|
||||
}
|
||||
|
||||
if (url.includes("douyin")){
|
||||
config.headers['Referer'] = url
|
||||
}
|
||||
|
||||
return axios.get(url, config).then(({data, headers}) => {
|
||||
let currentLen = 0
|
||||
const totalLen = headers['content-length']
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
data.on('data', ({length}) => {
|
||||
currentLen += length
|
||||
// @ts-ignore
|
||||
progressCallback?.(currentLen / totalLen)
|
||||
});
|
||||
|
||||
data.on('error', err => reject(err))
|
||||
if (xorStream) {
|
||||
data.pipe(xorStream).pipe(
|
||||
fs.createWriteStream(fullFileName).on('finish', () => {
|
||||
resolve({
|
||||
fullFileName,
|
||||
totalLen,
|
||||
});
|
||||
}),
|
||||
);
|
||||
}else{
|
||||
data.pipe(
|
||||
fs.createWriteStream(fullFileName).on('finish', () => {
|
||||
resolve({
|
||||
fullFileName,
|
||||
totalLen,
|
||||
});
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function decodeWxFile(fileName, decodeKey, fullFileName) {
|
||||
let xorStream = xorTransform(getDecryptionArray(decodeKey));
|
||||
let data = fs.createReadStream(fileName);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
data.on('error', err => reject(err));
|
||||
data.pipe(xorStream).pipe(
|
||||
fs.createWriteStream(fullFileName).on('finish', () => {
|
||||
resolve({
|
||||
fullFileName,
|
||||
});
|
||||
}),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function toSize(size: number) {
|
||||
if (size > 1048576) {
|
||||
return (size / 1048576).toFixed(2) + "MB"
|
||||
}
|
||||
if (size > 1024) {
|
||||
return (size / 1024).toFixed(2) + "KB"
|
||||
}
|
||||
return size + 'b'
|
||||
}
|
||||
|
||||
function suffix(type: string) {
|
||||
switch (type) {
|
||||
case "video/mp4":
|
||||
case "video/webm":
|
||||
case "video/ogg":
|
||||
case "video/x-msvideo":
|
||||
case "video/mpeg":
|
||||
case "video/quicktime":
|
||||
case "video/x-ms-wmv":
|
||||
case "video/x-flv":
|
||||
case "video/3gpp":
|
||||
case "video/x-matroska":
|
||||
return ".mp4";
|
||||
case "image/png":
|
||||
case "image/webp":
|
||||
case "image/jpeg":
|
||||
case "image/jpg":
|
||||
case "image/svg+xml":
|
||||
case "image/gif":
|
||||
case "image/avif":
|
||||
case "image/bmp":
|
||||
case "image/tiff":
|
||||
case "image/x-icon":
|
||||
case "image/heic":
|
||||
case "image/vnd.adobe.photoshop":
|
||||
return ".png";
|
||||
case "audio/mpeg":
|
||||
case "audio/wav":
|
||||
case "audio/aiff":
|
||||
case "audio/x-aiff":
|
||||
case "audio/aac":
|
||||
case "audio/ogg":
|
||||
case "audio/flac":
|
||||
case "audio/midi":
|
||||
case "audio/x-midi":
|
||||
case "audio/x-ms-wma":
|
||||
case "audio/opus":
|
||||
case "audio/webm":
|
||||
case "audio/mp4":
|
||||
return ".mp3";
|
||||
case "application/vnd.apple.mpegurl":
|
||||
case "application/x-mpegURL":
|
||||
return ".m3u8";
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
function getCurrentDateTimeFormatted() {
|
||||
const now = new Date();
|
||||
|
||||
const year = now.getFullYear();
|
||||
const month = String(now.getMonth() + 1).padStart(2, '0'); // 月份从0开始,所以要加1
|
||||
const day = String(now.getDate()).padStart(2, '0');
|
||||
const hours = String(now.getHours()).padStart(2, '0');
|
||||
const minutes = String(now.getMinutes()).padStart(2, '0');
|
||||
const seconds = String(now.getSeconds()).padStart(2, '0');
|
||||
|
||||
return `${year}${month}${day}${hours}${minutes}${seconds}`;
|
||||
}
|
||||
|
||||
export {downloadFile, toSize, decodeWxFile, suffix, getCurrentDateTimeFormatted}
|
||||
@@ -1,86 +0,0 @@
|
||||
function domReady(condition: DocumentReadyState[] = ['complete', 'interactive']) {
|
||||
return new Promise((resolve) => {
|
||||
if (condition.includes(document.readyState)) {
|
||||
resolve(true)
|
||||
} else {
|
||||
document.addEventListener('readystatechange', () => {
|
||||
if (condition.includes(document.readyState)) {
|
||||
resolve(true)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const safeDOM = {
|
||||
append(parent: HTMLElement, child: HTMLElement) {
|
||||
if (!Array.from(parent.children).find(e => e === child)) {
|
||||
return parent.appendChild(child)
|
||||
}
|
||||
},
|
||||
remove(parent: HTMLElement, child: HTMLElement) {
|
||||
if (Array.from(parent.children).find(e => e === child)) {
|
||||
return parent.removeChild(child)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
function useLoading() {
|
||||
const className = `loaders-css__square-spin`
|
||||
const styleContent = `
|
||||
@keyframes square-spin {
|
||||
25% { transform: perspective(100px) rotateX(180deg) rotateY(0); }
|
||||
50% { transform: perspective(100px) rotateX(180deg) rotateY(180deg); }
|
||||
75% { transform: perspective(100px) rotateX(0) rotateY(180deg); }
|
||||
100% { transform: perspective(100px) rotateX(0) rotateY(0); }
|
||||
}
|
||||
.${className} > div {
|
||||
animation-fill-mode: both;
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
background: #fff;
|
||||
animation: square-spin 3s 0s cubic-bezier(0.09, 0.57, 0.49, 0.9) infinite;
|
||||
}
|
||||
.app-loading-wrap {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #282c34;
|
||||
z-index: 9;
|
||||
}
|
||||
`
|
||||
const oStyle = document.createElement('style')
|
||||
const oDiv = document.createElement('div')
|
||||
|
||||
oStyle.id = 'app-loading-style'
|
||||
oStyle.innerHTML = styleContent
|
||||
oDiv.className = 'app-loading-wrap'
|
||||
oDiv.innerHTML = `<div class="${className}"><div></div></div>`
|
||||
|
||||
return {
|
||||
appendLoading() {
|
||||
safeDOM.append(document.head, oStyle)
|
||||
safeDOM.append(document.body, oDiv)
|
||||
},
|
||||
removeLoading() {
|
||||
safeDOM.remove(document.head, oStyle)
|
||||
safeDOM.remove(document.body, oDiv)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
const { appendLoading, removeLoading } = useLoading()
|
||||
domReady().then(appendLoading)
|
||||
|
||||
window.onmessage = (ev) => {
|
||||
ev.data.payload === 'removeLoading' && removeLoading()
|
||||
}
|
||||
|
||||
setTimeout(removeLoading, 4999)
|
||||
|
Before Width: | Height: | Size: 199 KiB |
@@ -1,12 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>爱享素材下载器</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,28 +0,0 @@
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDcDt23t6ioBoHG
|
||||
/Y2mOjxntWQa9dP3eNl+mAC6425DlEtyc6czNAIKuuM9wt+wAwDQAgrd5RaxdcpJ
|
||||
H1JlMkEtBFkIkdn0Ag98D7nwlVA9ON3xQi5Bkl+sN/oWOE8lOwvNyNNT6ZPu3qUS
|
||||
KY8SXZVY315daqz2eRWsm2otqjqbWGhh9c7FGHr3r9aAG08dyaO6OvK14GJIhNV+
|
||||
UOPH5hDMMaxurDt8znaSUw93b7D++aEninGro/s2LY4G91dgM8i4t88UWobXpqs5
|
||||
GMGTI0InLX2I66HkteH4RRfXXC9svA2CxN3yP294FIP7gdRQ1CGJeJcluzsjtx0V
|
||||
i2G9vrT1AgMBAAECggEAF0obfQ4a82183qqHC0iui+tOpOvPeyl3G0bLDPx09wIC
|
||||
2iITV//xF2GgGzE8q0wmEd2leMZ+GFn3BrYh6kPfUfxbz+RfxMtTCDZB34xt6YzT
|
||||
MG1op9ft+DQUa7WZ6r7NCQJwGzllRqqZncp4MeFlpPo+6nQXyh4WhSYNnredbENE
|
||||
uPZ63Kme4RZfMvtVso+XgAQM3oDih0onv1YitmNQpL9rRzlthTfybAT4737DBINq
|
||||
zsmBNE6QIsXnSKpzo11OtDgof2QM9ac6eAXf73oTpDxfodwCotILytKn+8WYvlR+
|
||||
T15uuknb4M3XI1FPVolkF4qtK5SLAAbVzV4DsCmuIQKBgQD6bTKKbL2huvU6dEKx
|
||||
bgS079LfQUxxOTClgwkhVsMxRtvcPBnHYMAsPK4mnMhEh9x+TF6wxMx0pmhQluPI
|
||||
ZULNBj/qdoiBL0RwVLA+9jgE0NeWB3XXFDsEavQBr9Q8CC0uzrsgsxFcvHpqqs2Q
|
||||
RtngxRWtJP06D6mKC23s4YjDHwKBgQDg9KUCFqOmWcRXyeg9gYMC4jFFQw4lUQBd
|
||||
sYpqSMHDw1b+T1W/dCPbwbxZL/+d8y930BYy9QYDtQwHdLyXCH0pHM7S6rfgr5xk
|
||||
2Szd8xBUIqmeV/zcR00mTeQHJ1M50VHfclAVgZgkpWSoLwbX+bXyx/mfqLAtynZ5
|
||||
yU9RfrT5awKBgQC0uJ8TlFvZXjFgyMvkfY/5/2R/ZwFCaFI573FkVNeyNP+vVNQJ
|
||||
tUGZ6wSGqvg/tIgjwPtIuA0QVZLMLcgeMy1dBhiUHIxwJetO4V77YPaWSxx5kdKx
|
||||
r1DT5FdI7FnOJNxufhQ/CdsKwJ3bYn3Mk8TiV3hIJnx0LR9dltfybeQjYwKBgDOY
|
||||
6aApATBOtrJMJXC2HA61QwfX8Y6tnZ/f8RefyJHWZEXAfLKFORRWw5TRZZgdB247
|
||||
1Furx81h4Xh0Vi1uTQb5DJdkLvjiTsTy60+dSMmDidQ/6ke8Mv3uL7dUVcqVMGpI
|
||||
FgZYy0TcitHot3EiXZFqPN9aGc7m+XXFruPKZEgxAoGBAMA96jsow7CzulU+GRW8
|
||||
Njg4zWuAEVErgPoNBcOXAVWLCTU/qGIEMNpZL6Ok34kf13pJDMjQ8eDuQHu5CSqf
|
||||
0ul5Zy85fwfVq2IvNAyYT8eflQprTejFw22CHhfPBfADVW9ro8dK/Jw+J/31Vh7V
|
||||
ILKEQKmPPzKs7kp/7Nz+2cT3
|
||||
-----END PRIVATE KEY-----
|
||||
@@ -1,28 +0,0 @@
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDcDt23t6ioBoHG
|
||||
/Y2mOjxntWQa9dP3eNl+mAC6425DlEtyc6czNAIKuuM9wt+wAwDQAgrd5RaxdcpJ
|
||||
H1JlMkEtBFkIkdn0Ag98D7nwlVA9ON3xQi5Bkl+sN/oWOE8lOwvNyNNT6ZPu3qUS
|
||||
KY8SXZVY315daqz2eRWsm2otqjqbWGhh9c7FGHr3r9aAG08dyaO6OvK14GJIhNV+
|
||||
UOPH5hDMMaxurDt8znaSUw93b7D++aEninGro/s2LY4G91dgM8i4t88UWobXpqs5
|
||||
GMGTI0InLX2I66HkteH4RRfXXC9svA2CxN3yP294FIP7gdRQ1CGJeJcluzsjtx0V
|
||||
i2G9vrT1AgMBAAECggEAF0obfQ4a82183qqHC0iui+tOpOvPeyl3G0bLDPx09wIC
|
||||
2iITV//xF2GgGzE8q0wmEd2leMZ+GFn3BrYh6kPfUfxbz+RfxMtTCDZB34xt6YzT
|
||||
MG1op9ft+DQUa7WZ6r7NCQJwGzllRqqZncp4MeFlpPo+6nQXyh4WhSYNnredbENE
|
||||
uPZ63Kme4RZfMvtVso+XgAQM3oDih0onv1YitmNQpL9rRzlthTfybAT4737DBINq
|
||||
zsmBNE6QIsXnSKpzo11OtDgof2QM9ac6eAXf73oTpDxfodwCotILytKn+8WYvlR+
|
||||
T15uuknb4M3XI1FPVolkF4qtK5SLAAbVzV4DsCmuIQKBgQD6bTKKbL2huvU6dEKx
|
||||
bgS079LfQUxxOTClgwkhVsMxRtvcPBnHYMAsPK4mnMhEh9x+TF6wxMx0pmhQluPI
|
||||
ZULNBj/qdoiBL0RwVLA+9jgE0NeWB3XXFDsEavQBr9Q8CC0uzrsgsxFcvHpqqs2Q
|
||||
RtngxRWtJP06D6mKC23s4YjDHwKBgQDg9KUCFqOmWcRXyeg9gYMC4jFFQw4lUQBd
|
||||
sYpqSMHDw1b+T1W/dCPbwbxZL/+d8y930BYy9QYDtQwHdLyXCH0pHM7S6rfgr5xk
|
||||
2Szd8xBUIqmeV/zcR00mTeQHJ1M50VHfclAVgZgkpWSoLwbX+bXyx/mfqLAtynZ5
|
||||
yU9RfrT5awKBgQC0uJ8TlFvZXjFgyMvkfY/5/2R/ZwFCaFI573FkVNeyNP+vVNQJ
|
||||
tUGZ6wSGqvg/tIgjwPtIuA0QVZLMLcgeMy1dBhiUHIxwJetO4V77YPaWSxx5kdKx
|
||||
r1DT5FdI7FnOJNxufhQ/CdsKwJ3bYn3Mk8TiV3hIJnx0LR9dltfybeQjYwKBgDOY
|
||||
6aApATBOtrJMJXC2HA61QwfX8Y6tnZ/f8RefyJHWZEXAfLKFORRWw5TRZZgdB247
|
||||
1Furx81h4Xh0Vi1uTQb5DJdkLvjiTsTy60+dSMmDidQ/6ke8Mv3uL7dUVcqVMGpI
|
||||
FgZYy0TcitHot3EiXZFqPN9aGc7m+XXFruPKZEgxAoGBAMA96jsow7CzulU+GRW8
|
||||
Njg4zWuAEVErgPoNBcOXAVWLCTU/qGIEMNpZL6Ok34kf13pJDMjQ8eDuQHu5CSqf
|
||||
0ul5Zy85fwfVq2IvNAyYT8eflQprTejFw22CHhfPBfADVW9ro8dK/Jw+J/31Vh7V
|
||||
ILKEQKmPPzKs7kp/7Nz+2cT3
|
||||
-----END PRIVATE KEY-----
|
||||