├── .github ├── ISSUE_TEMPLATE │ ├── config.yml │ ├── feature_request.yml │ └── bug_report.yml └── workflows │ └── publish.yml ├── src-tauri ├── build.rs ├── icons │ ├── icon.ico │ ├── icon.png │ ├── 32x32.png │ ├── icon.icns │ ├── 128x128.png │ ├── StoreLogo.png │ ├── 128x128@2x.png │ ├── Square30x30Logo.png │ ├── Square44x44Logo.png │ ├── Square71x71Logo.png │ ├── Square89x89Logo.png │ ├── Square107x107Logo.png │ ├── Square142x142Logo.png │ ├── Square150x150Logo.png │ ├── Square284x284Logo.png │ └── Square310x310Logo.png ├── .gitignore ├── src │ ├── main.rs │ ├── types │ │ ├── proxy_mode.rs │ │ ├── check_update_result.rs │ │ ├── mod.rs │ │ ├── app_qrcode_data.rs │ │ ├── web_qrcode_data.rs │ │ ├── archive_format.rs │ │ ├── app_qrcode_status.rs │ │ └── comic.rs │ ├── responses │ │ ├── confirm_app_qrcode_resp_data.rs │ │ ├── generate_web_qrcode_resp_data.rs │ │ ├── user_profile_resp_data.rs │ │ ├── web_qrcode_status_resp_data.rs │ │ ├── image_token_resp_data.rs │ │ ├── mod.rs │ │ ├── image_index_resp_data.rs │ │ ├── search_resp_data.rs │ │ ├── github_releases_resp.rs │ │ └── comic_resp_data.rs │ ├── utils.rs │ ├── extensions.rs │ ├── errors.rs │ ├── config.rs │ ├── lib.rs │ ├── events.rs │ ├── commands.rs │ ├── download_manager.rs │ └── bili_client.rs ├── capabilities │ └── default.json ├── tauri.conf.json └── Cargo.toml ├── src ├── main.ts ├── types.ts ├── vite-env.d.ts ├── App.vue ├── assets │ └── vue.svg ├── components │ ├── CookieLoginDialog.vue │ ├── ComicCard.vue │ ├── SettingsDialog.vue │ ├── SearchPane.vue │ ├── DownloadingList.vue │ └── EpisodePane.vue ├── AppContent.vue └── bindings.ts ├── .vscode └── extensions.json ├── tsconfig.node.json ├── .gitignore ├── index.html ├── tsconfig.json ├── uno.config.ts ├── package.json ├── LICENSE ├── public ├── vite.svg └── tauri.svg ├── vite.config.ts ├── components.d.ts ├── README.md └── auto-imports.d.ts /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | -------------------------------------------------------------------------------- /src-tauri/build.rs: -------------------------------------------------------------------------------- 1 | fn main() { 2 | tauri_build::build() 3 | } 4 | -------------------------------------------------------------------------------- /src-tauri/icons/icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lanyeeee/bilibili-manga-downloader/HEAD/src-tauri/icons/icon.ico -------------------------------------------------------------------------------- /src-tauri/icons/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lanyeeee/bilibili-manga-downloader/HEAD/src-tauri/icons/icon.png -------------------------------------------------------------------------------- /src-tauri/icons/32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lanyeeee/bilibili-manga-downloader/HEAD/src-tauri/icons/32x32.png -------------------------------------------------------------------------------- /src-tauri/icons/icon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lanyeeee/bilibili-manga-downloader/HEAD/src-tauri/icons/icon.icns -------------------------------------------------------------------------------- /src-tauri/icons/128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lanyeeee/bilibili-manga-downloader/HEAD/src-tauri/icons/128x128.png -------------------------------------------------------------------------------- /src-tauri/icons/StoreLogo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lanyeeee/bilibili-manga-downloader/HEAD/src-tauri/icons/StoreLogo.png -------------------------------------------------------------------------------- /src-tauri/icons/128x128@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lanyeeee/bilibili-manga-downloader/HEAD/src-tauri/icons/128x128@2x.png -------------------------------------------------------------------------------- /src-tauri/icons/Square30x30Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lanyeeee/bilibili-manga-downloader/HEAD/src-tauri/icons/Square30x30Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square44x44Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lanyeeee/bilibili-manga-downloader/HEAD/src-tauri/icons/Square44x44Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square71x71Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lanyeeee/bilibili-manga-downloader/HEAD/src-tauri/icons/Square71x71Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square89x89Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lanyeeee/bilibili-manga-downloader/HEAD/src-tauri/icons/Square89x89Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square107x107Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lanyeeee/bilibili-manga-downloader/HEAD/src-tauri/icons/Square107x107Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square142x142Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lanyeeee/bilibili-manga-downloader/HEAD/src-tauri/icons/Square142x142Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square150x150Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lanyeeee/bilibili-manga-downloader/HEAD/src-tauri/icons/Square150x150Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square284x284Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lanyeeee/bilibili-manga-downloader/HEAD/src-tauri/icons/Square284x284Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square310x310Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lanyeeee/bilibili-manga-downloader/HEAD/src-tauri/icons/Square310x310Logo.png -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import {createApp} from "vue"; 2 | import App from "./App.vue"; 3 | import "virtual:uno.css"; 4 | 5 | createApp(App).mount("#app"); 6 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "Vue.volar", 4 | "tauri-apps.tauri-vscode", 5 | "rust-lang.rust-analyzer" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | export type ComicInfo = { 2 | id: number; 3 | title: string; 4 | author_name: string[]; 5 | styles: string[]; 6 | is_finish: number; 7 | vertical_cover: string; 8 | } 9 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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 | bilibili_manga_downloader_lib::run() 6 | } 7 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | NoProxy, 8 | System, 9 | Custom, 10 | } 11 | -------------------------------------------------------------------------------- /src-tauri/src/responses/confirm_app_qrcode_resp_data.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | use specta::Type; 3 | 4 | #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)] 5 | pub struct ConfirmAppQrcodeRespData { 6 | pub code: i64, 7 | #[serde(default, alias = "message")] 8 | pub msg: String, 9 | } 10 | -------------------------------------------------------------------------------- /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": [ 6 | "main" 7 | ], 8 | "permissions": [ 9 | "core:default", 10 | "shell:allow-open", 11 | "dialog:allow-open" 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /src-tauri/src/types/check_update_result.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | use specta::Type; 3 | 4 | #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Type)] 5 | #[serde(rename_all = "camelCase")] 6 | pub struct CheckUpdateResult { 7 | pub normal_versions: Vec, 8 | pub important_versions: Vec, 9 | } 10 | -------------------------------------------------------------------------------- /src-tauri/src/types/mod.rs: -------------------------------------------------------------------------------- 1 | mod archive_format; 2 | mod check_update_result; 3 | mod comic; 4 | mod proxy_mode; 5 | mod web_qrcode_data; 6 | 7 | pub use archive_format::*; 8 | pub use check_update_result::*; 9 | pub use comic::*; 10 | pub use proxy_mode::*; 11 | pub use web_qrcode_data::*; 12 | 13 | pub type AsyncRwLock = tokio::sync::RwLock; 14 | -------------------------------------------------------------------------------- /src-tauri/src/types/app_qrcode_data.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | use specta::Type; 3 | 4 | #[derive(Default, Debug, Clone, PartialEq, Deserialize, Serialize, Type)] 5 | #[serde(rename_all = "camelCase")] 6 | pub struct AppQrcodeData { 7 | pub base64: String, 8 | #[serde(rename = "auth_code")] 9 | pub auth_code: String, 10 | } 11 | -------------------------------------------------------------------------------- /src-tauri/src/types/web_qrcode_data.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | use specta::Type; 3 | 4 | #[derive(Default, Debug, Clone, PartialEq, Deserialize, Serialize, Type)] 5 | #[serde(rename_all = "camelCase")] 6 | pub struct WebQrcodeData { 7 | pub base64: String, 8 | #[serde(rename = "qrcodeKey")] 9 | pub qrcode_key: String, 10 | } 11 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /src/App.vue: -------------------------------------------------------------------------------- 1 | 5 | 6 | 15 | -------------------------------------------------------------------------------- /src-tauri/src/responses/generate_web_qrcode_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 GenerateWebQrcodeRespData { 7 | pub url: String, 8 | #[serde(rename = "qrcode_key")] 9 | pub qrcode_key: String, 10 | } 11 | -------------------------------------------------------------------------------- /src-tauri/src/responses/user_profile_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 UserProfileRespData { 7 | pub mid: u64, 8 | pub face: String, 9 | #[serde(alias = "uname")] 10 | pub name: String, 11 | } 12 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Tauri + Vue + Typescript App 8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /src-tauri/src/responses/web_qrcode_status_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 WebQrcodeStatusRespData { 7 | pub url: String, 8 | #[serde(rename = "refresh_token")] 9 | pub refresh_token: String, 10 | pub timestamp: i64, 11 | pub code: i64, 12 | pub message: String, 13 | } 14 | -------------------------------------------------------------------------------- /src/assets/vue.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src-tauri/src/utils.rs: -------------------------------------------------------------------------------- 1 | pub fn filename_filter(s: &str) -> String { 2 | s.chars() 3 | .map(|c| match c { 4 | '\\' | '/' => ' ', 5 | ':' => ':', 6 | '*' => '⭐', 7 | '?' => '?', 8 | '"' => '\'', 9 | '<' => '《', 10 | '>' => '》', 11 | '|' => '丨', 12 | '.' => '·', 13 | _ => c, 14 | }) 15 | .collect::() 16 | .trim() 17 | .to_string() 18 | } 19 | -------------------------------------------------------------------------------- /src-tauri/src/responses/image_token_resp_data.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | pub type ImageTokenRespData = Vec; 4 | 5 | #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] 6 | #[serde(rename_all = "camelCase")] 7 | pub struct ImageTokenItemRespData { 8 | #[serde(rename = "complete_url")] 9 | pub complete_url: String, 10 | #[serde(rename = "hit_encrpyt")] 11 | pub hit_encrpyt: bool, 12 | pub url: String, 13 | pub token: String, 14 | } 15 | -------------------------------------------------------------------------------- /src-tauri/src/types/archive_format.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | use specta::Type; 3 | 4 | #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)] 5 | pub enum ArchiveFormat { 6 | #[default] 7 | Image, 8 | Zip, 9 | Cbz, 10 | } 11 | impl ArchiveFormat { 12 | pub fn extension(&self) -> &str { 13 | match self { 14 | ArchiveFormat::Image => "", 15 | ArchiveFormat::Zip => "zip", 16 | ArchiveFormat::Cbz => "cbz", 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /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/components/CookieLoginDialog.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | -------------------------------------------------------------------------------- /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)] 9 | pub struct CommandError(String); 10 | impl Serialize for CommandError { 11 | fn serialize(&self, serializer: S) -> Result 12 | where 13 | S: serde::Serializer, 14 | { 15 | serializer.serialize_str(&format!("{:#}", self.0)) 16 | } 17 | } 18 | impl From for CommandError 19 | where 20 | E: Into, 21 | { 22 | fn from(err: E) -> Self { 23 | Self(err.into().to_string_chain()) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /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 | 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 | -------------------------------------------------------------------------------- /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 | }) -------------------------------------------------------------------------------- /.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: 该功能可能的实现方式,或者其他已经实现该功能的应用等 -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "bilibili-manga-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", 14 | "@tauri-apps/plugin-dialog": "^2.0.1", 15 | "@tauri-apps/plugin-shell": "^2", 16 | "@viselect/vue": "^3.6.0", 17 | "naive-ui": "^2.40.1", 18 | "unocss": "^0.63.6", 19 | "unplugin-auto-import": "^0.18.3", 20 | "unplugin-vue-components": "^0.27.4", 21 | "vue": "^3.3.4" 22 | }, 23 | "devDependencies": { 24 | "@tauri-apps/cli": "^2", 25 | "@vitejs/plugin-vue": "^5.0.5", 26 | "typescript": "^5.2.2", 27 | "vite": "^5.3.1", 28 | "vue-tsc": "^2.0.22" 29 | }, 30 | "packageManager": "pnpm@9.5.0" 31 | } 32 | -------------------------------------------------------------------------------- /src-tauri/src/responses/mod.rs: -------------------------------------------------------------------------------- 1 | mod comic_resp_data; 2 | mod confirm_app_qrcode_resp_data; 3 | mod generate_web_qrcode_resp_data; 4 | mod github_releases_resp; 5 | mod image_index_resp_data; 6 | mod image_token_resp_data; 7 | mod search_resp_data; 8 | mod user_profile_resp_data; 9 | mod web_qrcode_status_resp_data; 10 | 11 | pub use comic_resp_data::*; 12 | pub use generate_web_qrcode_resp_data::*; 13 | pub use github_releases_resp::*; 14 | pub use image_index_resp_data::*; 15 | pub use image_token_resp_data::*; 16 | pub use search_resp_data::*; 17 | pub use user_profile_resp_data::*; 18 | pub use web_qrcode_status_resp_data::*; 19 | 20 | use serde::{Deserialize, Serialize}; 21 | 22 | #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] 23 | #[serde(rename_all = "camelCase")] 24 | pub struct BiliResp { 25 | pub code: i64, 26 | #[serde(default, alias = "message")] 27 | pub msg: String, 28 | pub data: Option, 29 | } 30 | -------------------------------------------------------------------------------- /src-tauri/tauri.conf.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://schema.tauri.app/config/2", 3 | "productName": "bilibili-manga-downloader", 4 | "version": "0.11.3", 5 | "identifier": "com.lanyeeee.bilibili-manga-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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | name: 反馈 Bug 2 | description: 反馈遇到的问题 3 | labels: [bug] 4 | title: "[Bug] 修改我!" 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: | 9 | 为了使我更好地帮助你,请提供以下信息。以及修改上方的标题 10 | - type: textarea 11 | id: desc 12 | attributes: 13 | label: 问题描述 14 | description: 发生了什么情况?复现条件(哪部漫画、哪个章节)是什么?问题能稳定触发吗? 15 | validations: 16 | required: true 17 | - type: textarea 18 | id: expected 19 | attributes: 20 | label: 预期行为 21 | description: 正常情况下应该发生什么 22 | validations: 23 | required: true 24 | - type: textarea 25 | id: actual 26 | attributes: 27 | label: 实际行为 28 | description: 实际上发生了什么 29 | validations: 30 | required: true 31 | - type: textarea 32 | id: media 33 | attributes: 34 | label: 截图或录屏 35 | description: 问题复现时候的截图或录屏 36 | placeholder: 点击文本框下面小长条可以上传文件 37 | - type: input 38 | id: version 39 | attributes: 40 | label: 工具版本号 41 | placeholder: v0.1 42 | validations: 43 | required: true 44 | - type: textarea 45 | id: other 46 | attributes: 47 | label: 其他 48 | description: 其他要补充的内容 49 | placeholder: 其他要补充的内容 50 | validations: 51 | required: false 52 | -------------------------------------------------------------------------------- /public/vite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src-tauri/src/types/app_qrcode_status.rs: -------------------------------------------------------------------------------- 1 | use crate::responses::{AppQrcodeStatusRespData, BiliResp, CookieInfoRespData, TokenInfoRespData}; 2 | use serde::{Deserialize, Serialize}; 3 | use specta::Type; 4 | 5 | #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)] 6 | #[serde(rename_all = "camelCase")] 7 | pub struct AppQrcodeStatus { 8 | pub code: i64, 9 | pub message: String, 10 | #[serde(rename = "is_new")] 11 | pub is_new: bool, 12 | pub mid: i64, 13 | #[serde(rename = "access_token")] 14 | pub access_token: String, 15 | #[serde(rename = "refresh_token")] 16 | pub refresh_token: String, 17 | #[serde(rename = "expires_in")] 18 | pub expires_in: i64, 19 | #[serde(rename = "token_info")] 20 | pub token_info: TokenInfoRespData, 21 | #[serde(rename = "cookie_info")] 22 | pub cookie_info: CookieInfoRespData, 23 | pub sso: Vec, 24 | } 25 | impl AppQrcodeStatus { 26 | pub fn from(bili_resp: BiliResp, app_qrcode_status_resp_data: AppQrcodeStatusRespData) -> Self { 27 | Self { 28 | code: bili_resp.code, 29 | message: bili_resp.msg, 30 | is_new: app_qrcode_status_resp_data.is_new, 31 | mid: app_qrcode_status_resp_data.mid, 32 | access_token: app_qrcode_status_resp_data.access_token, 33 | refresh_token: app_qrcode_status_resp_data.refresh_token, 34 | expires_in: app_qrcode_status_resp_data.expires_in, 35 | token_info: app_qrcode_status_resp_data.token_info, 36 | cookie_info: app_qrcode_status_resp_data.cookie_info, 37 | sso: app_qrcode_status_resp_data.sso, 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src-tauri/src/responses/image_index_resp_data.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] 4 | #[serde(rename_all = "camelCase")] 5 | pub struct ImageIndexRespData { 6 | pub host: String, 7 | pub images: Vec, 8 | #[serde(rename = "last_modified")] 9 | pub last_modified: String, 10 | pub path: String, 11 | pub video: VideoRespData, 12 | } 13 | 14 | #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] 15 | #[serde(rename_all = "camelCase")] 16 | pub struct ImageRespData { 17 | pub path: String, 18 | #[serde(rename = "video_path")] 19 | pub video_path: String, 20 | #[serde(rename = "video_size")] 21 | pub video_size: String, 22 | pub x: i64, 23 | pub y: i64, 24 | } 25 | 26 | #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] 27 | #[serde(rename_all = "camelCase")] 28 | pub struct VideoRespData { 29 | #[serde(rename = "bin_url")] 30 | pub bin_url: String, 31 | pub filename: String, 32 | #[serde(rename = "img_urls")] 33 | pub img_urls: Vec, 34 | #[serde(rename = "img_x_len")] 35 | pub img_x_len: i64, 36 | #[serde(rename = "img_x_size")] 37 | pub img_x_size: i64, 38 | #[serde(rename = "img_y_len")] 39 | pub img_y_len: i64, 40 | #[serde(rename = "img_y_size")] 41 | pub img_y_size: i64, 42 | #[serde(rename = "raw_height")] 43 | pub raw_height: String, 44 | #[serde(rename = "raw_rotate")] 45 | pub raw_rotate: String, 46 | #[serde(rename = "raw_width")] 47 | pub raw_width: String, 48 | pub resource: Vec, 49 | pub route: String, 50 | pub svid: String, 51 | } 52 | -------------------------------------------------------------------------------- /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 | 8 | // @ts-expect-error process is a nodejs global 9 | const host = process.env.TAURI_DEV_HOST; 10 | 11 | // https://vitejs.dev/config/ 12 | export default defineConfig(async () => ({ 13 | plugins: [ 14 | vue(), 15 | UnoCSS(), 16 | AutoImport({ 17 | imports: [ 18 | "vue", 19 | { 20 | "naive-ui": [ 21 | "useDialog", 22 | "useMessage", 23 | "useNotification", 24 | "useLoadingBar" 25 | ] 26 | } 27 | ] 28 | }), 29 | Components({ 30 | resolvers: [NaiveUiResolver()] 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 | -------------------------------------------------------------------------------- /src/components/ComicCard.vue: -------------------------------------------------------------------------------- 1 | 26 | 27 | 47 | 48 | -------------------------------------------------------------------------------- /src-tauri/src/config.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | 3 | use crate::types::{ArchiveFormat, ProxyMode}; 4 | 5 | use serde::{Deserialize, Serialize}; 6 | use specta::Type; 7 | use tauri::{AppHandle, Manager}; 8 | 9 | #[derive(Debug, Clone, Serialize, Deserialize, Type)] 10 | #[serde(rename_all = "camelCase")] 11 | pub struct Config { 12 | pub cookie: String, 13 | pub download_dir: PathBuf, 14 | pub archive_format: ArchiveFormat, 15 | pub last_update_check_ts: i64, 16 | pub proxy_mode: ProxyMode, 17 | pub proxy_host: String, 18 | pub proxy_port: u16, 19 | } 20 | 21 | impl Config { 22 | pub fn new(app: &AppHandle) -> anyhow::Result { 23 | let app_data_dir = app.path().app_data_dir()?; 24 | let config_path = app_data_dir.join("config.json"); 25 | // TODO: 实现Default trait以替代这种写法 26 | let default_config = Config { 27 | cookie: String::new(), 28 | download_dir: app_data_dir.join("漫画下载"), 29 | archive_format: ArchiveFormat::default(), 30 | last_update_check_ts: 0, 31 | proxy_mode: ProxyMode::default(), 32 | proxy_host: String::new(), 33 | proxy_port: 7890, 34 | }; 35 | // 如果配置文件存在且能够解析,则使用配置文件中的配置,否则使用默认配置 36 | let config = if config_path.exists() { 37 | let config_string = std::fs::read_to_string(config_path)?; 38 | serde_json::from_str(&config_string).unwrap_or(default_config) 39 | } else { 40 | default_config 41 | }; 42 | config.save(app)?; 43 | Ok(config) 44 | } 45 | 46 | pub fn save(&self, app: &AppHandle) -> anyhow::Result<()> { 47 | let app_data_dir = app.path().app_data_dir()?; 48 | let config_path = app_data_dir.join("config.json"); 49 | let config_string = serde_json::to_string_pretty(self)?; 50 | std::fs::write(config_path, config_string)?; 51 | Ok(()) 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /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 | ComicCard: typeof import('./src/components/ComicCard.vue')['default'] 11 | CookieLoginDialog: typeof import('./src/components/CookieLoginDialog.vue')['default'] 12 | DownloadingList: typeof import('./src/components/DownloadingList.vue')['default'] 13 | EpisodePane: typeof import('./src/components/EpisodePane.vue')['default'] 14 | NAvatar: typeof import('naive-ui')['NAvatar'] 15 | NButton: typeof import('naive-ui')['NButton'] 16 | NCard: typeof import('naive-ui')['NCard'] 17 | NCheckbox: typeof import('naive-ui')['NCheckbox'] 18 | NCheckboxGroup: typeof import('naive-ui')['NCheckboxGroup'] 19 | NDialog: typeof import('naive-ui')['NDialog'] 20 | NDivider: typeof import('naive-ui')['NDivider'] 21 | NDropdown: typeof import('naive-ui')['NDropdown'] 22 | NEmpty: typeof import('naive-ui')['NEmpty'] 23 | NInput: typeof import('naive-ui')['NInput'] 24 | NInputNumber: typeof import('naive-ui')['NInputNumber'] 25 | NMessageProvider: typeof import('naive-ui')['NMessageProvider'] 26 | NModal: typeof import('naive-ui')['NModal'] 27 | NModalProvider: typeof import('naive-ui')['NModalProvider'] 28 | NNotificationProvider: typeof import('naive-ui')['NNotificationProvider'] 29 | NPagination: typeof import('naive-ui')['NPagination'] 30 | NProgress: typeof import('naive-ui')['NProgress'] 31 | NRadio: typeof import('naive-ui')['NRadio'] 32 | NRadioGroup: typeof import('naive-ui')['NRadioGroup'] 33 | NTabPane: typeof import('naive-ui')['NTabPane'] 34 | NTabs: typeof import('naive-ui')['NTabs'] 35 | SearchPane: typeof import('./src/components/SearchPane.vue')['default'] 36 | SettingsDialog: typeof import('./src/components/SettingsDialog.vue')['default'] 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src-tauri/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "bilibili-manga-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 | # The `_lib` suffix may seem redundant but it is necessary 12 | # to make the lib name unique and wouldn't conflict with the bin name. 13 | # This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519 14 | name = "bilibili_manga_downloader_lib" 15 | crate-type = ["staticlib", "cdylib", "rlib"] 16 | 17 | [build-dependencies] 18 | tauri-build = { version = "2", features = [] } 19 | 20 | [dependencies] 21 | tauri = { version = "2", features = [] } 22 | tauri-plugin-shell = { version = "2" } 23 | tauri-plugin-dialog = { version = "2" } 24 | 25 | serde = { version = "1", features = ["derive"] } 26 | serde_json = { version = "1" } 27 | yaserde = { version = "0.11.2", features = ["yaserde_derive"] } 28 | 29 | specta = { version = "2.0.0-rc" } 30 | tauri-specta = { version = "2.0.0-rc", features = ["derive", "typescript"] } 31 | specta-typescript = { version = "0.0.7" } 32 | 33 | reqwest = { version = "0.12.8", features = ["rustls-tls", "gzip", "deflate", "zstd", "brotli"] } 34 | reqwest-retry = { version = "0.6.1" } 35 | reqwest-middleware = { version = "0.3.3 ", features = ["json"] } 36 | 37 | image = { version = "0.25.4", default-features = false, features = ["jpeg"] } 38 | base64 = { version = "0.22.1" } 39 | 40 | anyhow = { version = "1.0.91" } 41 | qrcode = { version = "0.14.1" } 42 | bytes = { version = "1.8.0" } 43 | tokio = { version = "1.41.0", features = ["full"] } 44 | showfile = { version = "0.1.1" } 45 | path-slash = { version = "0.2.1" } 46 | url = { version = "2.5.2" } 47 | md5 = { version = "0.7.0" } 48 | chrono = { version = "0.4.38" } 49 | zip = { version = "2.2.0", default-features = false } 50 | parking_lot = { version = "0.12.3", features = ["send_guard"] } 51 | semver = { version = "1.0.23" } 52 | rand = { version = "0.8.5" } 53 | hex = { version = "0.4.3" } 54 | aes = { version = "0.8.4" } 55 | byteorder = { version = "1.5.0" } 56 | percent-encoding = { version = "2.3.1" } 57 | 58 | [profile.release] 59 | strip = true 60 | lto = true 61 | codegen-units = 1 62 | panic = "abort" 63 | -------------------------------------------------------------------------------- /public/tauri.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /src/components/SettingsDialog.vue: -------------------------------------------------------------------------------- 1 | 24 | 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 哔哩哔哩漫画下载器 2 | 3 |

