├── .github
├── ISSUE_TEMPLATE
│ ├── bug_report.yml
│ ├── config.yml
│ └── feature_request.yml
└── workflows
│ └── Publish.yml
├── .gitignore
├── .prettierrc
├── .vscode
└── extensions.json
├── LICENSE
├── README.md
├── auto-imports.d.ts
├── components.d.ts
├── eslint.config.js
├── index.html
├── package.json
├── pnpm-lock.yaml
├── public
├── tauri.svg
└── vite.svg
├── src-tauri
├── .gitignore
├── Cargo.lock
├── Cargo.toml
├── build.rs
├── capabilities
│ └── default.json
├── icons
│ ├── 128x128.png
│ ├── 128x128@2x.png
│ ├── 32x32.png
│ ├── Square107x107Logo.png
│ ├── Square142x142Logo.png
│ ├── Square150x150Logo.png
│ ├── Square284x284Logo.png
│ ├── Square30x30Logo.png
│ ├── Square310x310Logo.png
│ ├── Square44x44Logo.png
│ ├── Square71x71Logo.png
│ ├── Square89x89Logo.png
│ ├── StoreLogo.png
│ ├── icon.icns
│ ├── icon.ico
│ └── icon.png
├── src
│ ├── commands.rs
│ ├── config.rs
│ ├── download_manager.rs
│ ├── errors.rs
│ ├── events.rs
│ ├── export.rs
│ ├── extensions.rs
│ ├── jm_client.rs
│ ├── lib.rs
│ ├── logger.rs
│ ├── main.rs
│ ├── responses
│ │ ├── get_chapter_resp_data.rs
│ │ ├── get_comic_resp_data.rs
│ │ ├── get_favorite_resp_data.rs
│ │ ├── get_user_profile_resp_data.rs
│ │ ├── mod.rs
│ │ ├── search_resp.rs
│ │ └── toggle_favorite_resp_data.rs
│ ├── types
│ │ ├── chapter_info.rs
│ │ ├── comic.rs
│ │ ├── comic_info.rs
│ │ ├── download_format.rs
│ │ ├── favorite_sort.rs
│ │ ├── get_favorite_result.rs
│ │ ├── log_level.rs
│ │ ├── mod.rs
│ │ ├── proxy_mode.rs
│ │ ├── search_result.rs
│ │ └── search_sort.rs
│ └── utils.rs
└── tauri.conf.json
├── src
├── App.vue
├── AppContent.vue
├── assets
│ └── vue.svg
├── bindings.ts
├── components
│ ├── AboutDialog.vue
│ ├── ComicCard.vue
│ ├── CompletedProgresses.vue
│ ├── DownloadedComicCard.vue
│ ├── FloatLabelInput.vue
│ ├── LogViewer.vue
│ ├── LoginDialog.vue
│ ├── SettingsDialog.vue
│ └── UncompletedProgresses.vue
├── main.ts
├── panes
│ ├── ChapterPane.vue
│ ├── DownloadedPane.vue
│ ├── DownloadingPane.vue
│ ├── FavoritePane.vue
│ └── SearchPane.vue
├── store.ts
├── types.ts
└── vite-env.d.ts
├── tsconfig.json
├── tsconfig.node.json
├── uno.config.ts
└── vite.config.ts
/.github/ISSUE_TEMPLATE/bug_report.yml:
--------------------------------------------------------------------------------
1 | name: 反馈 Bug
2 | description: 反馈遇到的问题
3 | labels: [bug]
4 | title: "[Bug] 修改我!"
5 | body:
6 | - type: checkboxes
7 | attributes:
8 | label: 在提交BUG之前
9 | description: 请先尝试使用最新版,也许这个问题已经在最新版修复
10 | options:
11 | - label: 我尝试使用了最新版,我确定这个问题在最新版中依然存在
12 | required: true
13 | - type: markdown
14 | attributes:
15 | value: |
16 | 为了使我更好地帮助你,请提供以下信息。以及修改上方的标题
17 | - type: checkboxes
18 | attributes:
19 | label: 复现步骤很重要
20 | options:
21 | - label: 我已经参考[这个issue](https://github.com/lanyeeee/jmcomic-downloader/issues/28)写清楚了复现步骤
22 | required: true
23 | - type: textarea
24 | id: desc
25 | attributes:
26 | label: 问题描述
27 | description: 发生了什么情况?有什么现状?复现条件是什么(哪部漫画,哪个章节)?
28 | validations:
29 | required: true
30 | - type: textarea
31 | id: expected
32 | attributes:
33 | label: 预期行为
34 | description: 正常情况下应该发生什么
35 | validations:
36 | required: true
37 | - type: textarea
38 | id: actual
39 | attributes:
40 | label: 实际行为
41 | description: 实际上发生了什么
42 | validations:
43 | required: true
44 | - type: textarea
45 | id: media
46 | attributes:
47 | label: 截图或录屏
48 | description: 问题复现时候的截图或录屏
49 | placeholder: 点击文本框下面小长条可以上传文件
50 | - type: input
51 | id: version
52 | attributes:
53 | label: 工具版本号(点`关于`按钮查看)
54 | placeholder: v0.1.0
55 | validations:
56 | required: true
57 | - type: textarea
58 | id: other
59 | attributes:
60 | label: 其他
61 | description: 其他要补充的内容
62 | placeholder: 其他要补充的内容
63 | validations:
64 | required: false
65 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/config.yml:
--------------------------------------------------------------------------------
1 | blank_issues_enabled: true
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/feature_request.yml:
--------------------------------------------------------------------------------
1 | name: 功能请求
2 | description: 想要请求添加某个功能
3 | labels: [enhancement]
4 | title: "[功能请求] 修改我!"
5 | body:
6 | - type: markdown
7 | attributes:
8 | value: |
9 | 为了使我更好地帮助你,请提供以下信息。以及上方的标题
10 | - type: textarea
11 | id: reason
12 | attributes:
13 | label: 原因
14 | description: 为什么想要这个功能
15 | validations:
16 | required: true
17 | - type: textarea
18 | id: desc
19 | attributes:
20 | label: 功能简述
21 | description: 想要个怎样的功能
22 | validations:
23 | required: true
24 | - type: textarea
25 | id: logic
26 | attributes:
27 | label: 功能逻辑
28 | description: 如何互交、如何使用等
29 | validations:
30 | required: true
31 | - type: textarea
32 | id: ref
33 | attributes:
34 | label: 实现参考
35 | description: 该功能可能的实现方式,或者其他已经实现该功能的应用等
--------------------------------------------------------------------------------
/.github/workflows/Publish.yml:
--------------------------------------------------------------------------------
1 | name: "publish"
2 |
3 | on:
4 | workflow_dispatch:
5 | push:
6 | tags:
7 | - "v*"
8 |
9 | jobs:
10 | get-version:
11 | runs-on: ubuntu-latest
12 | outputs:
13 | VERSION: ${{ steps.get_version.outputs.VERSION }}
14 | steps:
15 | - uses: actions/checkout@v4
16 |
17 | - name: Get version number
18 | id: get_version
19 | run: |
20 | VERSION=$(jq -r '.version' src-tauri/tauri.conf.json)
21 | echo "VERSION=$VERSION" >> $GITHUB_OUTPUT
22 |
23 | windows-build:
24 | needs: get-version
25 | env:
26 | VERSION: ${{ needs.get-version.outputs.VERSION }}
27 | outputs:
28 | VERSION: ${{ env.VERSION }}
29 | runs-on: windows-latest
30 | steps:
31 | - uses: actions/checkout@v4
32 |
33 | - name: Setup node
34 | uses: actions/setup-node@v4
35 | with:
36 | node-version: lts/*
37 |
38 | - name: Install Rust stable
39 | uses: dtolnay/rust-toolchain@stable
40 |
41 | - name: Install pnpm
42 | uses: pnpm/action-setup@v4
43 | with:
44 | run_install: false
45 |
46 | - name: Install frontend dependencies
47 | run: pnpm install
48 |
49 | - name: Build tauri app
50 | uses: tauri-apps/tauri-action@v0
51 |
52 | - name: Create artifacts directory
53 | run: mkdir -p artifacts
54 |
55 | - name: Copy nsis to release assets
56 | run: cp src-tauri/target/release/bundle/nsis/jmcomic-downloader_${{ env.VERSION }}_x64-setup.exe artifacts/jmcomic-downloader_${{ env.VERSION }}_windows_x64.exe
57 |
58 | - name: Zip portable to release assets
59 | run: |
60 | cd src-tauri/target/release
61 | 7z a -tzip ../../../artifacts/jmcomic-downloader_${{ env.VERSION }}_windows_x64_portable.zip jmcomic-downloader.exe
62 |
63 | - name: Upload artifacts
64 | uses: actions/upload-artifact@v4
65 | with:
66 | name: windows-assets
67 | path: artifacts/*
68 |
69 | linux-build:
70 | needs: get-version
71 | env:
72 | VERSION: ${{ needs.get-version.outputs.VERSION }}
73 | outputs:
74 | VERSION: ${{ env.VERSION }}
75 | runs-on: ubuntu-22.04
76 | steps:
77 | - name: install dependencies
78 | run: |
79 | sudo apt-get update
80 | sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf
81 | - uses: actions/checkout@v4
82 |
83 | - name: Setup node
84 | uses: actions/setup-node@v4
85 | with:
86 | node-version: lts/*
87 |
88 | - name: Install Rust stable
89 | uses: dtolnay/rust-toolchain@stable
90 |
91 | - name: Install pnpm
92 | uses: pnpm/action-setup@v4
93 | with:
94 | run_install: false
95 |
96 | - name: Install frontend dependencies
97 | run: pnpm install
98 |
99 | - name: Build tauri app
100 | uses: tauri-apps/tauri-action@v0
101 |
102 | - name: Create artifacts directory
103 | run: mkdir -p artifacts
104 |
105 | - name: Copy deb to release assets
106 | run: cp src-tauri/target/release/bundle/deb/jmcomic-downloader_${{ env.VERSION }}_amd64.deb artifacts/jmcomic-downloader_${{ env.VERSION }}_linux_amd64.deb
107 |
108 | - name: Copy rpm to release assets
109 | run: cp src-tauri/target/release/bundle/rpm/jmcomic-downloader-${{ env.VERSION }}-1.x86_64.rpm artifacts/jmcomic-downloader_${{ env.VERSION }}_linux_amd64.rpm
110 |
111 | - name: Zip portable to release assets
112 | run: |
113 | cd src-tauri/target/release
114 | tar -czf ../../../artifacts/jmcomic-downloader_${{ env.VERSION }}_linux_amd64_portable.tar.gz jmcomic-downloader
115 |
116 | - name: Upload artifacts
117 | uses: actions/upload-artifact@v4
118 | with:
119 | name: linux-assets
120 | path: artifacts/*
121 |
122 | macos-build:
123 | needs: get-version
124 | env:
125 | VERSION: ${{ needs.get-version.outputs.VERSION }}
126 | outputs:
127 | VERSION: ${{ env.VERSION }}
128 | strategy:
129 | fail-fast: false
130 | matrix:
131 | arch: [ aarch64, x86_64 ]
132 | runs-on: macos-latest
133 | steps:
134 | - uses: actions/checkout@v4
135 |
136 | - name: Setup node
137 | uses: actions/setup-node@v4
138 | with:
139 | node-version: lts/*
140 |
141 | - name: Install Rust stable
142 | uses: dtolnay/rust-toolchain@stable
143 | with:
144 | targets: ${{ matrix.arch }}-apple-darwin
145 |
146 | - name: Install pnpm
147 | uses: pnpm/action-setup@v4
148 | with:
149 | run_install: false
150 |
151 | - name: Install frontend dependencies
152 | run: pnpm install
153 |
154 | - name: Build tauri app
155 | uses: tauri-apps/tauri-action@v0
156 | with:
157 | args: --target ${{ matrix.arch }}-apple-darwin
158 |
159 | - name: Create artifacts directory
160 | run: mkdir -p artifacts
161 |
162 | - name: Copy dmg to release assets
163 | env:
164 | ARCH_ALIAS: ${{ matrix.arch == 'x86_64' && 'x64' || matrix.arch }}
165 | run: cp src-tauri/target/${{ matrix.arch }}-apple-darwin/release/bundle/dmg/jmcomic-downloader_${{ env.VERSION }}_${{ env.ARCH_ALIAS }}.dmg artifacts/jmcomic-downloader_${{ env.VERSION }}_macos_${{ matrix.arch }}.dmg
166 |
167 | - name: Upload artifacts
168 | uses: actions/upload-artifact@v4
169 | with:
170 | name: macos-assets-${{ matrix.arch }}
171 | path: artifacts/*
172 |
173 | create-release:
174 | needs: [ windows-build, linux-build, macos-build ]
175 | runs-on: ubuntu-latest
176 | permissions:
177 | contents: write
178 | steps:
179 | - name: Download Windows assets
180 | uses: actions/download-artifact@v4
181 | with:
182 | name: windows-assets
183 | path: artifacts/windows
184 |
185 | - name: Download Linux assets
186 | uses: actions/download-artifact@v4
187 | with:
188 | name: linux-assets
189 | path: artifacts/linux
190 |
191 | - name: Download macOS aarch64 assets
192 | uses: actions/download-artifact@v4
193 | with:
194 | name: macos-assets-aarch64
195 | path: artifacts/macos-aarch64
196 |
197 | - name: Download macOS x86_64 assets
198 | uses: actions/download-artifact@v4
199 | with:
200 | name: macos-assets-x86_64
201 | path: artifacts/macos-x86_64
202 |
203 | - name: Create GitHub Release
204 | id: create_release
205 | uses: softprops/action-gh-release@v2
206 | env:
207 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
208 | with:
209 | name: Desktop App v${{ needs.windows-build.outputs.VERSION }}
210 | body: |
211 | Take a look at the assets to download and install this app.
212 | files: |
213 | artifacts/windows/*
214 | artifacts/linux/*
215 | artifacts/macos-aarch64/*
216 | artifacts/macos-x86_64/*
217 | draft: true
218 | prerelease: false
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | pnpm-debug.log*
8 | lerna-debug.log*
9 |
10 | node_modules
11 | dist
12 | dist-ssr
13 | *.local
14 |
15 | # Editor directories and files
16 | .vscode/*
17 | !.vscode/extensions.json
18 | .idea
19 | .DS_Store
20 | *.suo
21 | *.ntvs*
22 | *.njsproj
23 | *.sln
24 | *.sw?
25 |
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "printWidth": 120,
3 | "tabWidth": 2,
4 | "singleQuote": true,
5 | "semi": false,
6 | "bracketSameLine": true,
7 | "endOfLine": "auto",
8 | "htmlWhitespaceSensitivity": "ignore"
9 | }
--------------------------------------------------------------------------------
/.vscode/extensions.json:
--------------------------------------------------------------------------------
1 | {
2 | "recommendations": [
3 | "Vue.volar",
4 | "tauri-apps.tauri-vscode",
5 | "rust-lang.rust-analyzer"
6 | ]
7 | }
8 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2024 kurisu_u
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | # 📚 禁漫天堂下载器
6 |
7 | 一个用于 18comic.vip 禁漫天堂 jmcomic 18comic 的多线程下载器,带图形界面,带收藏夹,**免费下载收费的漫画**,下载速度飞快。图形界面基于[Tauri](https://v2.tauri.app/start/)
8 |
9 | 🔽 在[Release页面](https://github.com/lanyeeee/jmcomic-downloader/releases)可以直接下载
10 |
11 | **如果本项目对你有帮助,欢迎点个 Star⭐ 支持!你的支持是我持续更新维护的动力🙏**
12 |
13 | # 🖥️ 图形界面
14 |
15 | 
16 |
17 | # 📖 使用方法
18 |
19 | #### 🚀 不使用收藏夹
20 |
21 | 1. **不需要登录**,直接使用`漫画搜索`,选择要下载的漫画,点击后进入`章节详情`
22 | 2. 在`章节详情`勾选要下载的章节,点击`下载勾选章节`按钮开始下载
23 | 3. 下载完成后点击`打开下载目录`按钮查看结果
24 |
25 | #### ⭐ 使用收藏夹
26 |
27 | 1. 点击`账号登录`按钮完成登录
28 | 2. 使用`漫画收藏`,选择要下载的漫画,点击后进入`章节详情`
29 | 3. 在`章节详情`勾选要下载的章节,点击`下载勾选章节`按钮开始下载
30 | 4. 下载完成后点击`打开下载目录`按钮查看结果
31 |
32 | 📹 下面的视频是完整使用流程,**没有H内容,请放心观看**
33 |
34 | https://github.com/user-attachments/assets/46096bd9-1fde-4474-b297-0f4389dbe770
35 |
36 | # ❓ 常见问题
37 |
38 | - [为什么下载过程中CPU占用很高](https://github.com/lanyeeee/jmcomic-downloader/discussions/11)
39 | - [使用Ubuntu22.04时,搜索结果和收藏夹无法加载封面图](https://github.com/lanyeeee/jmcomic-downloader/discussions/31)
40 | - [使用Ubuntu24.04时,窗口全白](https://github.com/lanyeeee/jmcomic-downloader/discussions/32)
41 |
42 | # 📚 哔咔漫画下载器
43 |
44 | [](https://github.com/lanyeeee/picacomic-downloader)
45 |
46 | # ⚠️ 关于被杀毒软件误判为病毒
47 |
48 | 对于个人开发者来说,这个问题几乎是无解的(~~需要购买数字证书给软件签名,甚至给杀毒软件交保护费~~)
49 | 我能想到的解决办法只有:
50 |
51 | 1. 根据下面的**如何构建(build)**,自行编译
52 | 2. 希望你相信我的承诺,我承诺你在[Release页面](https://github.com/lanyeeee/jmcomic-downloader/releases)下载到的所有东西都是安全的
53 |
54 | # 🛠️ 如何构建(build)
55 |
56 | 构建非常简单,一共就3条命令
57 | ~~前提是你已经安装了Rust、Node、pnpm~~
58 |
59 | #### 📋 前提
60 |
61 | - [Rust](https://www.rust-lang.org/tools/install)
62 | - [Node](https://nodejs.org/en)
63 | - [pnpm](https://pnpm.io/installation)
64 |
65 | #### 📝 步骤
66 |
67 | #### 1. 克隆本仓库
68 |
69 | ```
70 | git clone https://github.com/lanyeeee/jmcomic-downloader.git
71 | ```
72 |
73 | #### 2.安装依赖
74 |
75 | ```
76 | cd jmcomic-downloader
77 | pnpm install
78 | ```
79 |
80 | #### 3.构建(build)
81 |
82 | ```
83 | pnpm tauri build
84 | ```
85 |
86 | # 🤝 提交PR
87 |
88 | **PR请提交至`develop`分支**
89 |
90 | **如果想新加一个功能,请先开个`issue`或`discussion`讨论一下,避免无效工作**
91 |
92 | 其他情况的PR欢迎直接提交,比如:
93 |
94 | 1. 🔧 对原有功能的改进
95 | 2. 🐛 修复BUG
96 | 3. ⚡ 使用更轻量的库实现原有功能
97 | 4. 📝 修订文档
98 | 5. ⬆️ 升级、更新依赖的PR也会被接受
99 |
100 | # ⚠️ 免责声明
101 |
102 | - 本工具仅作学习、研究、交流使用,使用本工具的用户应自行承担风险
103 | - 作者不对使用本工具导致的任何损失、法律纠纷或其他后果负责
104 | - 作者不对用户使用本工具的行为负责,包括但不限于用户违反法律或任何第三方权益的行为
105 |
106 | # 💬 其他
107 |
108 | 任何使用中遇到的问题、任何希望添加的功能,都欢迎提交issue或开discussion交流,我会尽力解决
109 |
--------------------------------------------------------------------------------
/auto-imports.d.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable */
2 | /* prettier-ignore */
3 | // @ts-nocheck
4 | // noinspection JSUnusedGlobalSymbols
5 | // Generated by unplugin-auto-import
6 | // biome-ignore lint: disable
7 | export {}
8 | declare global {
9 | const EffectScope: typeof import('vue')['EffectScope']
10 | const computed: typeof import('vue')['computed']
11 | const createApp: typeof import('vue')['createApp']
12 | const customRef: typeof import('vue')['customRef']
13 | const defineAsyncComponent: typeof import('vue')['defineAsyncComponent']
14 | const defineComponent: typeof import('vue')['defineComponent']
15 | const effectScope: typeof import('vue')['effectScope']
16 | const getCurrentInstance: typeof import('vue')['getCurrentInstance']
17 | const getCurrentScope: typeof import('vue')['getCurrentScope']
18 | const h: typeof import('vue')['h']
19 | const inject: typeof import('vue')['inject']
20 | const isProxy: typeof import('vue')['isProxy']
21 | const isReactive: typeof import('vue')['isReactive']
22 | const isReadonly: typeof import('vue')['isReadonly']
23 | const isRef: typeof import('vue')['isRef']
24 | const markRaw: typeof import('vue')['markRaw']
25 | const nextTick: typeof import('vue')['nextTick']
26 | const onActivated: typeof import('vue')['onActivated']
27 | const onBeforeMount: typeof import('vue')['onBeforeMount']
28 | const onBeforeUnmount: typeof import('vue')['onBeforeUnmount']
29 | const onBeforeUpdate: typeof import('vue')['onBeforeUpdate']
30 | const onDeactivated: typeof import('vue')['onDeactivated']
31 | const onErrorCaptured: typeof import('vue')['onErrorCaptured']
32 | const onMounted: typeof import('vue')['onMounted']
33 | const onRenderTracked: typeof import('vue')['onRenderTracked']
34 | const onRenderTriggered: typeof import('vue')['onRenderTriggered']
35 | const onScopeDispose: typeof import('vue')['onScopeDispose']
36 | const onServerPrefetch: typeof import('vue')['onServerPrefetch']
37 | const onUnmounted: typeof import('vue')['onUnmounted']
38 | const onUpdated: typeof import('vue')['onUpdated']
39 | const onWatcherCleanup: typeof import('vue')['onWatcherCleanup']
40 | const provide: typeof import('vue')['provide']
41 | const reactive: typeof import('vue')['reactive']
42 | const readonly: typeof import('vue')['readonly']
43 | const ref: typeof import('vue')['ref']
44 | const resolveComponent: typeof import('vue')['resolveComponent']
45 | const shallowReactive: typeof import('vue')['shallowReactive']
46 | const shallowReadonly: typeof import('vue')['shallowReadonly']
47 | const shallowRef: typeof import('vue')['shallowRef']
48 | const toRaw: typeof import('vue')['toRaw']
49 | const toRef: typeof import('vue')['toRef']
50 | const toRefs: typeof import('vue')['toRefs']
51 | const toValue: typeof import('vue')['toValue']
52 | const triggerRef: typeof import('vue')['triggerRef']
53 | const unref: typeof import('vue')['unref']
54 | const useAttrs: typeof import('vue')['useAttrs']
55 | const useCssModule: typeof import('vue')['useCssModule']
56 | const useCssVars: typeof import('vue')['useCssVars']
57 | const useDialog: typeof import('naive-ui')['useDialog']
58 | const useId: typeof import('vue')['useId']
59 | const useLoadingBar: typeof import('naive-ui')['useLoadingBar']
60 | const useMessage: typeof import('naive-ui')['useMessage']
61 | const useModel: typeof import('vue')['useModel']
62 | const useNotification: typeof import('naive-ui')['useNotification']
63 | const useSlots: typeof import('vue')['useSlots']
64 | const useTemplateRef: typeof import('vue')['useTemplateRef']
65 | const watch: typeof import('vue')['watch']
66 | const watchEffect: typeof import('vue')['watchEffect']
67 | const watchPostEffect: typeof import('vue')['watchPostEffect']
68 | const watchSyncEffect: typeof import('vue')['watchSyncEffect']
69 | }
70 | // for type re-export
71 | declare global {
72 | // @ts-ignore
73 | export type { Component, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue'
74 | import('vue')
75 | }
76 |
--------------------------------------------------------------------------------
/components.d.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable */
2 | // @ts-nocheck
3 | // Generated by unplugin-vue-components
4 | // Read more: https://github.com/vuejs/core/pull/3399
5 | export {}
6 |
7 | /* prettier-ignore */
8 | declare module 'vue' {
9 | export interface GlobalComponents {
10 | AboutDialog: typeof import('./src/components/AboutDialog.vue')['default']
11 | ComicCard: typeof import('./src/components/ComicCard.vue')['default']
12 | CompletedProgresses: typeof import('./src/components/CompletedProgresses.vue')['default']
13 | DownloadedComicCard: typeof import('./src/components/DownloadedComicCard.vue')['default']
14 | FloatLabelInput: typeof import('./src/components/FloatLabelInput.vue')['default']
15 | LoginDialog: typeof import('./src/components/LoginDialog.vue')['default']
16 | LogViewer: typeof import('./src/components/LogViewer.vue')['default']
17 | NA: typeof import('naive-ui')['NA']
18 | NAvatar: typeof import('naive-ui')['NAvatar']
19 | NButton: typeof import('naive-ui')['NButton']
20 | NCard: typeof import('naive-ui')['NCard']
21 | NCheckbox: typeof import('naive-ui')['NCheckbox']
22 | NCheckboxGroup: typeof import('naive-ui')['NCheckboxGroup']
23 | NConfigProvider: typeof import('naive-ui')['NConfigProvider']
24 | NDialog: typeof import('naive-ui')['NDialog']
25 | NDropdown: typeof import('naive-ui')['NDropdown']
26 | NEl: typeof import('naive-ui')['NEl']
27 | NEmpty: typeof import('naive-ui')['NEmpty']
28 | NIcon: typeof import('naive-ui')['NIcon']
29 | NInput: typeof import('naive-ui')['NInput']
30 | NInputGroup: typeof import('naive-ui')['NInputGroup']
31 | NInputGroupLabel: typeof import('naive-ui')['NInputGroupLabel']
32 | NInputNumber: typeof import('naive-ui')['NInputNumber']
33 | NMessageProvider: typeof import('naive-ui')['NMessageProvider']
34 | NModal: typeof import('naive-ui')['NModal']
35 | NModalProvider: typeof import('naive-ui')['NModalProvider']
36 | NNotificationProvider: typeof import('naive-ui')['NNotificationProvider']
37 | NPagination: typeof import('naive-ui')['NPagination']
38 | NProgress: typeof import('naive-ui')['NProgress']
39 | NRadio: typeof import('naive-ui')['NRadio']
40 | NRadioGroup: typeof import('naive-ui')['NRadioGroup']
41 | NSelect: typeof import('naive-ui')['NSelect']
42 | NTabPane: typeof import('naive-ui')['NTabPane']
43 | NTabs: typeof import('naive-ui')['NTabs']
44 | NTooltip: typeof import('naive-ui')['NTooltip']
45 | NVirtualList: typeof import('naive-ui')['NVirtualList']
46 | SettingsDialog: typeof import('./src/components/SettingsDialog.vue')['default']
47 | UncompletedProgresses: typeof import('./src/components/UncompletedProgresses.vue')['default']
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/eslint.config.js:
--------------------------------------------------------------------------------
1 | import globals from 'globals'
2 | import pluginJs from '@eslint/js'
3 | import tseslint from 'typescript-eslint'
4 | import pluginVue from 'eslint-plugin-vue'
5 |
6 | /** @type {import('eslint').Linter.Config[]} */
7 | export default [
8 | { files: ['**/*.{js,mjs,cjs,ts,vue}'] },
9 | { languageOptions: { globals: globals.browser } },
10 | pluginJs.configs.recommended,
11 | ...tseslint.configs.recommended,
12 | ...pluginVue.configs['flat/essential'],
13 | {
14 | files: ['**/*.vue'],
15 | languageOptions: {
16 | parserOptions: {
17 | parser: tseslint.parser,
18 | ecmaFeatures: {
19 | jsx: true,
20 | },
21 | },
22 | },
23 | },
24 | ]
25 |
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | Tauri + Vue + Typescript App
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "jmcomic-downloader",
3 | "private": true,
4 | "version": "0.1.0",
5 | "type": "module",
6 | "scripts": {
7 | "dev": "vite",
8 | "build": "vue-tsc --noEmit && vite build",
9 | "preview": "vite preview",
10 | "tauri": "tauri"
11 | },
12 | "dependencies": {
13 | "@tauri-apps/api": ">=2.0.0-rc.0",
14 | "@tauri-apps/plugin-dialog": "2.0.0-rc.1",
15 | "@tauri-apps/plugin-shell": ">=2.0.0-rc.0",
16 | "@vicons/antd": "^0.13.0",
17 | "@viselect/vue": "^3.9.0",
18 | "@vitejs/plugin-vue-jsx": "^4.1.2",
19 | "naive-ui": "^2.40.1",
20 | "pinia": "^3.0.1",
21 | "prettier": "^3.4.2",
22 | "unocss": "^0.63.0",
23 | "unplugin-auto-import": "^0.18.3",
24 | "unplugin-vue-components": "^0.27.4",
25 | "vue": "^3.5.13"
26 | },
27 | "devDependencies": {
28 | "@eslint/js": "^9.18.0",
29 | "@tauri-apps/cli": ">=2.0.0-rc.0",
30 | "@vitejs/plugin-vue": "^5.0.5",
31 | "eslint": "^9.18.0",
32 | "eslint-plugin-vue": "^9.32.0",
33 | "globals": "^15.14.0",
34 | "typescript": "^5.2.2",
35 | "typescript-eslint": "^8.21.0",
36 | "vite": "^5.3.1",
37 | "vite-plugin-vue-devtools": "^7.7.2",
38 | "vue-tsc": "^2.0.22"
39 | },
40 | "packageManager": "pnpm@9.5.0"
41 | }
42 |
--------------------------------------------------------------------------------
/public/tauri.svg:
--------------------------------------------------------------------------------
1 |
7 |
--------------------------------------------------------------------------------
/public/vite.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src-tauri/.gitignore:
--------------------------------------------------------------------------------
1 | # Generated by Cargo
2 | # will have compiled files and executables
3 | /target/
4 |
5 | # Generated by Tauri
6 | # will have schema files for capabilities auto-completion
7 | /gen/schemas
8 |
--------------------------------------------------------------------------------
/src-tauri/Cargo.toml:
--------------------------------------------------------------------------------
1 | [package]
2 | name = "jmcomic-downloader"
3 | version = "0.1.0"
4 | description = "A Tauri App"
5 | authors = ["you"]
6 | edition = "2021"
7 |
8 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
9 |
10 | [lib]
11 | name = "jmcomic_downloader_lib"
12 | crate-type = ["staticlib", "cdylib", "rlib"]
13 |
14 | [build-dependencies]
15 | tauri-build = { version = "2", features = [] }
16 |
17 | [dependencies]
18 | tauri = { version = "2", features = [] }
19 | tauri-plugin-opener = { version = "2" }
20 | tauri-plugin-dialog = { version = "2" }
21 |
22 | serde = { version = "1", features = ["derive"] }
23 | serde_json = { version = "1" }
24 | yaserde = { version = "0.12.0", features = ["yaserde_derive"] }
25 |
26 | specta = { version = "2.0.0-rc", features = ["serde_json"] }
27 | tauri-specta = { version = "2.0.0-rc", features = ["derive", "typescript"] }
28 | specta-typescript = { version = "0.0.7" }
29 |
30 | reqwest = { version = "0.12", features = ["rustls-tls", "json", "__internal_proxy_sys_no_cache", "cookies"], default-features = false }
31 | reqwest-retry = { version = "0.6.1" }
32 | reqwest-middleware = { version = "0.3.3 " }
33 |
34 | base64 = { version = "0.22" }
35 | md5 = { version = "0.7.0" }
36 | aes = { version = "0.8.4" }
37 |
38 | anyhow = { version = "1" }
39 | tokio = { version = "1.40.0", features = ["full"] }
40 | bytes = { version = "1.7.2" }
41 | image = { version = "0.25.5", default-features = false, features = ["jpeg", "png", "webp"] }
42 | lopdf = { git = "https://github.com/lanyeeee/lopdf", features = ["embed_image_jpeg", "embed_image_png", "embed_image_webp"] }
43 | flate2 = { version = "1.0.34" }
44 | parking_lot = { version = "0.12.3", features = ["send_guard"] }
45 | rayon = { version = "1.10.0" }
46 | uuid = { version = "1.15.1", features = ["v4"] }
47 | zip = { version = "2.2.3", default-features = false }
48 | tracing = { version = "0.1.41" }
49 | tracing-subscriber = { version = "0.3.19", features = ["json", "time", "local-time"] }
50 | tracing-appender = { version = "0.2.3" }
51 | notify = { version = "8.0.0" }
52 |
53 | [profile.release]
54 | strip = true
55 | lto = true
56 | codegen-units = 1
57 | panic = "abort"
58 |
--------------------------------------------------------------------------------
/src-tauri/build.rs:
--------------------------------------------------------------------------------
1 | fn main() {
2 | tauri_build::build()
3 | }
4 |
--------------------------------------------------------------------------------
/src-tauri/capabilities/default.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "../gen/schemas/desktop-schema.json",
3 | "identifier": "default",
4 | "description": "Capability for the main window",
5 | "windows": ["main"],
6 | "permissions": [
7 | "core:default",
8 | "opener:default",
9 | "dialog:allow-open"
10 | ]
11 | }
12 |
--------------------------------------------------------------------------------
/src-tauri/icons/128x128.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/niuhuan/jmcomic-downloader/3209dcb563a9eaa7ef490dc252bc2d5ae837d2c4/src-tauri/icons/128x128.png
--------------------------------------------------------------------------------
/src-tauri/icons/128x128@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/niuhuan/jmcomic-downloader/3209dcb563a9eaa7ef490dc252bc2d5ae837d2c4/src-tauri/icons/128x128@2x.png
--------------------------------------------------------------------------------
/src-tauri/icons/32x32.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/niuhuan/jmcomic-downloader/3209dcb563a9eaa7ef490dc252bc2d5ae837d2c4/src-tauri/icons/32x32.png
--------------------------------------------------------------------------------
/src-tauri/icons/Square107x107Logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/niuhuan/jmcomic-downloader/3209dcb563a9eaa7ef490dc252bc2d5ae837d2c4/src-tauri/icons/Square107x107Logo.png
--------------------------------------------------------------------------------
/src-tauri/icons/Square142x142Logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/niuhuan/jmcomic-downloader/3209dcb563a9eaa7ef490dc252bc2d5ae837d2c4/src-tauri/icons/Square142x142Logo.png
--------------------------------------------------------------------------------
/src-tauri/icons/Square150x150Logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/niuhuan/jmcomic-downloader/3209dcb563a9eaa7ef490dc252bc2d5ae837d2c4/src-tauri/icons/Square150x150Logo.png
--------------------------------------------------------------------------------
/src-tauri/icons/Square284x284Logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/niuhuan/jmcomic-downloader/3209dcb563a9eaa7ef490dc252bc2d5ae837d2c4/src-tauri/icons/Square284x284Logo.png
--------------------------------------------------------------------------------
/src-tauri/icons/Square30x30Logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/niuhuan/jmcomic-downloader/3209dcb563a9eaa7ef490dc252bc2d5ae837d2c4/src-tauri/icons/Square30x30Logo.png
--------------------------------------------------------------------------------
/src-tauri/icons/Square310x310Logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/niuhuan/jmcomic-downloader/3209dcb563a9eaa7ef490dc252bc2d5ae837d2c4/src-tauri/icons/Square310x310Logo.png
--------------------------------------------------------------------------------
/src-tauri/icons/Square44x44Logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/niuhuan/jmcomic-downloader/3209dcb563a9eaa7ef490dc252bc2d5ae837d2c4/src-tauri/icons/Square44x44Logo.png
--------------------------------------------------------------------------------
/src-tauri/icons/Square71x71Logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/niuhuan/jmcomic-downloader/3209dcb563a9eaa7ef490dc252bc2d5ae837d2c4/src-tauri/icons/Square71x71Logo.png
--------------------------------------------------------------------------------
/src-tauri/icons/Square89x89Logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/niuhuan/jmcomic-downloader/3209dcb563a9eaa7ef490dc252bc2d5ae837d2c4/src-tauri/icons/Square89x89Logo.png
--------------------------------------------------------------------------------
/src-tauri/icons/StoreLogo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/niuhuan/jmcomic-downloader/3209dcb563a9eaa7ef490dc252bc2d5ae837d2c4/src-tauri/icons/StoreLogo.png
--------------------------------------------------------------------------------
/src-tauri/icons/icon.icns:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/niuhuan/jmcomic-downloader/3209dcb563a9eaa7ef490dc252bc2d5ae837d2c4/src-tauri/icons/icon.icns
--------------------------------------------------------------------------------
/src-tauri/icons/icon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/niuhuan/jmcomic-downloader/3209dcb563a9eaa7ef490dc252bc2d5ae837d2c4/src-tauri/icons/icon.ico
--------------------------------------------------------------------------------
/src-tauri/icons/icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/niuhuan/jmcomic-downloader/3209dcb563a9eaa7ef490dc252bc2d5ae837d2c4/src-tauri/icons/icon.png
--------------------------------------------------------------------------------
/src-tauri/src/commands.rs:
--------------------------------------------------------------------------------
1 | use std::sync::atomic::AtomicI64;
2 | use std::sync::Arc;
3 |
4 | // TODO: 用`#![allow(clippy::used_underscore_binding)]`来消除警告
5 | use anyhow::{anyhow, Context};
6 | use parking_lot::{Mutex, RwLock};
7 | use tauri::{AppHandle, State};
8 | use tauri_plugin_opener::OpenerExt;
9 | use tauri_specta::Event;
10 | use tokio::sync::Semaphore;
11 | use tokio::task::JoinSet;
12 |
13 | use crate::config::Config;
14 | use crate::download_manager::DownloadManager;
15 | use crate::errors::{CommandError, CommandResult};
16 | use crate::events::UpdateDownloadedFavoriteComicEvent;
17 | use crate::extensions::AnyhowErrorToStringChain;
18 | use crate::jm_client::JmClient;
19 | use crate::responses::GetUserProfileRespData;
20 | use crate::types::{Comic, FavoriteSort, GetFavoriteResult, SearchResultVariant, SearchSort};
21 | use crate::{export, logger};
22 |
23 | #[tauri::command]
24 | #[specta::specta]
25 | pub fn greet(name: &str) -> String {
26 | format!("Hello, {}! You've been greeted from Rust!", name)
27 | }
28 |
29 | #[tauri::command]
30 | #[specta::specta]
31 | #[allow(clippy::needless_pass_by_value)]
32 | pub fn get_config(config: State>) -> Config {
33 | config.read().clone()
34 | }
35 |
36 | #[tauri::command(async)]
37 | #[specta::specta]
38 | #[allow(clippy::needless_pass_by_value)]
39 | pub fn save_config(
40 | app: AppHandle,
41 | jm_client: State,
42 | config_state: State>,
43 | config: Config,
44 | ) -> CommandResult<()> {
45 | let proxy_changed = {
46 | let config_state = config_state.read();
47 | config_state.proxy_mode != config.proxy_mode
48 | || config_state.proxy_host != config.proxy_host
49 | || config_state.proxy_port != config.proxy_port
50 | };
51 |
52 | let enable_file_logger = config.enable_file_logger;
53 | let file_logger_changed = config_state.read().enable_file_logger != enable_file_logger;
54 |
55 | {
56 | let mut config_state = config_state.write();
57 | *config_state = config;
58 | config_state
59 | .save(&app)
60 | .map_err(|err| CommandError::from("保存配置失败", err))?;
61 | tracing::debug!("保存配置成功");
62 | }
63 |
64 | if proxy_changed {
65 | jm_client.reload_client();
66 | }
67 |
68 | if file_logger_changed {
69 | if enable_file_logger {
70 | logger::reload_file_logger()
71 | .map_err(|err| CommandError::from("重新加载文件日志失败", err))?;
72 | } else {
73 | logger::disable_file_logger()
74 | .map_err(|err| CommandError::from("禁用文件日志失败", err))?;
75 | }
76 | }
77 |
78 | Ok(())
79 | }
80 |
81 | #[tauri::command]
82 | #[specta::specta]
83 | pub async fn login(
84 | jm_client: State<'_, JmClient>,
85 | username: String,
86 | password: String,
87 | ) -> CommandResult {
88 | let user_profile = jm_client
89 | .login(&username, &password)
90 | .await
91 | .map_err(|err| CommandError::from("登录失败", err))?;
92 | Ok(user_profile)
93 | }
94 |
95 | #[tauri::command]
96 | #[specta::specta]
97 | pub async fn get_user_profile(
98 | jm_client: State<'_, JmClient>,
99 | ) -> CommandResult {
100 | let user_profile = jm_client
101 | .get_user_profile()
102 | .await
103 | .map_err(|err| CommandError::from("获取用户信息失败", err))?;
104 | Ok(user_profile)
105 | }
106 |
107 | #[tauri::command]
108 | #[specta::specta]
109 | pub async fn search(
110 | app: AppHandle,
111 | jm_client: State<'_, JmClient>,
112 | keyword: String,
113 | page: i64,
114 | sort: SearchSort,
115 | ) -> CommandResult {
116 | let search_resp = jm_client
117 | .search(&keyword, page, sort)
118 | .await
119 | .map_err(|err| CommandError::from("搜索失败", err))?;
120 | let search_result = SearchResultVariant::from_search_resp(&app, search_resp)
121 | .map_err(|err| CommandError::from("搜索失败", err))?;
122 | Ok(search_result)
123 | }
124 |
125 | #[tauri::command]
126 | #[specta::specta]
127 | pub async fn get_comic(
128 | app: AppHandle,
129 | jm_client: State<'_, JmClient>,
130 | aid: i64,
131 | ) -> CommandResult {
132 | let comic_resp_data = jm_client
133 | .get_comic(aid)
134 | .await
135 | .map_err(|err| CommandError::from("获取漫画信息失败", err))?;
136 | let comic = Comic::from_comic_resp_data(&app, comic_resp_data);
137 | Ok(comic)
138 | }
139 |
140 | #[tauri::command(async)]
141 | #[specta::specta]
142 | pub async fn get_favorite_folder(
143 | app: AppHandle,
144 | jm_client: State<'_, JmClient>,
145 | folder_id: i64,
146 | page: i64,
147 | sort: FavoriteSort,
148 | ) -> CommandResult {
149 | let get_favorite_resp_data = jm_client
150 | .get_favorite_folder(folder_id, page, sort)
151 | .await
152 | .map_err(|err| CommandError::from("获取收藏夹失败", err))?;
153 | let get_favorite_result = GetFavoriteResult::from_resp_data(&app, get_favorite_resp_data)
154 | .map_err(|err| CommandError::from("获取收藏夹失败", err))?;
155 | Ok(get_favorite_result)
156 | }
157 |
158 | #[allow(clippy::needless_pass_by_value)]
159 | #[tauri::command(async)]
160 | #[specta::specta]
161 | pub fn create_download_task(
162 | download_manager: State,
163 | comic: Comic,
164 | chapter_id: i64,
165 | ) -> CommandResult<()> {
166 | let comic_title = comic.name.clone();
167 | download_manager
168 | .create_download_task(comic, chapter_id)
169 | .map_err(|err| {
170 | let err_title = format!("`{comic_title}`的章节ID为`{chapter_id}`的下载任务创建失败");
171 | CommandError::from(&err_title, err)
172 | })?;
173 | tracing::debug!("创建章节ID为`{chapter_id}`的下载任务成功");
174 | Ok(())
175 | }
176 |
177 | #[allow(clippy::needless_pass_by_value)]
178 | #[tauri::command(async)]
179 | #[specta::specta]
180 | pub fn pause_download_task(
181 | download_manager: State,
182 | chapter_id: i64,
183 | ) -> CommandResult<()> {
184 | download_manager
185 | .pause_download_task(chapter_id)
186 | .map_err(|err| {
187 | CommandError::from(&format!("暂停章节ID为`{chapter_id}`的下载任务失败"), err)
188 | })?;
189 | tracing::debug!("暂停章节ID为`{chapter_id}`的下载任务成功");
190 | Ok(())
191 | }
192 |
193 | #[allow(clippy::needless_pass_by_value)]
194 | #[tauri::command(async)]
195 | #[specta::specta]
196 | pub fn resume_download_task(
197 | download_manager: State,
198 | chapter_id: i64,
199 | ) -> CommandResult<()> {
200 | download_manager
201 | .resume_download_task(chapter_id)
202 | .map_err(|err| {
203 | CommandError::from(&format!("恢复章节ID为`{chapter_id}`的下载任务失败"), err)
204 | })?;
205 | tracing::debug!("恢复章节ID为`{chapter_id}`的下载任务成功");
206 | Ok(())
207 | }
208 |
209 | #[allow(clippy::needless_pass_by_value)]
210 | #[tauri::command(async)]
211 | #[specta::specta]
212 | pub fn cancel_download_task(
213 | download_manager: State,
214 | chapter_id: i64,
215 | ) -> CommandResult<()> {
216 | download_manager
217 | .cancel_download_task(chapter_id)
218 | .map_err(|err| {
219 | CommandError::from(&format!("取消章节ID为`{chapter_id}`的下载任务失败"), err)
220 | })?;
221 | tracing::debug!("取消章节ID为`{chapter_id}`的下载任务成功");
222 | Ok(())
223 | }
224 |
225 | #[tauri::command(async)]
226 | #[specta::specta]
227 | pub async fn download_comic(
228 | app: AppHandle,
229 | jm_client: State<'_, JmClient>,
230 | download_manager: State<'_, DownloadManager>,
231 | aid: i64,
232 | ) -> CommandResult<()> {
233 | let comic = get_comic(app.clone(), jm_client, aid).await?;
234 | let chapter_ids: Vec = comic
235 | .chapter_infos
236 | .iter()
237 | .filter(|chapter_info| chapter_info.is_downloaded != Some(true))
238 | .map(|chapter_info| chapter_info.chapter_id)
239 | .collect();
240 | if chapter_ids.is_empty() {
241 | let comic_title = comic.name;
242 | return Err(CommandError::from(
243 | "一键下载漫画失败",
244 | anyhow!("漫画`{comic_title}`的所有章节都已存在于下载目录,无需重复下载"),
245 | ));
246 | }
247 | // 创建下载任务前,先创建元数据
248 | save_metadata(app, comic.clone())?;
249 |
250 | for chapter_id in chapter_ids {
251 | download_manager
252 | .create_download_task(comic.clone(), chapter_id)
253 | .map_err(|err| CommandError::from("一键下载漫画失败", err))?;
254 | }
255 | tracing::debug!("一键下载漫画成功,已为所有需要下载的章节创建下载任务");
256 | Ok(())
257 | }
258 |
259 | #[allow(clippy::cast_possible_wrap)]
260 | #[tauri::command(async)]
261 | #[specta::specta]
262 | pub async fn update_downloaded_favorite_comic(
263 | app: AppHandle,
264 | jm_client: State<'_, JmClient>,
265 | download_manager: State<'_, DownloadManager>,
266 | ) -> CommandResult<()> {
267 | let jm_client = jm_client.inner().clone();
268 | let favorite_comics = Arc::new(Mutex::new(vec![]));
269 | // 发送正在获取收藏夹事件
270 | let _ = UpdateDownloadedFavoriteComicEvent::GettingFolders.emit(&app);
271 | // 获取收藏夹第一页
272 | let first_page = jm_client
273 | .get_favorite_folder(0, 1, FavoriteSort::FavoriteTime)
274 | .await
275 | .map_err(|err| CommandError::from("更新收藏夹失败", err))?;
276 | favorite_comics.lock().extend(first_page.list);
277 | // 计算总页数
278 | let count = first_page.count;
279 | let total = first_page
280 | .total
281 | .parse::()
282 | .map_err(|err| CommandError::from("更新收藏夹失败", err))?;
283 | let page_count = (total / count) + 1;
284 | // 获取收藏夹剩余页
285 | let mut join_set = JoinSet::new();
286 | for page in 2..=page_count {
287 | let jm_client = jm_client.clone();
288 | let favorite_comics = favorite_comics.clone();
289 | join_set.spawn(async move {
290 | let page = jm_client
291 | .get_favorite_folder(0, page, FavoriteSort::FavoriteTime)
292 | .await?;
293 | favorite_comics.lock().extend(page.list);
294 | Ok::<(), anyhow::Error>(())
295 | });
296 | }
297 | // 等待所有请求完成
298 | while let Some(Ok(get_favorite_result)) = join_set.join_next().await {
299 | // 如果有请求失败,直接返回错误
300 | get_favorite_result.map_err(|err| CommandError::from("更新收藏夹失败", err))?;
301 | }
302 | // 至此,收藏夹已经全部获取完毕
303 | let favorite_comics = std::mem::take(&mut *favorite_comics.lock());
304 | let comics = Arc::new(Mutex::new(vec![]));
305 | // 限制并发数为10
306 | let sem = Arc::new(Semaphore::new(10));
307 | let current = Arc::new(AtomicI64::new(0));
308 | // 发送正在获取收藏夹漫画详情事件
309 | let total = favorite_comics.len() as i64;
310 | let _ = UpdateDownloadedFavoriteComicEvent::GettingComics { total }.emit(&app);
311 | // 获取收藏夹漫画的详细信息
312 | for favorite_comic in favorite_comics {
313 | let sem = sem.clone();
314 | let aid = favorite_comic
315 | .id
316 | .parse::()
317 | .map_err(|err| CommandError::from("更新收藏夹失败", err))?;
318 | let jm_client = jm_client.clone();
319 | let app = app.clone();
320 | let comics = comics.clone();
321 | let current = current.clone();
322 | join_set.spawn(async move {
323 | let permit = sem.acquire().await?;
324 | let comic_resp_data = jm_client.get_comic(aid).await?;
325 | drop(permit);
326 | let comic = Comic::from_comic_resp_data(&app, comic_resp_data);
327 | comics.lock().push(comic);
328 | let current = current.fetch_add(1, std::sync::atomic::Ordering::SeqCst) + 1;
329 | // 发送获取到收藏夹漫画详情事件
330 | let _ = UpdateDownloadedFavoriteComicEvent::ComicGot { current, total }.emit(&app);
331 | Ok::<(), anyhow::Error>(())
332 | });
333 | }
334 | // 等待所有请求完成
335 | while let Some(Ok(get_comic_result)) = join_set.join_next().await {
336 | // 如果有请求失败,直接返回错误
337 | get_comic_result.map_err(|err| CommandError::from("更新收藏夹失败", err))?;
338 | }
339 | // 至此,收藏夹漫画的详细信息已经全部获取完毕
340 | let comics = std::mem::take(&mut *comics.lock());
341 | // 过滤出已下载的漫画(至少有一个章节已下载)
342 | let downloaded_comics = comics
343 | .into_iter()
344 | .filter(|comic| {
345 | comic
346 | .chapter_infos
347 | .iter()
348 | .any(|chapter_info| chapter_info.is_downloaded == Some(true))
349 | })
350 | .collect::>();
351 | // 把已下载漫画中的未下载章节添加到下载队列中
352 | for comic in downloaded_comics {
353 | let chapter_ids_to_download = comic
354 | .chapter_infos
355 | .iter()
356 | .filter(|chapter| chapter.is_downloaded != Some(true))
357 | .map(|chapter| chapter.chapter_id);
358 |
359 | for chapter_id in chapter_ids_to_download {
360 | download_manager
361 | .create_download_task(comic.clone(), chapter_id)
362 | .map_err(|err| CommandError::from("更新收藏夹失败", err))?;
363 | }
364 | }
365 | // 发送下载任务创建完成事件
366 | let _ = UpdateDownloadedFavoriteComicEvent::DownloadTaskCreated.emit(&app);
367 |
368 | Ok(())
369 | }
370 |
371 | #[allow(clippy::needless_pass_by_value)]
372 | #[tauri::command(async)]
373 | #[specta::specta]
374 | pub fn show_path_in_file_manager(app: AppHandle, path: &str) -> CommandResult<()> {
375 | app.opener()
376 | .reveal_item_in_dir(path)
377 | .context(format!("在文件管理器中打开`{path}`失败"))
378 | .map_err(|err| CommandError::from("在文件管理器中打开失败", err))?;
379 | Ok(())
380 | }
381 |
382 | #[allow(clippy::needless_pass_by_value)]
383 | #[tauri::command(async)]
384 | #[specta::specta]
385 | pub fn show_comic_download_dir_in_file_manager(
386 | app: AppHandle,
387 | comic_title: String,
388 | ) -> CommandResult<()> {
389 | let comic_download_dir = Comic::get_comic_download_dir(&app, &comic_title);
390 | app.opener()
391 | .reveal_item_in_dir(&comic_download_dir)
392 | .context(format!("在文件管理器中打开`{comic_download_dir:?}`失败"))
393 | .map_err(|err| CommandError::from("在文件管理器中打开失败", err))?;
394 | Ok(())
395 | }
396 |
397 | #[tauri::command(async)]
398 | #[specta::specta]
399 | pub async fn sync_favorite_folder(jm_client: State<'_, JmClient>) -> CommandResult<()> {
400 | // 同步收藏夹的方式是随便收藏一个漫画
401 | // 调用两次toggle是因为要把新收藏的漫画取消收藏
402 | let task1 = jm_client.toggle_favorite_comic(468_984);
403 | let task2 = jm_client.toggle_favorite_comic(468_984);
404 | let (resp1, resp2) =
405 | tokio::try_join!(task1, task2).map_err(|err| CommandError::from("同步收藏夹失败", err))?;
406 | if resp1.toggle_type == resp2.toggle_type {
407 | let toggle_type = resp1.toggle_type;
408 | let err_title = "同步收藏夹失败";
409 | let err = anyhow!("两个请求都是`{toggle_type:?}`操作");
410 | return Err(CommandError::from(err_title, err));
411 | }
412 |
413 | Ok(())
414 | }
415 |
416 | #[allow(clippy::needless_pass_by_value)]
417 | #[tauri::command(async)]
418 | #[specta::specta]
419 | pub fn save_metadata(app: AppHandle, mut comic: Comic) -> CommandResult<()> {
420 | // 将Comic的is_downloaded字段设置为None,这样能使is_downloaded字段在序列化时被忽略
421 | comic.is_downloaded = None;
422 | // 将所有ChapterInfo的is_downloaded字段设置为None,这样能使is_downloaded字段在序列化时被忽略
423 | for chapter in &mut comic.chapter_infos {
424 | chapter.is_downloaded = None;
425 | }
426 |
427 | let comic_title = comic.name.clone();
428 | let comic_json = serde_json::to_string_pretty(&comic)
429 | .context(format!(
430 | "`{comic_title}`的元数据保存失败,将Comic序列化为json失败"
431 | ))
432 | .map_err(|err| CommandError::from("保存元数据失败", err))?;
433 | let comic_download_dir = Comic::get_comic_download_dir(&app, &comic_title);
434 | let metadata_path = comic_download_dir.join("元数据.json");
435 |
436 | std::fs::create_dir_all(&comic_download_dir)
437 | .context(format!("创建目录`{comic_download_dir:?}`失败"))
438 | .map_err(|err| CommandError::from("保存元数据失败", err))?;
439 |
440 | std::fs::write(&metadata_path, comic_json)
441 | .context(format!("写入文件`{metadata_path:?}`失败"))
442 | .map_err(|err| CommandError::from("保存元数据失败", err))?;
443 |
444 | Ok(())
445 | }
446 |
447 | #[allow(clippy::needless_pass_by_value)]
448 | #[tauri::command(async)]
449 | #[specta::specta]
450 | pub fn get_downloaded_comics(
451 | app: AppHandle,
452 | config: State>,
453 | ) -> CommandResult> {
454 | let download_dir = config.read().download_dir.clone();
455 | // 遍历下载目录,获取所有元数据文件的路径和修改时间
456 | let mut metadata_path_with_modify_time = std::fs::read_dir(&download_dir)
457 | .context(format!(
458 | "获取已下载的漫画失败,读取下载目录`{download_dir:?}`失败"
459 | ))
460 | .map_err(|err| CommandError::from("获取已下载的漫画失败", err))?
461 | .filter_map(Result::ok)
462 | .filter_map(|entry| {
463 | let metadata_path = entry.path().join("元数据.json");
464 | if !metadata_path.exists() {
465 | return None;
466 | }
467 | let modify_time = metadata_path.metadata().ok()?.modified().ok()?;
468 | Some((metadata_path, modify_time))
469 | })
470 | .collect::>();
471 | // 按照文件修改时间排序,最新的排在最前面
472 | metadata_path_with_modify_time.sort_by(|(_, a), (_, b)| b.cmp(a));
473 | let downloaded_comics = metadata_path_with_modify_time
474 | .iter()
475 | .filter_map(
476 | |(metadata_path, _)| match Comic::from_metadata(&app, metadata_path) {
477 | Ok(comic) => Some(comic),
478 | Err(err) => {
479 | let err_title = format!("读取元数据文件`{metadata_path:?}`失败");
480 | let string_chain = err.to_string_chain();
481 | tracing::error!(err_title, message = string_chain);
482 | None
483 | }
484 | },
485 | )
486 | .collect::>();
487 |
488 | Ok(downloaded_comics)
489 | }
490 |
491 | #[tauri::command(async)]
492 | #[specta::specta]
493 | #[allow(clippy::needless_pass_by_value)]
494 | pub fn export_cbz(app: AppHandle, comic: Comic) -> CommandResult<()> {
495 | let comic_title = &comic.name;
496 | export::cbz(&app, &comic)
497 | .context(format!("漫画`{comic_title}`导出cbz失败"))
498 | .map_err(|err| CommandError::from("导出cbz失败", err))?;
499 | Ok(())
500 | }
501 |
502 | #[tauri::command(async)]
503 | #[specta::specta]
504 | #[allow(clippy::needless_pass_by_value)]
505 | pub fn export_pdf(app: AppHandle, comic: Comic) -> CommandResult<()> {
506 | let comic_title = &comic.name;
507 | export::pdf(&app, &comic)
508 | .context(format!("漫画`{comic_title}`导出pdf失败"))
509 | .map_err(|err| CommandError::from("导出pdf失败", err))?;
510 | Ok(())
511 | }
512 |
513 | #[allow(clippy::needless_pass_by_value)]
514 | #[tauri::command(async)]
515 | #[specta::specta]
516 | pub fn get_logs_dir_size(app: AppHandle) -> CommandResult {
517 | let logs_dir = logger::logs_dir(&app)
518 | .context("获取日志目录失败")
519 | .map_err(|err| CommandError::from("获取日志目录大小失败", err))?;
520 | let logs_dir_size = std::fs::read_dir(&logs_dir)
521 | .context(format!("读取日志目录`{logs_dir:?}`失败"))
522 | .map_err(|err| CommandError::from("获取日志目录大小失败", err))?
523 | .filter_map(Result::ok)
524 | .filter_map(|entry| entry.metadata().ok())
525 | .map(|metadata| metadata.len())
526 | .sum::();
527 | tracing::debug!("获取日志目录大小成功");
528 | Ok(logs_dir_size)
529 | }
530 |
--------------------------------------------------------------------------------
/src-tauri/src/config.rs:
--------------------------------------------------------------------------------
1 | use std::path::{Path, PathBuf};
2 |
3 | use crate::types::{DownloadFormat, ProxyMode};
4 | use serde::{Deserialize, Serialize};
5 | use specta::Type;
6 | use tauri::{AppHandle, Manager};
7 |
8 | #[derive(Debug, Clone, Serialize, Deserialize, Type)]
9 | #[serde(rename_all = "camelCase")]
10 | pub struct Config {
11 | pub username: String,
12 | pub password: String,
13 | pub download_dir: PathBuf,
14 | pub export_dir: PathBuf,
15 | pub download_format: DownloadFormat,
16 | pub proxy_mode: ProxyMode,
17 | pub proxy_host: String,
18 | pub proxy_port: u16,
19 | pub enable_file_logger: bool,
20 | }
21 |
22 | impl Config {
23 | pub fn new(app: &AppHandle) -> anyhow::Result {
24 | let app_data_dir = app.path().app_data_dir()?;
25 | let config_path = app_data_dir.join("config.json");
26 |
27 | let config = if config_path.exists() {
28 | let config_string = std::fs::read_to_string(config_path)?;
29 | match serde_json::from_str(&config_string) {
30 | // 如果能够直接解析为Config,则直接返回
31 | Ok(config) => config,
32 | // 否则,将默认配置与文件中已有的配置合并
33 | // 以免新版本添加了新的配置项,用户升级到新版本后,所有配置项都被重置
34 | Err(_) => Config::merge_config(&config_string, &app_data_dir),
35 | }
36 | } else {
37 | Config::default(&app_data_dir)
38 | };
39 | config.save(app)?;
40 | Ok(config)
41 | }
42 |
43 | pub fn save(&self, app: &AppHandle) -> anyhow::Result<()> {
44 | let resource_dir = app.path().app_data_dir()?;
45 | let config_path = resource_dir.join("config.json");
46 | let config_string = serde_json::to_string_pretty(self)?;
47 | std::fs::write(config_path, config_string)?;
48 | Ok(())
49 | }
50 |
51 | fn merge_config(config_string: &str, app_data_dir: &Path) -> Config {
52 | let Ok(mut json_value) = serde_json::from_str::(config_string) else {
53 | return Config::default(app_data_dir);
54 | };
55 | let serde_json::Value::Object(ref mut map) = json_value else {
56 | return Config::default(app_data_dir);
57 | };
58 | let Ok(default_config_value) = serde_json::to_value(Config::default(app_data_dir)) else {
59 | return Config::default(app_data_dir);
60 | };
61 | let serde_json::Value::Object(default_map) = default_config_value else {
62 | return Config::default(app_data_dir);
63 | };
64 | for (key, value) in default_map {
65 | map.entry(key).or_insert(value);
66 | }
67 | let Ok(config) = serde_json::from_value(json_value) else {
68 | return Config::default(app_data_dir);
69 | };
70 | config
71 | }
72 |
73 | fn default(app_data_dir: &Path) -> Config {
74 | Config {
75 | username: String::new(),
76 | password: String::new(),
77 | download_dir: app_data_dir.join("漫画下载"),
78 | export_dir: app_data_dir.join("漫画导出"),
79 | download_format: DownloadFormat::default(),
80 | proxy_mode: ProxyMode::default(),
81 | proxy_host: "127.0.0.1".to_string(),
82 | proxy_port: 7890,
83 | enable_file_logger: true,
84 | }
85 | }
86 | }
87 |
--------------------------------------------------------------------------------
/src-tauri/src/errors.rs:
--------------------------------------------------------------------------------
1 | use serde::Serialize;
2 | use specta::Type;
3 |
4 | use crate::extensions::AnyhowErrorToStringChain;
5 |
6 | pub type CommandResult = Result;
7 |
8 | #[derive(Debug, Type, Serialize)]
9 | pub struct CommandError {
10 | pub err_title: String,
11 | pub err_message: String,
12 | }
13 |
14 | impl CommandError {
15 | pub fn from(err_title: &str, err: E) -> Self
16 | where
17 | E: Into,
18 | {
19 | let string_chain = err.into().to_string_chain();
20 | tracing::error!(err_title, message = string_chain);
21 | Self {
22 | err_title: err_title.to_string(),
23 | err_message: string_chain,
24 | }
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/src-tauri/src/events.rs:
--------------------------------------------------------------------------------
1 | use std::collections::HashMap;
2 |
3 | use serde::{Deserialize, Serialize};
4 | use specta::Type;
5 | use tauri_specta::Event;
6 |
7 | use crate::{
8 | download_manager::DownloadTaskState,
9 | types::{ChapterInfo, Comic, LogLevel},
10 | };
11 |
12 | #[derive(Debug, Clone, Serialize, Deserialize, Type, Event)]
13 | pub struct DownloadSpeedEvent {
14 | pub speed: String,
15 | }
16 |
17 | #[derive(Debug, Clone, Serialize, Deserialize, Type, Event)]
18 | #[serde(tag = "event", content = "data")]
19 | pub enum DownloadTaskEvent {
20 | #[serde(rename_all = "camelCase")]
21 | Create {
22 | state: DownloadTaskState,
23 | comic: Box,
24 | chapter_info: Box,
25 | downloaded_img_count: u32,
26 | total_img_count: u32,
27 | },
28 |
29 | #[serde(rename_all = "camelCase")]
30 | Update {
31 | chapter_id: i64,
32 | state: DownloadTaskState,
33 | downloaded_img_count: u32,
34 | total_img_count: u32,
35 | },
36 | }
37 |
38 | #[derive(Debug, Clone, Serialize, Deserialize, Type, Event)]
39 | #[serde(tag = "event", content = "data")]
40 | pub enum UpdateDownloadedFavoriteComicEvent {
41 | #[serde(rename_all = "camelCase")]
42 | GettingFolders,
43 |
44 | #[serde(rename_all = "camelCase")]
45 | GettingComics { total: i64 },
46 |
47 | #[serde(rename_all = "camelCase")]
48 | ComicGot { current: i64, total: i64 },
49 |
50 | #[serde(rename_all = "camelCase")]
51 | DownloadTaskCreated,
52 | }
53 |
54 | #[derive(Debug, Clone, Serialize, Deserialize, Type, Event)]
55 | #[serde(tag = "event", content = "data")]
56 | pub enum ExportCbzEvent {
57 | #[serde(rename_all = "camelCase")]
58 | Start {
59 | uuid: String,
60 | comic_title: String,
61 | total: u32,
62 | },
63 | #[serde(rename_all = "camelCase")]
64 | Progress { uuid: String, current: u32 },
65 | #[serde(rename_all = "camelCase")]
66 | Error { uuid: String },
67 | #[serde(rename_all = "camelCase")]
68 | End { uuid: String },
69 | }
70 |
71 | #[derive(Debug, Clone, Serialize, Deserialize, Type, Event)]
72 | #[serde(tag = "event", content = "data")]
73 | pub enum ExportPdfEvent {
74 | #[serde(rename_all = "camelCase")]
75 | CreateStart {
76 | uuid: String,
77 | comic_title: String,
78 | total: u32,
79 | },
80 | #[serde(rename_all = "camelCase")]
81 | CreateProgress { uuid: String, current: u32 },
82 | #[serde(rename_all = "camelCase")]
83 | CreateError { uuid: String },
84 | #[serde(rename_all = "camelCase")]
85 | CreateEnd { uuid: String },
86 |
87 | #[serde(rename_all = "camelCase")]
88 | MergeStart { uuid: String, comic_title: String },
89 | #[serde(rename_all = "camelCase")]
90 | MergeError { uuid: String },
91 | #[serde(rename_all = "camelCase")]
92 | MergeEnd { uuid: String },
93 | }
94 |
95 | #[derive(Debug, Clone, Serialize, Deserialize, Type, Event)]
96 | #[serde(rename_all = "camelCase")]
97 | pub struct LogEvent {
98 | pub timestamp: String,
99 | pub level: LogLevel,
100 | pub fields: HashMap,
101 | pub target: String,
102 | pub filename: String,
103 | #[serde(rename = "line_number")]
104 | pub line_number: i64,
105 | }
106 |
--------------------------------------------------------------------------------
/src-tauri/src/export.rs:
--------------------------------------------------------------------------------
1 | use std::{
2 | collections::BTreeMap,
3 | io::{Read, Write},
4 | path::{Path, PathBuf},
5 | sync::{atomic::AtomicU32, Arc},
6 | };
7 |
8 | use anyhow::{anyhow, Context};
9 |
10 | use lopdf::{
11 | content::{Content, Operation},
12 | dictionary, Bookmark, Document, Object, Stream,
13 | };
14 | use rayon::iter::{IntoParallelIterator, ParallelIterator};
15 | use tauri::AppHandle;
16 | use tauri_specta::Event;
17 | use zip::{write::SimpleFileOptions, ZipWriter};
18 |
19 | use crate::{
20 | events::{ExportCbzEvent, ExportPdfEvent},
21 | types::{Comic, ComicInfo},
22 | utils::filename_filter,
23 | };
24 |
25 | pub enum ExportArchive {
26 | Cbz,
27 | Pdf,
28 | }
29 |
30 | impl ExportArchive {
31 | pub fn extension(&self) -> &str {
32 | match self {
33 | ExportArchive::Cbz => "cbz",
34 | ExportArchive::Pdf => "pdf",
35 | }
36 | }
37 | }
38 |
39 | struct CbzErrorEventGuard {
40 | uuid: String,
41 | app: AppHandle,
42 | success: bool,
43 | }
44 |
45 | impl Drop for CbzErrorEventGuard {
46 | fn drop(&mut self) {
47 | if self.success {
48 | return;
49 | }
50 |
51 | let uuid = self.uuid.clone();
52 | let _ = ExportCbzEvent::Error { uuid }.emit(&self.app);
53 | }
54 | }
55 |
56 | #[allow(clippy::cast_possible_wrap)]
57 | #[allow(clippy::cast_possible_truncation)]
58 | pub fn cbz(app: &AppHandle, comic: &Comic) -> anyhow::Result<()> {
59 | let comic_title = &comic.name.clone();
60 | let downloaded_chapter_infos = comic
61 | .chapter_infos
62 | .iter()
63 | .filter(|chapter_info| chapter_info.is_downloaded.unwrap_or(false))
64 | .collect::>();
65 | // 生成格式化的xml
66 | let cfg = yaserde::ser::Config {
67 | perform_indent: true,
68 | ..Default::default()
69 | };
70 | let event_uuid = uuid::Uuid::new_v4().to_string();
71 | // 发送开始导出cbz事件
72 | let _ = ExportCbzEvent::Start {
73 | uuid: event_uuid.clone(),
74 | comic_title: comic_title.clone(),
75 | total: downloaded_chapter_infos.len() as u32,
76 | }
77 | .emit(app);
78 | // 如果success为false,drop时发送Error事件
79 | let mut error_event_guard = CbzErrorEventGuard {
80 | uuid: event_uuid.clone(),
81 | app: app.clone(),
82 | success: false,
83 | };
84 | // 用来记录导出进度
85 | let current = Arc::new(AtomicU32::new(0));
86 |
87 | let extension = ExportArchive::Cbz.extension();
88 | let comic_export_dir = Comic::get_comic_export_dir(app, comic_title);
89 | let chapter_export_dir = comic_export_dir.join(ExportArchive::Cbz.extension());
90 | // 保证导出目录存在
91 | std::fs::create_dir_all(&chapter_export_dir).context(format!(
92 | "`{comic_title}`创建目录`{chapter_export_dir:?}`失败"
93 | ))?;
94 | // 并发处理
95 | let downloaded_chapter_infos = downloaded_chapter_infos.into_par_iter();
96 | downloaded_chapter_infos.try_for_each(|chapter_info| -> anyhow::Result<()> {
97 | let chapter_title = chapter_info.chapter_title.clone();
98 | let err_prefix = format!("`{comic_title} - {chapter_title}`");
99 |
100 | let chapter_download_dir = chapter_info.get_chapter_download_dir(app, comic);
101 | // 生成ComicInfo
102 | let comic_info = ComicInfo::from(comic, chapter_info);
103 | // 序列化ComicInfo为xml
104 | let comic_info_xml = yaserde::ser::to_string_with_config(&comic_info, &cfg)
105 | .map_err(|err_msg| anyhow!("{err_prefix}序列化`ComicInfo.xml`失败: {err_msg}"))?;
106 | // 创建cbz文件
107 | let sanitized_chapter_title = filename_filter(&chapter_title);
108 | let zip_path = chapter_export_dir.join(format!("{sanitized_chapter_title}.{extension}"));
109 | let zip_file = std::fs::File::create(&zip_path)
110 | .context(format!("{err_prefix}创建文件`{zip_path:?}`失败"))?;
111 | let mut zip_writer = ZipWriter::new(zip_file);
112 | // 把ComicInfo.xml写入cbz
113 | zip_writer
114 | .start_file("ComicInfo.xml", SimpleFileOptions::default())
115 | .context(format!(
116 | "{err_prefix}在`{zip_path:?}`创建`ComicInfo.xml`失败"
117 | ))?;
118 | zip_writer
119 | .write_all(comic_info_xml.as_bytes())
120 | .context(format!("{err_prefix}写入`ComicInfo.xml`失败"))?;
121 | // 遍历下载目录,将文件写入cbz
122 | let image_entries = std::fs::read_dir(&chapter_download_dir)
123 | .context(format!(
124 | "{err_prefix}读取目录`{chapter_download_dir:?}`失败"
125 | ))?
126 | .filter_map(Result::ok);
127 | for image_entry in image_entries {
128 | let image_path = image_entry.path();
129 | if !image_path.is_file() {
130 | continue;
131 | }
132 |
133 | let filename = match image_path.file_name() {
134 | Some(name) => name.to_string_lossy(),
135 | None => continue,
136 | };
137 | // 将文件写入cbz
138 | zip_writer
139 | .start_file(&filename, SimpleFileOptions::default())
140 | .context(format!(
141 | "{err_prefix}在`{zip_path:?}`创建`{filename:?}`失败"
142 | ))?;
143 | let mut file =
144 | std::fs::File::open(&image_path).context(format!("打开`{image_path:?}`失败"))?;
145 | std::io::copy(&mut file, &mut zip_writer).context(format!(
146 | "{err_prefix}将`{image_path:?}`写入`{zip_path:?}`失败"
147 | ))?;
148 | }
149 |
150 | zip_writer
151 | .finish()
152 | .context(format!("{err_prefix}关闭`{zip_path:?}`失败"))?;
153 | // 更新导出cbz的进度
154 | let current = current.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;
155 | // 发送导出cbz进度事件
156 | let _ = ExportCbzEvent::Progress {
157 | uuid: event_uuid.clone(),
158 | current,
159 | }
160 | .emit(app);
161 | Ok(())
162 | })?;
163 | // 标记为成功,后面drop时就不会发送Error事件
164 | error_event_guard.success = true;
165 | // 发送导出cbz完成事件
166 | let _ = ExportCbzEvent::End { uuid: event_uuid }.emit(app);
167 |
168 | Ok(())
169 | }
170 |
171 | struct PdfCreateErrorEventGuard {
172 | uuid: String,
173 | app: AppHandle,
174 | success: bool,
175 | }
176 |
177 | impl Drop for PdfCreateErrorEventGuard {
178 | fn drop(&mut self) {
179 | if self.success {
180 | return;
181 | }
182 |
183 | let uuid = self.uuid.clone();
184 | let _ = ExportPdfEvent::CreateError { uuid }.emit(&self.app);
185 | }
186 | }
187 |
188 | struct PdfMergeErrorEventGuard {
189 | uuid: String,
190 | app: AppHandle,
191 | success: bool,
192 | }
193 |
194 | impl Drop for PdfMergeErrorEventGuard {
195 | fn drop(&mut self) {
196 | if self.success {
197 | return;
198 | }
199 |
200 | let uuid = self.uuid.clone();
201 | let _ = ExportPdfEvent::MergeError { uuid }.emit(&self.app);
202 | }
203 | }
204 |
205 | #[allow(clippy::cast_possible_truncation)]
206 | pub fn pdf(app: &AppHandle, comic: &Comic) -> anyhow::Result<()> {
207 | let comic_title = &comic.name.clone();
208 | let downloaded_chapter_infos = comic
209 | .chapter_infos
210 | .iter()
211 | .filter(|chapter_info| chapter_info.is_downloaded.unwrap_or(false))
212 | .collect::>();
213 | let event_uuid = uuid::Uuid::new_v4().to_string();
214 | // 发送开始创建pdf事件
215 | let _ = ExportPdfEvent::CreateStart {
216 | uuid: event_uuid.clone(),
217 | comic_title: comic_title.clone(),
218 | total: downloaded_chapter_infos.len() as u32,
219 | }
220 | .emit(app);
221 | // 如果success为false,drop时发送CreateError事件
222 | let mut create_error_event_guard = PdfCreateErrorEventGuard {
223 | uuid: event_uuid.clone(),
224 | app: app.clone(),
225 | success: false,
226 | };
227 | // 用来记录创建pdf的进度
228 | let current = Arc::new(AtomicU32::new(0));
229 |
230 | let extension = ExportArchive::Pdf.extension();
231 | let comic_export_dir = Comic::get_comic_export_dir(app, comic_title);
232 | let chapter_export_dir = comic_export_dir.join(ExportArchive::Pdf.extension());
233 | // 保证导出目录存在
234 | std::fs::create_dir_all(&chapter_export_dir).context(format!(
235 | "`{comic_title}`创建目录`{chapter_export_dir:?}`失败"
236 | ))?;
237 | // 并发处理
238 | let downloaded_chapter_infos = downloaded_chapter_infos.into_par_iter();
239 | downloaded_chapter_infos.try_for_each(|chapter_info| -> anyhow::Result<()> {
240 | let chapter_download_dir = chapter_info.get_chapter_download_dir(app, comic);
241 | let chapter_title = &chapter_info.chapter_title;
242 | let sanitized_chapter_title = filename_filter(chapter_title);
243 | // 创建pdf
244 | let pdf_path = chapter_export_dir.join(format!("{sanitized_chapter_title}.{extension}"));
245 | create_pdf(&chapter_download_dir, &pdf_path)
246 | .context(format!("`{comic_title} - {chapter_title}`创建pdf失败"))?;
247 | // 更新创建pdf的进度
248 | let current = current.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;
249 | // 发送创建pdf进度事件
250 | let _ = ExportPdfEvent::CreateProgress {
251 | uuid: event_uuid.clone(),
252 | current,
253 | }
254 | .emit(app);
255 | Ok(())
256 | })?;
257 | // 标记为成功,后面drop时就不会发送CreateError事件
258 | create_error_event_guard.success = true;
259 | // 发送创建pdf完成事件
260 | let _ = ExportPdfEvent::CreateEnd { uuid: event_uuid }.emit(app);
261 |
262 | let event_uuid = uuid::Uuid::new_v4().to_string();
263 | // 发送开始合并pdf事件
264 | let _ = ExportPdfEvent::MergeStart {
265 | uuid: event_uuid.clone(),
266 | comic_title: comic_title.clone(),
267 | }
268 | .emit(app);
269 | // 如果success为false,drop时发送MergeError事件
270 | let mut merge_error_event_guard = PdfMergeErrorEventGuard {
271 | uuid: event_uuid.clone(),
272 | app: app.clone(),
273 | success: false,
274 | };
275 |
276 | let pdf_filename = comic_export_dir
277 | .file_name()
278 | .context(format!(
279 | "获取`{comic_export_dir:?}`的目录名失败,请确保路径不是以`..`结尾"
280 | ))?
281 | .to_str()
282 | .context(format!(
283 | "获取`{comic_export_dir:?}`的目录名失败,包含非法字符"
284 | ))?;
285 | let pdf_path = comic_export_dir.join(format!("{pdf_filename}.{extension}"));
286 | // 合并pdf
287 | merge_pdf(&chapter_export_dir, &pdf_path).context(format!("`{comic_title}`合并pdf失败"))?;
288 | // 标记为成功,后面drop时就不会发送MergeError事件
289 | merge_error_event_guard.success = true;
290 | // 发送合并pdf完成事件
291 | let _ = ExportPdfEvent::MergeEnd { uuid: event_uuid }.emit(app);
292 | Ok(())
293 | }
294 |
295 | /// 用`chapter_download_dir`中的图片创建PDF,保存到`pdf_path`中
296 | #[allow(clippy::similar_names)]
297 | #[allow(clippy::cast_possible_truncation)]
298 | fn create_pdf(chapter_export_dir: &Path, pdf_path: &Path) -> anyhow::Result<()> {
299 | let mut image_paths = std::fs::read_dir(chapter_export_dir)
300 | .context(format!("读取目录`{chapter_export_dir:?}`失败"))?
301 | .filter_map(Result::ok)
302 | .map(|entry| entry.path())
303 | .collect::>();
304 | image_paths.sort_by(|a, b| a.file_name().cmp(&b.file_name()));
305 |
306 | let mut doc = Document::with_version("1.5");
307 | let pages_id = doc.new_object_id();
308 | let mut page_ids = vec![];
309 |
310 | for image_path in image_paths {
311 | if !image_path.is_file() {
312 | continue;
313 | }
314 |
315 | let buffer = read_image_to_buffer(&image_path)
316 | .context(format!("将`{image_path:?}`读取到buffer失败"))?;
317 | let (width, height) = image::image_dimensions(&image_path)
318 | .context(format!("获取`{image_path:?}`的尺寸失败"))?;
319 | let image_stream = lopdf::xobject::image_from(buffer)
320 | .context(format!("创建`{image_path:?}`的图片流失败"))?;
321 | // 将图片流添加到doc中
322 | let img_id = doc.add_object(image_stream);
323 | // 图片的名称,用于 Do 操作在页面上显示图片
324 | let img_name = format!("X{}", img_id.0);
325 | // 用于设置图片在页面上的位置和大小
326 | let cm_operation = Operation::new(
327 | "cm",
328 | vec![
329 | width.into(),
330 | 0.into(),
331 | 0.into(),
332 | height.into(),
333 | 0.into(),
334 | 0.into(),
335 | ],
336 | );
337 | // 用于显示图片
338 | let do_operation = Operation::new("Do", vec![Object::Name(img_name.as_bytes().to_vec())]);
339 | // 创建页面,设置图片的位置和大小,然后显示图片
340 | // 因为是从零开始创建PDF,所以没必要用 q 和 Q 操作保存和恢复图形状态
341 | let content = Content {
342 | operations: vec![cm_operation, do_operation],
343 | };
344 | let content_id = doc.add_object(Stream::new(dictionary! {}, content.encode()?));
345 | let page_id = doc.add_object(dictionary! {
346 | "Type" => "Page",
347 | "Parent" => pages_id,
348 | "Contents" => content_id,
349 | "MediaBox" => vec![0.into(), 0.into(), width.into(), height.into()],
350 | });
351 | // 将图片以 XObject 的形式添加到文档中
352 | // Do 操作只能引用 XObject(所以前面定义的 Do 操作的参数是 img_name, 而不是 img_id)
353 | doc.add_xobject(page_id, img_name.as_bytes(), img_id)?;
354 | // 记录新创建的页面的 ID
355 | page_ids.push(page_id);
356 | }
357 | // 将"Pages"添加到doc中
358 | let pages_dict = dictionary! {
359 | "Type" => "Pages",
360 | "Count" => page_ids.len() as u32,
361 | "Kids" => page_ids.into_iter().map(Object::Reference).collect::>(),
362 | };
363 | doc.objects.insert(pages_id, Object::Dictionary(pages_dict));
364 | // 新建一个"Catalog"对象,将"Pages"对象添加到"Catalog"对象中,然后将"Catalog"对象添加到doc中
365 | let catalog_id = doc.add_object(dictionary! {
366 | "Type" => "Catalog",
367 | "Pages" => pages_id,
368 | });
369 | doc.trailer.set("Root", catalog_id);
370 |
371 | doc.compress();
372 |
373 | doc.save(pdf_path)
374 | .context(format!("保存`{pdf_path:?}`失败"))?;
375 | Ok(())
376 | }
377 |
378 | /// 读取`image_path`中的图片数据到buffer中
379 | fn read_image_to_buffer(image_path: &Path) -> anyhow::Result> {
380 | let file = std::fs::File::open(image_path).context(format!("打开`{image_path:?}`失败"))?;
381 | let mut reader = std::io::BufReader::new(file);
382 | let mut buffer = vec![];
383 | reader
384 | .read_to_end(&mut buffer)
385 | .context(format!("读取`{image_path:?}`失败"))?;
386 | Ok(buffer)
387 | }
388 |
389 | /// 合并`chapter_export_dir`中的PDF,保存到`pdf_path`中
390 | #[allow(clippy::cast_possible_truncation)]
391 | fn merge_pdf(chapter_export_dir: &Path, pdf_path: &Path) -> anyhow::Result<()> {
392 | let mut chapter_pdf_paths = std::fs::read_dir(chapter_export_dir)
393 | .context(format!("读取目录`{chapter_export_dir:?}`失败"))?
394 | .filter_map(Result::ok)
395 | .map(|entry| entry.path())
396 | .collect::>();
397 | // 按照目录名中的索引进行排序
398 | chapter_pdf_paths.sort_by(|a, b| {
399 | let get_index = |path: &PathBuf| -> i64 {
400 | // 获取文件名
401 | let Some(file_name) = path.file_name() else {
402 | return i64::MAX;
403 | };
404 | // 转换为字符串,name_str格式为`第x话.pdf`或`第x话 xxx.pdf`
405 | let Some(name_str) = file_name.to_str() else {
406 | return i64::MAX;
407 | };
408 | // 提取数字部分
409 | let num_str = name_str
410 | .chars()
411 | .skip(1) // 跳过`第`
412 | .take_while(char::is_ascii_digit)
413 | .collect::();
414 | // 转换为数字
415 | num_str.parse().unwrap_or(i64::MAX)
416 | };
417 |
418 | // 将 i64 转换为可以比较的数据类型
419 | let index_a = get_index(a);
420 | let index_b = get_index(b);
421 |
422 | index_a
423 | .partial_cmp(&index_b)
424 | .unwrap_or(std::cmp::Ordering::Equal)
425 | });
426 |
427 | let mut doc = Document::with_version("1.5");
428 | let mut doc_page_ids = vec![];
429 | let mut doc_objects = BTreeMap::new();
430 |
431 | for chapter_pdf_path in chapter_pdf_paths {
432 | let mut chapter_doc =
433 | Document::load(&chapter_pdf_path).context(format!("加载`{chapter_pdf_path:?}`失败"))?;
434 | // 重新编号这个章节PDF的对象,避免与doc的对象编号冲突
435 | chapter_doc.renumber_objects_with(doc.max_id);
436 | doc.max_id = chapter_doc.max_id + 1;
437 | // 获取这个章节PDF中的所有页面,并给第一个页面添加书签
438 | let mut chapter_page_ids = vec![];
439 | for (page_num, object_id) in chapter_doc.get_pages() {
440 | // 第一个页面需要添加书签
441 | if page_num == 1 {
442 | let chapter_title = chapter_pdf_path
443 | .file_stem()
444 | .context(format!(
445 | "获取`{chapter_pdf_path:?}`的文件名失败,没有文件名"
446 | ))?
447 | .to_str()
448 | .context(format!(
449 | "获取`{chapter_pdf_path:?}`的文件名失败,包含非法字符"
450 | ))?
451 | .to_string();
452 | let bookmark = Bookmark::new(chapter_title, [0.0, 0.0, 1.0], 0, object_id);
453 | doc.add_bookmark(bookmark, None);
454 | }
455 | chapter_page_ids.push(object_id);
456 | }
457 |
458 | doc_page_ids.extend(chapter_page_ids);
459 | doc_objects.extend(chapter_doc.objects);
460 | }
461 | // 在doc中新建一个"Pages"对象,将所有章节的页面添加到这个"Pages"对象中
462 | let pages_id = doc.add_object(dictionary! {
463 | "Type" => "Pages",
464 | "Count" => doc_page_ids.len() as u32,
465 | "Kids" => doc_page_ids.into_iter().map(Object::Reference).collect::>(),
466 | });
467 |
468 | for (object_id, mut object) in doc_objects {
469 | match object.type_name().unwrap_or(b"") {
470 | b"Page" => {
471 | if let Ok(page_dict) = object.as_dict_mut() {
472 | // 将页面对象的"Parent"字段设置为新建的"Pages"对象,这样这个页面就成为了"Pages"对象的子页面
473 | page_dict.set("Parent", pages_id);
474 | doc.objects.insert(object_id, object);
475 | };
476 | }
477 | // 忽略这些对象
478 | b"Catalog" | b"Pages" | b"Outlines" | b"Outline" => {}
479 | // 将所有其他对象添加到doc中
480 | _ => {
481 | doc.objects.insert(object_id, object);
482 | }
483 | }
484 | }
485 | // 新建一个"Catalog"对象,将"Pages"对象添加到"Catalog"对象中,然后将"Catalog"对象添加到doc中
486 | let catalog_id = doc.add_object(dictionary! {
487 | "Type" => "Catalog",
488 | "Pages" => pages_id,
489 | });
490 | doc.trailer.set("Root", catalog_id);
491 | // 如果有书签没有关联到具体页面,将这些书签指向第一个页面
492 | doc.adjust_zero_pages();
493 | // 将书签添加到doc中
494 | if let Some(outline_id) = doc.build_outline() {
495 | if let Ok(Object::Dictionary(catalog_dict)) = doc.get_object_mut(catalog_id) {
496 | catalog_dict.set("Outlines", Object::Reference(outline_id));
497 | }
498 | }
499 | // 重新编号doc的对象
500 | doc.renumber_objects();
501 |
502 | doc.compress();
503 |
504 | doc.save(pdf_path)
505 | .context(format!("保存`{pdf_path:?}`失败"))?;
506 | Ok(())
507 | }
508 |
--------------------------------------------------------------------------------
/src-tauri/src/extensions.rs:
--------------------------------------------------------------------------------
1 | pub trait AnyhowErrorToStringChain {
2 | /// 将 `anyhow::Error` 转换为chain格式
3 | /// # Example
4 | /// 0: error message
5 | /// 1: error message
6 | /// 2: error message
7 | fn to_string_chain(&self) -> String;
8 | }
9 |
10 | impl AnyhowErrorToStringChain for anyhow::Error {
11 | fn to_string_chain(&self) -> String {
12 | use std::fmt::Write;
13 | self.chain()
14 | .enumerate()
15 | .fold(String::new(), |mut output, (i, e)| {
16 | let _ = writeln!(output, "{i}: {e}");
17 | output
18 | })
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/src-tauri/src/lib.rs:
--------------------------------------------------------------------------------
1 | use anyhow::Context;
2 | use events::{
3 | DownloadSpeedEvent, DownloadTaskEvent, ExportCbzEvent, ExportPdfEvent, LogEvent,
4 | UpdateDownloadedFavoriteComicEvent,
5 | };
6 | use parking_lot::RwLock;
7 | use tauri::{Manager, Wry};
8 |
9 | // TODO: 用prelude来消除警告
10 | use crate::commands::*;
11 | use crate::config::Config;
12 | use crate::download_manager::DownloadManager;
13 | use crate::jm_client::JmClient;
14 |
15 | mod commands;
16 | mod config;
17 | mod download_manager;
18 | mod errors;
19 | mod events;
20 | mod export;
21 | mod extensions;
22 | mod jm_client;
23 | mod logger;
24 | mod responses;
25 | mod types;
26 | mod utils;
27 |
28 | fn generate_context() -> tauri::Context {
29 | tauri::generate_context!()
30 | }
31 |
32 | // TODO: 添加Panic Doc
33 | #[cfg_attr(mobile, tauri::mobile_entry_point)]
34 | pub fn run() {
35 | let builder = tauri_specta::Builder::::new()
36 | .commands(tauri_specta::collect_commands![
37 | greet,
38 | get_config,
39 | save_config,
40 | login,
41 | search,
42 | get_comic,
43 | get_favorite_folder,
44 | get_user_profile,
45 | create_download_task,
46 | pause_download_task,
47 | resume_download_task,
48 | cancel_download_task,
49 | download_comic,
50 | update_downloaded_favorite_comic,
51 | show_path_in_file_manager,
52 | show_comic_download_dir_in_file_manager,
53 | sync_favorite_folder,
54 | save_metadata,
55 | get_downloaded_comics,
56 | export_cbz,
57 | export_pdf,
58 | get_logs_dir_size,
59 | ])
60 | .events(tauri_specta::collect_events![
61 | DownloadSpeedEvent,
62 | DownloadTaskEvent,
63 | UpdateDownloadedFavoriteComicEvent,
64 | ExportCbzEvent,
65 | ExportPdfEvent,
66 | LogEvent,
67 | ]);
68 |
69 | #[cfg(debug_assertions)]
70 | builder
71 | .export(
72 | specta_typescript::Typescript::default()
73 | .bigint(specta_typescript::BigIntExportBehavior::Number)
74 | .formatter(specta_typescript::formatter::prettier)
75 | .header("// @ts-nocheck"), // 跳过检查
76 | "../src/bindings.ts",
77 | )
78 | .expect("Failed to export typescript bindings");
79 |
80 | tauri::Builder::default()
81 | .plugin(tauri_plugin_dialog::init())
82 | .plugin(tauri_plugin_opener::init())
83 | .invoke_handler(builder.invoke_handler())
84 | .setup(move |app| {
85 | builder.mount_events(app);
86 |
87 | let app_data_dir = app
88 | .path()
89 | .app_data_dir()
90 | .context("failed to get app data dir")?;
91 |
92 | std::fs::create_dir_all(&app_data_dir)
93 | .context(format!("failed to create app data dir: {app_data_dir:?}"))?;
94 | println!("app data dir: {app_data_dir:?}");
95 |
96 | let config = RwLock::new(Config::new(app.handle())?);
97 | app.manage(config);
98 |
99 | let jm_client = JmClient::new(app.handle().clone());
100 | app.manage(jm_client);
101 |
102 | let download_manager = DownloadManager::new(app.handle().clone());
103 | app.manage(download_manager);
104 |
105 | logger::init(app.handle())?;
106 |
107 | Ok(())
108 | })
109 | .run(generate_context())
110 | .expect("error while running tauri application");
111 | }
112 |
--------------------------------------------------------------------------------
/src-tauri/src/logger.rs:
--------------------------------------------------------------------------------
1 | use std::{io::Write, sync::OnceLock};
2 |
3 | use anyhow::Context;
4 | use notify::{RecommendedWatcher, Watcher};
5 | use parking_lot::RwLock;
6 | use tauri::{AppHandle, Manager};
7 | use tauri_specta::Event;
8 | use tracing::{Level, Subscriber};
9 | use tracing_appender::{
10 | non_blocking::WorkerGuard,
11 | rolling::{RollingFileAppender, Rotation},
12 | };
13 | use tracing_subscriber::{
14 | filter::{filter_fn, FilterExt, Targets},
15 | fmt::{layer, time::LocalTime},
16 | layer::SubscriberExt,
17 | registry::LookupSpan,
18 | util::SubscriberInitExt,
19 | Layer, Registry,
20 | };
21 |
22 | use crate::{config::Config, events::LogEvent, extensions::AnyhowErrorToStringChain};
23 |
24 | struct LogEventWriter {
25 | app: AppHandle,
26 | }
27 |
28 | impl Write for LogEventWriter {
29 | fn write(&mut self, buf: &[u8]) -> std::io::Result {
30 | let log_string = String::from_utf8_lossy(buf);
31 | match serde_json::from_str::(&log_string) {
32 | Ok(log_event) => {
33 | let _ = log_event.emit(&self.app);
34 | }
35 | Err(err) => {
36 | let log_string = log_string.to_string();
37 | let err_msg = err.to_string();
38 | tracing::error!(log_string, err_msg, "将日志字符串解析为LogEvent失败");
39 | }
40 | }
41 | Ok(buf.len())
42 | }
43 |
44 | fn flush(&mut self) -> std::io::Result<()> {
45 | Ok(())
46 | }
47 | }
48 |
49 | static RELOAD_FN: OnceLock anyhow::Result<()> + Send + Sync>> = OnceLock::new();
50 | static GUARD: OnceLock>> = OnceLock::new();
51 |
52 | pub fn init(app: &AppHandle) -> anyhow::Result<()> {
53 | let lib_module_path = module_path!();
54 | let lib_target = lib_module_path.split("::").next().context(format!(
55 | "解析lib_target失败: lib_module_path={lib_module_path}"
56 | ))?;
57 | // 过滤掉来自其他库的日志
58 | let target_filter = Targets::new().with_target(lib_target, Level::TRACE);
59 | // 输出到文件
60 | let (file_layer, guard) = create_file_layer(app)?;
61 | let (reloadable_file_layer, reload_handle) = tracing_subscriber::reload::Layer::new(file_layer);
62 | // 输出到控制台
63 | let console_layer = layer()
64 | .with_writer(std::io::stdout)
65 | .with_timer(LocalTime::rfc_3339())
66 | .with_file(true)
67 | .with_line_number(true);
68 | // 发送到前端
69 | let log_event_writer = std::sync::Mutex::new(LogEventWriter { app: app.clone() });
70 | let log_event_layer = layer()
71 | .with_writer(log_event_writer)
72 | .with_timer(LocalTime::rfc_3339())
73 | .with_file(true)
74 | .with_line_number(true)
75 | .json()
76 | // 过滤掉来自这个文件的日志(LogEvent解析失败的日志),避免无限递归
77 | .with_filter(target_filter.clone().and(filter_fn(|metadata| {
78 | metadata.module_path() != Some(lib_module_path)
79 | })));
80 |
81 | Registry::default()
82 | .with(target_filter)
83 | .with(reloadable_file_layer)
84 | .with(console_layer)
85 | .with(log_event_layer)
86 | .init();
87 |
88 | GUARD.get_or_init(|| parking_lot::Mutex::new(guard));
89 | RELOAD_FN.get_or_init(move || {
90 | let app = app.clone();
91 | Box::new(move || {
92 | let (file_layer, guard) = create_file_layer(&app)?;
93 | reload_handle.reload(file_layer).context("reload失败")?;
94 | *GUARD.get().context("GUARD未初始化")?.lock() = guard;
95 | Ok(())
96 | })
97 | });
98 | tauri::async_runtime::spawn(file_log_watcher(app.clone()));
99 |
100 | Ok(())
101 | }
102 |
103 | pub fn reload_file_logger() -> anyhow::Result<()> {
104 | RELOAD_FN.get().context("RELOAD_FN未初始化")?()
105 | }
106 |
107 | pub fn disable_file_logger() -> anyhow::Result<()> {
108 | if let Some(guard) = GUARD.get().context("GUARD未初始化")?.lock().take() {
109 | drop(guard);
110 | };
111 | Ok(())
112 | }
113 |
114 | fn create_file_layer(
115 | app: &AppHandle,
116 | ) -> anyhow::Result<(Box + Send + Sync>, Option)>
117 | where
118 | S: Subscriber + for<'a> LookupSpan<'a>,
119 | {
120 | let enable_file_logger = app.state::>().read().enable_file_logger;
121 | // 如果不启用文件日志,则返回一个占位用的sink layer,不创建也不输出日志文件
122 | if !enable_file_logger {
123 | let sink_layer = layer()
124 | .with_writer(std::io::sink)
125 | .with_timer(LocalTime::rfc_3339())
126 | .with_ansi(false)
127 | .with_file(true)
128 | .with_line_number(true);
129 | return Ok((Box::new(sink_layer), None));
130 | }
131 | let logs_dir = logs_dir(app).context("获取日志目录失败")?;
132 | let file_appender = RollingFileAppender::builder()
133 | .filename_prefix("jmcomic-downloader")
134 | .filename_suffix("log")
135 | .rotation(Rotation::DAILY)
136 | .build(&logs_dir)
137 | .context("创建RollingFileAppender失败")?;
138 | let (non_blocking_appender, guard) = tracing_appender::non_blocking(file_appender);
139 | let file_layer = layer()
140 | .with_writer(non_blocking_appender)
141 | .with_timer(LocalTime::rfc_3339())
142 | .with_ansi(false)
143 | .with_file(true)
144 | .with_line_number(true);
145 | Ok((Box::new(file_layer), Some(guard)))
146 | }
147 |
148 | async fn file_log_watcher(app: AppHandle) {
149 | let (sender, mut receiver) = tokio::sync::mpsc::channel(1);
150 |
151 | let event_handler = move |res| {
152 | tauri::async_runtime::block_on(async {
153 | if let Err(err) = sender.send(res).await.map_err(anyhow::Error::from) {
154 | let err_title = "发送日志文件watcher事件失败";
155 | let string_chain = err.to_string_chain();
156 | tracing::error!(err_title, message = string_chain);
157 | }
158 | });
159 | };
160 |
161 | let mut watcher = match RecommendedWatcher::new(event_handler, notify::Config::default())
162 | .map_err(anyhow::Error::from)
163 | {
164 | Ok(watcher) => watcher,
165 | Err(err) => {
166 | let err_title = "创建日志文件watcher失败";
167 | let string_chain = err.to_string_chain();
168 | tracing::error!(err_title, message = string_chain);
169 | return;
170 | }
171 | };
172 |
173 | let logs_dir = match logs_dir(&app) {
174 | Ok(logs_dir) => logs_dir,
175 | Err(err) => {
176 | let err_title = "日志文件watcher获取日志目录失败";
177 | let string_chain = err.to_string_chain();
178 | tracing::error!(err_title, message = string_chain);
179 | return;
180 | }
181 | };
182 |
183 | if let Err(err) = std::fs::create_dir_all(&logs_dir) {
184 | let err_title = "创建日志目录失败";
185 | let string_chain = anyhow::Error::from(err).to_string_chain();
186 | tracing::error!(err_title, message = string_chain);
187 | return;
188 | }
189 |
190 | if let Err(err) = watcher
191 | .watch(&logs_dir, notify::RecursiveMode::NonRecursive)
192 | .map_err(anyhow::Error::from)
193 | {
194 | let err_title = "日志文件watcher监听日志目录失败";
195 | let string_chain = err.to_string_chain();
196 | tracing::error!(err_title, message = string_chain);
197 | return;
198 | }
199 |
200 | while let Some(res) = receiver.recv().await {
201 | match res.map_err(anyhow::Error::from) {
202 | Ok(event) => {
203 | if let notify::EventKind::Remove(_) = event.kind {
204 | if let Err(err) = reload_file_logger() {
205 | let err_title = "重置日志文件失败";
206 | let string_chain = err.to_string_chain();
207 | tracing::error!(err_title, message = string_chain);
208 | }
209 | }
210 | }
211 | Err(err) => {
212 | let err_title = "接收日志文件watcher事件失败";
213 | let string_chain = err.to_string_chain();
214 | tracing::error!(err_title, message = string_chain);
215 | }
216 | }
217 | }
218 | }
219 |
220 | pub fn logs_dir(app: &AppHandle) -> anyhow::Result {
221 | let app_data_dir = app
222 | .path()
223 | .app_data_dir()
224 | .context("获取app_data_dir目录失败")?;
225 | Ok(app_data_dir.join("日志"))
226 | }
227 |
--------------------------------------------------------------------------------
/src-tauri/src/main.rs:
--------------------------------------------------------------------------------
1 | // Prevents additional console window on Windows in release, DO NOT REMOVE!!
2 | #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
3 |
4 | fn main() {
5 | jmcomic_downloader_lib::run()
6 | }
7 |
--------------------------------------------------------------------------------
/src-tauri/src/responses/get_chapter_resp_data.rs:
--------------------------------------------------------------------------------
1 | use serde::{Deserialize, Serialize};
2 | use specta::Type;
3 |
4 | use super::{CategoryRespData, CategorySubRespData};
5 |
6 | #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
7 | #[serde(rename_all = "camelCase")]
8 | pub struct ComicInSearchRespData {
9 | pub id: String,
10 | pub author: String,
11 | pub name: String,
12 | pub image: String,
13 | pub category: CategoryRespData,
14 | #[serde(rename = "category_sub")]
15 | pub category_sub: CategorySubRespData,
16 | pub liked: bool,
17 | #[serde(rename = "is_favorite")]
18 | pub is_favorite: bool,
19 | #[serde(rename = "update_at")]
20 | pub update_at: i64,
21 | }
22 |
--------------------------------------------------------------------------------
/src-tauri/src/responses/get_comic_resp_data.rs:
--------------------------------------------------------------------------------
1 | use serde::{Deserialize, Serialize};
2 | use specta::Type;
3 |
4 | use super::SeriesRespData;
5 |
6 | #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
7 | #[serde(rename_all = "camelCase")]
8 | pub struct GetComicRespData {
9 | pub id: i64,
10 | pub name: String,
11 | pub addtime: String,
12 | pub description: String,
13 | #[serde(rename = "total_views")]
14 | pub total_views: String,
15 | pub likes: String,
16 | pub series: Vec,
17 | #[serde(rename = "series_id")]
18 | pub series_id: String,
19 | #[serde(rename = "comment_total")]
20 | pub comment_total: String,
21 | pub author: Vec,
22 | pub tags: Vec,
23 | pub works: Vec,
24 | pub actors: Vec,
25 | #[serde(rename = "related_list")]
26 | pub related_list: Vec,
27 | pub liked: bool,
28 | #[serde(rename = "is_favorite")]
29 | pub is_favorite: bool,
30 | #[serde(rename = "is_aids")]
31 | pub is_aids: bool,
32 | }
33 |
34 | #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
35 | #[serde(rename_all = "camelCase")]
36 | pub struct RelatedListRespData {
37 | pub id: String,
38 | pub author: String,
39 | pub name: String,
40 | pub image: String,
41 | }
42 |
--------------------------------------------------------------------------------
/src-tauri/src/responses/get_favorite_resp_data.rs:
--------------------------------------------------------------------------------
1 | use serde::{Deserialize, Serialize};
2 | use specta::Type;
3 |
4 | use super::{CategoryRespData, CategorySubRespData};
5 |
6 | #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
7 | #[serde(rename_all = "camelCase")]
8 | pub struct GetFavoriteRespData {
9 | pub list: Vec,
10 | #[serde(rename = "folder_list")]
11 | pub folder_list: Vec,
12 | pub total: String,
13 | pub count: i64,
14 | }
15 |
16 | #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
17 | #[serde(rename_all = "camelCase")]
18 | pub struct ComicInFavoriteRespData {
19 | pub id: String,
20 | pub author: String,
21 | pub description: Option,
22 | pub name: String,
23 | #[serde(rename = "latest_ep")]
24 | pub latest_ep: Option,
25 | #[serde(rename = "latest_ep_aid")]
26 | pub latest_ep_aid: Option,
27 | pub image: String,
28 | pub category: CategoryRespData,
29 | #[serde(rename = "category_sub")]
30 | pub category_sub: CategorySubRespData,
31 | }
32 |
33 | #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
34 | #[serde(rename_all = "camelCase")]
35 | pub struct FavoriteFolderRespData {
36 | #[serde(rename = "FID")]
37 | pub fid: String,
38 | #[serde(rename = "UID")]
39 | pub uid: String,
40 | pub name: String,
41 | }
42 |
--------------------------------------------------------------------------------
/src-tauri/src/responses/get_user_profile_resp_data.rs:
--------------------------------------------------------------------------------
1 | use serde::{Deserialize, Serialize};
2 | use specta::Type;
3 |
4 | use super::string_to_i64;
5 |
6 | #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
7 | #[serde(rename_all = "camelCase")]
8 | pub struct GetUserProfileRespData {
9 | pub uid: String,
10 | pub username: String,
11 | pub email: String,
12 | pub emailverified: String,
13 | pub photo: String,
14 | pub fname: String,
15 | pub gender: String,
16 | pub message: Option,
17 | #[serde(deserialize_with = "string_to_i64")]
18 | pub coin: i64,
19 | #[serde(rename = "album_favorites")]
20 | pub album_favorites: i64,
21 | pub s: String,
22 | #[serde(rename = "level_name")]
23 | pub level_name: String,
24 | pub level: i64,
25 | pub next_level_exp: i64,
26 | pub exp: String,
27 | pub exp_percent: f64,
28 | #[serde(rename = "album_favorites_max")]
29 | pub album_favorites_max: i64,
30 | #[serde(rename = "ad_free")]
31 | pub ad_free: bool,
32 | pub charge: String,
33 | pub jar: String,
34 | #[serde(rename = "invitation_qrcode")]
35 | pub invitation_qrcode: String,
36 | #[serde(rename = "invitation_url")]
37 | pub invitation_url: String,
38 | #[serde(rename = "invited_cnt")]
39 | pub invited_cnt: String,
40 | }
41 |
--------------------------------------------------------------------------------
/src-tauri/src/responses/mod.rs:
--------------------------------------------------------------------------------
1 | mod get_chapter_resp_data;
2 | mod get_comic_resp_data;
3 | mod get_favorite_resp_data;
4 | mod get_user_profile_resp_data;
5 | mod search_resp;
6 | mod toggle_favorite_resp_data;
7 |
8 | pub use get_chapter_resp_data::*;
9 | pub use get_comic_resp_data::*;
10 | pub use get_favorite_resp_data::*;
11 | pub use get_user_profile_resp_data::*;
12 | pub use search_resp::*;
13 | pub use toggle_favorite_resp_data::*;
14 |
15 | use serde::{Deserialize, Serialize};
16 | use specta::Type;
17 |
18 | #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
19 | #[serde(rename_all = "camelCase")]
20 | pub struct JmResp {
21 | pub code: i64,
22 | pub data: serde_json::Value,
23 | #[serde(default)]
24 | pub error_msg: String,
25 | }
26 |
27 | #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
28 | #[serde(rename_all = "camelCase")]
29 | pub struct SeriesRespData {
30 | pub id: String,
31 | pub name: String,
32 | pub sort: String,
33 | }
34 |
35 | #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
36 | #[serde(rename_all = "camelCase")]
37 | pub struct CategoryRespData {
38 | pub id: Option,
39 | pub title: Option,
40 | }
41 |
42 | #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
43 | #[serde(rename_all = "camelCase")]
44 | pub struct CategorySubRespData {
45 | pub id: Option,
46 | pub title: Option,
47 | }
48 |
49 | fn string_to_i64<'de, D>(d: D) -> Result
50 | where
51 | D: serde::Deserializer<'de>,
52 | {
53 | use serde_json::Value;
54 | let value: Value = serde::Deserialize::deserialize(d)?;
55 |
56 | match value {
57 | #[allow(clippy::cast_possible_truncation)]
58 | Value::Number(n) => Ok(n.as_i64().unwrap_or(0)),
59 | Value::String(s) => Ok(s.parse().unwrap_or(0)),
60 | _ => Err(serde::de::Error::custom(
61 | "`string_to_i64` 失败,value类型不是 `Number` 或 `String`",
62 | )),
63 | }
64 | }
65 |
--------------------------------------------------------------------------------
/src-tauri/src/responses/search_resp.rs:
--------------------------------------------------------------------------------
1 | use serde::{Deserialize, Serialize};
2 | use specta::Type;
3 |
4 | use super::{string_to_i64, ComicInSearchRespData, GetComicRespData, SeriesRespData};
5 |
6 | #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
7 | #[serde(rename_all = "camelCase")]
8 | pub struct RedirectRespData {
9 | #[serde(rename = "search_query")]
10 | pub search_query: String,
11 | pub total: i64,
12 | #[serde(rename = "redirect_aid")]
13 | pub redirect_aid: String,
14 | }
15 |
16 | #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
17 | pub enum SearchResp {
18 | SearchRespData(SearchRespData),
19 | // 用Box包装GetComicRespData,因为GetComicRespData比SearchRespData大得多
20 | // 如果不用Box包装,即使SearchResp的类型是SearchRespData,也会占用与GetComicRespData一样大的内存
21 | ComicRespData(Box),
22 | }
23 |
24 | #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
25 | #[serde(rename_all = "camelCase")]
26 | pub struct GetChapterRespData {
27 | pub id: i64,
28 | pub series: Vec,
29 | pub tags: String,
30 | pub name: String,
31 | pub images: Vec,
32 | pub addtime: String,
33 | #[serde(rename = "series_id")]
34 | pub series_id: String,
35 | #[serde(rename = "is_favorite")]
36 | pub is_favorite: bool,
37 | pub liked: bool,
38 | }
39 |
40 | #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
41 | #[serde(rename_all = "camelCase")]
42 | pub struct SearchRespData {
43 | #[serde(rename = "search_query")]
44 | pub search_query: String,
45 | #[serde(deserialize_with = "string_to_i64")]
46 | pub total: i64,
47 | pub content: Vec,
48 | }
49 |
--------------------------------------------------------------------------------
/src-tauri/src/responses/toggle_favorite_resp_data.rs:
--------------------------------------------------------------------------------
1 | use serde::{Deserialize, Serialize};
2 | use specta::Type;
3 |
4 | #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
5 | #[serde(rename_all = "camelCase")]
6 | pub struct ToggleFavoriteRespData {
7 | pub status: String,
8 | pub msg: String,
9 | #[serde(rename = "type")]
10 | pub toggle_type: ToggleType,
11 | }
12 |
13 | #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
14 | #[serde(rename_all = "camelCase")]
15 | pub enum ToggleType {
16 | #[default]
17 | Add,
18 | Remove,
19 | }
20 |
--------------------------------------------------------------------------------
/src-tauri/src/types/chapter_info.rs:
--------------------------------------------------------------------------------
1 | use std::path::PathBuf;
2 |
3 | use serde::{Deserialize, Serialize};
4 | use specta::Type;
5 | use tauri::AppHandle;
6 |
7 | use crate::utils::filename_filter;
8 |
9 | use super::Comic;
10 |
11 | #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
12 | #[serde(rename_all = "camelCase")]
13 | pub struct ChapterInfo {
14 | pub chapter_id: i64,
15 | pub chapter_title: String,
16 | #[serde(skip_serializing_if = "Option::is_none")]
17 | pub is_downloaded: Option,
18 | pub order: i64,
19 | }
20 |
21 | impl ChapterInfo {
22 | pub fn get_is_downloaded(app: &AppHandle, comic_title: &str, chapter_title: &str) -> bool {
23 | let comic_download_dir = Comic::get_comic_download_dir(app, comic_title);
24 |
25 | let chapter_title = filename_filter(chapter_title);
26 | comic_download_dir.join(chapter_title).exists()
27 | }
28 |
29 | pub fn get_temp_download_dir(&self, app: &AppHandle, comic: &Comic) -> PathBuf {
30 | let comic_download_dir = Comic::get_comic_download_dir(app, &comic.name);
31 |
32 | let chapter_title = filename_filter(&self.chapter_title);
33 | comic_download_dir.join(format!(".下载中-{chapter_title}")) // 以 `.下载中-` 开头,表示是临时目录
34 | }
35 |
36 | pub fn get_chapter_download_dir(&self, app: &AppHandle, comic: &Comic) -> PathBuf {
37 | let comic_download_dir = Comic::get_comic_download_dir(app, &comic.name);
38 |
39 | let chapter_title = filename_filter(&self.chapter_title);
40 | comic_download_dir.join(chapter_title)
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/src-tauri/src/types/comic.rs:
--------------------------------------------------------------------------------
1 | use std::path::{Path, PathBuf};
2 |
3 | use anyhow::Context;
4 | use parking_lot::RwLock;
5 | use serde::{Deserialize, Serialize};
6 | use specta::Type;
7 | use tauri::{AppHandle, Manager};
8 |
9 | use crate::{
10 | config::Config,
11 | responses::{GetComicRespData, RelatedListRespData},
12 | utils::filename_filter,
13 | };
14 |
15 | use super::ChapterInfo;
16 |
17 | #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
18 | #[serde(rename_all = "camelCase")]
19 | pub struct Comic {
20 | pub id: i64,
21 | pub name: String,
22 | pub addtime: String,
23 | pub description: String,
24 | #[serde(rename = "total_views")]
25 | pub total_views: String,
26 | pub likes: String,
27 | pub chapter_infos: Vec,
28 | #[serde(rename = "series_id")]
29 | pub series_id: String,
30 | #[serde(rename = "comment_total")]
31 | pub comment_total: String,
32 | pub author: Vec,
33 | pub tags: Vec,
34 | pub works: Vec,
35 | pub actors: Vec,
36 | #[serde(rename = "related_list")]
37 | pub related_list: Vec,
38 | pub liked: bool,
39 | #[serde(rename = "is_favorite")]
40 | pub is_favorite: bool,
41 | #[serde(rename = "is_aids")]
42 | pub is_aids: bool,
43 | #[serde(skip_serializing_if = "Option::is_none")]
44 | pub is_downloaded: Option,
45 | }
46 |
47 | impl Comic {
48 | pub fn from_comic_resp_data(app: &AppHandle, comic: GetComicRespData) -> Comic {
49 | let comic_title = &comic.name;
50 | let mut chapter_infos: Vec = comic
51 | .series
52 | .into_iter()
53 | .enumerate()
54 | .filter_map(|(index, s)| {
55 | let chapter_id = s.id.parse().ok()?;
56 | #[allow(clippy::cast_possible_wrap)]
57 | let order = (index + 1) as i64;
58 | let mut chapter_title = format!("第{order}话");
59 | if !s.name.is_empty() {
60 | chapter_title.push_str(&format!(" {}", &s.name));
61 | }
62 | let is_downloaded =
63 | ChapterInfo::get_is_downloaded(app, comic_title, &chapter_title);
64 | let chapter_info = ChapterInfo {
65 | chapter_id,
66 | chapter_title,
67 | is_downloaded: Some(is_downloaded),
68 | order,
69 | };
70 | Some(chapter_info)
71 | })
72 | .collect();
73 | // 如果没有章节信息,就添加一个默认的章节信息
74 | if chapter_infos.is_empty() {
75 | chapter_infos.push(ChapterInfo {
76 | chapter_id: comic.id,
77 | chapter_title: "第1话".to_owned(),
78 | is_downloaded: Some(ChapterInfo::get_is_downloaded(app, comic_title, "第1话")),
79 | order: 1,
80 | });
81 | }
82 |
83 | let is_downloaded = Comic::get_is_downloaded(app, &comic.name);
84 |
85 | Comic {
86 | id: comic.id,
87 | name: comic.name,
88 | addtime: comic.addtime,
89 | description: comic.description,
90 | total_views: comic.total_views,
91 | likes: comic.likes,
92 | chapter_infos,
93 | series_id: comic.series_id,
94 | comment_total: comic.comment_total,
95 | author: comic.author,
96 | tags: comic.tags,
97 | works: comic.works,
98 | actors: comic.actors,
99 | related_list: comic.related_list,
100 | liked: comic.liked,
101 | is_favorite: comic.is_favorite,
102 | is_aids: comic.is_aids,
103 | is_downloaded: Some(is_downloaded),
104 | }
105 | }
106 |
107 | pub fn from_metadata(app: &AppHandle, metadata_path: &Path) -> anyhow::Result {
108 | let comic_json = std::fs::read_to_string(metadata_path).context(format!(
109 | "从元数据转为Comic失败,读取元数据文件 {metadata_path:?} 失败"
110 | ))?;
111 | let mut comic = serde_json::from_str::(&comic_json).context(format!(
112 | "从元数据转为Comic失败,将 {metadata_path:?} 反序列化为Comic失败"
113 | ))?;
114 | // json中的is_downloaded字段是None,需要重新计算
115 | // 既然有元数据,就说明这个漫画已经下载(文件夹存在)了,直接设置为true
116 | comic.is_downloaded = Some(true);
117 | // 重新计算每个章节的is_downloaded字段
118 | for chapter_info in &mut comic.chapter_infos {
119 | let comic_title = &comic.name;
120 | let chapter_title = &chapter_info.chapter_title;
121 | let is_downloaded = ChapterInfo::get_is_downloaded(app, comic_title, chapter_title);
122 | chapter_info.is_downloaded = Some(is_downloaded);
123 | }
124 | Ok(comic)
125 | }
126 |
127 | pub fn get_is_downloaded(app: &AppHandle, comic_title: &str) -> bool {
128 | Comic::get_comic_download_dir(app, comic_title).exists()
129 | }
130 |
131 | // 这里脱裤子放屁,是为了后期方便扩展,例如给漫画目录加上作者名、id等
132 | pub fn get_comic_download_dir(app: &AppHandle, comic_title: &str) -> PathBuf {
133 | let comic_dir_name = Comic::comic_dir_name(app, comic_title);
134 | app.state::>()
135 | .read()
136 | .download_dir
137 | .join(comic_dir_name)
138 | }
139 |
140 | pub fn get_comic_export_dir(app: &AppHandle, comic_title: &str) -> PathBuf {
141 | let comic_dir_name = Comic::comic_dir_name(app, comic_title);
142 | app.state::>()
143 | .read()
144 | .export_dir
145 | .join(comic_dir_name)
146 | }
147 |
148 | fn comic_dir_name(_app: &AppHandle, comic_title: &str) -> String {
149 | filename_filter(comic_title)
150 | }
151 | }
152 |
--------------------------------------------------------------------------------
/src-tauri/src/types/comic_info.rs:
--------------------------------------------------------------------------------
1 | use serde::{Deserialize, Serialize};
2 | use specta::Type;
3 | use yaserde::{YaDeserialize, YaSerialize};
4 |
5 | use super::{ChapterInfo, Comic};
6 |
7 | /// /
8 | #[derive(
9 | Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type, YaSerialize, YaDeserialize,
10 | )]
11 | #[serde(rename_all = "camelCase")]
12 | pub struct ComicInfo {
13 | #[yaserde(rename = "Manga")]
14 | pub manga: String,
15 | /// 漫画名
16 | #[yaserde(rename = "Series")]
17 | pub series: String,
18 | /// 出版社
19 | #[yaserde(rename = "Publisher")]
20 | pub publisher: String,
21 | /// 作者
22 | #[yaserde(rename = "Writer")]
23 | pub writer: String,
24 | /// 漫画标签
25 | #[yaserde(rename = "Tags")]
26 | pub tags: String,
27 | // /// 漫画类型
28 | // #[yaserde(rename = "Genre")]
29 | // pub genre: String,
30 | #[yaserde(rename = "Summary")]
31 | pub summary: String,
32 | /// 章节名
33 | #[yaserde(rename = "Title")]
34 | pub title: String,
35 | /// 普通章节序号
36 | #[yaserde(rename = "Number")]
37 | pub number: Option,
38 | // /// 卷序号
39 | // #[yaserde(rename = "Volume")]
40 | // pub volume: Option,
41 | // /// 如果值为Special,则该章节会被Kavita视为特刊
42 | // #[yaserde(rename = "Format")]
43 | // pub format: Option,
44 | // /// 该章节的有多少页
45 | // #[yaserde(rename = "PageCount")]
46 | // pub page_count: i64,
47 | /// 章节总数
48 | /// - `0` => Ongoing
49 | /// - `非零`且与`Number`或`Volume`一致 => Completed
50 | /// - `其他非零值` => Ended
51 | #[yaserde(rename = "Count")]
52 | pub count: i64,
53 | }
54 | impl ComicInfo {
55 | #[allow(clippy::cast_possible_wrap)]
56 | pub fn from(comic: &Comic, chapter_info: &ChapterInfo) -> ComicInfo {
57 | let number = Some(chapter_info.order.to_string());
58 |
59 | let count = comic.chapter_infos.len() as i64;
60 |
61 | ComicInfo {
62 | manga: "Yes".to_string(),
63 | series: comic.name.clone(),
64 | publisher: "禁漫天堂".to_string(),
65 | writer: comic.author.join(", "),
66 | tags: comic.tags.join(", "),
67 | summary: comic.description.clone(),
68 | title: chapter_info.chapter_title.clone(),
69 | number,
70 | count,
71 | }
72 | }
73 | }
74 |
--------------------------------------------------------------------------------
/src-tauri/src/types/download_format.rs:
--------------------------------------------------------------------------------
1 | use serde::{Deserialize, Serialize};
2 | use specta::Type;
3 |
4 | #[derive(Debug, Default, Copy, Clone, PartialEq, Serialize, Deserialize, Type)]
5 | pub enum DownloadFormat {
6 | #[default]
7 | Jpeg,
8 | Png,
9 | Webp,
10 | }
11 |
12 | impl DownloadFormat {
13 | pub fn as_str(self) -> &'static str {
14 | match self {
15 | DownloadFormat::Jpeg => "jpg",
16 | DownloadFormat::Png => "png",
17 | DownloadFormat::Webp => "webp",
18 | }
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/src-tauri/src/types/favorite_sort.rs:
--------------------------------------------------------------------------------
1 | use serde::{Deserialize, Serialize};
2 | use specta::Type;
3 |
4 | #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
5 | pub enum FavoriteSort {
6 | FavoriteTime,
7 | UpdateTime,
8 | }
9 |
10 | impl FavoriteSort {
11 | pub fn as_str(&self) -> &'static str {
12 | match self {
13 | FavoriteSort::FavoriteTime => "mr",
14 | FavoriteSort::UpdateTime => "mp",
15 | }
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/src-tauri/src/types/get_favorite_result.rs:
--------------------------------------------------------------------------------
1 | use anyhow::Context;
2 | use serde::{Deserialize, Serialize};
3 | use specta::Type;
4 | use tauri::AppHandle;
5 |
6 | use crate::responses::{
7 | CategoryRespData, CategorySubRespData, ComicInFavoriteRespData, FavoriteFolderRespData,
8 | GetFavoriteRespData,
9 | };
10 |
11 | use super::Comic;
12 |
13 | #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
14 | #[serde(rename_all = "camelCase")]
15 | pub struct GetFavoriteResult {
16 | pub list: Vec,
17 | pub folder_list: Vec,
18 | pub total: i64,
19 | pub count: i64,
20 | }
21 |
22 | impl GetFavoriteResult {
23 | pub fn from_resp_data(
24 | app: &AppHandle,
25 | resp_data: GetFavoriteRespData,
26 | ) -> anyhow::Result {
27 | let list = resp_data
28 | .list
29 | .into_iter()
30 | .map(|comic| ComicInFavorite::from_resp_data(app, comic))
31 | .collect::>()?;
32 |
33 | let total: i64 = resp_data.total.parse().context("将total解析为i64失败")?;
34 |
35 | let get_favorite_result = GetFavoriteResult {
36 | list,
37 | folder_list: resp_data.folder_list,
38 | total,
39 | count: resp_data.count,
40 | };
41 |
42 | Ok(get_favorite_result)
43 | }
44 | }
45 |
46 | #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
47 | #[serde(rename_all = "camelCase")]
48 | pub struct ComicInFavorite {
49 | pub id: i64,
50 | pub author: String,
51 | pub description: Option,
52 | pub name: String,
53 | pub latest_ep: Option,
54 | pub latest_ep_aid: Option,
55 | pub image: String,
56 | pub category: CategoryRespData,
57 | pub category_sub: CategorySubRespData,
58 | pub is_downloaded: bool,
59 | }
60 |
61 | impl ComicInFavorite {
62 | pub fn from_resp_data(
63 | app: &AppHandle,
64 | resp_data: ComicInFavoriteRespData,
65 | ) -> anyhow::Result {
66 | let id: i64 = resp_data.id.parse().context("将id解析为i64失败")?;
67 | let is_downloaded = Comic::get_is_downloaded(app, &resp_data.name);
68 |
69 | let comic = ComicInFavorite {
70 | id,
71 | author: resp_data.author,
72 | description: resp_data.description,
73 | name: resp_data.name,
74 | latest_ep: resp_data.latest_ep,
75 | latest_ep_aid: resp_data.latest_ep_aid,
76 | image: resp_data.image,
77 | category: resp_data.category,
78 | category_sub: resp_data.category_sub,
79 | is_downloaded,
80 | };
81 |
82 | Ok(comic)
83 | }
84 | }
85 |
--------------------------------------------------------------------------------
/src-tauri/src/types/log_level.rs:
--------------------------------------------------------------------------------
1 | use serde::{Deserialize, Serialize};
2 | use specta::Type;
3 |
4 | #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
5 | pub enum LogLevel {
6 | #[serde(rename = "TRACE")]
7 | Trace,
8 | #[serde(rename = "DEBUG")]
9 | Debug,
10 | #[serde(rename = "INFO")]
11 | Info,
12 | #[serde(rename = "WARN")]
13 | Warn,
14 | #[serde(rename = "ERROR")]
15 | Error,
16 | }
17 |
--------------------------------------------------------------------------------
/src-tauri/src/types/mod.rs:
--------------------------------------------------------------------------------
1 | mod chapter_info;
2 | mod comic;
3 | mod comic_info;
4 | mod download_format;
5 | mod favorite_sort;
6 | mod get_favorite_result;
7 | mod log_level;
8 | mod proxy_mode;
9 | mod search_result;
10 | mod search_sort;
11 |
12 | pub use chapter_info::*;
13 | pub use comic::*;
14 | pub use comic_info::*;
15 | pub use download_format::*;
16 | pub use favorite_sort::*;
17 | pub use get_favorite_result::*;
18 | pub use log_level::*;
19 | pub use proxy_mode::*;
20 | pub use search_result::*;
21 | pub use search_sort::*;
22 |
--------------------------------------------------------------------------------
/src-tauri/src/types/proxy_mode.rs:
--------------------------------------------------------------------------------
1 | use serde::{Deserialize, Serialize};
2 | use specta::Type;
3 |
4 | #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
5 | pub enum ProxyMode {
6 | #[default]
7 | System,
8 | NoProxy,
9 | Custom,
10 | }
11 |
--------------------------------------------------------------------------------
/src-tauri/src/types/search_result.rs:
--------------------------------------------------------------------------------
1 | use anyhow::Context;
2 | use serde::{Deserialize, Serialize};
3 | use specta::Type;
4 | use tauri::AppHandle;
5 |
6 | use crate::responses::{
7 | CategoryRespData, CategorySubRespData, ComicInSearchRespData, SearchResp, SearchRespData,
8 | };
9 |
10 | use super::Comic;
11 |
12 | #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
13 | pub enum SearchResultVariant {
14 | SearchResult(SearchResult),
15 | // 用Box包装Comic,因为Comic比SearchResult大得多
16 | // 如果不用Box包装,即使SearchResult的类型是SearchResult,也会占用与Comic一样大的内存
17 | Comic(Box),
18 | }
19 |
20 | impl SearchResultVariant {
21 | pub fn from_search_resp(
22 | app: &AppHandle,
23 | search_resp: SearchResp,
24 | ) -> anyhow::Result {
25 | match search_resp {
26 | SearchResp::SearchRespData(search_resp_data) => {
27 | let search_result = SearchResult::from_resp_data(app, search_resp_data)?;
28 | Ok(SearchResultVariant::SearchResult(search_result))
29 | }
30 | SearchResp::ComicRespData(get_comic_resp) => {
31 | let comic = Comic::from_comic_resp_data(app, *get_comic_resp);
32 | Ok(SearchResultVariant::Comic(Box::new(comic)))
33 | }
34 | }
35 | }
36 | }
37 |
38 | #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
39 | #[serde(rename_all = "camelCase")]
40 | pub struct SearchResult {
41 | pub search_query: String,
42 | pub total: i64,
43 | pub content: Vec,
44 | }
45 |
46 | impl SearchResult {
47 | pub fn from_resp_data(
48 | app: &AppHandle,
49 | search_resp_data: SearchRespData,
50 | ) -> anyhow::Result {
51 | let content = search_resp_data
52 | .content
53 | .into_iter()
54 | .map(|comic| ComicInSearch::from_resp_data(app, comic))
55 | .collect::>()?;
56 |
57 | let search_result = SearchResult {
58 | search_query: search_resp_data.search_query,
59 | total: search_resp_data.total,
60 | content,
61 | };
62 |
63 | Ok(search_result)
64 | }
65 | }
66 |
67 | #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
68 | #[serde(rename_all = "camelCase")]
69 | pub struct ComicInSearch {
70 | pub id: i64,
71 | pub author: String,
72 | pub name: String,
73 | pub image: String,
74 | pub category: CategoryRespData,
75 | pub category_sub: CategorySubRespData,
76 | pub liked: bool,
77 | pub is_favorite: bool,
78 | pub update_at: i64,
79 | pub is_downloaded: bool,
80 | }
81 |
82 | impl ComicInSearch {
83 | pub fn from_resp_data(
84 | app: &AppHandle,
85 | resp_data: ComicInSearchRespData,
86 | ) -> anyhow::Result {
87 | let id: i64 = resp_data.id.parse().context("将id解析为i64失败")?;
88 | let is_downloaded = Comic::get_is_downloaded(app, &resp_data.name);
89 |
90 | let comic = ComicInSearch {
91 | id,
92 | author: resp_data.author,
93 | name: resp_data.name,
94 | image: resp_data.image,
95 | category: resp_data.category,
96 | category_sub: resp_data.category_sub,
97 | liked: resp_data.liked,
98 | is_favorite: resp_data.is_favorite,
99 | update_at: resp_data.update_at,
100 | is_downloaded,
101 | };
102 |
103 | Ok(comic)
104 | }
105 | }
106 |
--------------------------------------------------------------------------------
/src-tauri/src/types/search_sort.rs:
--------------------------------------------------------------------------------
1 | use serde::{Deserialize, Serialize};
2 | use specta::Type;
3 |
4 | #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
5 | pub enum SearchSort {
6 | Latest,
7 | View,
8 | Picture,
9 | Like,
10 | }
11 |
12 | impl SearchSort {
13 | pub fn as_str(&self) -> &'static str {
14 | match self {
15 | SearchSort::Latest => "mr",
16 | SearchSort::View => "mv",
17 | SearchSort::Picture => "mp",
18 | SearchSort::Like => "tf",
19 | }
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/src-tauri/src/utils.rs:
--------------------------------------------------------------------------------
1 | pub fn filename_filter(s: &str) -> String {
2 | s.chars()
3 | .map(|c| match c {
4 | '\\' | '/' | '\n' => ' ',
5 | ':' => ':',
6 | '*' => '⭐',
7 | '?' => '?',
8 | '"' => '\'',
9 | '<' => '《',
10 | '>' => '》',
11 | '|' => '丨',
12 | _ => c,
13 | })
14 | .collect::()
15 | .trim()
16 | .to_string()
17 | }
18 |
19 | // 计算MD5哈希并返回十六进制字符串
20 | pub fn md5_hex(data: &str) -> String {
21 | format!("{:x}", md5::compute(data))
22 | }
23 |
--------------------------------------------------------------------------------
/src-tauri/tauri.conf.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://schema.tauri.app/config/2.0.0-rc",
3 | "productName": "jmcomic-downloader",
4 | "version": "0.15.3",
5 | "identifier": "com.lanyeeee.jmcomic-downloader",
6 | "build": {
7 | "beforeDevCommand": "pnpm dev",
8 | "devUrl": "http://localhost:5005",
9 | "beforeBuildCommand": "pnpm build",
10 | "frontendDist": "../dist"
11 | },
12 | "app": {
13 | "windows": [
14 | {
15 | "title": "禁漫天堂下载器",
16 | "width": 800,
17 | "height": 600
18 | }
19 | ],
20 | "security": {
21 | "csp": null
22 | }
23 | },
24 | "bundle": {
25 | "active": true,
26 | "targets": "all",
27 | "licenseFile": "../LICENSE",
28 | "icon": [
29 | "icons/32x32.png",
30 | "icons/128x128.png",
31 | "icons/128x128@2x.png",
32 | "icons/icon.icns",
33 | "icons/icon.ico"
34 | ],
35 | "windows": {
36 | "nsis": {
37 | "installMode": "perMachine",
38 | "languages": [
39 | "SimpChinese"
40 | ]
41 | }
42 | }
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/src/App.vue:
--------------------------------------------------------------------------------
1 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
--------------------------------------------------------------------------------
/src/AppContent.vue:
--------------------------------------------------------------------------------
1 |
89 |
90 |
91 |
92 |
93 |
94 |
95 |
96 |
97 |
98 |
99 |
100 |
101 |
102 |
103 |
104 |
105 |
106 |
107 |
108 |
109 |
110 |
111 |
112 |
113 |
114 | 登录
115 |
116 |
117 |
118 |
119 |
120 |
121 |
122 | 日志
123 |
124 |
125 |
126 |
127 |
128 |
129 |
130 | 关于
131 |
132 |
133 |
139 |
140 | {{ store.userProfile.username }}
141 |
142 |
143 |
144 |
145 |
146 |
147 |
148 |
149 |
150 |
151 |
152 |
165 |
--------------------------------------------------------------------------------
/src/assets/vue.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/bindings.ts:
--------------------------------------------------------------------------------
1 | // @ts-nocheck
2 | // This file was generated by [tauri-specta](https://github.com/oscartbeaumont/tauri-specta). Do not edit this file manually.
3 |
4 | /** user-defined commands **/
5 |
6 |
7 | export const commands = {
8 | async greet(name: string) : Promise {
9 | return await TAURI_INVOKE("greet", { name });
10 | },
11 | async getConfig() : Promise {
12 | return await TAURI_INVOKE("get_config");
13 | },
14 | async saveConfig(config: Config) : Promise> {
15 | try {
16 | return { status: "ok", data: await TAURI_INVOKE("save_config", { config }) };
17 | } catch (e) {
18 | if(e instanceof Error) throw e;
19 | else return { status: "error", error: e as any };
20 | }
21 | },
22 | async login(username: string, password: string) : Promise> {
23 | try {
24 | return { status: "ok", data: await TAURI_INVOKE("login", { username, password }) };
25 | } catch (e) {
26 | if(e instanceof Error) throw e;
27 | else return { status: "error", error: e as any };
28 | }
29 | },
30 | async search(keyword: string, page: number, sort: SearchSort) : Promise> {
31 | try {
32 | return { status: "ok", data: await TAURI_INVOKE("search", { keyword, page, sort }) };
33 | } catch (e) {
34 | if(e instanceof Error) throw e;
35 | else return { status: "error", error: e as any };
36 | }
37 | },
38 | async getComic(aid: number) : Promise> {
39 | try {
40 | return { status: "ok", data: await TAURI_INVOKE("get_comic", { aid }) };
41 | } catch (e) {
42 | if(e instanceof Error) throw e;
43 | else return { status: "error", error: e as any };
44 | }
45 | },
46 | async getFavoriteFolder(folderId: number, page: number, sort: FavoriteSort) : Promise> {
47 | try {
48 | return { status: "ok", data: await TAURI_INVOKE("get_favorite_folder", { folderId, page, sort }) };
49 | } catch (e) {
50 | if(e instanceof Error) throw e;
51 | else return { status: "error", error: e as any };
52 | }
53 | },
54 | async getUserProfile() : Promise> {
55 | try {
56 | return { status: "ok", data: await TAURI_INVOKE("get_user_profile") };
57 | } catch (e) {
58 | if(e instanceof Error) throw e;
59 | else return { status: "error", error: e as any };
60 | }
61 | },
62 | async createDownloadTask(comic: Comic, chapterId: number) : Promise> {
63 | try {
64 | return { status: "ok", data: await TAURI_INVOKE("create_download_task", { comic, chapterId }) };
65 | } catch (e) {
66 | if(e instanceof Error) throw e;
67 | else return { status: "error", error: e as any };
68 | }
69 | },
70 | async pauseDownloadTask(chapterId: number) : Promise> {
71 | try {
72 | return { status: "ok", data: await TAURI_INVOKE("pause_download_task", { chapterId }) };
73 | } catch (e) {
74 | if(e instanceof Error) throw e;
75 | else return { status: "error", error: e as any };
76 | }
77 | },
78 | async resumeDownloadTask(chapterId: number) : Promise> {
79 | try {
80 | return { status: "ok", data: await TAURI_INVOKE("resume_download_task", { chapterId }) };
81 | } catch (e) {
82 | if(e instanceof Error) throw e;
83 | else return { status: "error", error: e as any };
84 | }
85 | },
86 | async cancelDownloadTask(chapterId: number) : Promise> {
87 | try {
88 | return { status: "ok", data: await TAURI_INVOKE("cancel_download_task", { chapterId }) };
89 | } catch (e) {
90 | if(e instanceof Error) throw e;
91 | else return { status: "error", error: e as any };
92 | }
93 | },
94 | async downloadComic(aid: number) : Promise> {
95 | try {
96 | return { status: "ok", data: await TAURI_INVOKE("download_comic", { aid }) };
97 | } catch (e) {
98 | if(e instanceof Error) throw e;
99 | else return { status: "error", error: e as any };
100 | }
101 | },
102 | async updateDownloadedFavoriteComic() : Promise> {
103 | try {
104 | return { status: "ok", data: await TAURI_INVOKE("update_downloaded_favorite_comic") };
105 | } catch (e) {
106 | if(e instanceof Error) throw e;
107 | else return { status: "error", error: e as any };
108 | }
109 | },
110 | async showPathInFileManager(path: string) : Promise> {
111 | try {
112 | return { status: "ok", data: await TAURI_INVOKE("show_path_in_file_manager", { path }) };
113 | } catch (e) {
114 | if(e instanceof Error) throw e;
115 | else return { status: "error", error: e as any };
116 | }
117 | },
118 | async showComicDownloadDirInFileManager(comicTitle: string) : Promise> {
119 | try {
120 | return { status: "ok", data: await TAURI_INVOKE("show_comic_download_dir_in_file_manager", { comicTitle }) };
121 | } catch (e) {
122 | if(e instanceof Error) throw e;
123 | else return { status: "error", error: e as any };
124 | }
125 | },
126 | async syncFavoriteFolder() : Promise> {
127 | try {
128 | return { status: "ok", data: await TAURI_INVOKE("sync_favorite_folder") };
129 | } catch (e) {
130 | if(e instanceof Error) throw e;
131 | else return { status: "error", error: e as any };
132 | }
133 | },
134 | async saveMetadata(comic: Comic) : Promise> {
135 | try {
136 | return { status: "ok", data: await TAURI_INVOKE("save_metadata", { comic }) };
137 | } catch (e) {
138 | if(e instanceof Error) throw e;
139 | else return { status: "error", error: e as any };
140 | }
141 | },
142 | async getDownloadedComics() : Promise> {
143 | try {
144 | return { status: "ok", data: await TAURI_INVOKE("get_downloaded_comics") };
145 | } catch (e) {
146 | if(e instanceof Error) throw e;
147 | else return { status: "error", error: e as any };
148 | }
149 | },
150 | async exportCbz(comic: Comic) : Promise> {
151 | try {
152 | return { status: "ok", data: await TAURI_INVOKE("export_cbz", { comic }) };
153 | } catch (e) {
154 | if(e instanceof Error) throw e;
155 | else return { status: "error", error: e as any };
156 | }
157 | },
158 | async exportPdf(comic: Comic) : Promise> {
159 | try {
160 | return { status: "ok", data: await TAURI_INVOKE("export_pdf", { comic }) };
161 | } catch (e) {
162 | if(e instanceof Error) throw e;
163 | else return { status: "error", error: e as any };
164 | }
165 | },
166 | async getLogsDirSize() : Promise> {
167 | try {
168 | return { status: "ok", data: await TAURI_INVOKE("get_logs_dir_size") };
169 | } catch (e) {
170 | if(e instanceof Error) throw e;
171 | else return { status: "error", error: e as any };
172 | }
173 | }
174 | }
175 |
176 | /** user-defined events **/
177 |
178 |
179 | export const events = __makeEvents__<{
180 | downloadSpeedEvent: DownloadSpeedEvent,
181 | downloadTaskEvent: DownloadTaskEvent,
182 | exportCbzEvent: ExportCbzEvent,
183 | exportPdfEvent: ExportPdfEvent,
184 | logEvent: LogEvent,
185 | updateDownloadedFavoriteComicEvent: UpdateDownloadedFavoriteComicEvent
186 | }>({
187 | downloadSpeedEvent: "download-speed-event",
188 | downloadTaskEvent: "download-task-event",
189 | exportCbzEvent: "export-cbz-event",
190 | exportPdfEvent: "export-pdf-event",
191 | logEvent: "log-event",
192 | updateDownloadedFavoriteComicEvent: "update-downloaded-favorite-comic-event"
193 | })
194 |
195 | /** user-defined constants **/
196 |
197 |
198 |
199 | /** user-defined types **/
200 |
201 | export type CategoryRespData = { id: string | null; title: string | null }
202 | export type CategorySubRespData = { id: string | null; title: string | null }
203 | export type ChapterInfo = { chapterId: number; chapterTitle: string; isDownloaded?: boolean | null; order: number }
204 | export type Comic = { id: number; name: string; addtime: string; description: string; total_views: string; likes: string; chapterInfos: ChapterInfo[]; series_id: string; comment_total: string; author: string[]; tags: string[]; works: string[]; actors: string[]; related_list: RelatedListRespData[]; liked: boolean; is_favorite: boolean; is_aids: boolean; isDownloaded?: boolean | null }
205 | export type ComicInFavorite = { id: number; author: string; description: string | null; name: string; latestEp: string | null; latestEpAid: string | null; image: string; category: CategoryRespData; categorySub: CategorySubRespData; isDownloaded: boolean }
206 | export type ComicInSearch = { id: number; author: string; name: string; image: string; category: CategoryRespData; categorySub: CategorySubRespData; liked: boolean; isFavorite: boolean; updateAt: number; isDownloaded: boolean }
207 | export type CommandError = { err_title: string; err_message: string }
208 | export type Config = { username: string; password: string; downloadDir: string; exportDir: string; downloadFormat: DownloadFormat; proxyMode: ProxyMode; proxyHost: string; proxyPort: number; enableFileLogger: boolean }
209 | export type DownloadFormat = "Jpeg" | "Png" | "Webp"
210 | export type DownloadSpeedEvent = { speed: string }
211 | export type DownloadTaskEvent = { event: "Create"; data: { state: DownloadTaskState; comic: Comic; chapterInfo: ChapterInfo; downloadedImgCount: number; totalImgCount: number } } | { event: "Update"; data: { chapterId: number; state: DownloadTaskState; downloadedImgCount: number; totalImgCount: number } }
212 | export type DownloadTaskState = "Pending" | "Downloading" | "Paused" | "Cancelled" | "Completed" | "Failed"
213 | export type ExportCbzEvent = { event: "Start"; data: { uuid: string; comicTitle: string; total: number } } | { event: "Progress"; data: { uuid: string; current: number } } | { event: "Error"; data: { uuid: string } } | { event: "End"; data: { uuid: string } }
214 | export type ExportPdfEvent = { event: "CreateStart"; data: { uuid: string; comicTitle: string; total: number } } | { event: "CreateProgress"; data: { uuid: string; current: number } } | { event: "CreateError"; data: { uuid: string } } | { event: "CreateEnd"; data: { uuid: string } } | { event: "MergeStart"; data: { uuid: string; comicTitle: string } } | { event: "MergeError"; data: { uuid: string } } | { event: "MergeEnd"; data: { uuid: string } }
215 | export type FavoriteFolderRespData = { FID: string; UID: string; name: string }
216 | export type FavoriteSort = "FavoriteTime" | "UpdateTime"
217 | export type GetFavoriteResult = { list: ComicInFavorite[]; folderList: FavoriteFolderRespData[]; total: number; count: number }
218 | export type GetUserProfileRespData = { uid: string; username: string; email: string; emailverified: string; photo: string; fname: string; gender: string; message: string | null; coin: number; album_favorites: number; s: string; level_name: string; level: number; nextLevelExp: number; exp: string; expPercent: number; album_favorites_max: number; ad_free: boolean; charge: string; jar: string; invitation_qrcode: string; invitation_url: string; invited_cnt: string }
219 | export type JsonValue = null | boolean | number | string | JsonValue[] | { [key in string]: JsonValue }
220 | export type LogEvent = { timestamp: string; level: LogLevel; fields: { [key in string]: JsonValue }; target: string; filename: string; line_number: number }
221 | export type LogLevel = "TRACE" | "DEBUG" | "INFO" | "WARN" | "ERROR"
222 | export type ProxyMode = "System" | "NoProxy" | "Custom"
223 | export type RelatedListRespData = { id: string; author: string; name: string; image: string }
224 | export type SearchResult = { searchQuery: string; total: number; content: ComicInSearch[] }
225 | export type SearchResultVariant = { SearchResult: SearchResult } | { Comic: Comic }
226 | export type SearchSort = "Latest" | "View" | "Picture" | "Like"
227 | export type UpdateDownloadedFavoriteComicEvent = { event: "GettingFolders" } | { event: "GettingComics"; data: { total: number } } | { event: "ComicGot"; data: { current: number; total: number } } | { event: "DownloadTaskCreated" }
228 |
229 | /** tauri-specta globals **/
230 |
231 | import {
232 | invoke as TAURI_INVOKE,
233 | Channel as TAURI_CHANNEL,
234 | } from "@tauri-apps/api/core";
235 | import * as TAURI_API_EVENT from "@tauri-apps/api/event";
236 | import { type WebviewWindow as __WebviewWindow__ } from "@tauri-apps/api/webviewWindow";
237 |
238 | type __EventObj__ = {
239 | listen: (
240 | cb: TAURI_API_EVENT.EventCallback,
241 | ) => ReturnType>;
242 | once: (
243 | cb: TAURI_API_EVENT.EventCallback,
244 | ) => ReturnType>;
245 | emit: null extends T
246 | ? (payload?: T) => ReturnType
247 | : (payload: T) => ReturnType;
248 | };
249 |
250 | export type Result =
251 | | { status: "ok"; data: T }
252 | | { status: "error"; error: E };
253 |
254 | function __makeEvents__>(
255 | mappings: Record,
256 | ) {
257 | return new Proxy(
258 | {} as unknown as {
259 | [K in keyof T]: __EventObj__ & {
260 | (handle: __WebviewWindow__): __EventObj__;
261 | };
262 | },
263 | {
264 | get: (_, event) => {
265 | const name = mappings[event as keyof T];
266 |
267 | return new Proxy((() => {}) as any, {
268 | apply: (_, __, [window]: [__WebviewWindow__]) => ({
269 | listen: (arg: any) => window.listen(name, arg),
270 | once: (arg: any) => window.once(name, arg),
271 | emit: (arg: any) => window.emit(name, arg),
272 | }),
273 | get: (_, command: keyof __EventObj__) => {
274 | switch (command) {
275 | case "listen":
276 | return (arg: any) => TAURI_API_EVENT.listen(name, arg);
277 | case "once":
278 | return (arg: any) => TAURI_API_EVENT.once(name, arg);
279 | case "emit":
280 | return (arg: any) => TAURI_API_EVENT.emit(name, arg);
281 | }
282 | },
283 | });
284 | },
285 | },
286 | );
287 | }
288 |
--------------------------------------------------------------------------------
/src/components/AboutDialog.vue:
--------------------------------------------------------------------------------
1 |
13 |
14 |
15 |
16 |
17 |
18 |
![icon]()
19 |
20 |
21 | 如果本项目对你有帮助,欢迎来
22 | GitHub
23 | 点个Star⭐支持!
24 |
25 |
你的支持是我持续更新维护的动力🙏
26 |
27 |
28 |
29 |
软件版本
30 |
v{{ version }}
31 |
32 |
33 | 开源地址
34 | GitHub
35 |
36 |
37 | 问题反馈
38 | GitHub Issues
39 |
40 |
41 |
42 |
43 | Copyright © 2024-2025
44 | lanyeeee
45 |
46 |
47 | Released under
48 | MIT License
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
--------------------------------------------------------------------------------
/src/components/ComicCard.vue:
--------------------------------------------------------------------------------
1 |
41 |
42 |
43 |
44 |
45 |
![]()
51 |
52 |
53 |
56 | {{ comicTitle }}
57 |
58 | 作者:{{ comicAuthor }}
59 | 分类:{{ comicCategory.title }} {{ comicCategorySub.title }}
60 |
61 |
62 |
63 | 打开下载目录
64 |
65 | 一键下载所有章节
66 |
67 |
68 |
69 |
70 |
71 |
--------------------------------------------------------------------------------
/src/components/CompletedProgresses.vue:
--------------------------------------------------------------------------------
1 |
16 |
17 |
18 |
19 |
23 |
24 | {{ comic.name }}
25 |
26 |
27 | {{ chapterInfo.chapterTitle }}
28 |
29 |
30 |
31 |
32 |
--------------------------------------------------------------------------------
/src/components/DownloadedComicCard.vue:
--------------------------------------------------------------------------------
1 |
39 |
40 |
41 |
42 |
43 |
![]()
49 |
50 |
53 | {{ comic.name }}
54 |
55 |
作者:{{ comic.author }}
56 |
57 | 打开下载目录
58 | 导出cbz
59 | 导出pdf
60 |
61 |
62 |
63 |
64 |
65 |
--------------------------------------------------------------------------------
/src/components/FloatLabelInput.vue:
--------------------------------------------------------------------------------
1 |
41 |
42 |
43 |
52 |
53 |
59 | {{ label }}
60 |
61 |
62 |
63 |
64 |
65 |
83 |
--------------------------------------------------------------------------------
/src/components/LogViewer.vue:
--------------------------------------------------------------------------------
1 |
137 |
138 |
139 |
140 |
145 |
146 |
147 |
148 |
149 |
150 |
151 |
152 | 打开日志目录
153 | 输出文件日志
154 |
155 |
156 |
157 |
158 |
165 |
166 |
167 | {{ formatedLog }}
168 |
169 |
170 |
171 |
172 |
173 | 清空日志浏览器
174 |
175 |
176 |
177 |
178 |
--------------------------------------------------------------------------------
/src/components/LoginDialog.vue:
--------------------------------------------------------------------------------
1 |
54 |
55 |
56 |
57 |
64 |
65 |
66 |
67 |
68 |
69 | 用户名和密码将以明文保存在配置文件中
70 |
71 | 记住我
72 |
73 |
74 |
75 | 清除配置文件中的用户名和密码
76 |
77 |
78 |
79 |
80 |
81 |
82 |
--------------------------------------------------------------------------------
/src/components/SettingsDialog.vue:
--------------------------------------------------------------------------------
1 |
25 |
26 |
27 |
28 |
29 |
30 |
31 | 下载格式:
32 |
33 |
34 | jpg
35 |
36 | 1. 有损
37 | (肉眼看不出)
38 |
39 | 2. 文件体积小
40 |
41 | 4. 宽高的上限为65534
42 | (某些条漫可能会超过这个上限导致报错)
43 |
44 | 3. 编码速度最快
45 |
46 |
47 |
48 |
49 | png
50 |
51 | 1. 无损
52 |
53 | 2. 文件体积大
54 | (约为jpg的5倍)
55 |
56 | 3. 编码速度最慢
57 |
58 |
59 |
60 |
61 | webp
62 |
63 | 1. 无损
64 |
65 | 2. 这是jm图片原本的格式
66 |
67 | 3. 文件体积大
68 | (约为jpg的4倍)
69 |
70 | 4. 宽高的上限为16383
71 | (某些条漫可能会超过这个上限导致报错)
72 |
73 | 5. 编码速度较慢
74 |
75 |
76 |
77 |
78 | 代理类型:
79 | 系统代理
80 | 直连
81 | 自定义
82 |
83 |
84 | http://
85 |
92 | :
93 |
99 |
100 | 打开配置目录
101 |
102 |
103 |
104 |
105 |
--------------------------------------------------------------------------------
/src/components/UncompletedProgresses.vue:
--------------------------------------------------------------------------------
1 |
259 |
260 |
261 |
268 | 左键拖动进行框选,右键打开菜单,双击暂停/继续
269 |
270 |
handleProgressDoubleClick(state, chapterId)"
280 | @contextmenu="() => handleProgressContextMenu(chapterId)">
281 |
282 |
283 | {{ comic.name }}
284 |
285 |
286 | {{ chapterInfo.chapterTitle }}
287 |
288 |
289 |
290 |
291 |
292 |
293 |
294 |
295 |
296 |
{{ indicator }}
297 |
303 | {{ indicator }}
304 |
305 |
306 |
307 |
308 |
316 |
317 |
318 |
319 |
332 |
--------------------------------------------------------------------------------
/src/main.ts:
--------------------------------------------------------------------------------
1 | import { createApp } from 'vue'
2 | import { createPinia } from 'pinia'
3 | import App from './App.vue'
4 | import 'virtual:uno.css'
5 |
6 | const pinia = createPinia()
7 | const app = createApp(App)
8 |
9 | app.use(pinia).mount('#app')
10 |
--------------------------------------------------------------------------------
/src/panes/ChapterPane.vue:
--------------------------------------------------------------------------------
1 |
195 |
196 |
197 |
198 |
199 | 左键拖动进行框选,右键打开菜单
200 | 刷新
201 | 下载勾选章节
202 |
203 |
204 |
212 |
213 |
226 |
227 |
228 |
229 |
230 |
![]()
235 |
236 |
237 | {{ store.pickedComic.name }}
238 | 作者:{{ store.pickedComic.author }}
239 | 标签:{{ store.pickedComic.tags }}
240 |
245 | 打开下载目录
246 |
247 |
248 |
249 |
250 |
251 |
259 |
260 |
261 |
262 |
287 |
--------------------------------------------------------------------------------
/src/panes/DownloadedPane.vue:
--------------------------------------------------------------------------------
1 |
233 |
234 |
235 |
236 |
237 | 导出目录
238 |
239 |
240 |
241 |
242 |
243 |
244 |
245 |
246 |
247 |
248 |
249 |
250 |
255 |
256 |
257 |
--------------------------------------------------------------------------------
/src/panes/DownloadingPane.vue:
--------------------------------------------------------------------------------
1 |
117 |
118 |
119 |
120 |
121 |
122 | 下载目录
123 |
124 |
125 |
126 |
127 |
128 |
129 |
130 |
131 |
132 |
133 |
134 |
135 |
136 |
137 |
138 | 配置
139 |
140 |
141 |
142 |
143 |
144 |
145 |
146 |
147 |
148 |
149 |
下载速度:{{ downloadSpeed }}
150 |
151 |
152 |
153 |
--------------------------------------------------------------------------------
/src/panes/FavoritePane.vue:
--------------------------------------------------------------------------------
1 |
104 |
105 |
106 |
107 |
108 |
114 |
120 | 更新漫画
121 | 收藏不对点我
122 |
123 |
124 |
125 |
134 |
135 |
136 |
141 |
142 |
143 |
--------------------------------------------------------------------------------
/src/panes/SearchPane.vue:
--------------------------------------------------------------------------------
1 |
72 |
73 |
74 |
75 |
76 |
82 |
89 |
95 |
96 |
97 |
98 |
99 |
100 |
101 |
102 |
103 |
104 |
113 |
114 |
115 |
121 |
122 |
123 |
--------------------------------------------------------------------------------
/src/store.ts:
--------------------------------------------------------------------------------
1 | import { defineStore } from 'pinia'
2 | import { CurrentTabName, ProgressData } from './types.ts'
3 | import { Comic, Config, GetFavoriteResult, GetUserProfileRespData, SearchResult } from './bindings.ts'
4 | import { ref } from 'vue'
5 |
6 | export const useStore = defineStore('store', () => {
7 | const config = ref()
8 | const userProfile = ref()
9 | const pickedComic = ref()
10 | const currentTabName = ref('search')
11 | const progresses = ref