├── .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 | ![image](https://github.com/user-attachments/assets/2ec6e5f9-a211-4325-8671-0a15f4bcba6c) 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 | [![Readme Card](https://github-readme-stats.vercel.app/api/pin/?username=lanyeeee&repo=picacomic-downloader)](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 | 2 | 3 | 4 | 5 | 6 | 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 | 28 | -------------------------------------------------------------------------------- /src/AppContent.vue: -------------------------------------------------------------------------------- 1 | 89 | 90 | 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 | 56 | -------------------------------------------------------------------------------- /src/components/ComicCard.vue: -------------------------------------------------------------------------------- 1 | 41 | 42 | 71 | -------------------------------------------------------------------------------- /src/components/CompletedProgresses.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 32 | -------------------------------------------------------------------------------- /src/components/DownloadedComicCard.vue: -------------------------------------------------------------------------------- 1 | 39 | 40 | 65 | -------------------------------------------------------------------------------- /src/components/FloatLabelInput.vue: -------------------------------------------------------------------------------- 1 | 41 | 42 | 64 | 65 | 83 | -------------------------------------------------------------------------------- /src/components/LogViewer.vue: -------------------------------------------------------------------------------- 1 | 137 | 138 | 178 | -------------------------------------------------------------------------------- /src/components/LoginDialog.vue: -------------------------------------------------------------------------------- 1 | 54 | 55 | 82 | -------------------------------------------------------------------------------- /src/components/SettingsDialog.vue: -------------------------------------------------------------------------------- 1 | 25 | 26 | 105 | -------------------------------------------------------------------------------- /src/components/UncompletedProgresses.vue: -------------------------------------------------------------------------------- 1 | 259 | 260 | 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 | 261 | 262 | 287 | -------------------------------------------------------------------------------- /src/panes/DownloadedPane.vue: -------------------------------------------------------------------------------- 1 | 233 | 234 | 257 | -------------------------------------------------------------------------------- /src/panes/DownloadingPane.vue: -------------------------------------------------------------------------------- 1 | 117 | 118 | 153 | -------------------------------------------------------------------------------- /src/panes/FavoritePane.vue: -------------------------------------------------------------------------------- 1 | 104 | 105 | 143 | -------------------------------------------------------------------------------- /src/panes/SearchPane.vue: -------------------------------------------------------------------------------- 1 | 72 | 73 | 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>(new Map()) 12 | const getFavoriteResult = ref() 13 | const searchResult = ref() 14 | 15 | return { config, userProfile, pickedComic, currentTabName, progresses, getFavoriteResult, searchResult } 16 | }) 17 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import { DownloadTaskEvent } from './bindings.ts' 2 | 3 | export type CurrentTabName = 'search' | 'favorite' | 'downloaded' | 'chapter' 4 | 5 | export type ProgressData = Extract['data'] & { 6 | percentage: number 7 | indicator: string 8 | } 9 | -------------------------------------------------------------------------------- /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | declare module '*.vue' { 4 | import type { DefineComponent } from 'vue' 5 | const component: DefineComponent<{}, {}, any> 6 | export default component 7 | } 8 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "module": "ESNext", 6 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "bundler", 11 | "allowImportingTsExtensions": true, 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "noEmit": true, 15 | "jsx": "preserve", 16 | "jsxImportSource": "vue", 17 | /* Linting */ 18 | "strict": true, 19 | "noUnusedLocals": true, 20 | "noUnusedParameters": true, 21 | "noFallthroughCasesInSwitch": true 22 | }, 23 | "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"], 24 | "references": [{ "path": "./tsconfig.node.json" }] 25 | } 26 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "skipLibCheck": true, 5 | "module": "ESNext", 6 | "moduleResolution": "bundler", 7 | "allowSyntheticDefaultImports": true 8 | }, 9 | "include": ["vite.config.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /uno.config.ts: -------------------------------------------------------------------------------- 1 | import { 2 | defineConfig, 3 | presetAttributify, 4 | presetIcons, 5 | presetTypography, 6 | presetUno, 7 | presetWebFonts, 8 | transformerDirectives, 9 | transformerVariantGroup 10 | } from 'unocss' 11 | 12 | export default defineConfig({ 13 | shortcuts: [ 14 | // ... 15 | ], 16 | theme: { 17 | colors: { 18 | // ... 19 | } 20 | }, 21 | presets: [ 22 | presetUno(), 23 | presetAttributify(), 24 | presetIcons(), 25 | presetTypography(), 26 | presetWebFonts({ 27 | fonts: { 28 | // ... 29 | }, 30 | }), 31 | ], 32 | transformers: [ 33 | transformerDirectives(), 34 | transformerVariantGroup(), 35 | ], 36 | }) -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import vue from '@vitejs/plugin-vue' 3 | import AutoImport from 'unplugin-auto-import/vite' 4 | import Components from 'unplugin-vue-components/vite' 5 | import { NaiveUiResolver } from 'unplugin-vue-components/resolvers' 6 | import UnoCSS from 'unocss/vite' 7 | import vueJsx from '@vitejs/plugin-vue-jsx' 8 | import vueDevTools from 'vite-plugin-vue-devtools' 9 | 10 | // @ts-expect-error process is a nodejs global 11 | const host = process.env.TAURI_DEV_HOST 12 | 13 | // https://vitejs.dev/config/ 14 | export default defineConfig(async () => ({ 15 | plugins: [ 16 | vue(), 17 | UnoCSS(), 18 | vueJsx(), 19 | vueDevTools(), 20 | AutoImport({ 21 | imports: [ 22 | 'vue', 23 | { 24 | 'naive-ui': ['useDialog', 'useMessage', 'useNotification', 'useLoadingBar'], 25 | }, 26 | ], 27 | }), 28 | Components({ 29 | resolvers: [NaiveUiResolver()], 30 | }), 31 | ], 32 | 33 | // Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build` 34 | // 35 | // 1. prevent vite from obscuring rust errors 36 | clearScreen: false, 37 | // 2. tauri expects a fixed port, fail if that port is not available 38 | server: { 39 | port: 5005, 40 | strictPort: true, 41 | host: host || false, 42 | hmr: host 43 | ? { 44 | protocol: 'ws', 45 | host, 46 | port: 1421, 47 | } 48 | : undefined, 49 | watch: { 50 | // 3. tell vite to ignore watching `src-tauri` 51 | ignored: ['**/src-tauri/**'], 52 | }, 53 | }, 54 | })) 55 | --------------------------------------------------------------------------------