4 | 5 |

6 | 7 | 8 | 9 | 一个用于 哔哩哔哩漫画 B漫 的多线程下载器,带图形界面,支持特典下载,下载速度飞快。图形界面基于[Tauri](https://v2.tauri.app/start/) 10 | 11 | 在[Release页面](https://github.com/lanyeeee/bilibili-manga-downloader/releases)可以直接下载 12 | 13 | **如果本项目对你有帮助,欢迎点个 Star⭐ 支持!你的支持是我持续更新维护的动力🙏** 14 | 15 | # 图形界面 16 | 17 | ![image](https://github.com/user-attachments/assets/09f12266-a3a7-4337-90b9-7be1ae649e88) 18 | 19 | 20 | # 使用方法 21 | 22 | 1. 点击`二维码登录`按钮,使用官方APP完成扫码登录(~~手动输入`SESSDATA`也行~~) 23 | 2. 使用`漫画搜索`,通过`关键词`搜索漫画,点击漫画的`封面`或`标题`,进入`章节详情`(也可以通过`漫画ID`直达`章节详情`) 24 | 3. 在`章节详情`勾选要下载的章节,点击`下载勾选章节`按钮开始下载 25 | 4. 下载完成后点击`打开下载目录`按钮查看结果 26 | 27 | 下面的视频是完整使用流程 28 | 29 | https://github.com/user-attachments/assets/dc4d5b63-dba6-4d72-b9d1-c9b0b6f79ef3 30 | 31 | # 哔哩哔哩漫画去水印工具 32 | 33 | [![Readme Card](https://github-readme-stats.vercel.app/api/pin/?username=lanyeeee&repo=bilibili-manga-watermark-remover)](https://github.com/lanyeeee/bilibili-manga-watermark-remover) 34 | 35 | 36 | # 关于被杀毒软件误判为病毒 37 | 38 | 对于个人开发者来说,这个问题几乎是无解的(~~需要购买数字证书给软件签名,甚至给杀毒软件交保护费~~) 39 | 我能想到的解决办法只有: 40 | 41 | 1. 根据下面的**如何构建(build)**,自行编译 42 | 2. 希望你相信我的承诺,我承诺你在[Release页面](https://github.com/lanyeeee/bilibili-manga-downloader/releases)下载到的所有东西都是安全的 43 | 44 | # 关于软件传播 45 | 46 | 私下传播的软件可能因篡改而携带病毒,为避免用户因使用私下传播的版本而感染病毒,甚至因此来找我麻烦 47 | 我只保证在[Release页面](https://github.com/lanyeeee/bilibili-manga-downloader/releases)下载到的东西是安全的 48 | 49 | 若需要私下传播该软件,请务必进行以下操作: 50 | 1. 修改软件标识符(`src-tauri/tauri.conf.json`的`identifier`字段)然后重新编译 51 | 2. 仅传播经过重新编译的版本,并明确标注这是经过修改的版本 52 | 53 | # 如何构建(build) 54 | 55 | 构建非常简单,一共就3条命令 56 | ~~前提是你已经安装了Rust、Node、pnpm~~ 57 | 58 | #### 前提 59 | 60 | - [Rust](https://www.rust-lang.org/tools/install) 61 | - [Node](https://nodejs.org/en) 62 | - [pnpm](https://pnpm.io/installation) 63 | 64 | #### 步骤 65 | 66 | #### 1. 克隆本仓库 67 | 68 | ``` 69 | git clone https://github.com/lanyeeee/bilibili-manga-downloader.git 70 | ``` 71 | 72 | #### 2.安装依赖 73 | 74 | ``` 75 | cd bilibili-manga-downloader 76 | pnpm install 77 | ``` 78 | 79 | #### 3.构建(build) 80 | 81 | ``` 82 | pnpm tauri build 83 | ``` 84 | 85 | # 提交PR 86 | 87 | **PR请提交至`develop`分支** 88 | 89 | **如果想新加一个功能,请先开个`issue`或`discussion`讨论一下,避免无效工作** 90 | 91 | 其他情况的PR欢迎直接提交,比如: 92 | 93 | 1. 对原有功能的改进 94 | 2. 使用更轻量的库实现原有功能 95 | 3. 修订文档 96 | 4. 升级、更新依赖的PR也会被接受 97 | 98 | # 免责声明 99 | 100 | - 本工具仅作学习、研究、交流使用,使用本工具的用户应自行承担风险 101 | - 作者不对使用本工具导致的任何损失、法律纠纷或其他后果负责 102 | - 作者不对用户使用本工具的行为负责,包括但不限于用户违反法律或任何第三方权益的行为 103 | 104 | # 其他 105 | 106 | 任何使用中遇到的问题、任何希望添加的功能,都欢迎提交issue或开discussion交流,我会尽力解决 107 | -------------------------------------------------------------------------------- /src-tauri/src/lib.rs: -------------------------------------------------------------------------------- 1 | mod bili_client; 2 | mod commands; 3 | mod config; 4 | mod download_manager; 5 | mod errors; 6 | mod events; 7 | mod extensions; 8 | mod responses; 9 | mod types; 10 | mod utils; 11 | 12 | use crate::commands::*; 13 | use crate::config::Config; 14 | use crate::download_manager::DownloadManager; 15 | use crate::events::prelude::*; 16 | use anyhow::Context; 17 | use parking_lot::RwLock; 18 | use tauri::{Manager, Wry}; 19 | 20 | fn generate_context() -> tauri::Context { 21 | tauri::generate_context!() 22 | } 23 | 24 | #[cfg_attr(mobile, tauri::mobile_entry_point)] 25 | pub fn run() { 26 | let builder = tauri_specta::Builder::::new() 27 | .commands(tauri_specta::collect_commands![ 28 | greet, 29 | get_config, 30 | save_config, 31 | generate_web_qrcode, 32 | get_web_qrcode_status, 33 | search, 34 | get_comic, 35 | download_episodes, 36 | show_path_in_file_manager, 37 | get_user_profile, 38 | check_update, 39 | ]) 40 | .events(tauri_specta::collect_events![ 41 | RemoveWatermarkStartEvent, 42 | RemoveWatermarkSuccessEvent, 43 | RemoveWatermarkErrorEvent, 44 | RemoveWatermarkEndEvent, 45 | DownloadPendingEvent, 46 | DownloadStartEvent, 47 | DownloadImageSuccessEvent, 48 | DownloadImageErrorEvent, 49 | DownloadEndEvent, 50 | DownloadSpeedEvent, 51 | SetProxyErrorEvent, 52 | ]); 53 | 54 | #[cfg(debug_assertions)] 55 | builder 56 | .export( 57 | specta_typescript::Typescript::default() 58 | .bigint(specta_typescript::BigIntExportBehavior::Number) 59 | .formatter(specta_typescript::formatter::prettier) 60 | .header("// @ts-nocheck"), // 跳过检查 61 | "../src/bindings.ts", 62 | ) 63 | .expect("Failed to export typescript bindings"); 64 | 65 | tauri::Builder::default() 66 | .plugin(tauri_plugin_dialog::init()) 67 | .plugin(tauri_plugin_shell::init()) 68 | .invoke_handler(builder.invoke_handler()) 69 | .setup(move |app| { 70 | builder.mount_events(app); 71 | 72 | let app_data_dir = app 73 | .path() 74 | .app_data_dir() 75 | .context("failed to get app data dir")?; 76 | 77 | std::fs::create_dir_all(&app_data_dir) 78 | .context(format!("failed to create app data dir: {app_data_dir:?}"))?; 79 | println!("app data dir: {app_data_dir:?}"); 80 | 81 | let config = RwLock::new(Config::new(app.handle())?); 82 | app.manage(config); 83 | 84 | let download_manager = DownloadManager::new(app.handle()); 85 | app.manage(download_manager); 86 | 87 | let bili_client = bili_client::BiliClient::new(app.handle().clone()); 88 | app.manage(bili_client); 89 | 90 | Ok(()) 91 | }) 92 | .run(generate_context()) 93 | .expect("error while running tauri application"); 94 | } 95 | -------------------------------------------------------------------------------- /src/components/SearchPane.vue: -------------------------------------------------------------------------------- 1 | 50 | 51 | -------------------------------------------------------------------------------- /src-tauri/src/responses/search_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 SearchRespData { 7 | #[serde(rename = "comic_data")] 8 | pub comic_data: SearchComicRespData, 9 | #[serde(rename = "novel_data")] 10 | pub novel_data: SearchNovelRespData, 11 | } 12 | 13 | #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)] 14 | #[serde(rename_all = "camelCase")] 15 | pub struct SearchComicRespData { 16 | pub list: Vec, 17 | #[serde(rename = "total_page")] 18 | pub total_page: i64, 19 | #[serde(rename = "total_num")] 20 | pub total_num: i64, 21 | pub similar: String, 22 | #[serde(rename = "se_id")] 23 | pub se_id: String, 24 | pub banner: BannerRespData, 25 | } 26 | 27 | #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)] 28 | #[serde(rename_all = "camelCase")] 29 | pub struct ComicInSearchRespData { 30 | pub id: i64, 31 | pub title: String, 32 | #[serde(rename = "square_cover")] 33 | pub square_cover: String, 34 | #[serde(rename = "vertical_cover")] 35 | pub vertical_cover: String, 36 | #[serde(rename = "author_name")] 37 | pub author_name: Vec, 38 | pub styles: Vec, 39 | #[serde(rename = "is_finish")] 40 | pub is_finish: i64, 41 | #[serde(rename = "allow_wait_free")] 42 | pub allow_wait_free: bool, 43 | #[serde(rename = "discount_type")] 44 | pub discount_type: i64, 45 | #[serde(rename = "type")] 46 | pub type_field: i64, 47 | pub wiki: WikiRespData, 48 | } 49 | 50 | #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)] 51 | #[serde(rename_all = "camelCase")] 52 | pub struct WikiRespData { 53 | pub id: i64, 54 | pub title: String, 55 | #[serde(rename = "origin_title")] 56 | pub origin_title: String, 57 | #[serde(rename = "vertical_cover")] 58 | pub vertical_cover: String, 59 | pub producer: String, 60 | #[serde(rename = "author_name")] 61 | pub author_name: Vec, 62 | #[serde(rename = "publish_time")] 63 | pub publish_time: String, 64 | pub frequency: String, 65 | } 66 | 67 | #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)] 68 | #[serde(rename_all = "camelCase")] 69 | pub struct BannerRespData { 70 | pub icon: String, 71 | pub title: String, 72 | pub url: String, 73 | } 74 | 75 | #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)] 76 | #[serde(rename_all = "camelCase")] 77 | pub struct SearchNovelRespData { 78 | pub total: i64, 79 | pub list: Vec, 80 | } 81 | 82 | #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)] 83 | #[serde(rename_all = "camelCase")] 84 | pub struct NovelInSearchRespData { 85 | #[serde(rename = "novel_id")] 86 | pub novel_id: i64, 87 | pub title: String, 88 | #[serde(rename = "v_cover")] 89 | pub v_cover: String, 90 | #[serde(rename = "finish_status")] 91 | pub finish_status: i64, 92 | pub status: i64, 93 | #[serde(rename = "discount_type")] 94 | pub discount_type: i64, 95 | pub numbers: i64, 96 | pub style: StyleRespData, 97 | pub evaluate: String, 98 | pub author: String, 99 | pub tag: TagRespData, 100 | } 101 | 102 | #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)] 103 | #[serde(rename_all = "camelCase")] 104 | pub struct StyleRespData { 105 | pub id: i64, 106 | pub name: String, 107 | } 108 | 109 | #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)] 110 | #[serde(rename_all = "camelCase")] 111 | pub struct TagRespData { 112 | pub id: i64, 113 | pub name: String, 114 | } 115 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /src-tauri/src/events.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | 3 | use serde::{Deserialize, Serialize}; 4 | use specta::Type; 5 | use tauri_specta::Event; 6 | 7 | pub mod prelude { 8 | pub use crate::events::{ 9 | DownloadEndEvent, DownloadImageErrorEvent, DownloadImageSuccessEvent, DownloadPendingEvent, 10 | DownloadSpeedEvent, DownloadStartEvent, RemoveWatermarkEndEvent, RemoveWatermarkErrorEvent, 11 | RemoveWatermarkStartEvent, RemoveWatermarkSuccessEvent, SetProxyErrorEvent, 12 | }; 13 | } 14 | 15 | #[derive(Serialize, Deserialize, Clone, Type)] 16 | #[serde(rename_all = "camelCase")] 17 | pub struct RemoveWatermarkStartEventPayload { 18 | pub dir_path: PathBuf, 19 | pub total: u32, 20 | } 21 | #[derive(Serialize, Deserialize, Clone, Type, Event)] 22 | pub struct RemoveWatermarkStartEvent(pub RemoveWatermarkStartEventPayload); 23 | 24 | #[derive(Serialize, Deserialize, Clone, Type)] 25 | #[serde(rename_all = "camelCase")] 26 | pub struct RemoveWatermarkSuccessEventPayload { 27 | pub dir_path: PathBuf, 28 | pub img_path: PathBuf, 29 | pub current: u32, 30 | } 31 | #[derive(Serialize, Deserialize, Clone, Type, Event)] 32 | pub struct RemoveWatermarkSuccessEvent(pub RemoveWatermarkSuccessEventPayload); 33 | 34 | #[derive(Serialize, Deserialize, Clone, Type)] 35 | #[serde(rename_all = "camelCase")] 36 | pub struct RemoveWatermarkErrorEventPayload { 37 | pub dir_path: PathBuf, 38 | pub img_path: PathBuf, 39 | pub err_msg: String, 40 | } 41 | #[derive(Serialize, Deserialize, Clone, Type, Event)] 42 | pub struct RemoveWatermarkErrorEvent(pub RemoveWatermarkErrorEventPayload); 43 | 44 | #[derive(Serialize, Deserialize, Clone, Type)] 45 | #[serde(rename_all = "camelCase")] 46 | pub struct RemoveWatermarkEndEventPayload { 47 | pub dir_path: PathBuf, 48 | } 49 | #[derive(Serialize, Deserialize, Clone, Type, Event)] 50 | pub struct RemoveWatermarkEndEvent(pub RemoveWatermarkEndEventPayload); 51 | 52 | #[derive(Serialize, Deserialize, Clone, Type)] 53 | #[serde(rename_all = "camelCase")] 54 | pub struct DownloadPendingEventPayload { 55 | pub id: i64, 56 | pub comic_title: String, 57 | pub episode_title: String, 58 | } 59 | #[derive(Serialize, Deserialize, Clone, Type, Event)] 60 | pub struct DownloadPendingEvent(pub DownloadPendingEventPayload); 61 | 62 | #[derive(Serialize, Deserialize, Clone, Type)] 63 | #[serde(rename_all = "camelCase")] 64 | pub struct DownloadStartEventPayload { 65 | pub id: i64, 66 | pub total: u32, 67 | } 68 | #[derive(Serialize, Deserialize, Clone, Type, Event)] 69 | pub struct DownloadStartEvent(pub DownloadStartEventPayload); 70 | 71 | #[derive(Serialize, Deserialize, Clone, Type)] 72 | #[serde(rename_all = "camelCase")] 73 | pub struct DownloadImageSuccessEventPayload { 74 | pub id: i64, 75 | pub url: String, 76 | pub current: u32, 77 | } 78 | #[derive(Serialize, Deserialize, Clone, Type, Event)] 79 | pub struct DownloadImageSuccessEvent(pub DownloadImageSuccessEventPayload); 80 | 81 | #[derive(Serialize, Deserialize, Clone, Type)] 82 | #[serde(rename_all = "camelCase")] 83 | pub struct DownloadImageErrorEventPayload { 84 | pub id: i64, 85 | pub url: String, 86 | pub err_msg: String, 87 | } 88 | #[derive(Serialize, Deserialize, Clone, Type, Event)] 89 | pub struct DownloadImageErrorEvent(pub DownloadImageErrorEventPayload); 90 | 91 | #[derive(Serialize, Deserialize, Clone, Type)] 92 | #[serde(rename_all = "camelCase")] 93 | pub struct DownloadEndEventPayload { 94 | pub id: i64, 95 | pub err_msg: Option, 96 | } 97 | #[derive(Serialize, Deserialize, Clone, Type, Event)] 98 | pub struct DownloadEndEvent(pub DownloadEndEventPayload); 99 | 100 | #[derive(Serialize, Deserialize, Clone, Type)] 101 | #[serde(rename_all = "camelCase")] 102 | pub struct DownloadSpeedEventPayload { 103 | pub speed: String, 104 | } 105 | #[derive(Serialize, Deserialize, Clone, Type, Event)] 106 | pub struct DownloadSpeedEvent(pub DownloadSpeedEventPayload); 107 | 108 | #[derive(Serialize, Deserialize, Clone, Type)] 109 | #[serde(rename_all = "camelCase")] 110 | pub struct SetProxyErrorEventPayload { 111 | pub err_msg: String, 112 | } 113 | #[derive(Serialize, Deserialize, Clone, Type, Event)] 114 | pub struct SetProxyErrorEvent(pub SetProxyErrorEventPayload); 115 | -------------------------------------------------------------------------------- /src/components/DownloadingList.vue: -------------------------------------------------------------------------------- 1 | 112 | 113 | -------------------------------------------------------------------------------- /src/AppContent.vue: -------------------------------------------------------------------------------- 1 | 107 | 108 | 141 | -------------------------------------------------------------------------------- /src-tauri/src/responses/github_releases_resp.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | pub type GithubReleasesResp = Vec; 4 | 5 | #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] 6 | #[serde(rename_all = "camelCase")] 7 | pub struct GithubReleaseInfoResp { 8 | pub url: String, 9 | #[serde(rename = "assets_url")] 10 | pub assets_url: String, 11 | #[serde(rename = "upload_url")] 12 | pub upload_url: String, 13 | #[serde(rename = "html_url")] 14 | pub html_url: String, 15 | pub id: i64, 16 | pub author: GithubReleaseAuthorResp, 17 | #[serde(rename = "node_id")] 18 | pub node_id: String, 19 | #[serde(rename = "tag_name")] 20 | pub tag_name: String, 21 | #[serde(rename = "target_commitish")] 22 | pub target_commitish: String, 23 | pub name: String, 24 | pub draft: bool, 25 | pub prerelease: bool, 26 | #[serde(rename = "created_at")] 27 | pub created_at: String, 28 | #[serde(rename = "published_at")] 29 | pub published_at: String, 30 | pub assets: Vec, 31 | #[serde(rename = "tarball_url")] 32 | pub tarball_url: String, 33 | #[serde(rename = "zipball_url")] 34 | pub zipball_url: String, 35 | pub body: String, 36 | #[serde(rename = "discussion_url")] 37 | pub discussion_url: Option, 38 | pub reactions: Option, 39 | } 40 | 41 | #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] 42 | #[serde(rename_all = "camelCase")] 43 | pub struct GithubReleaseAuthorResp { 44 | pub login: String, 45 | pub id: i64, 46 | #[serde(rename = "node_id")] 47 | pub node_id: String, 48 | #[serde(rename = "avatar_url")] 49 | pub avatar_url: String, 50 | #[serde(rename = "gravatar_id")] 51 | pub gravatar_id: String, 52 | pub url: String, 53 | #[serde(rename = "html_url")] 54 | pub html_url: String, 55 | #[serde(rename = "followers_url")] 56 | pub followers_url: String, 57 | #[serde(rename = "following_url")] 58 | pub following_url: String, 59 | #[serde(rename = "gists_url")] 60 | pub gists_url: String, 61 | #[serde(rename = "starred_url")] 62 | pub starred_url: String, 63 | #[serde(rename = "subscriptions_url")] 64 | pub subscriptions_url: String, 65 | #[serde(rename = "organizations_url")] 66 | pub organizations_url: String, 67 | #[serde(rename = "repos_url")] 68 | pub repos_url: String, 69 | #[serde(rename = "events_url")] 70 | pub events_url: String, 71 | #[serde(rename = "received_events_url")] 72 | pub received_events_url: String, 73 | #[serde(rename = "type")] 74 | pub type_field: String, 75 | #[serde(rename = "user_view_type")] 76 | pub user_view_type: String, 77 | #[serde(rename = "site_admin")] 78 | pub site_admin: bool, 79 | } 80 | 81 | #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] 82 | #[serde(rename_all = "camelCase")] 83 | pub struct GithubReleaseAssetResp { 84 | pub url: String, 85 | pub id: i64, 86 | #[serde(rename = "node_id")] 87 | pub node_id: String, 88 | pub name: String, 89 | pub label: Option, 90 | pub uploader: GithubReleaseUploaderResp, 91 | #[serde(rename = "content_type")] 92 | pub content_type: String, 93 | pub state: String, 94 | pub size: i64, 95 | #[serde(rename = "download_count")] 96 | pub download_count: i64, 97 | #[serde(rename = "created_at")] 98 | pub created_at: String, 99 | #[serde(rename = "updated_at")] 100 | pub updated_at: String, 101 | #[serde(rename = "browser_download_url")] 102 | pub browser_download_url: String, 103 | } 104 | 105 | #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] 106 | #[serde(rename_all = "camelCase")] 107 | pub struct GithubReleaseUploaderResp { 108 | pub login: String, 109 | pub id: i64, 110 | #[serde(rename = "node_id")] 111 | pub node_id: String, 112 | #[serde(rename = "avatar_url")] 113 | pub avatar_url: String, 114 | #[serde(rename = "gravatar_id")] 115 | pub gravatar_id: String, 116 | pub url: String, 117 | #[serde(rename = "html_url")] 118 | pub html_url: String, 119 | #[serde(rename = "followers_url")] 120 | pub followers_url: String, 121 | #[serde(rename = "following_url")] 122 | pub following_url: String, 123 | #[serde(rename = "gists_url")] 124 | pub gists_url: String, 125 | #[serde(rename = "starred_url")] 126 | pub starred_url: String, 127 | #[serde(rename = "subscriptions_url")] 128 | pub subscriptions_url: String, 129 | #[serde(rename = "organizations_url")] 130 | pub organizations_url: String, 131 | #[serde(rename = "repos_url")] 132 | pub repos_url: String, 133 | #[serde(rename = "events_url")] 134 | pub events_url: String, 135 | #[serde(rename = "received_events_url")] 136 | pub received_events_url: String, 137 | #[serde(rename = "type")] 138 | pub type_field: String, 139 | #[serde(rename = "user_view_type")] 140 | pub user_view_type: String, 141 | #[serde(rename = "site_admin")] 142 | pub site_admin: bool, 143 | } 144 | 145 | #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] 146 | #[serde(rename_all = "camelCase")] 147 | pub struct GithubReleaseReactionsResp { 148 | pub url: String, 149 | #[serde(rename = "total_count")] 150 | pub total_count: i64, 151 | #[serde(rename = "+1")] 152 | pub n1: i64, 153 | #[serde(rename = "-1")] 154 | pub n12: i64, 155 | pub laugh: i64, 156 | pub hooray: i64, 157 | pub confused: i64, 158 | pub heart: i64, 159 | pub rocket: i64, 160 | pub eyes: i64, 161 | } 162 | -------------------------------------------------------------------------------- /src-tauri/src/commands.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | use std::vec; 3 | 4 | use anyhow::anyhow; 5 | use parking_lot::RwLock; 6 | use path_slash::PathBufExt; 7 | use reqwest::StatusCode; 8 | use tauri::{AppHandle, State}; 9 | 10 | use crate::bili_client::BiliClient; 11 | use crate::config::Config; 12 | use crate::download_manager::DownloadManager; 13 | use crate::errors::CommandResult; 14 | use crate::responses::{ 15 | GithubReleasesResp, SearchRespData, UserProfileRespData, 16 | WebQrcodeStatusRespData, 17 | }; 18 | use crate::types::{CheckUpdateResult, Comic, EpisodeInfo, WebQrcodeData}; 19 | 20 | #[tauri::command] 21 | #[specta::specta] 22 | pub fn greet(name: &str) -> String { 23 | format!("Hello, {}! You've been greeted from Rust!", name) 24 | } 25 | 26 | #[tauri::command] 27 | #[specta::specta] 28 | #[allow(clippy::needless_pass_by_value)] 29 | pub fn get_config(config: State>) -> Config { 30 | config.read().clone() 31 | } 32 | 33 | #[tauri::command(async)] 34 | #[specta::specta] 35 | #[allow(clippy::needless_pass_by_value)] 36 | pub async fn save_config( 37 | app: AppHandle, 38 | bili_client: State<'_, BiliClient>, 39 | config_state: State<'_, RwLock>, 40 | config: Config, 41 | ) -> CommandResult<()> { 42 | let need_recreate = { 43 | let config_state = config_state.read(); 44 | config_state.proxy_mode != config.proxy_mode 45 | || config_state.proxy_host != config.proxy_host 46 | || config_state.proxy_port != config.proxy_port 47 | }; 48 | 49 | *config_state.write() = config; 50 | config_state.write().save(&app)?; 51 | 52 | if need_recreate { 53 | bili_client.recreate_http_client().await; 54 | } 55 | 56 | Ok(()) 57 | } 58 | 59 | #[tauri::command(async)] 60 | #[specta::specta] 61 | pub async fn generate_web_qrcode( 62 | bili_client: State<'_, BiliClient>, 63 | ) -> CommandResult { 64 | let web_qrcode_data = bili_client.generate_web_qrcode().await?; 65 | Ok(web_qrcode_data) 66 | } 67 | 68 | #[tauri::command(async)] 69 | #[specta::specta] 70 | pub async fn get_web_qrcode_status( 71 | bili_client: State<'_, BiliClient>, 72 | qrcode_key: String, 73 | ) -> CommandResult { 74 | let web_qrcode_status = bili_client.get_web_qrcode_status(&qrcode_key).await?; 75 | Ok(web_qrcode_status) 76 | } 77 | 78 | #[tauri::command(async)] 79 | #[specta::specta] 80 | pub async fn get_user_profile( 81 | bili_client: State<'_, BiliClient>, 82 | ) -> CommandResult { 83 | let user_profile_resp_data = bili_client.get_user_profile().await?; 84 | Ok(user_profile_resp_data) 85 | } 86 | 87 | #[tauri::command(async)] 88 | #[specta::specta] 89 | pub async fn search( 90 | bili_client: State<'_, BiliClient>, 91 | keyword: &str, 92 | page_num: i64, 93 | ) -> CommandResult { 94 | let search_resp_data = bili_client.search(keyword, page_num).await?; 95 | Ok(search_resp_data) 96 | } 97 | 98 | #[tauri::command(async)] 99 | #[specta::specta] 100 | pub async fn get_comic(bili_client: State<'_, BiliClient>, comic_id: i64) -> CommandResult { 101 | let comic = bili_client.get_comic(comic_id).await?; 102 | Ok(comic) 103 | } 104 | 105 | #[tauri::command(async)] 106 | #[specta::specta] 107 | pub async fn download_episodes( 108 | download_manager: State<'_, DownloadManager>, 109 | episodes: Vec, 110 | ) -> CommandResult<()> { 111 | for ep in episodes { 112 | download_manager.submit_episode(ep).await?; 113 | } 114 | Ok(()) 115 | } 116 | 117 | #[tauri::command(async)] 118 | #[specta::specta] 119 | pub fn show_path_in_file_manager(path: &str) -> CommandResult<()> { 120 | let path = PathBuf::from_slash(path); 121 | if !path.exists() { 122 | return Err(anyhow!("路径`{path:?}`不存在").into()); 123 | } 124 | showfile::show_path_in_file_manager(path); 125 | Ok(()) 126 | } 127 | 128 | #[tauri::command(async)] 129 | #[specta::specta] 130 | pub async fn check_update(app: AppHandle) -> CommandResult { 131 | let http_client = reqwest::ClientBuilder::new().build()?; 132 | let http_resp = http_client 133 | .get("https://api.github.com/repos/lanyeeee/bilibili-manga-downloader/releases") 134 | .header("user-agent", "lanyeeee/bilibili-manga-downloader") 135 | .send() 136 | .await?; 137 | let status = http_resp.status(); 138 | let body = http_resp.text().await?; 139 | if status != StatusCode::OK { 140 | return Err(anyhow!("获取更新信息失败,预料之外的状态码({status}: {body})").into()); 141 | } 142 | // current_version 格式为 0.0.0 的版本号 143 | let current_version = app.package_info().version.to_string(); 144 | // 滤出 tag_name 为 v0.0.0 格式且大于当前版本的 release 145 | let releases = serde_json::from_str::(&body)? 146 | .into_iter() 147 | .filter_map(|release| { 148 | // 滤出 tag_name 为 v0.0.0 格式的 release 149 | let tag_name = &release.tag_name; 150 | if !tag_name.starts_with('v') { 151 | return None; 152 | } 153 | if tag_name[1..].split('.').count() != 3 { 154 | return None; 155 | } 156 | // 滤出大于当前版本的 release 157 | let Ok(current_version) = semver::Version::parse(¤t_version) else { 158 | return None; 159 | }; 160 | let Ok(release_version) = semver::Version::parse(&tag_name[1..]) else { 161 | return None; 162 | }; 163 | if release_version <= current_version { 164 | return None; 165 | } 166 | Some(release) 167 | }); 168 | // 将 release 的 tag_name 提取出来 169 | let mut normal_releases = vec![]; 170 | let mut important_releases = vec![]; 171 | for release in releases { 172 | if release.name.contains("重要重要重要") { 173 | important_releases.push(release.tag_name); 174 | } else { 175 | normal_releases.push(release.tag_name); 176 | } 177 | } 178 | // 返回检查更新结果 179 | let check_update_result = CheckUpdateResult { 180 | normal_versions: normal_releases, 181 | important_versions: important_releases, 182 | }; 183 | 184 | Ok(check_update_result) 185 | } 186 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: "publish" 2 | 3 | on: 4 | push: 5 | tags: 6 | - "v*" 7 | 8 | env: 9 | REPO_NAME: bilibili-manga-downloader 10 | 11 | jobs: 12 | get-version: 13 | runs-on: ubuntu-latest 14 | outputs: 15 | VERSION: ${{ steps.get_version.outputs.VERSION }} 16 | steps: 17 | - uses: actions/checkout@v4 18 | 19 | - name: Get version number 20 | id: get_version 21 | run: | 22 | VERSION=$(jq -r '.version' src-tauri/tauri.conf.json) 23 | echo "VERSION=$VERSION" >> $GITHUB_OUTPUT 24 | 25 | windows-build: 26 | needs: get-version 27 | env: 28 | VERSION: ${{ needs.get-version.outputs.VERSION }} 29 | outputs: 30 | VERSION: ${{ env.VERSION }} 31 | runs-on: windows-latest 32 | steps: 33 | - uses: actions/checkout@v4 34 | 35 | - name: Setup node 36 | uses: actions/setup-node@v4 37 | with: 38 | node-version: lts/* 39 | 40 | - name: Install Rust stable 41 | uses: dtolnay/rust-toolchain@stable 42 | 43 | - name: Install pnpm 44 | uses: pnpm/action-setup@v4 45 | with: 46 | run_install: false 47 | 48 | - name: Install frontend dependencies 49 | run: pnpm install 50 | 51 | - name: Build tauri app 52 | uses: tauri-apps/tauri-action@v0 53 | 54 | - name: Create artifacts directory 55 | run: mkdir -p artifacts 56 | 57 | - name: Copy nsis to release assets 58 | run: cp src-tauri/target/release/bundle/nsis/${{ env.REPO_NAME }}_${{ env.VERSION }}_x64-setup.exe artifacts/${{ env.REPO_NAME }}_${{ env.VERSION }}_windows_x64.exe 59 | 60 | - name: Zip portable to release assets 61 | run: | 62 | cd src-tauri/target/release 63 | 7z a -tzip ../../../artifacts/${{ env.REPO_NAME }}_${{ env.VERSION }}_windows_x64_portable.zip ${{ env.REPO_NAME }}.exe 64 | 65 | - name: Upload artifacts 66 | uses: actions/upload-artifact@v4 67 | with: 68 | name: windows-assets 69 | path: artifacts/* 70 | 71 | linux-build: 72 | needs: get-version 73 | env: 74 | VERSION: ${{ needs.get-version.outputs.VERSION }} 75 | outputs: 76 | VERSION: ${{ env.VERSION }} 77 | runs-on: ubuntu-24.04 78 | steps: 79 | - name: install dependencies 80 | run: | 81 | sudo apt-get update 82 | sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf 83 | - uses: actions/checkout@v4 84 | 85 | - name: Setup node 86 | uses: actions/setup-node@v4 87 | with: 88 | node-version: lts/* 89 | 90 | - name: Install Rust stable 91 | uses: dtolnay/rust-toolchain@stable 92 | 93 | - name: Install pnpm 94 | uses: pnpm/action-setup@v4 95 | with: 96 | run_install: false 97 | 98 | - name: Install frontend dependencies 99 | run: pnpm install 100 | 101 | - name: Build tauri app 102 | uses: tauri-apps/tauri-action@v0 103 | 104 | - name: Create artifacts directory 105 | run: mkdir -p artifacts 106 | 107 | - name: Copy deb to release assets 108 | run: cp src-tauri/target/release/bundle/deb/${{ env.REPO_NAME }}_${{ env.VERSION }}_amd64.deb artifacts/${{ env.REPO_NAME }}_${{ env.VERSION }}_linux_amd64.deb 109 | 110 | - name: Copy rpm to release assets 111 | run: cp src-tauri/target/release/bundle/rpm/${{ env.REPO_NAME }}-${{ env.VERSION }}-1.x86_64.rpm artifacts/${{ env.REPO_NAME }}_${{ env.VERSION }}_linux_amd64.rpm 112 | 113 | - name: Zip portable to release assets 114 | run: | 115 | cd src-tauri/target/release 116 | tar -czf ../../../artifacts/${{ env.REPO_NAME }}_${{ env.VERSION }}_linux_amd64_portable.tar.gz ${{ env.REPO_NAME }} 117 | 118 | - name: Upload artifacts 119 | uses: actions/upload-artifact@v4 120 | with: 121 | name: linux-assets 122 | path: artifacts/* 123 | 124 | macos-build: 125 | needs: get-version 126 | env: 127 | VERSION: ${{ needs.get-version.outputs.VERSION }} 128 | outputs: 129 | VERSION: ${{ env.VERSION }} 130 | strategy: 131 | fail-fast: false 132 | matrix: 133 | arch: [ aarch64, x86_64 ] 134 | runs-on: macos-latest 135 | steps: 136 | - uses: actions/checkout@v4 137 | 138 | - name: Setup node 139 | uses: actions/setup-node@v4 140 | with: 141 | node-version: lts/* 142 | 143 | - name: Install Rust stable 144 | uses: dtolnay/rust-toolchain@stable 145 | with: 146 | targets: ${{ matrix.arch }}-apple-darwin 147 | 148 | - name: Install pnpm 149 | uses: pnpm/action-setup@v4 150 | with: 151 | run_install: false 152 | 153 | - name: Install frontend dependencies 154 | run: pnpm install 155 | 156 | - name: Build tauri app 157 | uses: tauri-apps/tauri-action@v0 158 | with: 159 | args: --target ${{ matrix.arch }}-apple-darwin 160 | 161 | - name: Create artifacts directory 162 | run: mkdir -p artifacts 163 | 164 | - name: Copy dmg to release assets 165 | env: 166 | ARCH_ALIAS: ${{ matrix.arch == 'x86_64' && 'x64' || matrix.arch }} 167 | run: cp src-tauri/target/${{ matrix.arch }}-apple-darwin/release/bundle/dmg/${{ env.REPO_NAME }}_${{ env.VERSION }}_${{ env.ARCH_ALIAS }}.dmg artifacts/${{ env.REPO_NAME }}_${{ env.VERSION }}_macos_${{ matrix.arch }}.dmg 168 | 169 | - name: Upload artifacts 170 | uses: actions/upload-artifact@v4 171 | with: 172 | name: macos-assets-${{ matrix.arch }} 173 | path: artifacts/* 174 | 175 | create-release: 176 | needs: [ windows-build, linux-build, macos-build ] 177 | runs-on: ubuntu-latest 178 | permissions: 179 | contents: write 180 | steps: 181 | - name: Download Windows assets 182 | uses: actions/download-artifact@v4 183 | with: 184 | name: windows-assets 185 | path: artifacts/windows 186 | 187 | - name: Download Linux assets 188 | uses: actions/download-artifact@v4 189 | with: 190 | name: linux-assets 191 | path: artifacts/linux 192 | 193 | - name: Download macOS aarch64 assets 194 | uses: actions/download-artifact@v4 195 | with: 196 | name: macos-assets-aarch64 197 | path: artifacts/macos-aarch64 198 | 199 | - name: Download macOS x86_64 assets 200 | uses: actions/download-artifact@v4 201 | with: 202 | name: macos-assets-x86_64 203 | path: artifacts/macos-x86_64 204 | 205 | - name: List files in artifacts directory 206 | run: ls -R artifacts 207 | 208 | - name: Create GitHub Release 209 | id: create_release 210 | uses: softprops/action-gh-release@v2 211 | env: 212 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 213 | with: 214 | name: Desktop App v${{ needs.windows-build.outputs.VERSION }} 215 | body: | 216 | Take a look at the assets to download and install this app. 217 | files: | 218 | artifacts/windows/* 219 | artifacts/linux/* 220 | artifacts/macos-aarch64/* 221 | artifacts/macos-x86_64/* 222 | draft: true 223 | prerelease: false -------------------------------------------------------------------------------- /src/components/EpisodePane.vue: -------------------------------------------------------------------------------- 1 | 116 | 117 | 176 | 177 | -------------------------------------------------------------------------------- /src-tauri/src/responses/comic_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 | #[allow(clippy::struct_excessive_bools)] 7 | #[allow(clippy::struct_field_names)] 8 | pub struct ComicRespData { 9 | pub id: i64, 10 | pub title: String, 11 | #[serde(rename = "comic_type")] 12 | pub comic_type: i64, 13 | #[serde(rename = "page_default")] 14 | pub page_default: i64, 15 | #[serde(rename = "page_allow")] 16 | pub page_allow: i64, 17 | #[serde(rename = "horizontal_cover")] 18 | pub horizontal_cover: String, 19 | #[serde(rename = "square_cover")] 20 | pub square_cover: String, 21 | #[serde(rename = "vertical_cover")] 22 | pub vertical_cover: String, 23 | #[serde(rename = "author_name")] 24 | pub author_name: Vec, 25 | pub styles: Vec, 26 | #[serde(rename = "last_ord")] 27 | pub last_ord: f64, 28 | #[serde(rename = "is_finish")] 29 | pub is_finish: i64, 30 | pub status: i64, 31 | pub fav: i64, 32 | #[serde(rename = "read_order")] 33 | pub read_order: f64, 34 | pub evaluate: String, 35 | pub total: i64, 36 | #[serde(rename = "ep_list")] 37 | pub ep_list: Vec, 38 | #[serde(rename = "release_time")] 39 | pub release_time: String, 40 | #[serde(rename = "is_limit")] 41 | pub is_limit: i64, 42 | #[serde(rename = "read_epid")] 43 | pub read_epid: i64, 44 | #[serde(rename = "last_read_time")] 45 | pub last_read_time: String, 46 | #[serde(rename = "is_download")] 47 | pub is_download: i64, 48 | #[serde(rename = "read_short_title")] 49 | pub read_short_title: String, 50 | pub styles2: Vec, 51 | #[serde(rename = "renewal_time")] 52 | pub renewal_time: String, 53 | #[serde(rename = "last_short_title")] 54 | pub last_short_title: String, 55 | #[serde(rename = "discount_type")] 56 | pub discount_type: i64, 57 | pub discount: i64, 58 | #[serde(rename = "discount_end")] 59 | pub discount_end: String, 60 | #[serde(rename = "no_reward")] 61 | pub no_reward: bool, 62 | #[serde(rename = "batch_discount_type")] 63 | pub batch_discount_type: i64, 64 | #[serde(rename = "ep_discount_type")] 65 | pub ep_discount_type: i64, 66 | #[serde(rename = "has_fav_activity")] 67 | pub has_fav_activity: bool, 68 | #[serde(rename = "fav_free_amount")] 69 | pub fav_free_amount: i64, 70 | #[serde(rename = "allow_wait_free")] 71 | pub allow_wait_free: bool, 72 | #[serde(rename = "wait_hour")] 73 | pub wait_hour: i64, 74 | #[serde(rename = "wait_free_at")] 75 | pub wait_free_at: String, 76 | #[serde(rename = "no_danmaku")] 77 | pub no_danmaku: i64, 78 | #[serde(rename = "auto_pay_status")] 79 | pub auto_pay_status: i64, 80 | #[serde(rename = "no_month_ticket")] 81 | pub no_month_ticket: bool, 82 | pub immersive: bool, 83 | #[serde(rename = "no_discount")] 84 | pub no_discount: bool, 85 | #[serde(rename = "show_type")] 86 | pub show_type: i64, 87 | #[serde(rename = "pay_mode")] 88 | pub pay_mode: i64, 89 | #[serde(rename = "classic_lines")] 90 | pub classic_lines: String, 91 | #[serde(rename = "pay_for_new")] 92 | pub pay_for_new: i64, 93 | #[serde(rename = "fav_comic_info")] 94 | pub fav_comic_info: FavComicInfoRespData, 95 | #[serde(rename = "serial_status")] 96 | pub serial_status: i64, 97 | #[serde(rename = "album_count")] 98 | pub album_count: i64, 99 | #[serde(rename = "wiki_id")] 100 | pub wiki_id: i64, 101 | #[serde(rename = "disable_coupon_amount")] 102 | pub disable_coupon_amount: i64, 103 | #[serde(rename = "japan_comic")] 104 | pub japan_comic: bool, 105 | #[serde(rename = "interact_value")] 106 | pub interact_value: String, 107 | #[serde(rename = "temporary_finish_time")] 108 | pub temporary_finish_time: String, 109 | pub introduction: String, 110 | #[serde(rename = "comment_status")] 111 | pub comment_status: i64, 112 | #[serde(rename = "no_screenshot")] 113 | pub no_screenshot: bool, 114 | #[serde(rename = "type")] 115 | pub type_field: i64, 116 | #[serde(rename = "no_rank")] 117 | pub no_rank: bool, 118 | #[serde(rename = "presale_text")] 119 | pub presale_text: String, 120 | #[serde(rename = "presale_discount")] 121 | pub presale_discount: i64, 122 | #[serde(rename = "no_leaderboard")] 123 | pub no_leaderboard: bool, 124 | #[serde(rename = "auto_pay_info")] 125 | pub auto_pay_info: AutoPayInfoRespData, 126 | pub orientation: i64, 127 | #[serde(rename = "story_elems")] 128 | pub story_elems: Vec, 129 | pub tags: Vec, 130 | #[serde(rename = "is_star_hall")] 131 | pub is_star_hall: i64, 132 | #[serde(rename = "hall_icon_text")] 133 | pub hall_icon_text: String, 134 | #[serde(rename = "rookie_fav_tip")] 135 | pub rookie_fav_tip: RookieFavTipRespData, 136 | pub authors: Vec, 137 | #[serde(rename = "comic_alias")] 138 | pub comic_alias: Vec, 139 | #[serde(rename = "horizontal_covers")] 140 | pub horizontal_covers: Vec, 141 | #[serde(rename = "data_info")] 142 | pub data_info: DataInfoRespData, 143 | #[serde(rename = "last_short_title_msg")] 144 | pub last_short_title_msg: String, 145 | } 146 | 147 | #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)] 148 | #[serde(rename_all = "camelCase")] 149 | pub struct EpisodeRespData { 150 | pub id: i64, 151 | pub ord: f64, 152 | pub read: i64, 153 | #[serde(rename = "pay_mode")] 154 | pub pay_mode: i64, 155 | #[serde(rename = "is_locked")] 156 | pub is_locked: bool, 157 | #[serde(rename = "pay_gold")] 158 | pub pay_gold: i64, 159 | pub size: i64, 160 | #[serde(rename = "short_title")] 161 | pub short_title: String, 162 | #[serde(rename = "is_in_free")] 163 | pub is_in_free: bool, 164 | pub title: String, 165 | pub cover: String, 166 | #[serde(rename = "pub_time")] 167 | pub pub_time: String, 168 | pub comments: i64, 169 | #[serde(rename = "unlock_expire_at")] 170 | pub unlock_expire_at: String, 171 | #[serde(rename = "unlock_type")] 172 | pub unlock_type: i64, 173 | #[serde(rename = "allow_wait_free")] 174 | pub allow_wait_free: bool, 175 | pub progress: String, 176 | #[serde(rename = "like_count")] 177 | pub like_count: i64, 178 | #[serde(rename = "chapter_id")] 179 | pub chapter_id: i64, 180 | #[serde(rename = "type")] 181 | pub type_field: i64, 182 | pub extra: i64, 183 | #[serde(rename = "image_count")] 184 | pub image_count: i64, 185 | #[serde(rename = "index_last_modified")] 186 | pub index_last_modified: String, 187 | #[serde(rename = "jump_url")] 188 | pub jump_url: String, 189 | } 190 | 191 | #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)] 192 | #[serde(rename_all = "camelCase")] 193 | pub struct Styles2RespData { 194 | pub id: i64, 195 | pub name: String, 196 | } 197 | 198 | #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)] 199 | #[serde(rename_all = "camelCase")] 200 | pub struct FavComicInfoRespData { 201 | #[serde(rename = "has_fav_activity")] 202 | pub has_fav_activity: bool, 203 | #[serde(rename = "fav_free_amount")] 204 | pub fav_free_amount: i64, 205 | #[serde(rename = "fav_coupon_type")] 206 | pub fav_coupon_type: i64, 207 | } 208 | 209 | #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)] 210 | #[serde(rename_all = "camelCase")] 211 | pub struct AutoPayInfoRespData { 212 | #[serde(rename = "auto_pay_orders")] 213 | pub auto_pay_orders: Vec, 214 | pub id: i64, 215 | } 216 | 217 | #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)] 218 | #[serde(rename_all = "camelCase")] 219 | pub struct AutoPayOrderRespData { 220 | pub id: i64, 221 | pub title: String, 222 | } 223 | 224 | #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)] 225 | #[serde(rename_all = "camelCase")] 226 | pub struct StoryElemRespData { 227 | pub id: i64, 228 | pub name: String, 229 | } 230 | 231 | #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)] 232 | #[serde(rename_all = "camelCase")] 233 | pub struct TagRespData { 234 | pub id: i64, 235 | pub name: String, 236 | } 237 | 238 | #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)] 239 | #[serde(rename_all = "camelCase")] 240 | pub struct RookieFavTipRespData { 241 | #[serde(rename = "is_show")] 242 | pub is_show: bool, 243 | pub used: i64, 244 | pub total: i64, 245 | } 246 | 247 | #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)] 248 | #[serde(rename_all = "camelCase")] 249 | pub struct AuthorRespData { 250 | pub id: i64, 251 | pub name: String, 252 | pub cname: String, 253 | } 254 | 255 | #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)] 256 | #[serde(rename_all = "camelCase")] 257 | pub struct DataInfoRespData { 258 | #[serde(rename = "read_score")] 259 | pub read_score: ReadScoreRespData, 260 | #[serde(rename = "interactive_value")] 261 | pub interactive_value: InteractiveValueRespData, 262 | } 263 | 264 | #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)] 265 | #[serde(rename_all = "camelCase")] 266 | #[allow(clippy::struct_field_names)] 267 | pub struct ReadScoreRespData { 268 | #[serde(rename = "read_score")] 269 | pub read_score: String, 270 | #[serde(rename = "is_jump")] 271 | pub is_jump: bool, 272 | pub increase: IncreaseRespData, 273 | pub percentile: f64, 274 | pub description: String, 275 | } 276 | 277 | #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)] 278 | #[serde(rename_all = "camelCase")] 279 | pub struct InteractiveValueRespData { 280 | #[serde(rename = "interact_value")] 281 | pub interact_value: String, 282 | #[serde(rename = "is_jump")] 283 | pub is_jump: bool, 284 | pub increase: IncreaseRespData, 285 | pub percentile: f64, 286 | pub description: String, 287 | } 288 | 289 | #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)] 290 | #[serde(rename_all = "camelCase")] 291 | pub struct IncreaseRespData { 292 | pub days: i64, 293 | #[serde(rename = "increase_percent")] 294 | pub increase_percent: i64, 295 | } 296 | -------------------------------------------------------------------------------- /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 generateWebQrcode() : Promise> { 23 | try { 24 | return { status: "ok", data: await TAURI_INVOKE("generate_web_qrcode") }; 25 | } catch (e) { 26 | if(e instanceof Error) throw e; 27 | else return { status: "error", error: e as any }; 28 | } 29 | }, 30 | async getWebQrcodeStatus(qrcodeKey: string) : Promise> { 31 | try { 32 | return { status: "ok", data: await TAURI_INVOKE("get_web_qrcode_status", { qrcodeKey }) }; 33 | } catch (e) { 34 | if(e instanceof Error) throw e; 35 | else return { status: "error", error: e as any }; 36 | } 37 | }, 38 | async search(keyword: string, pageNum: number) : Promise> { 39 | try { 40 | return { status: "ok", data: await TAURI_INVOKE("search", { keyword, pageNum }) }; 41 | } catch (e) { 42 | if(e instanceof Error) throw e; 43 | else return { status: "error", error: e as any }; 44 | } 45 | }, 46 | async getComic(comicId: number) : Promise> { 47 | try { 48 | return { status: "ok", data: await TAURI_INVOKE("get_comic", { comicId }) }; 49 | } catch (e) { 50 | if(e instanceof Error) throw e; 51 | else return { status: "error", error: e as any }; 52 | } 53 | }, 54 | async downloadEpisodes(episodes: EpisodeInfo[]) : Promise> { 55 | try { 56 | return { status: "ok", data: await TAURI_INVOKE("download_episodes", { episodes }) }; 57 | } catch (e) { 58 | if(e instanceof Error) throw e; 59 | else return { status: "error", error: e as any }; 60 | } 61 | }, 62 | async showPathInFileManager(path: string) : Promise> { 63 | try { 64 | return { status: "ok", data: await TAURI_INVOKE("show_path_in_file_manager", { path }) }; 65 | } catch (e) { 66 | if(e instanceof Error) throw e; 67 | else return { status: "error", error: e as any }; 68 | } 69 | }, 70 | async getUserProfile() : Promise> { 71 | try { 72 | return { status: "ok", data: await TAURI_INVOKE("get_user_profile") }; 73 | } catch (e) { 74 | if(e instanceof Error) throw e; 75 | else return { status: "error", error: e as any }; 76 | } 77 | }, 78 | async checkUpdate() : Promise> { 79 | try { 80 | return { status: "ok", data: await TAURI_INVOKE("check_update") }; 81 | } catch (e) { 82 | if(e instanceof Error) throw e; 83 | else return { status: "error", error: e as any }; 84 | } 85 | } 86 | } 87 | 88 | /** user-defined events **/ 89 | 90 | 91 | export const events = __makeEvents__<{ 92 | downloadEndEvent: DownloadEndEvent, 93 | downloadImageErrorEvent: DownloadImageErrorEvent, 94 | downloadImageSuccessEvent: DownloadImageSuccessEvent, 95 | downloadPendingEvent: DownloadPendingEvent, 96 | downloadSpeedEvent: DownloadSpeedEvent, 97 | downloadStartEvent: DownloadStartEvent, 98 | removeWatermarkEndEvent: RemoveWatermarkEndEvent, 99 | removeWatermarkErrorEvent: RemoveWatermarkErrorEvent, 100 | removeWatermarkStartEvent: RemoveWatermarkStartEvent, 101 | removeWatermarkSuccessEvent: RemoveWatermarkSuccessEvent, 102 | setProxyErrorEvent: SetProxyErrorEvent 103 | }>({ 104 | downloadEndEvent: "download-end-event", 105 | downloadImageErrorEvent: "download-image-error-event", 106 | downloadImageSuccessEvent: "download-image-success-event", 107 | downloadPendingEvent: "download-pending-event", 108 | downloadSpeedEvent: "download-speed-event", 109 | downloadStartEvent: "download-start-event", 110 | removeWatermarkEndEvent: "remove-watermark-end-event", 111 | removeWatermarkErrorEvent: "remove-watermark-error-event", 112 | removeWatermarkStartEvent: "remove-watermark-start-event", 113 | removeWatermarkSuccessEvent: "remove-watermark-success-event", 114 | setProxyErrorEvent: "set-proxy-error-event" 115 | }) 116 | 117 | /** user-defined constants **/ 118 | 119 | 120 | 121 | /** user-defined types **/ 122 | 123 | export type ArchiveFormat = "Image" | "Zip" | "Cbz" 124 | export type Author = { id: number; name: string; cname: string } 125 | export type AutoPayInfo = { auto_pay_orders: AutoPayOrder[]; id: number } 126 | export type AutoPayOrder = { id: number; title: string } 127 | export type BannerRespData = { icon: string; title: string; url: string } 128 | export type CheckUpdateResult = { normalVersions: string[]; importantVersions: string[] } 129 | export type Comic = { id: number; title: string; comic_type: number; page_default: number; page_allow: number; horizontal_cover: string; square_cover: string; vertical_cover: string; author_name: string[]; styles: string[]; last_ord: number; is_finish: number; status: number; fav: number; read_order: number; evaluate: string; total: number; episodeInfos: EpisodeInfo[]; release_time: string; is_limit: number; read_epid: number; last_read_time: string; is_download: number; read_short_title: string; styles2: Styles2[]; renewal_time: string; last_short_title: string; discount_type: number; discount: number; discount_end: string; no_reward: boolean; batch_discount_type: number; ep_discount_type: number; has_fav_activity: boolean; fav_free_amount: number; allow_wait_free: boolean; wait_hour: number; wait_free_at: string; no_danmaku: number; auto_pay_status: number; no_month_ticket: boolean; immersive: boolean; no_discount: boolean; show_type: number; pay_mode: number; classic_lines: string; pay_for_new: number; fav_comic_info: FavComicInfo; serial_status: number; album_count: number; wiki_id: number; disable_coupon_amount: number; japan_comic: boolean; interact_value: string; temporary_finish_time: string; introduction: string; comment_status: number; no_screenshot: boolean; type: number; no_rank: boolean; presale_text: string; presale_discount: number; no_leaderboard: boolean; auto_pay_info: AutoPayInfo; orientation: number; story_elems: StoryElem[]; tags: Tag[]; is_star_hall: number; hall_icon_text: string; rookie_fav_tip: RookieFavTip; authors: Author[]; comic_alias: string[]; horizontal_covers: string[]; data_info: DataInfo; last_short_title_msg: string } 130 | export type ComicInSearchRespData = { id: number; title: string; square_cover: string; vertical_cover: string; author_name: string[]; styles: string[]; is_finish: number; allow_wait_free: boolean; discount_type: number; type: number; wiki: WikiRespData } 131 | export type ComicInfo = { manga: string; series: string; publisher: string; writer: string; genre: string; summary: string; count: number; title: string; number: string; pageCount: number; year: number; month: number; day: number } 132 | export type CommandError = string 133 | export type Config = { cookie: string; downloadDir: string; archiveFormat: ArchiveFormat; lastUpdateCheckTs: number; proxyMode: ProxyMode; proxyHost: string; proxyPort: number } 134 | export type DataInfo = { read_score: ReadScore; interactive_value: InteractiveValue } 135 | export type DownloadEndEvent = DownloadEndEventPayload 136 | export type DownloadEndEventPayload = { id: number; errMsg: string | null } 137 | export type DownloadImageErrorEvent = DownloadImageErrorEventPayload 138 | export type DownloadImageErrorEventPayload = { id: number; url: string; errMsg: string } 139 | export type DownloadImageSuccessEvent = DownloadImageSuccessEventPayload 140 | export type DownloadImageSuccessEventPayload = { id: number; url: string; current: number } 141 | export type DownloadPendingEvent = DownloadPendingEventPayload 142 | export type DownloadPendingEventPayload = { id: number; comicTitle: string; episodeTitle: string } 143 | export type DownloadSpeedEvent = DownloadSpeedEventPayload 144 | export type DownloadSpeedEventPayload = { speed: string } 145 | export type DownloadStartEvent = DownloadStartEventPayload 146 | export type DownloadStartEventPayload = { id: number; total: number } 147 | export type EpisodeInfo = { episodeId: number; episodeTitle: string; comicId: number; comicTitle: string; isLocked: boolean; isDownloaded: boolean; comicInfo: ComicInfo } 148 | export type FavComicInfo = { has_fav_activity: boolean; fav_free_amount: number; fav_coupon_type: number } 149 | export type Increase = { days: number; increase_percent: number } 150 | export type InteractiveValue = { interact_value: string; is_jump: boolean; increase: Increase; percentile: number; description: string } 151 | export type NovelInSearchRespData = { novel_id: number; title: string; v_cover: string; finish_status: number; status: number; discount_type: number; numbers: number; style: StyleRespData; evaluate: string; author: string; tag: TagRespData } 152 | export type ProxyMode = "NoProxy" | "System" | "Custom" 153 | export type ReadScore = { read_score: string; is_jump: boolean; increase: Increase; percentile: number; description: string } 154 | export type RemoveWatermarkEndEvent = RemoveWatermarkEndEventPayload 155 | export type RemoveWatermarkEndEventPayload = { dirPath: string } 156 | export type RemoveWatermarkErrorEvent = RemoveWatermarkErrorEventPayload 157 | export type RemoveWatermarkErrorEventPayload = { dirPath: string; imgPath: string; errMsg: string } 158 | export type RemoveWatermarkStartEvent = RemoveWatermarkStartEventPayload 159 | export type RemoveWatermarkStartEventPayload = { dirPath: string; total: number } 160 | export type RemoveWatermarkSuccessEvent = RemoveWatermarkSuccessEventPayload 161 | export type RemoveWatermarkSuccessEventPayload = { dirPath: string; imgPath: string; current: number } 162 | export type RookieFavTip = { is_show: boolean; used: number; total: number } 163 | export type SearchComicRespData = { list: ComicInSearchRespData[]; total_page: number; total_num: number; similar: string; se_id: string; banner: BannerRespData } 164 | export type SearchNovelRespData = { total: number; list: NovelInSearchRespData[] } 165 | export type SearchRespData = { comic_data: SearchComicRespData; novel_data: SearchNovelRespData } 166 | export type SetProxyErrorEvent = SetProxyErrorEventPayload 167 | export type SetProxyErrorEventPayload = { errMsg: string } 168 | export type StoryElem = { id: number; name: string } 169 | export type StyleRespData = { id: number; name: string } 170 | export type Styles2 = { id: number; name: string } 171 | export type Tag = { id: number; name: string } 172 | export type TagRespData = { id: number; name: string } 173 | export type UserProfileRespData = { mid: number; face: string; name: string } 174 | export type WebQrcodeData = { base64: string; qrcodeKey: string } 175 | export type WebQrcodeStatusRespData = { url: string; refresh_token: string; timestamp: number; code: number; message: string } 176 | export type WikiRespData = { id: number; title: string; origin_title: string; vertical_cover: string; producer: string; author_name: string[]; publish_time: string; frequency: string } 177 | 178 | /** tauri-specta globals **/ 179 | 180 | import { 181 | invoke as TAURI_INVOKE, 182 | Channel as TAURI_CHANNEL, 183 | } from "@tauri-apps/api/core"; 184 | import * as TAURI_API_EVENT from "@tauri-apps/api/event"; 185 | import { type WebviewWindow as __WebviewWindow__ } from "@tauri-apps/api/webviewWindow"; 186 | 187 | type __EventObj__ = { 188 | listen: ( 189 | cb: TAURI_API_EVENT.EventCallback, 190 | ) => ReturnType>; 191 | once: ( 192 | cb: TAURI_API_EVENT.EventCallback, 193 | ) => ReturnType>; 194 | emit: null extends T 195 | ? (payload?: T) => ReturnType 196 | : (payload: T) => ReturnType; 197 | }; 198 | 199 | export type Result = 200 | | { status: "ok"; data: T } 201 | | { status: "error"; error: E }; 202 | 203 | function __makeEvents__>( 204 | mappings: Record, 205 | ) { 206 | return new Proxy( 207 | {} as unknown as { 208 | [K in keyof T]: __EventObj__ & { 209 | (handle: __WebviewWindow__): __EventObj__; 210 | }; 211 | }, 212 | { 213 | get: (_, event) => { 214 | const name = mappings[event as keyof T]; 215 | 216 | return new Proxy((() => {}) as any, { 217 | apply: (_, __, [window]: [__WebviewWindow__]) => ({ 218 | listen: (arg: any) => window.listen(name, arg), 219 | once: (arg: any) => window.once(name, arg), 220 | emit: (arg: any) => window.emit(name, arg), 221 | }), 222 | get: (_, command: keyof __EventObj__) => { 223 | switch (command) { 224 | case "listen": 225 | return (arg: any) => TAURI_API_EVENT.listen(name, arg); 226 | case "once": 227 | return (arg: any) => TAURI_API_EVENT.once(name, arg); 228 | case "emit": 229 | return (arg: any) => TAURI_API_EVENT.emit(name, arg); 230 | } 231 | }, 232 | }); 233 | }, 234 | }, 235 | ); 236 | } 237 | -------------------------------------------------------------------------------- /src-tauri/src/download_manager.rs: -------------------------------------------------------------------------------- 1 | use crate::bili_client::BiliClient; 2 | use crate::config::Config; 3 | use crate::events; 4 | use crate::events::{DownloadSpeedEvent, DownloadSpeedEventPayload}; 5 | use crate::extensions::AnyhowErrorToStringChain; 6 | use crate::types::{ArchiveFormat, EpisodeInfo}; 7 | use aes::cipher::consts::U16; 8 | use aes::cipher::generic_array::GenericArray; 9 | use aes::cipher::{BlockDecrypt, KeyInit}; 10 | use aes::Aes256; 11 | use std::fs::File; 12 | use std::path::{Path, PathBuf}; 13 | use std::sync::atomic::{AtomicU64, Ordering}; 14 | use std::sync::Arc; 15 | use std::time::Duration; 16 | 17 | use anyhow::{anyhow, Context}; 18 | use base64::engine::general_purpose; 19 | use base64::Engine; 20 | use byteorder::{BigEndian, ByteOrder}; 21 | use bytes::Bytes; 22 | use parking_lot::RwLock; 23 | use percent_encoding::percent_decode_str; 24 | use rand::Rng; 25 | use tauri::{AppHandle, Manager}; 26 | use tauri_specta::Event; 27 | use tokio::sync::mpsc::Receiver; 28 | use tokio::sync::{mpsc, Semaphore}; 29 | use url::Url; 30 | use zip::write::SimpleFileOptions; 31 | use zip::ZipWriter; 32 | 33 | // TODO: EpisodeInfo与AlbumPlusItem的内存差距过大,应该用Box包裹EpisodeInfo 34 | enum DownloadPayload { 35 | Episode(EpisodeInfo), 36 | } 37 | 38 | /// 用于管理下载任务 39 | /// 40 | /// 克隆 `DownloadManager` 的开销极小,性能开销几乎可以忽略不计。 41 | /// 可以放心地在多个线程中传递和使用它的克隆副本。 42 | /// 43 | /// 具体来说: 44 | /// - `app` 是 `AppHandle` 类型,根据 `Tauri` 文档,它的克隆开销是极小的。 45 | /// - 其他字段都被 `Arc` 包裹,这些字段的克隆操作仅仅是增加引用计数。 46 | #[derive(Clone)] 47 | pub struct DownloadManager { 48 | app: AppHandle, 49 | sender: Arc>, 50 | ep_sem: Arc, 51 | byte_per_sec: Arc, 52 | } 53 | 54 | impl DownloadManager { 55 | pub fn new(app: &AppHandle) -> Self { 56 | let (sender, receiver) = mpsc::channel::(32); 57 | 58 | let manager = DownloadManager { 59 | app: app.clone(), 60 | sender: Arc::new(sender), 61 | ep_sem: Arc::new(Semaphore::new(1)), 62 | byte_per_sec: Arc::new(AtomicU64::new(0)), 63 | }; 64 | 65 | tauri::async_runtime::spawn(Self::log_download_speed(app.clone())); 66 | tauri::async_runtime::spawn(Self::receiver_loop(app.clone(), receiver)); 67 | 68 | manager 69 | } 70 | 71 | pub async fn submit_episode(&self, ep_info: EpisodeInfo) -> anyhow::Result<()> { 72 | let value = DownloadPayload::Episode(ep_info); 73 | self.sender.send(value).await?; 74 | Ok(()) 75 | } 76 | 77 | #[allow(clippy::cast_precision_loss)] 78 | // TODO: 换个函数名,如emit_download_speed_loop 79 | async fn log_download_speed(app: AppHandle) { 80 | let mut interval = tokio::time::interval(Duration::from_secs(1)); 81 | 82 | loop { 83 | interval.tick().await; 84 | let manager = app.state::(); 85 | let byte_per_sec = manager.byte_per_sec.swap(0, Ordering::Relaxed); 86 | let mega_byte_per_sec = byte_per_sec as f64 / 1024.0 / 1024.0; 87 | let speed = format!("{mega_byte_per_sec:.2} MB/s"); 88 | emit_download_speed_event(&app, speed); 89 | } 90 | } 91 | 92 | async fn receiver_loop(app: AppHandle, mut receiver: Receiver) { 93 | while let Some(payload) = receiver.recv().await { 94 | let manager = app.state::().inner().clone(); 95 | match payload { 96 | DownloadPayload::Episode(ep_info) => { 97 | tauri::async_runtime::spawn(manager.process_episode(ep_info)); 98 | } 99 | } 100 | } 101 | } 102 | 103 | #[allow(clippy::cast_possible_truncation)] 104 | async fn process_episode(self, ep_info: EpisodeInfo) { 105 | emit_pending_event( 106 | &self.app, 107 | ep_info.episode_id, 108 | ep_info.comic_title.clone(), 109 | ep_info.episode_title.clone(), 110 | ); 111 | // 限制同时下载的章节数量 112 | let permit = match self.ep_sem.acquire().await.map_err(anyhow::Error::from) { 113 | Ok(permit) => permit, 114 | Err(err) => { 115 | let err = err.context("获取下载章节的semaphore失败"); 116 | let err_msg = err.to_string_chain(); 117 | emit_end_event(&self.app, ep_info.episode_id, Some(err_msg)); 118 | return; 119 | } 120 | }; 121 | // 获取path_urls 122 | let bili_client = self.bili_client(); 123 | let image_index_resp_data = match bili_client 124 | .get_image_index(ep_info.comic_id, ep_info.episode_id) 125 | .await 126 | { 127 | Ok(data) => data, 128 | Err(err) => { 129 | let comic_title = ep_info.comic_title.clone(); 130 | let chapter_title = ep_info.episode_title.clone(); 131 | let err = err.context(format!( 132 | "获取 {comic_title} - {chapter_title} 的ImageIndex失败" 133 | )); 134 | let id = ep_info.episode_id; 135 | let err_msg = err.to_string_chain(); 136 | emit_end_event(&self.app, id, Some(err_msg)); 137 | return; 138 | } 139 | }; 140 | let path_urls: Vec = image_index_resp_data 141 | .images 142 | .iter() 143 | .map(|img| img.path.clone()) 144 | .collect(); 145 | // 发送下载开始事件 146 | let total = path_urls.len() as u32; 147 | emit_start_event(&self.app, ep_info.episode_id, total); 148 | // 准备下载需要的变量 149 | let mut current = 0; 150 | // 下载前先创建临时下载目录 151 | let temp_download_dir = get_ep_temp_download_dir(&self.app, &ep_info); 152 | if let Err(err) = std::fs::create_dir_all(&temp_download_dir).map_err(anyhow::Error::from) { 153 | let id = ep_info.episode_id; 154 | let err = err.context(format!("创建目录 {temp_download_dir:?} 失败")); 155 | let err_msg = err.to_string_chain(); 156 | emit_end_event(&self.app, id, Some(err_msg)); 157 | return; 158 | } 159 | // 逐一下载图片 160 | for (i, path_url) in path_urls.into_iter().enumerate() { 161 | let urls = vec![path_url.clone()]; 162 | let image_token_resp_data = match bili_client 163 | .get_image_token(ep_info.comic_id, ep_info.episode_id, &urls) 164 | .await 165 | { 166 | Ok(data) => data, 167 | Err(err) => { 168 | let id = ep_info.episode_id; 169 | let err_msg = err.to_string_chain(); 170 | emit_error_event(&self.app, id, path_url, err_msg); 171 | // 如果获取图片下载链接失败,则不再下载剩余的图片,直接跳出循环 172 | break; 173 | } 174 | }; 175 | let save_path = temp_download_dir.join(format!("{:03}.jpg", i + 1)); 176 | // 构造图片下载链接 177 | let url = &image_token_resp_data[0].complete_url; 178 | // 下载图片 179 | if let Err(err) = self.download_image(url, &save_path).await { 180 | let id = ep_info.episode_id; 181 | let err_msg = err.to_string_chain(); 182 | emit_error_event(&self.app, id, url.clone(), err_msg); 183 | // 如果下载失败,则不再下载剩余的图片,直接跳出循环 184 | break; 185 | } 186 | // 下载完成后,更新章节下载进度 187 | current += 1; 188 | emit_success_event( 189 | &self.app, 190 | ep_info.episode_id, 191 | save_path.to_string_lossy().to_string(), // TODO: 把save_path.to_string_lossy().to_string()保存到一个变量里,像current一样 192 | current, 193 | ); 194 | // 每下载完一张图片,都休息300-800ms 195 | let sleep_time = rand::thread_rng().gen_range(300..=800); 196 | tokio::time::sleep(Duration::from_millis(sleep_time)).await; 197 | } 198 | // 该章节的图片下载完成,释放permit,允许其他章节下载 199 | drop(permit); 200 | // 检查此章节的图片是否全部下载成功 201 | // 此章节的图片未全部下载成功 202 | if current != total { 203 | let err_msg = Some(format!("总共有 {total} 张图片,但只下载了 {current} 张")); 204 | emit_end_event(&self.app, ep_info.episode_id, err_msg); 205 | return; 206 | } 207 | // 此章节的图片全部下载成功,保存图片 208 | let err_msg = match self.save_archive(&ep_info, &temp_download_dir) { 209 | Ok(_) => None, 210 | Err(err) => Some(err.to_string_chain()), 211 | }; 212 | emit_end_event(&self.app, ep_info.episode_id, err_msg); 213 | } 214 | 215 | fn save_archive( 216 | &self, 217 | ep_info: &EpisodeInfo, 218 | temp_download_dir: &PathBuf, 219 | ) -> anyhow::Result<()> { 220 | let archive_format = self 221 | .app 222 | .state::>() 223 | .read() 224 | .archive_format 225 | .clone(); 226 | 227 | let Some(parent) = temp_download_dir.parent() else { 228 | let err_msg = Some(format!("无法获取 {temp_download_dir:?} 的父目录")); 229 | emit_end_event(&self.app, ep_info.episode_id, err_msg); 230 | return Ok(()); 231 | }; 232 | 233 | let download_dir = parent.join(&ep_info.episode_title); 234 | // TODO: 把每种格式的保存操作提取到一个函数里 235 | match archive_format { 236 | ArchiveFormat::Image => { 237 | if download_dir.exists() { 238 | std::fs::remove_dir_all(&download_dir) 239 | .context(format!("删除 {download_dir:?} 失败"))?; 240 | } 241 | 242 | std::fs::rename(temp_download_dir, &download_dir).context(format!( 243 | "将 {temp_download_dir:?} 重命名为 {download_dir:?} 失败" 244 | ))?; 245 | } 246 | ArchiveFormat::Cbz | ArchiveFormat::Zip => { 247 | let comic_info_path = temp_download_dir.join("ComicInfo.xml"); 248 | let comic_info_xml = yaserde::ser::to_string(&ep_info.comic_info) 249 | .map_err(|err_msg| anyhow!("序列化 {comic_info_path:?} 失败: {err_msg}"))?; 250 | std::fs::write(&comic_info_path, comic_info_xml) 251 | .context(format!("创建 {comic_info_path:?} 失败"))?; 252 | 253 | let zip_path = download_dir.with_extension(archive_format.extension()); 254 | let zip_file = 255 | File::create(&zip_path).context(format!("创建 {zip_path:?} 失败"))?; 256 | 257 | let mut zip_writer = ZipWriter::new(zip_file); 258 | 259 | for entry in std::fs::read_dir(temp_download_dir)?.filter_map(Result::ok) { 260 | let path = entry.path(); 261 | if !path.is_file() { 262 | continue; 263 | } 264 | 265 | let filename = match path.file_name() { 266 | Some(name) => name.to_string_lossy(), 267 | None => continue, 268 | }; 269 | 270 | zip_writer 271 | .start_file(&filename, SimpleFileOptions::default()) 272 | .context(format!("在 {zip_path:?} 创建 {filename:?} 失败"))?; 273 | 274 | let mut file = File::open(&path).context(format!("打开 {path:?} 失败"))?; 275 | 276 | std::io::copy(&mut file, &mut zip_writer) 277 | .context(format!("将 {path:?} 写入 {zip_path:?} 失败"))?; 278 | } 279 | 280 | zip_writer 281 | .finish() 282 | .context(format!("关闭 {zip_path:?} 失败"))?; 283 | 284 | std::fs::remove_dir_all(temp_download_dir) 285 | .context(format!("删除 {temp_download_dir:?} 失败"))?; 286 | } 287 | } 288 | Ok(()) 289 | } 290 | 291 | async fn download_image(&self, url: &str, save_path: &Path) -> anyhow::Result<()> { 292 | let image_data = self 293 | .bili_client() 294 | .get_image_bytes(url) 295 | .await 296 | .context(format!("下载图片 {url} 失败"))?; 297 | // 如果图片链接中包含cpx参数,则需要解密图片数据 298 | let parsed_url = Url::parse(url).context(format!("解析图片链接 {url} 失败"))?; 299 | let cpx_query = parsed_url.query_pairs().find(|(key, _)| key == "cpx"); 300 | let image_data = if let Some((_, cpx)) = cpx_query { 301 | decrypt_img_data(image_data, &cpx).context(format!("解密图片 {url} 失败"))? 302 | } else { 303 | image_data 304 | }; 305 | // 保存图片 306 | std::fs::write(save_path, &image_data).context(format!("保存图片 {save_path:?} 失败"))?; 307 | // 记录下载字节数 308 | self.byte_per_sec 309 | .fetch_add(image_data.len() as u64, Ordering::Relaxed); 310 | Ok(()) 311 | } 312 | 313 | fn bili_client(&self) -> BiliClient { 314 | self.app.state::().inner().clone() 315 | } 316 | } 317 | 318 | fn get_ep_temp_download_dir(app: &AppHandle, ep_info: &EpisodeInfo) -> PathBuf { 319 | app.state::>() 320 | .read() 321 | .download_dir 322 | .join(&ep_info.comic_title) 323 | .join(format!(".下载中-{}", ep_info.episode_title)) // 以 `.下载中-` 开头,表示是临时目录 324 | } 325 | 326 | fn emit_start_event(app: &AppHandle, id: i64, total: u32) { 327 | let payload = events::DownloadStartEventPayload { id, total }; 328 | let event = events::DownloadStartEvent(payload); 329 | let _ = event.emit(app); 330 | } 331 | 332 | fn emit_pending_event(app: &AppHandle, id: i64, comic_title: String, episode_title: String) { 333 | let payload = events::DownloadPendingEventPayload { 334 | id, 335 | comic_title, 336 | episode_title, 337 | }; 338 | let event = events::DownloadPendingEvent(payload); 339 | let _ = event.emit(app); 340 | } 341 | 342 | fn emit_success_event(app: &AppHandle, id: i64, url: String, current: u32) { 343 | let payload = events::DownloadImageSuccessEventPayload { id, url, current }; 344 | let event = events::DownloadImageSuccessEvent(payload); 345 | let _ = event.emit(app); 346 | } 347 | 348 | fn emit_error_event(app: &AppHandle, id: i64, url: String, err_msg: String) { 349 | let payload = events::DownloadImageErrorEventPayload { id, url, err_msg }; 350 | let event = events::DownloadImageErrorEvent(payload); 351 | let _ = event.emit(app); 352 | } 353 | 354 | fn emit_end_event(app: &AppHandle, id: i64, err_msg: Option) { 355 | let payload = events::DownloadEndEventPayload { id, err_msg }; 356 | let event = events::DownloadEndEvent(payload); 357 | let _ = event.emit(app); 358 | } 359 | 360 | fn emit_download_speed_event(app: &AppHandle, speed: String) { 361 | let payload = DownloadSpeedEventPayload { speed }; 362 | let event = DownloadSpeedEvent(payload); 363 | let _ = event.emit(app); 364 | } 365 | 366 | fn aes_cbc_decrypt(encrypted_data: &[u8], key: &[u8], iv: &[u8]) -> Vec { 367 | const BLOCK_SIZE: usize = 16; 368 | let cipher = Aes256::new(GenericArray::from_slice(key)); 369 | // 存储解密后的数据 370 | let mut decrypted_data_with_padding = Vec::with_capacity(encrypted_data.len()); 371 | // 将IV作为初始化块处理,解密时与第一个加密块进行异或 372 | let mut previous_block: GenericArray = *GenericArray::from_slice(iv); 373 | // 逐块解密 374 | for chunk in encrypted_data.chunks(BLOCK_SIZE) { 375 | let mut block = GenericArray::clone_from_slice(chunk); 376 | cipher.decrypt_block(&mut block); 377 | // 与前一个块进行异或操作 378 | for i in 0..BLOCK_SIZE { 379 | block[i] ^= previous_block[i]; // 与前一个块进行异或 380 | } 381 | // 将解密后的数据追加到解密结果 382 | decrypted_data_with_padding.extend_from_slice(&block); 383 | // 将当前块的密文作为下一个块的IV 384 | previous_block = GenericArray::clone_from_slice(chunk); 385 | } 386 | 387 | // 去除PKCS#7填充,根据最后一个字节的值确定填充长度 388 | let padding_len = decrypted_data_with_padding.last().copied().unwrap() as usize; 389 | let data_len = decrypted_data_with_padding.len() - padding_len; 390 | decrypted_data_with_padding[..data_len].to_vec() 391 | } 392 | 393 | fn decrypt_img_data(img_data: Bytes, cpx: &str) -> anyhow::Result { 394 | // 如果数据能够被解析为图片格式,则直接返回 395 | if image::guess_format(&img_data).is_ok() { 396 | return Ok(img_data); 397 | } 398 | // 否则,解密图片数据 399 | let img_flag = img_data[0]; 400 | if img_flag != 1 { 401 | return Err(anyhow!( 402 | "解密图片数据失败,预料之外的图片数据标志位: {img_flag}" 403 | )); 404 | } 405 | let data_length = BigEndian::read_u32(&img_data[1..5]) as usize; 406 | if data_length + 5 > img_data.len() { 407 | return Ok(img_data); 408 | }; 409 | // 准备解密所需的数据 410 | let cpx_text = percent_decode_str(cpx).decode_utf8_lossy().to_string(); 411 | let cpx_char = general_purpose::STANDARD.decode(cpx_text)?; 412 | let iv = &cpx_char[60..76]; 413 | let key = &img_data[data_length + 5..]; 414 | let content = &img_data[5..data_length + 5]; 415 | // 如果数据长度小于20496,则直接解密 416 | if content.len() < 20496 { 417 | let decrypted_data = aes_cbc_decrypt(content, key, iv); 418 | return Ok(decrypted_data.into()); 419 | } 420 | // 否则,先解密前20496字节,再拼接后面的数据 421 | let img_head = aes_cbc_decrypt(&content[0..20496], key, iv); 422 | let mut decrypted_data = img_head; 423 | decrypted_data.extend_from_slice(&content[20496..]); 424 | Ok(decrypted_data.into()) 425 | } 426 | -------------------------------------------------------------------------------- /src-tauri/src/types/comic.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | 3 | use crate::config::Config; 4 | use crate::responses::{ComicRespData, EpisodeRespData}; 5 | use crate::utils::filename_filter; 6 | 7 | use chrono::{Datelike, NaiveDateTime}; 8 | use parking_lot::RwLock; 9 | use serde::{Deserialize, Serialize}; 10 | use specta::Type; 11 | use tauri::{AppHandle, Manager}; 12 | use yaserde::{YaDeserialize, YaSerialize}; 13 | 14 | #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)] 15 | #[serde(rename_all = "camelCase")] 16 | #[allow(clippy::struct_excessive_bools)] 17 | #[allow(clippy::struct_field_names)] 18 | pub struct Comic { 19 | pub id: i64, 20 | pub title: String, 21 | #[serde(rename = "comic_type")] 22 | pub comic_type: i64, 23 | #[serde(rename = "page_default")] 24 | pub page_default: i64, 25 | #[serde(rename = "page_allow")] 26 | pub page_allow: i64, 27 | #[serde(rename = "horizontal_cover")] 28 | pub horizontal_cover: String, 29 | #[serde(rename = "square_cover")] 30 | pub square_cover: String, 31 | #[serde(rename = "vertical_cover")] 32 | pub vertical_cover: String, 33 | #[serde(rename = "author_name")] 34 | pub author_name: Vec, 35 | pub styles: Vec, 36 | #[serde(rename = "last_ord")] 37 | pub last_ord: f64, 38 | #[serde(rename = "is_finish")] 39 | pub is_finish: i64, 40 | pub status: i64, 41 | pub fav: i64, 42 | #[serde(rename = "read_order")] 43 | pub read_order: f64, 44 | pub evaluate: String, 45 | pub total: i64, 46 | pub episode_infos: Vec, 47 | #[serde(rename = "release_time")] 48 | pub release_time: String, 49 | #[serde(rename = "is_limit")] 50 | pub is_limit: i64, 51 | #[serde(rename = "read_epid")] 52 | pub read_epid: i64, 53 | #[serde(rename = "last_read_time")] 54 | pub last_read_time: String, 55 | #[serde(rename = "is_download")] 56 | pub is_download: i64, 57 | #[serde(rename = "read_short_title")] 58 | pub read_short_title: String, 59 | pub styles2: Vec, 60 | #[serde(rename = "renewal_time")] 61 | pub renewal_time: String, 62 | #[serde(rename = "last_short_title")] 63 | pub last_short_title: String, 64 | #[serde(rename = "discount_type")] 65 | pub discount_type: i64, 66 | pub discount: i64, 67 | #[serde(rename = "discount_end")] 68 | pub discount_end: String, 69 | #[serde(rename = "no_reward")] 70 | pub no_reward: bool, 71 | #[serde(rename = "batch_discount_type")] 72 | pub batch_discount_type: i64, 73 | #[serde(rename = "ep_discount_type")] 74 | pub ep_discount_type: i64, 75 | #[serde(rename = "has_fav_activity")] 76 | pub has_fav_activity: bool, 77 | #[serde(rename = "fav_free_amount")] 78 | pub fav_free_amount: i64, 79 | #[serde(rename = "allow_wait_free")] 80 | pub allow_wait_free: bool, 81 | #[serde(rename = "wait_hour")] 82 | pub wait_hour: i64, 83 | #[serde(rename = "wait_free_at")] 84 | pub wait_free_at: String, 85 | #[serde(rename = "no_danmaku")] 86 | pub no_danmaku: i64, 87 | #[serde(rename = "auto_pay_status")] 88 | pub auto_pay_status: i64, 89 | #[serde(rename = "no_month_ticket")] 90 | pub no_month_ticket: bool, 91 | pub immersive: bool, 92 | #[serde(rename = "no_discount")] 93 | pub no_discount: bool, 94 | #[serde(rename = "show_type")] 95 | pub show_type: i64, 96 | #[serde(rename = "pay_mode")] 97 | pub pay_mode: i64, 98 | #[serde(rename = "classic_lines")] 99 | pub classic_lines: String, 100 | #[serde(rename = "pay_for_new")] 101 | pub pay_for_new: i64, 102 | #[serde(rename = "fav_comic_info")] 103 | pub fav_comic_info: FavComicInfo, 104 | #[serde(rename = "serial_status")] 105 | pub serial_status: i64, 106 | #[serde(rename = "album_count")] 107 | pub album_count: i64, 108 | #[serde(rename = "wiki_id")] 109 | pub wiki_id: i64, 110 | #[serde(rename = "disable_coupon_amount")] 111 | pub disable_coupon_amount: i64, 112 | #[serde(rename = "japan_comic")] 113 | pub japan_comic: bool, 114 | #[serde(rename = "interact_value")] 115 | pub interact_value: String, 116 | #[serde(rename = "temporary_finish_time")] 117 | pub temporary_finish_time: String, 118 | pub introduction: String, 119 | #[serde(rename = "comment_status")] 120 | pub comment_status: i64, 121 | #[serde(rename = "no_screenshot")] 122 | pub no_screenshot: bool, 123 | #[serde(rename = "type")] 124 | pub type_field: i64, 125 | #[serde(rename = "no_rank")] 126 | pub no_rank: bool, 127 | #[serde(rename = "presale_text")] 128 | pub presale_text: String, 129 | #[serde(rename = "presale_discount")] 130 | pub presale_discount: i64, 131 | #[serde(rename = "no_leaderboard")] 132 | pub no_leaderboard: bool, 133 | #[serde(rename = "auto_pay_info")] 134 | pub auto_pay_info: AutoPayInfo, 135 | pub orientation: i64, 136 | #[serde(rename = "story_elems")] 137 | pub story_elems: Vec, 138 | pub tags: Vec, 139 | #[serde(rename = "is_star_hall")] 140 | pub is_star_hall: i64, 141 | #[serde(rename = "hall_icon_text")] 142 | pub hall_icon_text: String, 143 | #[serde(rename = "rookie_fav_tip")] 144 | pub rookie_fav_tip: RookieFavTip, 145 | pub authors: Vec, 146 | #[serde(rename = "comic_alias")] 147 | pub comic_alias: Vec, 148 | #[serde(rename = "horizontal_covers")] 149 | pub horizontal_covers: Vec, 150 | #[serde(rename = "data_info")] 151 | pub data_info: DataInfo, 152 | #[serde(rename = "last_short_title_msg")] 153 | pub last_short_title_msg: String, 154 | } 155 | impl Comic { 156 | #[allow(clippy::too_many_lines)] 157 | // TODO: 统一用from实现,以减少代码行数 158 | pub fn from(app: &AppHandle, comic: ComicRespData) -> Self { 159 | let comic_title = filename_filter(&comic.title); 160 | let mut episode_infos: Vec = comic 161 | .ep_list 162 | .into_iter() 163 | .filter_map(|ep| { 164 | let episode_title = Self::get_episode_title(&ep); 165 | let comic_title = comic_title.clone(); 166 | let is_downloaded = Self::get_is_downloaded(app, &episode_title, &comic_title); 167 | const TIME_FORMAT: &str = "%Y-%m-%d %H:%M:%S"; 168 | let pub_time = NaiveDateTime::parse_from_str(&ep.pub_time, TIME_FORMAT).ok()?; 169 | 170 | let comic_info = ComicInfo { 171 | manga: "Yes".to_string(), 172 | series: comic_title.clone(), 173 | publisher: "哔哩哔哩漫画".to_string(), 174 | writer: comic 175 | .authors 176 | .iter() 177 | .map(|a| a.name.as_str()) 178 | .collect::>() 179 | .join(", "), 180 | genre: comic.styles.join(", "), 181 | summary: comic.evaluate.clone(), 182 | count: comic.total, 183 | title: episode_title.clone(), 184 | number: ep.ord.to_string(), 185 | page_count: ep.image_count, 186 | year: pub_time.year(), 187 | month: pub_time.month(), 188 | day: pub_time.day(), 189 | }; 190 | 191 | let episode_info = EpisodeInfo { 192 | episode_id: ep.id, 193 | episode_title, 194 | comic_id: comic.id, 195 | comic_title, 196 | is_locked: ep.is_locked, 197 | is_downloaded, 198 | comic_info, 199 | }; 200 | Some(episode_info) 201 | }) 202 | .collect(); 203 | // 解决章节标题重复的问题 204 | let mut ep_title_count = HashMap::new(); 205 | // 统计章节标题出现的次数 206 | for ep in &episode_infos { 207 | let Some(count) = ep_title_count.get_mut(&ep.episode_title) else { 208 | ep_title_count.insert(ep.episode_title.clone(), 1); 209 | continue; 210 | }; 211 | *count += 1; 212 | } 213 | // 只保留重复的章节标题 214 | ep_title_count.retain(|_, v| *v > 1); 215 | // 为重复的章节标题添加序号 216 | for ep in &mut episode_infos { 217 | let Some(count) = ep_title_count.get_mut(&ep.episode_title) else { 218 | continue; 219 | }; 220 | ep.episode_title = format!("{}-{}", ep.episode_title, count); 221 | *count -= 1; 222 | } 223 | 224 | episode_infos.reverse(); 225 | 226 | let styles2 = comic 227 | .styles2 228 | .into_iter() 229 | .map(|s| Styles2 { 230 | id: s.id, 231 | name: s.name, 232 | }) 233 | .collect(); 234 | 235 | let fav_comic_info = FavComicInfo { 236 | has_fav_activity: comic.fav_comic_info.has_fav_activity, 237 | fav_free_amount: comic.fav_comic_info.fav_free_amount, 238 | fav_coupon_type: comic.fav_comic_info.fav_coupon_type, 239 | }; 240 | 241 | let auto_pay_info = AutoPayInfo { 242 | auto_pay_orders: comic 243 | .auto_pay_info 244 | .auto_pay_orders 245 | .into_iter() 246 | .map(|order| AutoPayOrder { 247 | id: order.id, 248 | title: order.title, 249 | }) 250 | .collect(), 251 | id: comic.auto_pay_info.id, 252 | }; 253 | 254 | let story_elems = comic 255 | .story_elems 256 | .into_iter() 257 | .map(|elem| StoryElem { 258 | id: elem.id, 259 | name: elem.name, 260 | }) 261 | .collect(); 262 | 263 | let tags = comic 264 | .tags 265 | .into_iter() 266 | .map(|tag| Tag { 267 | id: tag.id, 268 | name: tag.name, 269 | }) 270 | .collect(); 271 | 272 | let rookie_fav_tip = RookieFavTip { 273 | is_show: comic.rookie_fav_tip.is_show, 274 | used: comic.rookie_fav_tip.used, 275 | total: comic.rookie_fav_tip.total, 276 | }; 277 | 278 | let authors = comic 279 | .authors 280 | .into_iter() 281 | .map(|author| Author { 282 | id: author.id, 283 | name: author.name, 284 | cname: author.cname, 285 | }) 286 | .collect(); 287 | 288 | let data_info = DataInfo { 289 | read_score: ReadScore { 290 | read_score: comic.data_info.read_score.read_score, 291 | is_jump: comic.data_info.read_score.is_jump, 292 | increase: Increase { 293 | days: comic.data_info.read_score.increase.days, 294 | increase_percent: comic.data_info.read_score.increase.increase_percent, 295 | }, 296 | percentile: comic.data_info.read_score.percentile, 297 | description: comic.data_info.read_score.description, 298 | }, 299 | interactive_value: InteractiveValue { 300 | interact_value: comic.data_info.interactive_value.interact_value, 301 | is_jump: comic.data_info.interactive_value.is_jump, 302 | increase: Increase { 303 | days: comic.data_info.interactive_value.increase.days, 304 | increase_percent: comic.data_info.interactive_value.increase.increase_percent, 305 | }, 306 | percentile: comic.data_info.interactive_value.percentile, 307 | description: comic.data_info.interactive_value.description, 308 | }, 309 | }; 310 | 311 | Self { 312 | id: comic.id, 313 | title: comic.title, 314 | comic_type: comic.comic_type, 315 | page_default: comic.page_default, 316 | page_allow: comic.page_allow, 317 | horizontal_cover: comic.horizontal_cover, 318 | square_cover: comic.square_cover, 319 | vertical_cover: comic.vertical_cover, 320 | author_name: comic.author_name, 321 | styles: comic.styles, 322 | last_ord: comic.last_ord, 323 | is_finish: comic.is_finish, 324 | status: comic.status, 325 | fav: comic.fav, 326 | read_order: comic.read_order, 327 | evaluate: comic.evaluate, 328 | total: comic.total, 329 | episode_infos, 330 | release_time: comic.release_time, 331 | is_limit: comic.is_limit, 332 | read_epid: comic.read_epid, 333 | last_read_time: comic.last_read_time, 334 | is_download: comic.is_download, 335 | read_short_title: comic.read_short_title, 336 | styles2, 337 | renewal_time: comic.renewal_time, 338 | last_short_title: comic.last_short_title, 339 | discount_type: comic.discount_type, 340 | discount: comic.discount, 341 | discount_end: comic.discount_end, 342 | no_reward: comic.no_reward, 343 | batch_discount_type: comic.batch_discount_type, 344 | ep_discount_type: comic.ep_discount_type, 345 | has_fav_activity: comic.has_fav_activity, 346 | fav_free_amount: comic.fav_free_amount, 347 | allow_wait_free: comic.allow_wait_free, 348 | wait_hour: comic.wait_hour, 349 | wait_free_at: comic.wait_free_at, 350 | no_danmaku: comic.no_danmaku, 351 | auto_pay_status: comic.auto_pay_status, 352 | no_month_ticket: comic.no_month_ticket, 353 | immersive: comic.immersive, 354 | no_discount: comic.no_discount, 355 | show_type: comic.show_type, 356 | pay_mode: comic.pay_mode, 357 | classic_lines: comic.classic_lines, 358 | pay_for_new: comic.pay_for_new, 359 | fav_comic_info, 360 | serial_status: comic.serial_status, 361 | album_count: comic.album_count, 362 | wiki_id: comic.wiki_id, 363 | disable_coupon_amount: comic.disable_coupon_amount, 364 | japan_comic: comic.japan_comic, 365 | interact_value: comic.interact_value, 366 | temporary_finish_time: comic.temporary_finish_time, 367 | introduction: comic.introduction, 368 | comment_status: comic.comment_status, 369 | no_screenshot: comic.no_screenshot, 370 | type_field: comic.type_field, 371 | no_rank: comic.no_rank, 372 | presale_text: comic.presale_text, 373 | presale_discount: comic.presale_discount, 374 | no_leaderboard: comic.no_leaderboard, 375 | auto_pay_info, 376 | orientation: comic.orientation, 377 | story_elems, 378 | tags, 379 | is_star_hall: comic.is_star_hall, 380 | hall_icon_text: comic.hall_icon_text, 381 | rookie_fav_tip, 382 | authors, 383 | comic_alias: comic.comic_alias, 384 | horizontal_covers: comic.horizontal_covers, 385 | data_info, 386 | last_short_title_msg: comic.last_short_title_msg, 387 | } 388 | } 389 | fn get_episode_title(ep: &EpisodeRespData) -> String { 390 | let title = filename_filter(&ep.title); 391 | let short_title = filename_filter(&ep.short_title); 392 | let ep_title = if title == short_title { 393 | title 394 | } else { 395 | format!("{short_title} {title}") 396 | }; 397 | ep_title.trim().to_string() 398 | } 399 | fn get_is_downloaded(app: &AppHandle, ep_title: &str, comic_title: &str) -> bool { 400 | let config = app.state::>(); 401 | let config = config.read(); 402 | config 403 | .download_dir 404 | .join(comic_title) 405 | .join(ep_title) 406 | .with_extension(config.archive_format.extension()) 407 | .exists() 408 | } 409 | } 410 | 411 | #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)] 412 | #[serde(rename_all = "camelCase")] 413 | pub struct EpisodeInfo { 414 | pub episode_id: i64, 415 | pub episode_title: String, 416 | pub comic_id: i64, 417 | pub comic_title: String, 418 | pub is_locked: bool, 419 | pub is_downloaded: bool, 420 | pub comic_info: ComicInfo, 421 | } 422 | 423 | #[derive( 424 | Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type, YaSerialize, YaDeserialize, 425 | )] 426 | #[serde(rename_all = "camelCase")] 427 | pub struct ComicInfo { 428 | #[yaserde(rename = "Manga")] 429 | pub manga: String, 430 | #[yaserde(rename = "Series")] 431 | pub series: String, 432 | #[yaserde(rename = "Publisher")] 433 | pub publisher: String, 434 | #[yaserde(rename = "Writer")] 435 | pub writer: String, 436 | #[yaserde(rename = "Genre")] 437 | pub genre: String, 438 | #[yaserde(rename = "Summary")] 439 | pub summary: String, 440 | #[yaserde(rename = "Count")] 441 | pub count: i64, 442 | #[yaserde(rename = "Title")] 443 | pub title: String, 444 | #[yaserde(rename = "Number")] 445 | pub number: String, 446 | #[yaserde(rename = "PageCount")] 447 | pub page_count: i64, 448 | #[yaserde(rename = "Year")] 449 | pub year: i32, 450 | #[yaserde(rename = "Month")] 451 | pub month: u32, 452 | #[yaserde(rename = "Day")] 453 | pub day: u32, 454 | } 455 | 456 | #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)] 457 | #[serde(rename_all = "camelCase")] 458 | pub struct Styles2 { 459 | pub id: i64, 460 | pub name: String, 461 | } 462 | 463 | #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)] 464 | #[serde(rename_all = "camelCase")] 465 | pub struct FavComicInfo { 466 | #[serde(rename = "has_fav_activity")] 467 | pub has_fav_activity: bool, 468 | #[serde(rename = "fav_free_amount")] 469 | pub fav_free_amount: i64, 470 | #[serde(rename = "fav_coupon_type")] 471 | pub fav_coupon_type: i64, 472 | } 473 | 474 | #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)] 475 | #[serde(rename_all = "camelCase")] 476 | pub struct AutoPayInfo { 477 | #[serde(rename = "auto_pay_orders")] 478 | pub auto_pay_orders: Vec, 479 | pub id: i64, 480 | } 481 | 482 | #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)] 483 | #[serde(rename_all = "camelCase")] 484 | pub struct AutoPayOrder { 485 | pub id: i64, 486 | pub title: String, 487 | } 488 | 489 | #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)] 490 | #[serde(rename_all = "camelCase")] 491 | pub struct StoryElem { 492 | pub id: i64, 493 | pub name: String, 494 | } 495 | 496 | #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)] 497 | #[serde(rename_all = "camelCase")] 498 | pub struct Tag { 499 | pub id: i64, 500 | pub name: String, 501 | } 502 | 503 | #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)] 504 | #[serde(rename_all = "camelCase")] 505 | pub struct RookieFavTip { 506 | #[serde(rename = "is_show")] 507 | pub is_show: bool, 508 | pub used: i64, 509 | pub total: i64, 510 | } 511 | 512 | #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)] 513 | #[serde(rename_all = "camelCase")] 514 | pub struct Author { 515 | pub id: i64, 516 | pub name: String, 517 | pub cname: String, 518 | } 519 | 520 | #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)] 521 | #[serde(rename_all = "camelCase")] 522 | pub struct DataInfo { 523 | #[serde(rename = "read_score")] 524 | pub read_score: ReadScore, 525 | #[serde(rename = "interactive_value")] 526 | pub interactive_value: InteractiveValue, 527 | } 528 | 529 | #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)] 530 | #[serde(rename_all = "camelCase")] 531 | #[allow(clippy::struct_field_names)] 532 | pub struct ReadScore { 533 | #[serde(rename = "read_score")] 534 | pub read_score: String, 535 | #[serde(rename = "is_jump")] 536 | pub is_jump: bool, 537 | pub increase: Increase, 538 | pub percentile: f64, 539 | pub description: String, 540 | } 541 | 542 | #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)] 543 | #[serde(rename_all = "camelCase")] 544 | pub struct InteractiveValue { 545 | #[serde(rename = "interact_value")] 546 | pub interact_value: String, 547 | #[serde(rename = "is_jump")] 548 | pub is_jump: bool, 549 | pub increase: Increase, 550 | pub percentile: f64, 551 | pub description: String, 552 | } 553 | 554 | #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)] 555 | #[serde(rename_all = "camelCase")] 556 | pub struct Increase { 557 | pub days: i64, 558 | #[serde(rename = "increase_percent")] 559 | pub increase_percent: i64, 560 | } 561 | -------------------------------------------------------------------------------- /src-tauri/src/bili_client.rs: -------------------------------------------------------------------------------- 1 | use crate::config::Config; 2 | use crate::events::{SetProxyErrorEvent, SetProxyErrorEventPayload}; 3 | use crate::extensions::AnyhowErrorToStringChain; 4 | use crate::responses::{ 5 | BiliResp, ComicRespData, GenerateWebQrcodeRespData, ImageIndexRespData, ImageTokenRespData, 6 | SearchRespData, UserProfileRespData, WebQrcodeStatusRespData, 7 | }; 8 | use crate::types::{AsyncRwLock, Comic, ProxyMode, WebQrcodeData}; 9 | use anyhow::{anyhow, Context}; 10 | use base64::engine::general_purpose; 11 | use base64::Engine; 12 | use bytes::Bytes; 13 | use image::Rgb; 14 | use parking_lot::RwLock; 15 | use qrcode::QrCode; 16 | use reqwest::StatusCode; 17 | use reqwest_middleware::ClientWithMiddleware; 18 | use reqwest_retry::policies::ExponentialBackoff; 19 | use reqwest_retry::RetryTransientMiddleware; 20 | use serde_json::json; 21 | use std::io::Cursor; 22 | use std::sync::Arc; 23 | use tauri::{AppHandle, Manager}; 24 | use tauri_specta::Event; 25 | 26 | #[allow(clippy::unreadable_literal)] 27 | #[derive(Clone)] 28 | pub struct BiliClient { 29 | app: AppHandle, 30 | http_client: Arc>, 31 | } 32 | 33 | impl BiliClient { 34 | pub fn new(app: AppHandle) -> Self { 35 | let http_client = create_http_client(&app); 36 | let http_client = Arc::new(AsyncRwLock::new(http_client)); 37 | Self { app, http_client } 38 | } 39 | 40 | pub async fn recreate_http_client(&self) { 41 | let http_client = create_http_client(&self.app); 42 | *self.http_client.write().await = http_client; 43 | } 44 | 45 | pub async fn generate_web_qrcode(&self) -> anyhow::Result { 46 | // 发送生成二维码请求 47 | let http_resp = self 48 | .http_client 49 | .read() 50 | .await 51 | .get("https://passport.bilibili.com/x/passport-login/web/qrcode/generate") 52 | .send() 53 | .await?; 54 | // 检查http响应状态码 55 | let status = http_resp.status(); 56 | let body = http_resp.text().await?; 57 | if status != StatusCode::OK { 58 | return Err(anyhow::anyhow!( 59 | "生成Web二维码失败,预料之外的状态码({status}): {body}" 60 | )); 61 | } 62 | // 尝试将body解析为BiliResp 63 | let bili_resp = serde_json::from_str::(&body) 64 | .context(format!("将body解析为BiliResp失败: {body}"))?; 65 | // 检查BiliResp的code字段 66 | if bili_resp.code != 0 { 67 | return Err(anyhow!("生成Web二维码失败,预料之外的code: {bili_resp:?}")); 68 | } 69 | // 检查BiliResp的data是否存在 70 | let Some(data) = bili_resp.data else { 71 | return Err(anyhow!("生成Web二维码失败,data字段不存在: {bili_resp:?}")); 72 | }; 73 | // 尝试将data解析为GenerateWebQrcodeRespData 74 | let data_str = data.to_string(); 75 | let generate_qrcode_resp_data = 76 | serde_json::from_str::(&data_str).context(format!( 77 | "生成Web二维码失败,将data解析为GenerateQrcodeRespData失败: {data_str}" 78 | ))?; 79 | // 生成二维码 80 | let qr_code = QrCode::new(generate_qrcode_resp_data.url) 81 | .context("生成Web二维码失败,从url创建QrCode失败")?; 82 | let img = qr_code.render::>().build(); 83 | let mut img_data: Vec = Vec::new(); 84 | img.write_to(&mut Cursor::new(&mut img_data), image::ImageFormat::Jpeg) 85 | .context("生成Web二维码失败,将QrCode写入img_data失败")?; 86 | let base64 = general_purpose::STANDARD.encode(img_data); 87 | let web_qrcode_data = WebQrcodeData { 88 | base64, 89 | qrcode_key: generate_qrcode_resp_data.qrcode_key, 90 | }; 91 | 92 | Ok(web_qrcode_data) 93 | } 94 | 95 | pub async fn get_web_qrcode_status( 96 | &self, 97 | qrcode_key: &str, 98 | ) -> anyhow::Result { 99 | let params = json!({ 100 | "qrcode_key": qrcode_key, 101 | }); 102 | // 发送获取二维码状态请求 103 | let http_resp = self 104 | .http_client 105 | .read() 106 | .await 107 | .get("https://passport.bilibili.com/x/passport-login/web/qrcode/poll") 108 | .query(¶ms) 109 | .send() 110 | .await?; 111 | // 检查http响应状态码 112 | let status = http_resp.status(); 113 | let body = http_resp.text().await?; 114 | if status != StatusCode::OK { 115 | return Err(anyhow!( 116 | "获取Web二维码状态失败,预料之外的状态码({status}): {body}" 117 | )); 118 | } 119 | // 尝试将body解析为BiliResp 120 | let bili_resp = serde_json::from_str::(&body).context(format!( 121 | "获取Web二维码状态失败,将body解析为BiliResp失败: {body}" 122 | ))?; 123 | // 检查BiliResp的code字段 124 | if bili_resp.code != 0 { 125 | return Err(anyhow!( 126 | "获取Web二维码状态失败,预料之外的code: {bili_resp:?}" 127 | )); 128 | } 129 | // 检查BiliResp的data是否存在 130 | let Some(data) = bili_resp.data else { 131 | return Err(anyhow!( 132 | "获取Web二维码状态失败,data字段不存在: {bili_resp:?}" 133 | )); 134 | }; 135 | // 尝试将data解析为WebQrcodeStatusRespData 136 | let data_str = data.to_string(); 137 | let web_qrcode_status_resp_data = 138 | serde_json::from_str::(&data_str).context(format!( 139 | "获取二维码状态失败,将data解析为QrcodeStatusRespData失败: {data_str}" 140 | ))?; 141 | 142 | Ok(web_qrcode_status_resp_data) 143 | } 144 | 145 | pub async fn get_user_profile(&self) -> anyhow::Result { 146 | let cookie = self.cookie(); 147 | // 发送获取用户信息请求 148 | let http_resp = self 149 | .http_client 150 | .read() 151 | .await 152 | .get("https://api.bilibili.com/x/web-interface/nav") 153 | .header("cookie", cookie) 154 | .send() 155 | .await?; 156 | // 检查http响应状态码 157 | let status = http_resp.status(); 158 | let body = http_resp.text().await?; 159 | if status != StatusCode::OK { 160 | return Err(anyhow!( 161 | "获取用户信息失败,预料之外的状态码({status}): {body}" 162 | )); 163 | } 164 | // 尝试将body解析为BiliResp 165 | let bili_resp = serde_json::from_str::(&body) 166 | .context(format!("将body解析为BiliResp失败: {body}"))?; 167 | // 检查BiliResp的code字段 168 | if bili_resp.code != 0 { 169 | return Err(anyhow!("获取用户信息失败,预料之外的code: {bili_resp:?}")); 170 | } 171 | // 检查BiliResp的data是否存在 172 | let Some(data) = bili_resp.data else { 173 | return Err(anyhow!("获取用户信息失败,data字段不存在: {bili_resp:?}")); 174 | }; 175 | // 尝试将data解析为UserProfileRespData 176 | let data_str = data.to_string(); 177 | let user_profile_resp_data = serde_json::from_str::(&data_str) 178 | .context(format!( 179 | "获取用户信息失败,将data解析为UserProfileRespData失败: {data_str}" 180 | ))?; 181 | 182 | Ok(user_profile_resp_data) 183 | } 184 | 185 | pub async fn search(&self, keyword: &str, page_num: i64) -> anyhow::Result { 186 | let payload = json!({ 187 | "keyword": keyword, 188 | "pageNum": page_num, 189 | "pageSize": 20, 190 | }); 191 | // 发送搜索漫画请求 192 | let http_resp = self 193 | .http_client 194 | .read() 195 | .await 196 | .post("https://manga.bilibili.com/twirp/search.v1.Search/SearchKeyword") 197 | .json(&payload) 198 | .send() 199 | .await?; 200 | // 检查http响应状态码 201 | let status = http_resp.status(); 202 | let body = http_resp.text().await?; 203 | if status != StatusCode::OK { 204 | return Err(anyhow!("搜索漫画失败,预料之外的状态码({status}): {body}")); 205 | } 206 | // 尝试将body解析为BiliResp 207 | let bili_resp = serde_json::from_str::(&body) 208 | .context(format!("将body解析为BiliResp失败: {body}"))?; 209 | // 检查BiliResp的code字段 210 | if bili_resp.code != 0 { 211 | return Err(anyhow!("搜索漫画失败,预料之外的code: {bili_resp:?}")); 212 | } 213 | // 检查BiliResp的data是否存在 214 | let Some(data) = bili_resp.data else { 215 | return Err(anyhow!("搜索漫画失败,data字段不存在: {bili_resp:?}")); 216 | }; 217 | // 尝试将data解析为SearchRespData 218 | let data_str = data.to_string(); 219 | let search_resp_data = serde_json::from_str::(&data_str).context( 220 | format!("搜索漫画失败,将data解析为SearchRespData失败: {data_str}"), 221 | )?; 222 | 223 | Ok(search_resp_data) 224 | } 225 | 226 | pub async fn get_comic(&self, comic_id: i64) -> anyhow::Result { 227 | let cookie = self.cookie(); 228 | let referer = format!("https://manga.bilibili.com/detail/mc{comic_id}?from=manga_person"); 229 | let params = json!({ 230 | "device": "pc", 231 | "platform": "web", 232 | }); 233 | let payload = json!({"comic_id": comic_id}); 234 | // 发送获取漫画详情请求 235 | let http_resp = self 236 | .http_client 237 | .read() 238 | .await 239 | .post("https://manga.bilibili.com/twirp/comic.v1.Comic/ComicDetail") 240 | .query(¶ms) 241 | .header("accept", "application/json, text/plain, */*") 242 | .header("accept-encoding", "gzip, deflate, br, zstd") 243 | .header("accept-language", "zh-CN,zh;q=0.9") 244 | .header("content-type", "application/json;charset=UTF-8") 245 | .header("cookie", cookie) 246 | .header("origin", "https://manga.bilibili.com") 247 | .header("priority", "u=1, i") 248 | .header("referer", referer) 249 | .header("sec-ch-ua", r#""Google Chrome";v="131", "Chromium";v="131", "Not_A Brand";v="24""#) 250 | .header("sec-ch-ua-mobile", "?0") 251 | .header("sec-ch-ua-mobile", r#""Windows""#) 252 | .header("sec-fetch-dest", "empty") 253 | .header("sec-fetch-mode", "cors") 254 | .header("sec-fetch-site", "same-origin") 255 | .header("user-agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36") 256 | .json(&payload) 257 | .send() 258 | .await?; 259 | // 检查http响应状态码 260 | let status = http_resp.status(); 261 | let body = http_resp.text().await?; 262 | if status != StatusCode::OK { 263 | return Err(anyhow!( 264 | "获取漫画详情失败,预料之外的状态码({status}): {body}" 265 | )); 266 | } 267 | // 尝试将body解析为BiliResp 268 | let bili_resp = serde_json::from_str::(&body).context(format!( 269 | "获取漫画详情失败,将body解析为BiliResp失败: {body}" 270 | ))?; 271 | // 检查BiliResp的code字段 272 | if bili_resp.code == 99 { 273 | return Err(anyhow!("获取漫画详情失败,Cookie不完整,请返回浏览器刷新页面后重新获取完整的Cookie: {bili_resp:?}")); 274 | } 275 | if bili_resp.code != 0 { 276 | return Err(anyhow!("获取漫画详情失败,预料之外的code: {bili_resp:?}")); 277 | } 278 | // 检查BiliResp的data是否存在 279 | let Some(data) = bili_resp.data else { 280 | return Err(anyhow!("获取漫画详情失败,data字段不存在: {bili_resp:?}")); 281 | }; 282 | // 尝试将data解析为ComicRespData 283 | let data_str = data.to_string(); 284 | let comic_resp_data = serde_json::from_str::(&data_str).context(format!( 285 | "获取漫画详情失败,将data解析为ComicRespData失败: {data_str}" 286 | ))?; 287 | let comic = Comic::from(&self.app, comic_resp_data); 288 | 289 | Ok(comic) 290 | } 291 | 292 | pub async fn get_image_index( 293 | &self, 294 | comic_id: i64, 295 | episode_id: i64, 296 | ) -> anyhow::Result { 297 | let cookie = self.cookie(); 298 | let referer = format!("https://manga.bilibili.com/mc{comic_id}/{episode_id}"); 299 | let params = json!({ 300 | "device": "pc", 301 | "platform": "web", 302 | }); 303 | let payload = json!({"ep_id": episode_id}); 304 | // 发送获取ImageIndex的请求 305 | let http_resp = self.http_client.read().await 306 | .post("https://manga.bilibili.com/twirp/comic.v1.Comic/GetImageIndex") 307 | .query(¶ms) 308 | .header("accept", "application/json, text/plain, */*") 309 | .header("accept-encoding", "gzip, deflate, br, zstd") 310 | .header("accept-language", "zh-CN,zh;q=0.9") 311 | .header("content-type", "application/json;charset=UTF-8") 312 | .header("cookie", cookie) 313 | .header("origin", "https://manga.bilibili.com") 314 | .header("priority", "u=1, i") 315 | .header("referer", referer) 316 | .header("sec-ch-ua", r#""Google Chrome";v="131", "Chromium";v="131", "Not_A Brand";v="24""#) 317 | .header("sec-ch-ua-mobile", "?0") 318 | .header("sec-ch-ua-mobile", r#""Windows""#) 319 | .header("sec-fetch-dest", "empty") 320 | .header("sec-fetch-mode", "cors") 321 | .header("sec-fetch-site", "same-origin") 322 | .header("user-agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36") 323 | .json(&payload) 324 | .send() 325 | .await?; 326 | // 检查http响应状态码 327 | let status = http_resp.status(); 328 | let body = http_resp.text().await?; 329 | if status != StatusCode::OK { 330 | return Err(anyhow!( 331 | "获取章节 `{episode_id}` 的ImageIndex失败,预料之外的状态码({status}): {body}" 332 | )); 333 | } 334 | // 尝试将body解析为BiliResp 335 | let bili_resp = serde_json::from_str::(&body).context(format!( 336 | "获取章节 `{episode_id}` 的ImageIndex失败,将body解析为BiliResp失败: {body}" 337 | ))?; 338 | // 检查BiliResp的code字段 339 | if bili_resp.code != 0 { 340 | return Err(anyhow!( 341 | "获取章节 `{episode_id}` 的ImageIndex失败,预料之外的code: {bili_resp:?}" 342 | )); 343 | } 344 | // 检查BiliResp的data是否存在 345 | let Some(data) = bili_resp.data else { 346 | return Err(anyhow!( 347 | "获取章节 `{episode_id}` 的ImageIndex失败,data字段不存在: {bili_resp:?}" 348 | )); 349 | }; 350 | // 尝试将data解析为ImageIndexRespData 351 | let data_str = data.to_string(); 352 | let image_index_data = serde_json::from_str::(&data_str).context(format!( 353 | "获取章节 `{episode_id}` 的ImageIndex失败,将data解析为ImageIndexRespData失败: {data_str}" 354 | ))?; 355 | 356 | Ok(image_index_data) 357 | } 358 | 359 | pub async fn get_image_token( 360 | &self, 361 | comic_id: i64, 362 | episode_id: i64, 363 | urls: &Vec, 364 | ) -> anyhow::Result { 365 | let cookie = self.cookie(); 366 | let referer = format!("https://manga.bilibili.com/mc{comic_id}/{episode_id}"); 367 | let params = json!({ 368 | "device": "pc", 369 | "platform": "web", 370 | }); 371 | let urls_str = serde_json::to_string(urls)?; 372 | let payload = json!({"urls": urls_str}); 373 | // 发送获取ImageToken的请求 374 | let http_resp = self.http_client.read().await 375 | .post("https://manga.bilibili.com/twirp/comic.v1.Comic/ImageToken") 376 | .query(¶ms) 377 | .header("accept", "application/json, text/plain, */*") 378 | .header("accept-encoding", "gzip, deflate, br, zstd") 379 | .header("accept-language", "zh-CN,zh;q=0.9") 380 | .header("content-type", "application/json;charset=UTF-8") 381 | .header("cookie", cookie) 382 | .header("origin", "https://manga.bilibili.com") 383 | .header("priority", "u=1, i") 384 | .header("referer", referer) 385 | .header("sec-ch-ua", r#""Google Chrome";v="131", "Chromium";v="131", "Not_A Brand";v="24""#) 386 | .header("sec-ch-ua-mobile", "?0") 387 | .header("sec-ch-ua-mobile", r#""Windows""#) 388 | .header("sec-fetch-dest", "empty") 389 | .header("sec-fetch-mode", "cors") 390 | .header("sec-fetch-site", "same-origin") 391 | .header("user-agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36") 392 | .json(&payload) 393 | .send() 394 | .await?; 395 | // 检查http响应状态码 396 | let status = http_resp.status(); 397 | let body = http_resp.text().await?; 398 | if status != StatusCode::OK { 399 | return Err(anyhow!( 400 | "获取ImageToken失败,预料之外的状态码({status}): {body}" 401 | )); 402 | } 403 | // 尝试将body解析为BiliResp 404 | let bili_resp = serde_json::from_str::(&body).context(format!( 405 | "获取ImageToken失败,将body解析为BiliResp失败: {body}" 406 | ))?; 407 | // 检查BiliResp的code字段 408 | if bili_resp.code != 0 { 409 | return Err(anyhow!("获取ImageToken失败,预料之外的code: {bili_resp:?}")); 410 | } 411 | // 检查BiliResp的data是否存在 412 | let Some(data) = bili_resp.data else { 413 | return Err(anyhow!("获取ImageToken失败,data字段不存在: {bili_resp:?}")); 414 | }; 415 | // 尝试将data解析为ImageTokenRespData 416 | let data_str = data.to_string(); 417 | let image_token_data = serde_json::from_str::(&data_str).context( 418 | format!("获取ImageToken失败,将data解析为ImageTokenRespData失败: {data_str}"), 419 | )?; 420 | 421 | Ok(image_token_data) 422 | } 423 | 424 | pub async fn get_image_bytes(&self, url: &str) -> anyhow::Result { 425 | // 发送下载图片请求 426 | let http_resp = self.http_client.read().await.get(url) 427 | .header("accept", "*/*") 428 | .header("accept-encoding", "gzip, deflate, br, zstd") 429 | .header("accept-language", "zh-CN,zh;q=0.9") 430 | .header("origin", "https://manga.bilibili.com") 431 | .header("referer", "https://manga.bilibili.com/") 432 | .header("sec-ch-ua", r#""Google Chrome";v="131", "Chromium";v="131", "Not_A Brand";v="24""#) 433 | .header("sec-ch-ua-mobile", "?0") 434 | .header("sec-ch-ua-mobile", r#""Windows""#) 435 | .header("sec-fetch-dest", "empty") 436 | .header("sec-fetch-mode", "cors") 437 | .header("sec-fetch-site", "cross-site") 438 | .header("user-agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36") 439 | .send() 440 | .await?; 441 | // 检查http响应状态码 442 | let status = http_resp.status(); 443 | if status != StatusCode::OK { 444 | let body = http_resp.text().await?; 445 | return Err(anyhow!("下载图片 {url} 失败,预料之外的状态码: {body}")); 446 | } 447 | // 读取图片数据 448 | let image_data = http_resp.bytes().await?; 449 | 450 | Ok(image_data) 451 | } 452 | 453 | fn cookie(&self) -> String { 454 | self.app.state::>().read().cookie.clone() 455 | } 456 | } 457 | 458 | fn create_http_client(app: &AppHandle) -> ClientWithMiddleware { 459 | let builder = reqwest::ClientBuilder::new() 460 | .use_rustls_tls() 461 | .danger_accept_invalid_certs(true); 462 | 463 | let proxy_mode = app.state::>().read().proxy_mode.clone(); 464 | let builder = match proxy_mode { 465 | ProxyMode::NoProxy => builder.no_proxy(), 466 | ProxyMode::System => builder, 467 | ProxyMode::Custom => { 468 | let config = app.state::>(); 469 | let config = config.read(); 470 | let proxy_host = &config.proxy_host; 471 | let proxy_port = &config.proxy_port; 472 | let proxy_url = format!("http://{proxy_host}:{proxy_port}"); 473 | 474 | match reqwest::Proxy::all(&proxy_url).map_err(anyhow::Error::from) { 475 | Ok(proxy) => builder.proxy(proxy), 476 | Err(err) => { 477 | let err = err.context(format!("BiliClient设置代理 {proxy_url} 失败")); 478 | emit_set_proxy_error_event(app, err.to_string_chain()); 479 | builder 480 | } 481 | } 482 | } 483 | }; 484 | 485 | let retry_policy = ExponentialBackoff::builder().build_with_max_retries(3); 486 | 487 | reqwest_middleware::ClientBuilder::new(builder.build().unwrap()) 488 | .with(RetryTransientMiddleware::new_with_policy(retry_policy)) 489 | .build() 490 | } 491 | 492 | fn emit_set_proxy_error_event(app: &AppHandle, err_msg: String) { 493 | let payload = SetProxyErrorEventPayload { err_msg }; 494 | let event = SetProxyErrorEvent(payload); 495 | let _ = event.emit(app); 496 | } 497 | --------------------------------------------------------------------------------