mirror of
https://git.um-react.app/um/um-react.git
synced 2026-10-22 22:43:01 +00:00
Compare commits
10
Commits
a11caaae92
...
v0.6.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
10e857a903 | ||
|
|
537391ed03 | ||
|
|
881aa5e44c | ||
|
|
757bbd2172 | ||
|
|
3f14a3acff | ||
|
|
b142c1ba34 | ||
|
|
4df197ac4d | ||
|
|
07d160c547 | ||
|
|
3059f495ab | ||
|
|
024847a45a |
@@ -7,7 +7,9 @@ body:
|
|||||||
value: |
|
value: |
|
||||||
### Windows 客户端
|
### Windows 客户端
|
||||||
|
|
||||||
目前 Windows 客户端仅支持 19.51 或更低版本下载的歌曲文件。
|
目前 Windows 客户端支持 19.57 至 22.76(目前最新版)下载的歌曲文件,但需要导入由 [qmpc-mmkv-cli](https://git.um-react.app/um/qmpc-mmkv/releases) 提取的密钥文件 `qmpc-mmkv-v1.mmkv`。
|
||||||
|
|
||||||
|
使用 19.51 或更低版本下载的歌曲文件无需密钥。
|
||||||
|
|
||||||
* [web.archive.org 镜像](https://web.archive.org/web/2023/https://dldir1v6.qq.com/music/clntupate/QQMusic_Setup_1951.exe)
|
* [web.archive.org 镜像](https://web.archive.org/web/2023/https://dldir1v6.qq.com/music/clntupate/QQMusic_Setup_1951.exe)
|
||||||
* [通过 Telegram 下载](https://t.me/um_lsr_ch/24)
|
* [通过 Telegram 下载](https://t.me/um_lsr_ch/24)
|
||||||
|
|||||||
@@ -4,8 +4,6 @@ on: [push]
|
|||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
env:
|
|
||||||
npm_config_registry: https://registry.npmmirror.com
|
|
||||||
steps:
|
steps:
|
||||||
- name: Check out repository code
|
- name: Check out repository code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|||||||
@@ -1,3 +1 @@
|
|||||||
use-node-version=24.7.0
|
|
||||||
engine-strict=true
|
|
||||||
@unlock-music:registry=https://git.um-react.app/api/packages/um/npm/
|
@unlock-music:registry=https://git.um-react.app/api/packages/um/npm/
|
||||||
|
|||||||
+2
-2
@@ -1,4 +1,4 @@
|
|||||||
FROM node:22-slim AS build
|
FROM node:24.22.0-slim AS build
|
||||||
ENV PNPM_HOME="/p"
|
ENV PNPM_HOME="/p"
|
||||||
ENV PATH="$PNPM_HOME:$PATH"
|
ENV PATH="$PNPM_HOME:$PATH"
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
@@ -9,7 +9,7 @@ RUN corepack enable pnpm \
|
|||||||
&& apt-get clean \
|
&& apt-get clean \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
COPY package.json pnpm-lock.yaml .npmrc ./
|
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc ./
|
||||||
RUN pnpm exec true
|
RUN pnpm exec true
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN pnpm install --frozen-lockfile
|
RUN pnpm install --frozen-lockfile
|
||||||
|
|||||||
@@ -40,7 +40,7 @@
|
|||||||
- [x] 蜻蜓 FM (`.qta`)
|
- [x] 蜻蜓 FM (`.qta`)
|
||||||
- 需要提取设备 ID。
|
- 需要提取设备 ID。
|
||||||
|
|
||||||
[^qm-key-pc]: QQ 音乐的 PC 客户端仅支持 v19.51 或更低版本。
|
[^qm-key-pc]: QQ 音乐的 PC 客户端(v19.57 至 v22.76)需要导入由 [qmpc-mmkv-cli](https://git.um-react.app/um/qmpc-mmkv/releases) 提取的密钥文件 `qmpc-mmkv-v1.mmkv`。
|
||||||
|
|
||||||
[^qm-key-android]: QQ 音乐的安卓客户端支持需要获取超级管理员权限后提取密钥数据库并导入后使用。
|
[^qm-key-android]: QQ 音乐的安卓客户端支持需要获取超级管理员权限后提取密钥数据库并导入后使用。
|
||||||
|
|
||||||
|
|||||||
+13
-1
@@ -10,7 +10,7 @@
|
|||||||
|
|
||||||
#### 2、检查您的平台。
|
#### 2、检查您的平台。
|
||||||
|
|
||||||
日前,<mark>仅 Windows 客户端 v19.43 或以下版本</mark>下载的歌曲无需密钥,其余平台的官方正式版本均需要提取密钥。
|
日前,Windows 客户端 <mark>v19.57 至 v22.76</mark>(目前最新版)下载的歌曲需要导入由 [qmpc-mmkv-cli](https://git.um-react.app/um/qmpc-mmkv/releases) 提取的密钥文件 `qmpc-mmkv-v1.mmkv`;<mark>v19.51 或更低版本</mark>下载的歌曲无需密钥。其余平台的官方正式版本均需要提取密钥。
|
||||||
|
|
||||||
> iOS 用户提取歌曲困难,建议换用电脑操作;Android 用户提取密钥需要 root,也建议用电脑操作。
|
> iOS 用户提取歌曲困难,建议换用电脑操作;Android 用户提取密钥需要 root,也建议用电脑操作。
|
||||||
|
|
||||||
@@ -71,6 +71,18 @@
|
|||||||
- 雷电模拟器可以在「模拟器设置」 → 「其他设置」中启用 root 特权。
|
- 雷电模拟器可以在「模拟器设置」 → 「其他设置」中启用 root 特权。
|
||||||

|

|
||||||
|
|
||||||
|
### 批量下载约十首后失败 / 无法全部下载
|
||||||
|
|
||||||
|
Chromium 系浏览器会限制页面在短时间内通过程序触发的**多个**文件下载(常见表现为大约十个文件后静默中断,或提示「此网站正在尝试下载多个文件」)。
|
||||||
|
|
||||||
|
本站的「下载全部」会按「设定 → 通用设置 → 批量下载」中勾选的顺序尝试:
|
||||||
|
|
||||||
|
1. 选择输出目录,直接写入文件(推荐,Chrome / Edge)
|
||||||
|
2. 流式生成单个 ZIP 并保存
|
||||||
|
3. 在内存中生成 ZIP 后触发一次下载(兼容性最好)
|
||||||
|
|
||||||
|
若全部失败会弹出提示。大批量转换也可使用 [命令行版本](https://git.um-react.app/um/cli)。
|
||||||
|
|
||||||
### Via 等浏览器无法正常解密/下载
|
### Via 等浏览器无法正常解密/下载
|
||||||
|
|
||||||
⚠️ 手机端浏览器支持有限,请使用最新版本的 Chrome 或 Firefox 官方浏览器。
|
⚠️ 手机端浏览器支持有限,请使用最新版本的 Chrome 或 Firefox 官方浏览器。
|
||||||
|
|||||||
@@ -4,8 +4,8 @@
|
|||||||
|
|
||||||
## 安装依赖
|
## 安装依赖
|
||||||
|
|
||||||
- 安装 Node v16.17 或更高,推荐当前最新的 Node LTS 版本。
|
- 安装 Node 24.22.0;pnpm 也会根据 `package.json` 中的 `devEngines.runtime` 自动获取该版本。
|
||||||
- 安装/激活 `pnpm` [^1]:`corepack prepare pnpm@latest --activate`
|
- 安装/激活项目指定的 `pnpm` [^1]:`corepack prepare pnpm@12.14.0 --activate`
|
||||||
- 安装软件依赖:`pnpm i --frozen-lockfile`
|
- 安装软件依赖:`pnpm i --frozen-lockfile`
|
||||||
|
|
||||||
[^1]: 参考 pnpm 说明「[使用 Corepack 安装](https://pnpm.io/zh/installation#使用-corepack-安装)」。
|
[^1]: 参考 pnpm 说明「[使用 Corepack 安装](https://pnpm.io/zh/installation#使用-corepack-安装)」。
|
||||||
@@ -34,6 +34,10 @@ pnpm build
|
|||||||
|
|
||||||
[vite-preview-url]: http://localhost:4173/
|
[vite-preview-url]: http://localhost:4173/
|
||||||
|
|
||||||
|
### TypeScript 工具链
|
||||||
|
|
||||||
|
编译和 ESLint 统一使用 TypeScript 6.x,暂不升级到 TypeScript 7。
|
||||||
|
|
||||||
## 打包 `.zip`
|
## 打包 `.zip`
|
||||||
|
|
||||||
建议在 Linux 环境下执行,可参考 `.drone.yml` CI 文件。
|
建议在 Linux 环境下执行,可参考 `.drone.yml` CI 文件。
|
||||||
|
|||||||
@@ -9,6 +9,10 @@
|
|||||||
<link rel="icon" href="/favicon.ico" />
|
<link rel="icon" href="/favicon.ico" />
|
||||||
<link rel="apple-touch-icon" href="/pwa-512x512.png" sizes="512x512" />
|
<link rel="apple-touch-icon" href="/pwa-512x512.png" sizes="512x512" />
|
||||||
<meta name="theme-color" content="#4DBA87" />
|
<meta name="theme-color" content="#4DBA87" />
|
||||||
|
<script>
|
||||||
|
// 设置基础路径,如果希望部署在/music-unlock/,则改为 '/music-unlock/',默认部署在根路径
|
||||||
|
window.BASE_URL = '/';
|
||||||
|
</script>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
+59
-65
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "um-react",
|
"name": "um-react",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.6.0",
|
"version": "0.6.2",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "vite",
|
"start": "vite",
|
||||||
@@ -17,65 +17,66 @@
|
|||||||
"prepare": "simple-git-hooks"
|
"prepare": "simple-git-hooks"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@reduxjs/toolkit": "^2.9.0",
|
"@reduxjs/toolkit": "^2.13.0",
|
||||||
"@unlock-music/crypto": "^0.1.12",
|
"@unlock-music/crypto": "^0.1.12",
|
||||||
"classnames": "^2.5.1",
|
"classnames": "^2.5.1",
|
||||||
"nanoid": "^5.1.6",
|
"fflate": "^0.8.3",
|
||||||
|
"idb-keyval": "^6.3.0",
|
||||||
|
"nanoid": "^6.0.2",
|
||||||
"radash": "^12.1.1",
|
"radash": "^12.1.1",
|
||||||
"react": "^19.2.0",
|
"react": "^19.3.1",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.3.1",
|
||||||
"react-dropzone": "^14.3.8",
|
"react-dropzone": "^20.1.4",
|
||||||
"react-icons": "^5.5.0",
|
"react-icons": "^5.7.0",
|
||||||
"react-redux": "^9.2.0",
|
"react-redux": "^9.3.0",
|
||||||
"react-router": "^7.9.4",
|
"react-router": "^8.5.0",
|
||||||
"react-syntax-highlighter": "^15.6.6",
|
"react-syntax-highlighter": "^16.1.2",
|
||||||
"react-toastify": "^11.0.5",
|
"react-toastify": "^11.1.0",
|
||||||
"sql.js": "^1.13.0"
|
"sql.js": "^1.15.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.37.0",
|
"@eslint/js": "^10.0.1",
|
||||||
"@rollup/plugin-replace": "^6.0.2",
|
"@rollup/plugin-replace": "^6.0.3",
|
||||||
"@tailwindcss/vite": "^4.1.14",
|
"@tailwindcss/vite": "^4.3.3",
|
||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^7.0.1",
|
||||||
"@testing-library/react": "^16.3.0",
|
"@testing-library/react": "^16.3.3",
|
||||||
"@testing-library/user-event": "^14.6.1",
|
"@testing-library/user-event": "^14.6.8",
|
||||||
"@types/node": "^24.7.2",
|
"@types/node": "^26.6.5",
|
||||||
"@types/react": "^19.2.2",
|
"@types/react": "^19.3.0",
|
||||||
"@types/react-dom": "^19.2.2",
|
"@types/react-dom": "^19.3.0",
|
||||||
"@types/react-syntax-highlighter": "^15.5.13",
|
"@types/react-syntax-highlighter": "^15.5.13",
|
||||||
"@types/sql.js": "^1.4.9",
|
"@types/sql.js": "^1.4.11",
|
||||||
"@types/tar-stream": "^3.1.4",
|
"@types/tar-stream": "^3.1.5",
|
||||||
"@types/wicg-file-system-access": "^2023.10.7",
|
"@types/wicg-file-system-access": "^2023.10.7",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.46.1",
|
"@typescript-eslint/eslint-plugin": "^8.72.0",
|
||||||
"@typescript-eslint/parser": "^8.46.1",
|
"@typescript-eslint/parser": "^8.72.0",
|
||||||
"@vitejs/plugin-react": "^5.0.4",
|
"@vitejs/plugin-react": "^6.1.2",
|
||||||
"@vitest/coverage-v8": "^3.2.4",
|
"@vitest/coverage-v8": "^5.0.5",
|
||||||
"@vitest/ui": "^3.2.4",
|
"@vitest/ui": "^5.0.5",
|
||||||
"daisyui": "^5.3.2",
|
"daisyui": "^5.7.50",
|
||||||
"eslint": "^9.37.0",
|
"eslint": "^10.13.0",
|
||||||
"eslint-config-prettier": "^10.1.8",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
"eslint-plugin-react-hooks": "^7.0.0",
|
"eslint-plugin-react-hooks": "^7.1.1",
|
||||||
"eslint-plugin-react-refresh": "^0.4.24",
|
"eslint-plugin-react-refresh": "^0.5.7",
|
||||||
"globals": "^16.4.0",
|
"globals": "^17.13.0",
|
||||||
"husky": "^9.1.7",
|
"husky": "^9.1.7",
|
||||||
"jsdom": "^27.0.0",
|
"jsdom": "^30.1.3",
|
||||||
"lint-staged": "^16.2.4",
|
"lint-staged": "^17.6.0",
|
||||||
"prettier": "^3.6.2",
|
"prettier": "^3.9.10",
|
||||||
"rollup": "^4.52.4",
|
"rollup": "^4.64.12",
|
||||||
"sass": "^1.93.2",
|
"sass": "^1.105.2",
|
||||||
"simple-git-hooks": "^2.13.1",
|
"simple-git-hooks": "^2.14.0",
|
||||||
"tailwindcss": "^4.1.14",
|
"tailwindcss": "^4.3.3",
|
||||||
"tar-stream": "^3.1.7",
|
"tar-stream": "^3.2.2",
|
||||||
"terser": "^5.44.0",
|
"terser": "^5.51.2",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^6.0.3",
|
||||||
"typescript-eslint": "^8.46.1",
|
"typescript-eslint": "^8.72.0",
|
||||||
"vite": "^7.1.10",
|
"vite": "^8.3.4",
|
||||||
"vite-plugin-pwa": "^1.1.0",
|
"vite-plugin-pwa": "^2.0.0",
|
||||||
"vite-plugin-top-level-await": "^1.6.0",
|
"vite-plugin-wasm": "^3.6.0",
|
||||||
"vite-plugin-wasm": "^3.5.0",
|
"vitest": "^5.0.5",
|
||||||
"vitest": "^3.2.4",
|
"workbox-build": "^7.4.1",
|
||||||
"workbox-build": "^7.3.0",
|
"workbox-window": "^7.4.1"
|
||||||
"workbox-window": "^7.3.0"
|
|
||||||
},
|
},
|
||||||
"lint-staged": {
|
"lint-staged": {
|
||||||
"*": "prettier --write --ignore-unknown",
|
"*": "prettier --write --ignore-unknown",
|
||||||
@@ -90,19 +91,12 @@
|
|||||||
"printWidth": 120,
|
"printWidth": 120,
|
||||||
"tabWidth": 2
|
"tabWidth": 2
|
||||||
},
|
},
|
||||||
"pnpm": {
|
"devEngines": {
|
||||||
"patchedDependencies": {
|
"runtime": {
|
||||||
"sql.js": "patches/sql.js.patch"
|
"name": "node",
|
||||||
|
"version": "24.22.0",
|
||||||
|
"onFail": "download"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"overrides": {
|
"packageManager": "[email protected]"
|
||||||
"sourcemap-codec": "npm:@jridgewell/[email protected]"
|
|
||||||
},
|
|
||||||
"onlyBuiltDependencies": [
|
|
||||||
"@swc/core",
|
|
||||||
"@tailwindcss/oxide",
|
|
||||||
"esbuild",
|
|
||||||
"simple-git-hooks"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"packageManager": "[email protected]"
|
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+3417
-3721
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,16 @@
|
|||||||
|
engineStrict: true
|
||||||
|
|
||||||
|
patchedDependencies:
|
||||||
|
sql.js: patches/sql.js.patch
|
||||||
|
|
||||||
|
overrides:
|
||||||
|
sourcemap-codec: npm:@jridgewell/[email protected]
|
||||||
|
|
||||||
|
allowBuilds:
|
||||||
|
'@parcel/watcher': false
|
||||||
|
'@swc/core': true
|
||||||
|
'@tailwindcss/oxide': true
|
||||||
|
esbuild: true
|
||||||
|
simple-git-hooks: true
|
||||||
|
|
||||||
|
minimumReleaseAge: 0
|
||||||
@@ -25,7 +25,7 @@ export function AppRoot() {
|
|||||||
useEffect(() => persistSettings(store), []);
|
useEffect(() => persistSettings(store), []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BrowserRouter>
|
<BrowserRouter basename={window.BASE_URL || '/'}>
|
||||||
<Provider store={store}>
|
<Provider store={store}>
|
||||||
<div role="tablist" className="tabs tabs-border w-full justify-center border-b-2 border-base-200 box-content">
|
<div role="tablist" className="tabs tabs-border w-full justify-center border-b-2 border-base-200 box-content">
|
||||||
<NavLink to="/" role="tab" className={tabClassNames}>
|
<NavLink to="/" role="tab" className={tabClassNames}>
|
||||||
|
|||||||
+149
-41
@@ -3,9 +3,17 @@ import { FaDownload } from 'react-icons/fa';
|
|||||||
import { useAppSelector } from '~/hooks';
|
import { useAppSelector } from '~/hooks';
|
||||||
import { toast } from 'react-toastify';
|
import { toast } from 'react-toastify';
|
||||||
import { SimpleQueue } from '~/util/SimpleQueue';
|
import { SimpleQueue } from '~/util/SimpleQueue';
|
||||||
|
import { selectFinalBatchDownloadSettings } from '~/features/settings/settingsSelector';
|
||||||
|
import type { BatchDownloadSettings } from '~/features/settings/settingsSlice';
|
||||||
|
import { supportsDirectoryPicker, supportsStreamingZipDownload } from '~/util/batchDownloadCapabilities';
|
||||||
|
import { resolveWritableDirectory } from '~/util/downloadDirectoryHandle';
|
||||||
|
import { downloadZipAsBlob, downloadZipViaSavePicker, ZipSourceFile } from '~/util/streamZip';
|
||||||
|
import { triggerAnchorDownload } from '~/util/triggerDownload';
|
||||||
|
|
||||||
export function DownloadAll() {
|
export function DownloadAll() {
|
||||||
const files = useAppSelector(selectFiles);
|
const files = useAppSelector(selectFiles);
|
||||||
|
const batchSettings = useAppSelector(selectFinalBatchDownloadSettings);
|
||||||
|
|
||||||
const downloadAllAsync = async () => {
|
const downloadAllAsync = async () => {
|
||||||
const fileList = Object.values(files);
|
const fileList = Object.values(files);
|
||||||
const fileCount = fileList.length;
|
const fileCount = fileList.length;
|
||||||
@@ -14,51 +22,53 @@ export function DownloadAll() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 判断所有文件是否处理完成
|
|
||||||
const allComplete = fileList.every((file) => file.state !== ProcessState.PROCESSING);
|
const allComplete = fileList.every((file) => file.state !== ProcessState.PROCESSING);
|
||||||
if (!allComplete) {
|
if (!allComplete) {
|
||||||
toast.warning('请等待所有文件解密完成');
|
toast.warning('请等待所有文件解密完成');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 过滤处理失败的文件
|
|
||||||
const completeFiles = fileList.filter((file) => file.state === ProcessState.COMPLETE);
|
const completeFiles = fileList.filter((file) => file.state === ProcessState.COMPLETE);
|
||||||
|
if (completeFiles.length === 0) {
|
||||||
|
toast.warning('没有可下载的已解密文件');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// 准备下载
|
// Single file: direct blob download, no ZIP / directory picker.
|
||||||
let dir: FileSystemDirectoryHandle | null = null;
|
if (completeFiles.length === 1) {
|
||||||
try {
|
downloadSingleBlob(completeFiles[0]);
|
||||||
dir = await window.showDirectoryPicker({ mode: 'readwrite' });
|
toast.success('已开始下载');
|
||||||
} catch (e) {
|
return;
|
||||||
if (e instanceof Error && e.name === 'AbortError') {
|
|
||||||
return; // user cancelled
|
|
||||||
}
|
}
|
||||||
console.error(e);
|
|
||||||
|
const modes = buildEnabledModes(batchSettings);
|
||||||
|
if (modes.length === 0) {
|
||||||
|
toast.error('批量下载失败:未启用任何可用的下载方式,请在「设定 → 通用设置」中勾选。');
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
toast.warning('开始下载,请稍候');
|
toast.warning('开始下载,请稍候');
|
||||||
const queue = new SimpleQueue(8);
|
|
||||||
const promises = Object.values(completeFiles).map(async (file) => {
|
|
||||||
try {
|
|
||||||
await queue.enter();
|
|
||||||
await downloadFile(file, dir);
|
|
||||||
} catch (e) {
|
|
||||||
console.error(`下载失败: ${file.fileName}`, e);
|
|
||||||
toast.error(`出现错误: ${e as Error}`);
|
|
||||||
throw e;
|
|
||||||
} finally {
|
|
||||||
queue.leave();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const promiseResults = await Promise.allSettled(promises);
|
for (const mode of modes) {
|
||||||
const success = promiseResults.filter((result) => result.status === 'fulfilled').length;
|
try {
|
||||||
const level = success === fileCount ? 'success' : success === 0 ? 'error' : 'warning';
|
const result = await runMode(mode, completeFiles, batchSettings.rememberDirectory);
|
||||||
toast[level](`成功下载: ${success}/${fileCount}首`);
|
if (result === 'ok') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// cancelled / skipped → try next mode
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`批量下载模式 ${mode} 失败`, e);
|
||||||
|
toast.error(`下载方式失败,尝试下一种: ${e instanceof Error ? e.message : String(e)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.error('批量下载失败:当前浏览器不支持或您取消了所有可用方式。可在「设定 → 通用设置」调整选项。');
|
||||||
};
|
};
|
||||||
|
|
||||||
function onDownloadAll() {
|
function onDownloadAll() {
|
||||||
downloadAllAsync().catch((e) => {
|
downloadAllAsync().catch((e) => {
|
||||||
// this should not happen
|
|
||||||
console.error('下载全部出现错误', e);
|
console.error('下载全部出现错误', e);
|
||||||
|
toast.error(`批量下载出现错误: ${e instanceof Error ? e.message : String(e)}`);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,22 +79,120 @@ export function DownloadAll() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function downloadFile(file: DecryptedAudioFile, dir: FileSystemDirectoryHandle | null) {
|
type BatchMode = 'directory' | 'streamingZip' | 'blobZip';
|
||||||
if (dir) {
|
|
||||||
const fileHandle = await dir.getFileHandle(file.cleanName + '.' + file.ext, { create: true });
|
function buildEnabledModes(
|
||||||
|
settings: Pick<BatchDownloadSettings, 'directoryPicker' | 'streamingZip' | 'blobZip'>,
|
||||||
|
): BatchMode[] {
|
||||||
|
const modes: BatchMode[] = [];
|
||||||
|
if (settings.directoryPicker && supportsDirectoryPicker()) {
|
||||||
|
modes.push('directory');
|
||||||
|
}
|
||||||
|
if (settings.streamingZip && supportsStreamingZipDownload()) {
|
||||||
|
modes.push('streamingZip');
|
||||||
|
}
|
||||||
|
if (settings.blobZip) {
|
||||||
|
modes.push('blobZip');
|
||||||
|
}
|
||||||
|
return modes;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runMode(
|
||||||
|
mode: BatchMode,
|
||||||
|
completeFiles: DecryptedAudioFile[],
|
||||||
|
rememberDirectory: boolean,
|
||||||
|
): Promise<'ok' | 'cancelled'> {
|
||||||
|
switch (mode) {
|
||||||
|
case 'directory':
|
||||||
|
return downloadToDirectory(completeFiles, rememberDirectory);
|
||||||
|
case 'streamingZip':
|
||||||
|
return downloadStreamingZip(completeFiles);
|
||||||
|
case 'blobZip':
|
||||||
|
await downloadBlobZip(completeFiles);
|
||||||
|
return 'ok';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadToDirectory(
|
||||||
|
completeFiles: DecryptedAudioFile[],
|
||||||
|
remember: boolean,
|
||||||
|
): Promise<'ok' | 'cancelled'> {
|
||||||
|
const dir = await resolveWritableDirectory(remember);
|
||||||
|
if (!dir) {
|
||||||
|
return 'cancelled';
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileCount = completeFiles.length;
|
||||||
|
const queue = new SimpleQueue(8);
|
||||||
|
const promises = completeFiles.map(async (file) => {
|
||||||
|
try {
|
||||||
|
await queue.enter();
|
||||||
|
await writeFileToDirectory(file, dir);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`下载失败: ${file.fileName}`, e);
|
||||||
|
toast.error(`出现错误: ${e instanceof Error ? e.message : String(e)}`);
|
||||||
|
throw e;
|
||||||
|
} finally {
|
||||||
|
queue.leave();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const promiseResults = await Promise.allSettled(promises);
|
||||||
|
const success = promiseResults.filter((result) => result.status === 'fulfilled').length;
|
||||||
|
const level = success === fileCount ? 'success' : success === 0 ? 'error' : 'warning';
|
||||||
|
toast[level](`成功下载: ${success}/${fileCount}首`);
|
||||||
|
|
||||||
|
if (success === 0) {
|
||||||
|
throw new Error('未能写入任何文件到所选目录');
|
||||||
|
}
|
||||||
|
return 'ok';
|
||||||
|
}
|
||||||
|
|
||||||
|
function decryptedDownloadName(file: DecryptedAudioFile): string {
|
||||||
|
return `${file.cleanName}.${file.ext}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeFileToDirectory(file: DecryptedAudioFile, dir: FileSystemDirectoryHandle) {
|
||||||
|
const fileHandle = await dir.getFileHandle(decryptedDownloadName(file), { create: true });
|
||||||
const fileStream = await fileHandle.createWritable();
|
const fileStream = await fileHandle.createWritable();
|
||||||
try {
|
try {
|
||||||
const res = await fetch(file.decrypted);
|
const res = await fetch(file.decrypted);
|
||||||
await res.body?.pipeTo(fileStream);
|
if (!res.ok) {
|
||||||
} catch {
|
throw new Error(`无法读取文件: ${decryptedDownloadName(file)} (${res.status})`);
|
||||||
await fileStream.abort();
|
|
||||||
}
|
}
|
||||||
} else {
|
if (!res.body) {
|
||||||
const anchor = document.createElement('a');
|
await fileStream.write(await res.arrayBuffer());
|
||||||
anchor.href = file.decrypted;
|
await fileStream.close();
|
||||||
anchor.download = file.cleanName + '.' + file.ext;
|
return;
|
||||||
document.body.append(anchor);
|
}
|
||||||
anchor.click();
|
await res.body.pipeTo(fileStream);
|
||||||
anchor.remove();
|
} catch (e) {
|
||||||
|
await fileStream.abort();
|
||||||
|
throw e;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toZipSources(files: DecryptedAudioFile[]): ZipSourceFile[] {
|
||||||
|
return files.map((file) => ({
|
||||||
|
name: decryptedDownloadName(file),
|
||||||
|
url: file.decrypted,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadSingleBlob(file: DecryptedAudioFile) {
|
||||||
|
triggerAnchorDownload(file.decrypted, decryptedDownloadName(file));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadStreamingZip(completeFiles: DecryptedAudioFile[]): Promise<'ok' | 'cancelled'> {
|
||||||
|
const sources = toZipSources(completeFiles);
|
||||||
|
const result = await downloadZipViaSavePicker(sources);
|
||||||
|
if (result === 'ok') {
|
||||||
|
toast.success(`已导出 ZIP(${completeFiles.length} 首)`);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadBlobZip(completeFiles: DecryptedAudioFile[]): Promise<void> {
|
||||||
|
await downloadZipAsBlob(toZipSources(completeFiles));
|
||||||
|
toast.success(`已开始下载 ZIP(${completeFiles.length} 首)`);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { FileInput } from '../FileInput';
|
||||||
|
|
||||||
|
test('receives multiple files from the file picker', async () => {
|
||||||
|
const onReceiveFiles = vi.fn();
|
||||||
|
const files = [new File(['first'], 'first.qmc3'), new File(['second'], 'second.mflac')];
|
||||||
|
const { container } = render(<FileInput onReceiveFiles={onReceiveFiles}>Choose files</FileInput>);
|
||||||
|
const input = container.querySelector('input');
|
||||||
|
if (!input) throw new Error('File input is missing');
|
||||||
|
|
||||||
|
await userEvent.upload(input, files);
|
||||||
|
|
||||||
|
await waitFor(() => expect(onReceiveFiles).toHaveBeenCalledOnce());
|
||||||
|
expect(onReceiveFiles.mock.calls[0][0]).toEqual(files);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('receives multiple dropped files', async () => {
|
||||||
|
const onReceiveFiles = vi.fn();
|
||||||
|
const files = [new File(['first'], 'first.kgm'), new File(['second'], 'second.ncm')];
|
||||||
|
render(<FileInput onReceiveFiles={onReceiveFiles}>Drop files</FileInput>);
|
||||||
|
|
||||||
|
fireEvent.drop(screen.getByText('Drop files'), {
|
||||||
|
dataTransfer: {
|
||||||
|
files,
|
||||||
|
items: files.map((file) => ({ kind: 'file', type: file.type, getAsFile: () => file })),
|
||||||
|
types: ['Files'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => expect(onReceiveFiles).toHaveBeenCalledOnce());
|
||||||
|
expect(onReceiveFiles.mock.calls[0][0]).toEqual(files);
|
||||||
|
});
|
||||||
@@ -11,6 +11,22 @@ export function OtherFAQ() {
|
|||||||
<p>该项目进行解密处理。如果加密前的资源没有内嵌元信息或封面,解密的文件也没有。</p>
|
<p>该项目进行解密处理。如果加密前的资源没有内嵌元信息或封面,解密的文件也没有。</p>
|
||||||
<p>请使用第三方工具进行编辑或管理元信息。</p>
|
<p>请使用第三方工具进行编辑或管理元信息。</p>
|
||||||
|
|
||||||
|
<Header3 id="batch-download">批量下载约十首后失败 / 无法全部下载</Header3>
|
||||||
|
<p>
|
||||||
|
Chromium 系浏览器会限制页面在短时间内通过程序触发的<strong>多个</strong>
|
||||||
|
文件下载(常见表现为大约十个文件后静默中断,或提示「此网站正在尝试下载多个文件」)。
|
||||||
|
</p>
|
||||||
|
<p className="my-2">本站的「下载全部」会按「设定 → 通用设置 → 批量下载」中勾选的顺序尝试:</p>
|
||||||
|
<ol className="list-decimal pl-8 my-2">
|
||||||
|
<li>选择输出目录,直接写入文件(推荐,Chrome / Edge)</li>
|
||||||
|
<li>流式生成单个 ZIP 并保存</li>
|
||||||
|
<li>在内存中生成 ZIP 后触发一次下载(兼容性最好)</li>
|
||||||
|
</ol>
|
||||||
|
<p>
|
||||||
|
若全部失败会弹出提示。大批量转换也可使用
|
||||||
|
<ExtLink href="https://git.um-react.app/um/cli">命令行版本</ExtLink>。
|
||||||
|
</p>
|
||||||
|
|
||||||
<Header3 id="android-browsers">安卓: 浏览器支持说明</Header3>
|
<Header3 id="android-browsers">安卓: 浏览器支持说明</Header3>
|
||||||
<p>⚠️ 手机端浏览器支持有限,请使用最新版本的 Chrome 或 Firefox 官方浏览器。</p>
|
<p>⚠️ 手机端浏览器支持有限,请使用最新版本的 Chrome 或 Firefox 官方浏览器。</p>
|
||||||
<div className="flex flex-col md:flex-row gap-2 md:gap-8">
|
<div className="flex flex-col md:flex-row gap-2 md:gap-8">
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
import { useCallback, useEffect, useId, useState, type ReactNode } from 'react';
|
||||||
|
import { useAppDispatch, useAppSelector } from '~/hooks';
|
||||||
|
import { selectStagingBatchDownloadSettings } from '../settingsSelector';
|
||||||
|
import { BatchDownloadSettings, updateBatchDownloadSetting } from '../settingsSlice';
|
||||||
|
import { supportsDirectoryPicker, supportsStreamingZipDownload } from '~/util/batchDownloadCapabilities';
|
||||||
|
import { clearRememberedDirectoryHandle, hasRememberedDirectoryHandle } from '~/util/downloadDirectoryHandle';
|
||||||
|
import { toast } from 'react-toastify';
|
||||||
|
|
||||||
|
export function PanelGeneral() {
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const batch = useAppSelector(selectStagingBatchDownloadSettings);
|
||||||
|
const canDirectoryPicker = supportsDirectoryPicker();
|
||||||
|
const canStreamingZip = supportsStreamingZipDownload();
|
||||||
|
const [hasRememberedDir, setHasRememberedDir] = useState(false);
|
||||||
|
|
||||||
|
const refreshRemembered = useCallback(() => {
|
||||||
|
hasRememberedDirectoryHandle()
|
||||||
|
.then(setHasRememberedDir)
|
||||||
|
.catch(() => setHasRememberedDir(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
refreshRemembered();
|
||||||
|
}, [refreshRemembered]);
|
||||||
|
|
||||||
|
const setBatch = (key: keyof BatchDownloadSettings, value: boolean) => {
|
||||||
|
dispatch(updateBatchDownloadSetting({ key, value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClearDirectory = async () => {
|
||||||
|
await clearRememberedDirectoryHandle();
|
||||||
|
refreshRemembered();
|
||||||
|
toast.info('已清除记住的输出目录');
|
||||||
|
};
|
||||||
|
|
||||||
|
const idDir = useId();
|
||||||
|
const idRemember = useId();
|
||||||
|
const idStreamZip = useId();
|
||||||
|
const idBlobZip = useId();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-0 flex-col grow px-1">
|
||||||
|
<h2 className="text-2xl font-bold mb-4">通用设置</h2>
|
||||||
|
|
||||||
|
<h3 className="text-xl font-semibold mb-2">批量下载</h3>
|
||||||
|
<p className="text-sm text-base-content/80 mb-4">
|
||||||
|
点击「下载全部」时,将按下方勾选项的顺序依次尝试。若选中的模式均不可用或全部失败,会弹出提示。
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<BatchOption
|
||||||
|
id={idDir}
|
||||||
|
checked={batch.directoryPicker && canDirectoryPicker}
|
||||||
|
disabled={!canDirectoryPicker}
|
||||||
|
dimmed={!canDirectoryPicker}
|
||||||
|
title="使用选择目录批量下载"
|
||||||
|
description={
|
||||||
|
canDirectoryPicker
|
||||||
|
? '通过 showDirectoryPicker 将解密文件直接写入所选目录(推荐,Chrome / Edge)。'
|
||||||
|
: '当前浏览器不支持 showDirectoryPicker。'
|
||||||
|
}
|
||||||
|
onChange={(checked) => setBatch('directoryPicker', checked)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className={`ml-6 flex flex-col gap-2 ${batch.directoryPicker && canDirectoryPicker ? '' : 'opacity-50'}`}>
|
||||||
|
<BatchOption
|
||||||
|
id={idRemember}
|
||||||
|
checked={batch.rememberDirectory}
|
||||||
|
disabled={!batch.directoryPicker || !canDirectoryPicker}
|
||||||
|
title="记住输出目录"
|
||||||
|
description="将目录句柄保存在本机;下次下载时可能仍需确认写入权限。"
|
||||||
|
onChange={(checked) => setBatch('rememberDirectory', checked)}
|
||||||
|
/>
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<span className="text-base-content/70">{hasRememberedDir ? '已记住一个输出目录' : '尚未记住输出目录'}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-xs btn-ghost"
|
||||||
|
disabled={!hasRememberedDir}
|
||||||
|
onClick={() => void handleClearDirectory()}
|
||||||
|
>
|
||||||
|
清除
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<BatchOption
|
||||||
|
id={idStreamZip}
|
||||||
|
checked={batch.streamingZip && canStreamingZip}
|
||||||
|
disabled={!canStreamingZip}
|
||||||
|
dimmed={!canStreamingZip}
|
||||||
|
title="流式生成 ZIP"
|
||||||
|
description={
|
||||||
|
canStreamingZip
|
||||||
|
? '边打包边写入单个 ZIP 文件(showSaveFilePicker),内存占用较低。'
|
||||||
|
: '当前浏览器不支持 showSaveFilePicker,无法流式写出 ZIP。'
|
||||||
|
}
|
||||||
|
onChange={(checked) => setBatch('streamingZip', checked)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<BatchOption
|
||||||
|
id={idBlobZip}
|
||||||
|
checked={batch.blobZip}
|
||||||
|
title="生成单个 ZIP 并触发下载"
|
||||||
|
description={
|
||||||
|
<>
|
||||||
|
在内存中生成 ZIP 后触发一次浏览器下载。兼容性最好;ZIP 需整包驻留内存,建议合计不超过约
|
||||||
|
500MB~1GB(视设备可用内存而定,过大可能失败或卡顿)。
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
onChange={(checked) => setBatch('blobZip', checked)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type BatchOptionProps = {
|
||||||
|
id: string;
|
||||||
|
checked: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
dimmed?: boolean;
|
||||||
|
title: string;
|
||||||
|
description: ReactNode;
|
||||||
|
onChange: (checked: boolean) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function BatchOption({ id, checked, disabled, dimmed, title, description, onChange }: BatchOptionProps) {
|
||||||
|
return (
|
||||||
|
<label className={`flex items-start gap-2 ${dimmed ? 'opacity-50' : ''}`}>
|
||||||
|
<input
|
||||||
|
id={id}
|
||||||
|
type="checkbox"
|
||||||
|
className="checkbox checkbox-sm mt-0.5"
|
||||||
|
checked={checked}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(e) => onChange(e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
<span className="font-medium">{title}</span>
|
||||||
|
<span className="block text-sm text-base-content/70">{description}</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { HiWord } from '~/components/HelpText/HiWord';
|
|||||||
import NoopExecutable from './assets/noop.exe?base64';
|
import NoopExecutable from './assets/noop.exe?base64';
|
||||||
import NoopExecutableSource from './assets/noop.asm.txt?base64';
|
import NoopExecutableSource from './assets/noop.asm.txt?base64';
|
||||||
|
|
||||||
|
const QMPC_MMKV_RELEASES_URL = 'https://git.um-react.app/um/qmpc-mmkv/releases';
|
||||||
const PC_CLIENT_URL = 'https://web.archive.org/web/2023/https://dldir1v6.qq.com/music/clntupate/QQMusic_Setup_1951.exe';
|
const PC_CLIENT_URL = 'https://web.archive.org/web/2023/https://dldir1v6.qq.com/music/clntupate/QQMusic_Setup_1951.exe';
|
||||||
const PC_CLIENT_TG_URL = 'https://t.me/um_lsr_ch/24';
|
const PC_CLIENT_TG_URL = 'https://t.me/um_lsr_ch/24';
|
||||||
|
|
||||||
@@ -10,14 +11,20 @@ export function InstructionsPC() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<p className="mt-2">
|
<p className="mt-2">
|
||||||
使用 <span className="text-error">19.57 或更高版本</span>下载的歌曲文件
|
使用 <span className="text-primary">19.57 至 22.76</span>下载的歌曲文件
|
||||||
<HiWord>需要导入密钥</HiWord>。
|
<HiWord>需要导入密钥</HiWord>。
|
||||||
<br />
|
|
||||||
目前未公开密钥获取方式,因此不支持。
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p className="mt-4">
|
<p className="mt-4">
|
||||||
使用 <span className="text-primary">19.51 或更低版本</span>下载的歌曲文件
|
请从{' '}
|
||||||
|
<ExtLink className="link-info" href={QMPC_MMKV_RELEASES_URL}>
|
||||||
|
qmpc-mmkv-cli
|
||||||
|
</ExtLink>{' '}
|
||||||
|
获取工具,提取密钥文件 <code>qmpc-mmkv-v1.mmkv</code> 后在本页导入。
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p className="mt-4">
|
||||||
|
使用 <span className="text-base-content/70">19.51 或更低版本</span>下载的歌曲文件
|
||||||
<HiWord>无需密钥</HiWord>。
|
<HiWord>无需密钥</HiWord>。
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
import { debounce } from 'radash';
|
import { debounce } from 'radash';
|
||||||
|
|
||||||
import type { AppStore } from '~/store';
|
import type { AppStore } from '~/store';
|
||||||
import { settingsSlice, setProductionChanges, ProductionSettings } from './settingsSlice';
|
import {
|
||||||
|
settingsSlice,
|
||||||
|
setProductionChanges,
|
||||||
|
ProductionSettings,
|
||||||
|
DEFAULT_BATCH_DOWNLOAD_SETTINGS,
|
||||||
|
BatchDownloadSettings,
|
||||||
|
} from './settingsSlice';
|
||||||
import { enumObject } from '~/util/objects';
|
import { enumObject } from '~/util/objects';
|
||||||
import { getLogger } from '~/util/logUtils';
|
import { getLogger } from '~/util/logUtils';
|
||||||
import { parseKwm2ProductionKey } from './keyFormats';
|
import { parseKwm2ProductionKey } from './keyFormats';
|
||||||
@@ -9,8 +15,24 @@ import { deepClone } from '~/util/deepClone';
|
|||||||
|
|
||||||
const DEFAULT_STORAGE_KEY = 'um-react-settings';
|
const DEFAULT_STORAGE_KEY = 'um-react-settings';
|
||||||
|
|
||||||
|
function mergeBatchDownload(source: Partial<BatchDownloadSettings> | undefined): BatchDownloadSettings {
|
||||||
|
const result = { ...DEFAULT_BATCH_DOWNLOAD_SETTINGS };
|
||||||
|
if (!source) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
for (const key of Object.keys(DEFAULT_BATCH_DOWNLOAD_SETTINGS) as (keyof BatchDownloadSettings)[]) {
|
||||||
|
if (typeof source[key] === 'boolean') {
|
||||||
|
result[key] = source[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
function mergeSettings(settings: ProductionSettings): ProductionSettings {
|
function mergeSettings(settings: ProductionSettings): ProductionSettings {
|
||||||
const draft = deepClone(settingsSlice.getInitialState().production);
|
const draft = deepClone(settingsSlice.getInitialState().production);
|
||||||
|
|
||||||
|
draft.general.batchDownload = mergeBatchDownload(settings?.general?.batchDownload);
|
||||||
|
|
||||||
if (settings?.qmc2) {
|
if (settings?.qmc2) {
|
||||||
const { allowFuzzyNameSearch, keys } = settings.qmc2;
|
const { allowFuzzyNameSearch, keys } = settings.qmc2;
|
||||||
for (const [k, v] of enumObject(keys)) {
|
for (const [k, v] of enumObject(keys)) {
|
||||||
|
|||||||
@@ -66,3 +66,6 @@ export const selectKugouKey = (state: RootState, hdr: ParseKugouHeaderResponse):
|
|||||||
|
|
||||||
export const selectStagingQtfmAndroidKey = (state: RootState) => state.settings.staging.qtfm.android;
|
export const selectStagingQtfmAndroidKey = (state: RootState) => state.settings.staging.qtfm.android;
|
||||||
export const selectQtfmAndroidKey = (state: RootState) => state.settings.production.qtfm.android;
|
export const selectQtfmAndroidKey = (state: RootState) => state.settings.production.qtfm.android;
|
||||||
|
|
||||||
|
export const selectStagingBatchDownloadSettings = (state: RootState) => state.settings.staging.general.batchDownload;
|
||||||
|
export const selectFinalBatchDownloadSettings = (state: RootState) => state.settings.production.general.batchDownload;
|
||||||
|
|||||||
@@ -21,7 +21,17 @@ import {
|
|||||||
StagingKugouKey,
|
StagingKugouKey,
|
||||||
} from './keyFormats';
|
} from './keyFormats';
|
||||||
|
|
||||||
|
export interface BatchDownloadSettings {
|
||||||
|
directoryPicker: boolean;
|
||||||
|
rememberDirectory: boolean;
|
||||||
|
streamingZip: boolean;
|
||||||
|
blobZip: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface StagingSettings {
|
export interface StagingSettings {
|
||||||
|
general: {
|
||||||
|
batchDownload: BatchDownloadSettings;
|
||||||
|
};
|
||||||
qmc2: {
|
qmc2: {
|
||||||
keys: StagingQMCv2Key[];
|
keys: StagingQMCv2Key[];
|
||||||
allowFuzzyNameSearch: boolean;
|
allowFuzzyNameSearch: boolean;
|
||||||
@@ -38,6 +48,9 @@ export interface StagingSettings {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface ProductionSettings {
|
export interface ProductionSettings {
|
||||||
|
general: {
|
||||||
|
batchDownload: BatchDownloadSettings;
|
||||||
|
};
|
||||||
qmc2: {
|
qmc2: {
|
||||||
keys: ProductionQMCv2Keys; // { [fileName]: ekey }
|
keys: ProductionQMCv2Keys; // { [fileName]: ekey }
|
||||||
allowFuzzyNameSearch: boolean;
|
allowFuzzyNameSearch: boolean;
|
||||||
@@ -53,6 +66,13 @@ export interface ProductionSettings {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_BATCH_DOWNLOAD_SETTINGS: BatchDownloadSettings = {
|
||||||
|
directoryPicker: true,
|
||||||
|
rememberDirectory: true,
|
||||||
|
streamingZip: true,
|
||||||
|
blobZip: true,
|
||||||
|
};
|
||||||
|
|
||||||
export interface SettingsState {
|
export interface SettingsState {
|
||||||
dirty: boolean;
|
dirty: boolean;
|
||||||
staging: StagingSettings;
|
staging: StagingSettings;
|
||||||
@@ -62,12 +82,14 @@ export interface SettingsState {
|
|||||||
const initialState: SettingsState = {
|
const initialState: SettingsState = {
|
||||||
dirty: false,
|
dirty: false,
|
||||||
staging: {
|
staging: {
|
||||||
|
general: { batchDownload: { ...DEFAULT_BATCH_DOWNLOAD_SETTINGS } },
|
||||||
qmc2: { allowFuzzyNameSearch: true, keys: [] },
|
qmc2: { allowFuzzyNameSearch: true, keys: [] },
|
||||||
kwm2: { keys: [] },
|
kwm2: { keys: [] },
|
||||||
qtfm: { android: '' },
|
qtfm: { android: '' },
|
||||||
kugou: { keys: [] },
|
kugou: { keys: [] },
|
||||||
},
|
},
|
||||||
production: {
|
production: {
|
||||||
|
general: { batchDownload: { ...DEFAULT_BATCH_DOWNLOAD_SETTINGS } },
|
||||||
qmc2: { allowFuzzyNameSearch: true, keys: {} },
|
qmc2: { allowFuzzyNameSearch: true, keys: {} },
|
||||||
kwm2: { keys: {} },
|
kwm2: { keys: {} },
|
||||||
kugou: { keys: {} },
|
kugou: { keys: {} },
|
||||||
@@ -76,6 +98,9 @@ const initialState: SettingsState = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const stagingToProduction = (staging: StagingSettings): ProductionSettings => ({
|
const stagingToProduction = (staging: StagingSettings): ProductionSettings => ({
|
||||||
|
general: {
|
||||||
|
batchDownload: { ...staging.general.batchDownload },
|
||||||
|
},
|
||||||
qmc2: {
|
qmc2: {
|
||||||
keys: stagingKeyToProduction(staging.qmc2.keys, qmc2StagingToProductionKey, qmc2StagingToProductionValue),
|
keys: stagingKeyToProduction(staging.qmc2.keys, qmc2StagingToProductionKey, qmc2StagingToProductionValue),
|
||||||
allowFuzzyNameSearch: staging.qmc2.allowFuzzyNameSearch,
|
allowFuzzyNameSearch: staging.qmc2.allowFuzzyNameSearch,
|
||||||
@@ -90,6 +115,9 @@ const stagingToProduction = (staging: StagingSettings): ProductionSettings => ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const productionToStaging = (production: ProductionSettings): StagingSettings => ({
|
const productionToStaging = (production: ProductionSettings): StagingSettings => ({
|
||||||
|
general: {
|
||||||
|
batchDownload: { ...production.general.batchDownload },
|
||||||
|
},
|
||||||
qmc2: {
|
qmc2: {
|
||||||
keys: productionKeyToStaging(production.qmc2.keys, qmc2ProductionToStaging),
|
keys: productionKeyToStaging(production.qmc2.keys, qmc2ProductionToStaging),
|
||||||
allowFuzzyNameSearch: production.qmc2.allowFuzzyNameSearch,
|
allowFuzzyNameSearch: production.qmc2.allowFuzzyNameSearch,
|
||||||
@@ -208,6 +236,13 @@ export const settingsSlice = createSlice({
|
|||||||
state.staging.qtfm.android = deviceKey;
|
state.staging.qtfm.android = deviceKey;
|
||||||
state.dirty = true;
|
state.dirty = true;
|
||||||
},
|
},
|
||||||
|
updateBatchDownloadSetting(
|
||||||
|
state,
|
||||||
|
{ payload: { key, value } }: PayloadAction<{ key: keyof BatchDownloadSettings; value: boolean }>,
|
||||||
|
) {
|
||||||
|
state.staging.general.batchDownload[key] = value;
|
||||||
|
state.dirty = true;
|
||||||
|
},
|
||||||
//
|
//
|
||||||
discardStagingChanges: (state) => {
|
discardStagingChanges: (state) => {
|
||||||
state.dirty = false;
|
state.dirty = false;
|
||||||
@@ -252,6 +287,7 @@ export const {
|
|||||||
kugouImportKeys,
|
kugouImportKeys,
|
||||||
|
|
||||||
qtfmAndroidUpdateKey,
|
qtfmAndroidUpdateKey,
|
||||||
|
updateBatchDownloadSetting,
|
||||||
|
|
||||||
commitStagingChange,
|
commitStagingChange,
|
||||||
discardStagingChanges,
|
discardStagingChanges,
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import type { FC } from 'react';
|
import type { FC } from 'react';
|
||||||
|
import { PanelGeneral } from '~/features/settings/panels/PanelGeneral.tsx';
|
||||||
import { PanelQMCv2Key } from '~/features/settings/panels/PanelQMCv2Key.tsx';
|
import { PanelQMCv2Key } from '~/features/settings/panels/PanelQMCv2Key.tsx';
|
||||||
import { PanelKWMv2Key } from '~/features/settings/panels/PanelKWMv2Key.tsx';
|
import { PanelKWMv2Key } from '~/features/settings/panels/PanelKWMv2Key.tsx';
|
||||||
import { PanelKGGKey } from '~/features/settings/panels/PanelKGGKey.tsx';
|
import { PanelKGGKey } from '~/features/settings/panels/PanelKGGKey.tsx';
|
||||||
import { PanelQingTing } from '~/features/settings/panels/PanelQingTing.tsx';
|
import { PanelQingTing } from '~/features/settings/panels/PanelQingTing.tsx';
|
||||||
|
|
||||||
export const SETTINGS_TABS: Record<string, { name: string; Tab: FC }> = {
|
export const SETTINGS_TABS: Record<string, { name: string; Tab: FC }> = {
|
||||||
|
general: { name: '通用设置', Tab: PanelGeneral },
|
||||||
qmc: { name: 'QMCv2 密钥', Tab: PanelQMCv2Key },
|
qmc: { name: 'QMCv2 密钥', Tab: PanelQMCv2Key },
|
||||||
kwm: { name: 'KWMv2 密钥', Tab: PanelKWMv2Key },
|
kwm: { name: 'KWMv2 密钥', Tab: PanelKWMv2Key },
|
||||||
kgg: { name: 'KGG 密钥', Tab: PanelKGGKey },
|
kgg: { name: 'KGG 密钥', Tab: PanelKGGKey },
|
||||||
qtfm: { name: '蜻蜓 FM', Tab: PanelQingTing },
|
qtfm: { name: '蜻蜓 FM', Tab: PanelQingTing },
|
||||||
// misc: { name: '其它/待定', Tab: () => <p>这里空空如也~</p> },
|
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import '@testing-library/jest-dom';
|
import '@testing-library/jest-dom/vitest';
|
||||||
|
|
||||||
// FIXME: Use something like jsdom-worker?
|
// FIXME: Use something like jsdom-worker?
|
||||||
// see: https://github.com/developit/jsdom-worker
|
// see: https://github.com/developit/jsdom-worker
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export function renderWithProviders(
|
|||||||
ui: React.ReactElement,
|
ui: React.ReactElement,
|
||||||
{ preloadedState = {}, store = setupStore(preloadedState), ...renderOptions }: ExtendedRenderOptions = {},
|
{ preloadedState = {}, store = setupStore(preloadedState), ...renderOptions }: ExtendedRenderOptions = {},
|
||||||
) {
|
) {
|
||||||
function Wrapper({ children }: PropsWithChildren<unknown>): JSX.Element {
|
function Wrapper({ children }: PropsWithChildren<unknown>): React.JSX.Element {
|
||||||
return <Provider store={store}>{children}</Provider>;
|
return <Provider store={store}>{children}</Provider>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { MMKVParser } from '../MMKVParser';
|
import { MMKVParser } from '../MMKVParser';
|
||||||
|
|
||||||
const makeViewFromBuffer = (buff: Buffer) =>
|
const makeViewFromBuffer = (buff: Buffer<ArrayBuffer>) =>
|
||||||
new DataView(buff.buffer.slice(buff.byteOffset, buff.byteOffset + buff.byteLength));
|
new DataView(buff.buffer.slice(buff.byteOffset, buff.byteOffset + buff.byteLength));
|
||||||
|
|
||||||
test('throw error on broken file', () => {
|
test('throw error on broken file', () => {
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
export function supportsDirectoryPicker(): boolean {
|
||||||
|
return typeof window !== 'undefined' && typeof window.showDirectoryPicker === 'function';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function supportsSaveFilePicker(): boolean {
|
||||||
|
return typeof window !== 'undefined' && typeof window.showSaveFilePicker === 'function';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function supportsStreamingZipDownload(): boolean {
|
||||||
|
return supportsSaveFilePicker();
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { get, set, del } from 'idb-keyval';
|
||||||
|
import { isAbortError } from '~/util/fsPicker';
|
||||||
|
|
||||||
|
const DIRECTORY_HANDLE_KEY = 'um-react-batch-download-dir';
|
||||||
|
|
||||||
|
export async function getRememberedDirectoryHandle(): Promise<FileSystemDirectoryHandle | null> {
|
||||||
|
try {
|
||||||
|
const handle = await get<FileSystemDirectoryHandle>(DIRECTORY_HANDLE_KEY);
|
||||||
|
return handle ?? null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setRememberedDirectoryHandle(handle: FileSystemDirectoryHandle): Promise<void> {
|
||||||
|
await set(DIRECTORY_HANDLE_KEY, handle);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearRememberedDirectoryHandle(): Promise<void> {
|
||||||
|
await del(DIRECTORY_HANDLE_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function hasRememberedDirectoryHandle(): Promise<boolean> {
|
||||||
|
return (await getRememberedDirectoryHandle()) !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyDirectoryPermission(
|
||||||
|
handle: FileSystemDirectoryHandle,
|
||||||
|
mode: FileSystemPermissionMode = 'readwrite',
|
||||||
|
): Promise<boolean> {
|
||||||
|
const opts: FileSystemHandlePermissionDescriptor = { mode };
|
||||||
|
if ((await handle.queryPermission(opts)) === 'granted') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if ((await handle.requestPermission(opts)) === 'granted') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a writable directory: remembered handle (if allowed) → picker.
|
||||||
|
* Returns null when the user cancels the picker.
|
||||||
|
*/
|
||||||
|
export async function resolveWritableDirectory(remember: boolean): Promise<FileSystemDirectoryHandle | null> {
|
||||||
|
if (remember) {
|
||||||
|
const remembered = await getRememberedDirectoryHandle();
|
||||||
|
if (remembered && (await verifyDirectoryPermission(remembered))) {
|
||||||
|
return remembered;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const dir = await window.showDirectoryPicker({ mode: 'readwrite' });
|
||||||
|
if (remember) {
|
||||||
|
await setRememberedDirectoryHandle(dir);
|
||||||
|
}
|
||||||
|
return dir;
|
||||||
|
} catch (e) {
|
||||||
|
if (isAbortError(e)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
/** True when the user cancelled a File System Access picker (or similar AbortError). */
|
||||||
|
export function isAbortError(e: unknown): boolean {
|
||||||
|
return e instanceof DOMException && e.name === 'AbortError';
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { readFileSync } from 'node:fs';
|
import { readFileSync } from 'node:fs';
|
||||||
import { parseAndroidKuwoEKey, parseIosKuwoEKey } from '../kuwo';
|
import { parseAndroidKuwoEKey, parseIosKuwoEKey } from '../kuwo';
|
||||||
|
|
||||||
const makeViewFromBuffer = (buff: Buffer) =>
|
const makeViewFromBuffer = (buff: Buffer<ArrayBuffer>) =>
|
||||||
new DataView(buff.buffer.slice(buff.byteOffset, buff.byteOffset + buff.byteLength));
|
new DataView(buff.buffer.slice(buff.byteOffset, buff.byteOffset + buff.byteLength));
|
||||||
|
|
||||||
test('parse kuwo android ekey mmkv file "cn.kuwo.player.mmkv.defaultconfig"', () => {
|
test('parse kuwo android ekey mmkv file "cn.kuwo.player.mmkv.defaultconfig"', () => {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { readFileSync } from 'node:fs';
|
import { readFileSync } from 'node:fs';
|
||||||
import { parseAndroidQmEKey } from '../qm';
|
import { parseAndroidQmEKey } from '../qm';
|
||||||
|
|
||||||
const makeViewFromBuffer = (buff: Buffer) =>
|
const makeViewFromBuffer = (buff: Buffer<ArrayBuffer>) =>
|
||||||
new DataView(buff.buffer.slice(buff.byteOffset, buff.byteOffset + buff.byteLength));
|
new DataView(buff.buffer.slice(buff.byteOffset, buff.byteOffset + buff.byteLength));
|
||||||
|
|
||||||
test('parse qm mmkv file', () => {
|
test('parse qm mmkv file', () => {
|
||||||
|
|||||||
@@ -0,0 +1,160 @@
|
|||||||
|
import { Zip, ZipPassThrough } from 'fflate';
|
||||||
|
import { isAbortError } from '~/util/fsPicker';
|
||||||
|
import { triggerAnchorDownload } from '~/util/triggerDownload';
|
||||||
|
|
||||||
|
export interface ZipSourceFile {
|
||||||
|
/** Path inside the ZIP (UTF-8; non-ASCII gets Language Encoding Flag). */
|
||||||
|
name: string;
|
||||||
|
/** Blob URL or other fetchable URL of file contents. */
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a streaming ZIP (STORE / no recompression) from fetchable URLs.
|
||||||
|
* Filenames with non-ASCII characters get ZIP UTF-8 (LEF) via fflate.
|
||||||
|
*/
|
||||||
|
export function createZipReadableStream(files: ZipSourceFile[]): ReadableStream<Uint8Array> {
|
||||||
|
const zip = new Zip();
|
||||||
|
|
||||||
|
return new ReadableStream<Uint8Array>({
|
||||||
|
start(controller) {
|
||||||
|
zip.ondata = (err, data, final) => {
|
||||||
|
if (err) {
|
||||||
|
controller.error(err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
controller.enqueue(data);
|
||||||
|
if (final) {
|
||||||
|
controller.close();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const usedNames = new Set<string>();
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
const name = uniqueZipName(file.name, usedNames);
|
||||||
|
const entry = new ZipPassThrough(name);
|
||||||
|
zip.add(entry);
|
||||||
|
|
||||||
|
const res = await fetch(file.url);
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`无法读取文件: ${file.name} (${res.status})`);
|
||||||
|
}
|
||||||
|
if (!res.body) {
|
||||||
|
const buf = new Uint8Array(await res.arrayBuffer());
|
||||||
|
entry.push(buf, true);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = res.body.getReader();
|
||||||
|
for (;;) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) {
|
||||||
|
entry.push(new Uint8Array(0), true);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
entry.push(value, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
zip.end();
|
||||||
|
} catch (e) {
|
||||||
|
zip.terminate();
|
||||||
|
controller.error(e);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
},
|
||||||
|
cancel() {
|
||||||
|
zip.terminate();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Split `name.ext` → base + ext (ext includes the leading dot). */
|
||||||
|
function splitBaseExt(name: string): { base: string; ext: string } {
|
||||||
|
const dot = name.lastIndexOf('.');
|
||||||
|
if (dot > 0) {
|
||||||
|
return { base: name.slice(0, dot), ext: name.slice(dot) };
|
||||||
|
}
|
||||||
|
return { base: name, ext: '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
function uniqueZipName(name: string, used: Set<string>): string {
|
||||||
|
if (!used.has(name)) {
|
||||||
|
used.add(name);
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { base, ext } = splitBaseExt(name);
|
||||||
|
let i = 2;
|
||||||
|
let candidate: string;
|
||||||
|
do {
|
||||||
|
candidate = `${base} (${i})${ext}`;
|
||||||
|
i += 1;
|
||||||
|
} while (used.has(candidate));
|
||||||
|
used.add(candidate);
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function defaultZipFileName(files: ZipSourceFile[]): string {
|
||||||
|
const now = new Date();
|
||||||
|
const date = [
|
||||||
|
now.getFullYear(),
|
||||||
|
String(now.getMonth() + 1).padStart(2, '0'),
|
||||||
|
String(now.getDate()).padStart(2, '0'),
|
||||||
|
].join('-');
|
||||||
|
const first = files[0]?.name ?? '已解密的音乐';
|
||||||
|
const stem = splitBaseExt(first).base || '已解密的音乐';
|
||||||
|
return `${date} - ${stem} 等.zip`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function downloadZipViaSavePicker(
|
||||||
|
files: ZipSourceFile[],
|
||||||
|
suggestedName = defaultZipFileName(files),
|
||||||
|
): Promise<'ok' | 'cancelled'> {
|
||||||
|
let handle: FileSystemFileHandle;
|
||||||
|
try {
|
||||||
|
handle = await window.showSaveFilePicker({
|
||||||
|
suggestedName,
|
||||||
|
types: [
|
||||||
|
{
|
||||||
|
description: 'ZIP archive',
|
||||||
|
accept: { 'application/zip': ['.zip'] },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
if (isAbortError(e)) {
|
||||||
|
return 'cancelled';
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
|
||||||
|
const writable = await handle.createWritable();
|
||||||
|
try {
|
||||||
|
await createZipReadableStream(files).pipeTo(writable);
|
||||||
|
} catch (e) {
|
||||||
|
try {
|
||||||
|
await writable.abort();
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
return 'ok';
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function downloadZipAsBlob(files: ZipSourceFile[], filename = defaultZipFileName(files)): Promise<void> {
|
||||||
|
const blob = await new Response(createZipReadableStream(files), {
|
||||||
|
headers: { 'Content-Type': 'application/zip' },
|
||||||
|
}).blob();
|
||||||
|
const objectUrl = URL.createObjectURL(blob);
|
||||||
|
try {
|
||||||
|
triggerAnchorDownload(objectUrl, filename);
|
||||||
|
} finally {
|
||||||
|
// Revoke after a short delay so the download can start.
|
||||||
|
setTimeout(() => URL.revokeObjectURL(objectUrl), 60_000);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
/** Programmatically trigger a browser file download via a temporary anchor. */
|
||||||
|
export function triggerAnchorDownload(href: string, filename: string): void {
|
||||||
|
const anchor = document.createElement('a');
|
||||||
|
anchor.href = href;
|
||||||
|
anchor.download = filename;
|
||||||
|
document.body.append(anchor);
|
||||||
|
anchor.click();
|
||||||
|
anchor.remove();
|
||||||
|
}
|
||||||
Vendored
+4
@@ -17,3 +17,7 @@ declare module '*&mac-command' {
|
|||||||
export const commandName: string;
|
export const commandName: string;
|
||||||
export const tarName: string;
|
export const tarName: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
declare interface Window {
|
||||||
|
BASE_URL: string;
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { Plugin } from 'vite';
|
|||||||
|
|
||||||
export const base64Loader: Plugin = {
|
export const base64Loader: Plugin = {
|
||||||
name: 'base64-loader',
|
name: 'base64-loader',
|
||||||
async transform(_: unknown, id: string) {
|
async load(id: string) {
|
||||||
const [path, query] = id.split('?');
|
const [path, query] = id.split('?');
|
||||||
if (query != 'base64') return null;
|
if (query != 'base64') return null;
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import tar from 'tar-stream';
|
|||||||
|
|
||||||
export const macCommandLoader: Plugin = {
|
export const macCommandLoader: Plugin = {
|
||||||
name: 'mac-command-loader',
|
name: 'mac-command-loader',
|
||||||
async transform(_: unknown, id: string) {
|
async load(id: string) {
|
||||||
const [path, query] = id.split('?');
|
const [path, query] = id.split('?');
|
||||||
if (!query || !query.includes('mac-command')) return null;
|
if (!query || !query.includes('mac-command')) return null;
|
||||||
|
|
||||||
@@ -23,7 +23,8 @@ export const macCommandLoader: Plugin = {
|
|||||||
|
|
||||||
const chunks: Buffer[] = [];
|
const chunks: Buffer[] = [];
|
||||||
for await (const chunk of tarball) {
|
for await (const chunk of tarball) {
|
||||||
chunks.push(chunk as Buffer);
|
if (!Buffer.isBuffer(chunk)) throw new TypeError('Expected a tarball Buffer chunk');
|
||||||
|
chunks.push(chunk);
|
||||||
}
|
}
|
||||||
const dataBuffer = Buffer.concat(chunks);
|
const dataBuffer = Buffer.concat(chunks);
|
||||||
const base64 = dataBuffer.toString('base64');
|
const base64 = dataBuffer.toString('base64');
|
||||||
|
|||||||
+1
-2
@@ -2,7 +2,7 @@
|
|||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "ESNext",
|
"target": "ESNext",
|
||||||
"lib": ["DOM", "DOM.Iterable", "ESNext"],
|
"lib": ["DOM", "DOM.Iterable", "ESNext"],
|
||||||
"types": ["@types/wicg-file-system-access", "vitest/globals", "@testing-library/jest-dom"],
|
"types": ["node", "@types/wicg-file-system-access", "vitest/globals", "@testing-library/jest-dom/vitest"],
|
||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
/* Bundler mode */
|
/* Bundler mode */
|
||||||
@@ -17,7 +17,6 @@
|
|||||||
"noUnusedLocals": true,
|
"noUnusedLocals": true,
|
||||||
"noUnusedParameters": true,
|
"noUnusedParameters": true,
|
||||||
"noFallthroughCasesInSwitch": true,
|
"noFallthroughCasesInSwitch": true,
|
||||||
"baseUrl": ".",
|
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"paths": {
|
"paths": {
|
||||||
"~/*": ["./src/*"],
|
"~/*": ["./src/*"],
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
"allowSyntheticDefaultImports": true
|
"allowSyntheticDefaultImports": true
|
||||||
},
|
},
|
||||||
"include": ["vite.config.ts", "support/**/*.ts"]
|
"include": ["vite.config.ts", "support/**/*.ts"]
|
||||||
|
|||||||
+17
-17
@@ -6,23 +6,23 @@ import { defineConfig } from 'vitest/config';
|
|||||||
import react from '@vitejs/plugin-react';
|
import react from '@vitejs/plugin-react';
|
||||||
import wasm from 'vite-plugin-wasm';
|
import wasm from 'vite-plugin-wasm';
|
||||||
import replace from '@rollup/plugin-replace';
|
import replace from '@rollup/plugin-replace';
|
||||||
import topLevelAwait from 'vite-plugin-top-level-await';
|
|
||||||
import { VitePWA } from 'vite-plugin-pwa';
|
import { VitePWA } from 'vite-plugin-pwa';
|
||||||
import tailwindcss from '@tailwindcss/vite';
|
import tailwindcss from '@tailwindcss/vite';
|
||||||
|
|
||||||
import { tryCommand } from './support/command';
|
import { tryCommand } from './support/command.ts';
|
||||||
import { base64Loader } from './support/b64-loader';
|
import { base64Loader } from './support/b64-loader.ts';
|
||||||
import { macCommandLoader } from './support/mac-command-loader';
|
import { macCommandLoader } from './support/mac-command-loader.ts';
|
||||||
|
|
||||||
const projectRoot = url.fileURLToPath(new URL('.', import.meta.url));
|
const projectRoot = url.fileURLToPath(new URL('.', import.meta.url));
|
||||||
const pkg = JSON.parse(fs.readFileSync(projectRoot + '/package.json', 'utf-8'));
|
const pkg = JSON.parse(fs.readFileSync(projectRoot + '/package.json', 'utf-8')) as { version: string };
|
||||||
|
|
||||||
const COMMAND_GIT_VERSION = 'git describe --long --dirty --tags --always';
|
const COMMAND_GIT_VERSION = 'git describe --long --dirty --tags --always';
|
||||||
const shortCommit = process.env.GIT_COMMIT || tryCommand(COMMAND_GIT_VERSION, __dirname, 'unknown');
|
const shortCommit = process.env.GIT_COMMIT || tryCommand(COMMAND_GIT_VERSION, projectRoot, 'unknown');
|
||||||
const version = `${pkg.version} (${shortCommit})`;
|
const version = `${pkg.version} (${shortCommit})`;
|
||||||
|
|
||||||
// https://vitejs.dev/config/
|
// https://vitejs.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
|
base: './',
|
||||||
worker: {
|
worker: {
|
||||||
format: 'es',
|
format: 'es',
|
||||||
},
|
},
|
||||||
@@ -58,7 +58,6 @@ export default defineConfig({
|
|||||||
}),
|
}),
|
||||||
react(),
|
react(),
|
||||||
wasm(),
|
wasm(),
|
||||||
topLevelAwait(),
|
|
||||||
VitePWA({
|
VitePWA({
|
||||||
registerType: 'prompt',
|
registerType: 'prompt',
|
||||||
workbox: {
|
workbox: {
|
||||||
@@ -90,22 +89,22 @@ export default defineConfig({
|
|||||||
],
|
],
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
'~': path.resolve(__dirname, 'src'),
|
'~': path.resolve(projectRoot, 'src'),
|
||||||
'@sql-wasm': path.resolve(__dirname, 'node_modules', 'sql.js', 'dist', 'sql-wasm.wasm'),
|
'@sql-wasm': path.resolve(projectRoot, 'node_modules', 'sql.js', 'dist', 'sql-wasm.wasm'),
|
||||||
|
|
||||||
// workaround for vite, workbox (PWA)
|
// workaround for vite, workbox (PWA)
|
||||||
module: path.resolve(__dirname, 'src', 'dummy.mjs'),
|
module: path.resolve(projectRoot, 'src', 'dummy.mjs'),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
build: {
|
build: {
|
||||||
minify: true,
|
minify: true,
|
||||||
rollupOptions: {
|
rolldownOptions: {
|
||||||
output: {
|
output: {
|
||||||
manualChunks: {
|
manualChunks(id) {
|
||||||
core: ['react', 'react-dom'],
|
if (/\/node_modules\/(react|react-dom)\//.test(id)) return 'core';
|
||||||
router: ['react-router'],
|
if (/\/node_modules\/react-router\//.test(id)) return 'router';
|
||||||
store: ['react-redux', '@reduxjs/toolkit'],
|
if (/\/node_modules\/(react-redux|@reduxjs\/toolkit)\//.test(id)) return 'store';
|
||||||
extras: ['react-dropzone', 'react-toastify'],
|
if (/\/node_modules\/(react-dropzone|react-toastify)\//.test(id)) return 'extras';
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -114,11 +113,12 @@ export default defineConfig({
|
|||||||
globals: true,
|
globals: true,
|
||||||
mockReset: true,
|
mockReset: true,
|
||||||
environment: 'jsdom',
|
environment: 'jsdom',
|
||||||
|
pool: 'vmThreads',
|
||||||
setupFiles: ['src/test-utils/setup-jest.ts'],
|
setupFiles: ['src/test-utils/setup-jest.ts'],
|
||||||
// workaround: sql.js is not ESModule friendly, yet...
|
// workaround: sql.js is not ESModule friendly, yet...
|
||||||
deps: {
|
deps: {
|
||||||
optimizer: {
|
optimizer: {
|
||||||
web: {
|
client: {
|
||||||
include: ['sql.js'],
|
include: ['sql.js'],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user