28 Commits

Author SHA1 Message Date
鲁树人
d514a87198 0.6.0 2025-10-15 22:47:59 +09:00
鲁树人
5c537ab8d9 ci: simplify release 2025-10-15 22:47:59 +09:00
鲁树人
48f10e8e30 fix: layout issue with android instruction in mobile screen 2025-10-15 22:08:51 +09:00
鲁树人
dfac382cbd Merge pull request '添加 Mac QQ 音乐 v10 的说明' (#3) from feat/mac-qqmusic-v10 into main
Reviewed-on: https://git.um-react.app/um/um-react/pulls/3
2025-10-15 12:59:19 +00:00
鲁树人
7b2558c585 feat: update instructions and scripts for QQMusic Mac v10.7 and v8.8.0 2025-10-15 02:08:32 +09:00
鲁树人
fb52b0197c feat: add insturctions on how to dump keys for v10 2025-10-15 00:57:56 +09:00
鲁树人
f49f629917 build: allow vite to access 'index.html' 2025-10-14 07:54:21 +09:00
鲁树人
8093d30579 chore: remove npm package lock file 2025-10-14 07:53:54 +09:00
鲁树人
4fe6efec1f 0.5.2 2025-09-08 21:31:34 +09:00
鲁树人
c4fe9ce938 fix: update dependencies 2025-09-08 21:31:34 +09:00
鲁树人
045bea8084 chore: log which decipher was used 2025-09-08 20:47:13 +09:00
鲁树人
c68195eb9a feat: add instructions to block update for qqmusic mac 8.8.0 2025-09-05 22:15:15 +09:00
鲁树人
b986a2ef99 0.5.1 2025-09-03 21:32:27 +09:00
鲁树人
b48d9b0079 chore: update major versions 2025-09-03 21:32:16 +09:00
鲁树人
62e49804a5 chore: update deps 2025-09-03 21:27:38 +09:00
鲁树人
c41e5ae531 build: sort out deps 2025-09-03 21:21:01 +09:00
鲁树人
27c33a7d20 fix: replace link to new git repo 2025-09-03 21:19:37 +09:00
鲁树人
bbb557eafd Merge pull request 'fix(downloadAll): 一点点修改' (#92) from awalol/um-react:fix-downloadall into main
Reviewed-on: https://git.unlock-music.dev/um/um-react/pulls/92
2025-07-16 10:05:52 +00:00
awalol
befe35e5bc ui(DownloadAll): button position 2025-07-15 18:33:37 +00:00
awalol
1fb6526cdb docs: update faq 2025-07-15 18:33:37 +00:00
awalol
d122eaecf5 refactor(DownloadAll): 调整按钮位置 2025-07-15 18:33:37 +00:00
awalol
2da766168c refactor(DownloadAll): 并行下载 2025-07-15 18:33:37 +00:00
awalol
17200150dd feat(DownloadAll): 并行下载 2025-07-15 18:33:37 +00:00
awalol
2c461df5fc fix(downloadAll): 防止未解密文件导致的下载失败 优化目录选择框的弹出 优化权限请求 2025-07-15 18:33:37 +00:00
鲁树人
b493391371 Merge pull request '为解密文件添加 MIME 类型' (#95) from awalol/um-react:fix-mime into main
Reviewed-on: https://git.unlock-music.dev/um/um-react/pulls/95
2025-07-14 11:27:00 +00:00
awalol
13b67f40aa refactor(MIME): add getMimeTypeFromExt function 2025-07-14 01:51:28 +08:00
awalol
fbe8ef8ba1 fix: add MIME type for the decrypted file 2025-07-11 23:40:31 +08:00
鲁树人
54f784d778 ci: fix wry build 2025-07-05 06:45:43 +09:00
44 changed files with 2037 additions and 13363 deletions

View File

@@ -20,8 +20,13 @@ body:
目前 Mac 客户端仅支持 v8.8.0 或更低版本下载的歌曲文件。 目前 Mac 客户端仅支持 v8.8.0 或更低版本下载的歌曲文件。
* [web.archive.org 镜像](https://web.archive.org/web/20230903/https://dldir1.qq.com/music/clntupate/mac/QQMusicMac_Mgr.dmg)
* [通过 Telegram 下载](https://t.me/um_lsr_ch/21) * [通过 Telegram 下载](https://t.me/um_lsr_ch/21)
安装好客户端后可以加装更新屏蔽更新:
* [屏蔽更新](https://t.me/um_lsr_ch/29)
--- ---
如果你确定你的客户端版本符合上述描述,并遇到了问题,请继续填写下面的表单。 如果你确定你的客户端版本符合上述描述,并遇到了问题,请继续填写下面的表单。

View File

@@ -26,6 +26,35 @@ jobs:
with: with:
name: site name: site
path: dist/ path: dist/
- name: Create Release Package
if: startsWith(github.ref, 'refs/tags/')
run: |
VERSION=${GITHUB_REF#refs/tags/v}
mkdir release/
python3 -m zipfile -c "release/um-react-${VERSION}.zip" dist/.
cp win64/dist/*.zip "release/um-react-win64-${VERSION}.zip"
- name: Create Draft Release
if: startsWith(github.ref, 'refs/tags/')
uses: akkuman/gitea-release-action@v1
with:
draft: true
files: |
release/um-react-*.zip
release/um-react-win64-*.zip
body: |
上个版本:[v0.0.0](https://git.um-react.app/um/um-react/releases/tag/v0.0.0)
## 🐛 修正
- 修正内容
## ✨ 新增
- 新增内容
## 🔧 维护
- 维护内容
- name: Prepare for deployment - name: Prepare for deployment
run: | run: |
cp um-react.zip dist/"release-${GITHUB_SHA}.zip" cp um-react.zip dist/"release-${GITHUB_SHA}.zip"

4
.npmrc
View File

@@ -1,3 +1,3 @@
use-node-version=22.12.0 use-node-version=24.7.0
engine-strict=true engine-strict=true
@unlock-music:registry=https://git.unlock-music.dev/api/packages/um/npm/ @unlock-music:registry=https://git.um-react.app/api/packages/um/npm/

View File

@@ -1,6 +1,6 @@
# Unlock Music 音乐解锁 (React) # Unlock Music 音乐解锁 (React)
[![Build Status](https://git.unlock-music.dev/um/um-react/actions/workflows/build.yaml/badge.svg)][um-react-actions] [![Build Status](https://git.um-react.app/um/um-react/actions/workflows/build.yaml/badge.svg)][um-react-actions]
- 在浏览器中解锁加密的音乐文件。 Unlock encrypted music file in the browser. - 在浏览器中解锁加密的音乐文件。 Unlock encrypted music file in the browser.
- 查看[原基于 Vue 的 Unlock Music 项目][um-vue] - 查看[原基于 Vue 的 Unlock Music 项目][um-vue]
@@ -13,11 +13,11 @@
> **WARNING** > **WARNING**
> 在本站 fork 不会起到备份的作用,只会浪费服务器储存空间。如无必要请勿 fork 该仓库。 > 在本站 fork 不会起到备份的作用,只会浪费服务器储存空间。如无必要请勿 fork 该仓库。
[授权协议]: https://git.unlock-music.dev/um/um-react/src/branch/main/LICENSE [授权协议]: https://git.um-react.app/um/um-react/src/branch/main/LICENSE
[um-vue]: https://git.unlock-music.dev/um/web [um-vue]: https://git.um-react.app/um/web
[unlock-music/cli]: https://git.unlock-music.dev/um/cli [unlock-music/cli]: https://git.um-react.app/um/cli
[`@unlock_music_chat`]: https://t.me/unlock_music_chat [`@unlock_music_chat`]: https://t.me/unlock_music_chat
[um-react-actions]: https://git.unlock-music.dev/um/um-react/actions?workflow=build.yaml [um-react-actions]: https://git.um-react.app/um/um-react/actions?workflow=build.yaml
⚠️ 手机端浏览器支持有限,请使用最新版本的 Chrome 或 Firefox 官方浏览器。 ⚠️ 手机端浏览器支持有限,请使用最新版本的 Chrome 或 Firefox 官方浏览器。
@@ -55,16 +55,16 @@
遇到解密出错的情况,请一并携带错误信息(诊断信息)并简单描述错误的重现过程。 遇到解密出错的情况,请一并携带错误信息(诊断信息)并简单描述错误的重现过程。
待实现的算法支持可[追踪 `crypto` 标签](https://git.unlock-music.dev/um/um-react/issues?labels=67)。 待实现的算法支持可[追踪 `crypto` 标签](https://git.um-react.app/um/um-react/issues?labels=67)。
[project-issues]: https://git.unlock-music.dev/um/um-react/issues/new [project-issues]: https://git.um-react.app/um/um-react/issues/new
## 使用 Docker 构建、部署 (Linux) ## 使用 Docker 构建、部署 (Linux)
首先克隆仓库并进入目录: 首先克隆仓库并进入目录:
```sh ```sh
git clone https://git.unlock-music.dev/um/um-react.git git clone https://git.um-react.app/um/um-react.git
cd um-react cd um-react
``` ```
@@ -115,15 +115,15 @@ docker run -d -p 8080:80 --name um-react um-react
## 相关项目 ## 相关项目
- [Unlock Music (Web)](https://git.unlock-music.dev/um/web) - 原始项目 - [Unlock Music (Web)](https://git.um-react.app/um/web) - 原始项目
- [Unlock Music (Cli)](https://git.unlock-music.dev/um/cli) - 命令行批量处理版 - [Unlock Music (Cli)](https://git.um-react.app/um/cli) - 命令行批量处理版
- [lib_um_crypto_rust](https://git.unlock-music.dev/um/lib_um_crypto_rust) - 项目引入的解密算法实现 - [lib_um_crypto_rust](https://git.um-react.app/um/lib_um_crypto_rust) - 项目引入的解密算法实现
- [NPM 包](https://git.unlock-music.dev/um/-/packages/npm/@unlock-music%2Fcrypto) - [NPM 包](https://git.um-react.app/um/-/packages/npm/@unlock-music%2Fcrypto)
- [um-react (Electron 前端)](https://github.com/CarlGao4/um-react-electron) - 使用 Electron 框架封装的本地可执行文件。 - [um-react (Electron 前端)](https://github.com/CarlGao4/um-react-electron) - 使用 Electron 框架封装的本地可执行文件。
- [GitHub 下载](https://github.com/CarlGao4/um-react-electron/releases/latest) | [仓库镜像](https://git.unlock-music.dev/CarlGao4/um-react-electron) - [GitHub 下载](https://github.com/CarlGao4/um-react-electron/releases/latest)
- [um-react-wry](https://git.unlock-music.dev/um/um-react-wry) - 使用 WRY 框架封装的 Win64 单文件 ( - [um-react-wry](https://git.um-react.app/um/um-react-wry) - 使用 WRY 框架封装的 Win64 单文件 (
需要[安装 Edge WebView2 运行时][webview2_redist]Win10+ 操作系统自带) 需要[安装 Edge WebView2 运行时][webview2_redist]Win10+ 操作系统自带)
- [本地下载](https://git.unlock-music.dev/um/um-react/releases/latest) | 寻找文件名为 `um-react-win64-` 开头的附件 - [本地下载](https://git.um-react.app/um/um-react/releases/latest) | 寻找文件名为 `um-react-win64-` 开头的附件
[webview2_redist]: https://go.microsoft.com/fwlink/p/?LinkId=2124703 [webview2_redist]: https://go.microsoft.com/fwlink/p/?LinkId=2124703

View File

@@ -16,7 +16,7 @@
- 进入上层目录:`cd ..` - 进入上层目录:`cd ..`
- 克隆 `lib_um_crypto_rust` 仓库 - 克隆 `lib_um_crypto_rust` 仓库
- `git clone https://git.unlock-music.dev/um/lib_um_crypto_rust.git` - `git clone https://git.um-react.app/um/lib_um_crypto_rust.git`
- 进入 SDK 目录:`cd lib_um_crypto_rust ; cd um_wasm_loader` - 进入 SDK 目录:`cd lib_um_crypto_rust ; cd um_wasm_loader`
- 安装所有 Node 以来:`pnpm i` - 安装所有 Node 以来:`pnpm i`
- 构建:`pnpm build` - 构建:`pnpm build`

View File

@@ -69,7 +69,7 @@
- WSA 可以参考 [MagiskOnWSALocal](https://github.com/LSPosed/MagiskOnWSALocal) 的说明操作。 - WSA 可以参考 [MagiskOnWSALocal](https://github.com/LSPosed/MagiskOnWSALocal) 的说明操作。
- 雷电模拟器可以在「模拟器设置」 → 「其他设置」中启用 root 特权。 - 雷电模拟器可以在「模拟器设置」 → 「其他设置」中启用 root 特权。
![雷电模拟器 其他设置](../src/faq/assets/ld_settings_misc.webp) ![雷电模拟器 其他设置](../src/faq/assets/ld_settings_misc@2x.webp)
### Via 等浏览器无法正常解密/下载 ### Via 等浏览器无法正常解密/下载
@@ -87,10 +87,6 @@
- 无法下载解密后内容 - 无法下载解密后内容
- 下载的文件名错误 - 下载的文件名错误
### 新版解锁网站没有批量下载
目前没有做。抱歉。
## 仍有问题? ## 仍有问题?
欢迎进入[Telegram 交流群](https://t.me/unlock_music_chat),一起探讨。 欢迎进入[Telegram 交流群](https://t.me/unlock_music_chat),一起探讨。

11870
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
{ {
"name": "um-react", "name": "um-react",
"private": true, "private": true,
"version": "0.5.0", "version": "0.6.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"start": "vite", "start": "vite",
@@ -17,60 +17,62 @@
"prepare": "simple-git-hooks" "prepare": "simple-git-hooks"
}, },
"dependencies": { "dependencies": {
"@reduxjs/toolkit": "^2.8.2", "@reduxjs/toolkit": "^2.9.0",
"@unlock-music/crypto": "0.1.10", "@unlock-music/crypto": "^0.1.12",
"classnames": "^2.5.1", "classnames": "^2.5.1",
"nanoid": "^5.1.5", "nanoid": "^5.1.5",
"radash": "^12.1.1", "radash": "^12.1.1",
"react": "^19.1.0", "react": "^19.1.1",
"react-dom": "^19.1.0", "react-dom": "^19.1.1",
"react-dropzone": "^14.3.8", "react-dropzone": "^14.3.8",
"react-icons": "^5.5.0", "react-icons": "^5.5.0",
"react-redux": "^9.2.0", "react-redux": "^9.2.0",
"react-router": "^7.6.3", "react-router": "^7.8.2",
"react-syntax-highlighter": "^15.6.1", "react-syntax-highlighter": "^15.6.6",
"react-toastify": "^11.0.5", "react-toastify": "^11.0.5",
"sql.js": "^1.13.0" "sql.js": "^1.13.0"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.30.1", "@eslint/js": "^9.35.0",
"@rollup/plugin-replace": "^6.0.2", "@rollup/plugin-replace": "^6.0.2",
"@tailwindcss/vite": "^4.1.11", "@tailwindcss/vite": "^4.1.13",
"@testing-library/jest-dom": "^6.6.3", "@testing-library/jest-dom": "^6.8.0",
"@testing-library/react": "^16.3.0", "@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1", "@testing-library/user-event": "^14.6.1",
"@types/node": "^24.0.10", "@types/node": "^24.3.1",
"@types/react": "^19.1.8", "@types/react": "^19.1.12",
"@types/react-dom": "^19.1.6", "@types/react-dom": "^19.1.9",
"@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.9",
"@types/tar-stream": "^3.1.4",
"@types/wicg-file-system-access": "^2023.10.6", "@types/wicg-file-system-access": "^2023.10.6",
"@typescript-eslint/eslint-plugin": "^8.35.1", "@typescript-eslint/eslint-plugin": "^8.42.0",
"@typescript-eslint/parser": "^8.35.1", "@typescript-eslint/parser": "^8.42.0",
"@vitejs/plugin-react": "^4.6.0", "@vitejs/plugin-react": "^5.0.2",
"@vitest/coverage-v8": "^3.2.4", "@vitest/coverage-v8": "^3.2.4",
"@vitest/ui": "^3.2.4", "@vitest/ui": "^3.2.4",
"daisyui": "^5.0.43", "daisyui": "^5.1.8",
"eslint": "^9.30.1", "eslint": "^9.35.0",
"eslint-config-prettier": "^10.1.5", "eslint-config-prettier": "^10.1.8",
"eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20", "eslint-plugin-react-refresh": "^0.4.20",
"globals": "^16.3.0", "globals": "^16.3.0",
"husky": "^9.1.7", "husky": "^9.1.7",
"jsdom": "^26.1.0", "jsdom": "^26.1.0",
"lint-staged": "^16.1.2", "lint-staged": "^16.1.6",
"prettier": "^3.6.2", "prettier": "^3.6.2",
"rollup": "^4.44.2", "rollup": "^4.50.1",
"sass": "^1.89.2", "sass": "^1.92.1",
"simple-git-hooks": "^2.13.0", "simple-git-hooks": "^2.13.1",
"tailwindcss": "^4.1.11", "tailwindcss": "^4.1.13",
"terser": "^5.43.1", "tar-stream": "^3.1.7",
"typescript": "^5.8.3", "terser": "^5.44.0",
"typescript-eslint": "^8.35.1", "typescript": "^5.9.2",
"vite": "^6.3.5", "typescript-eslint": "^8.42.0",
"vite-plugin-pwa": "^1.0.1", "vite": "^7.1.5",
"vite-plugin-top-level-await": "^1.5.0", "vite-plugin-pwa": "^1.0.3",
"vite-plugin-wasm": "^3.4.1", "vite-plugin-top-level-await": "^1.6.0",
"vite-plugin-wasm": "^3.5.0",
"vitest": "^3.2.4", "vitest": "^3.2.4",
"workbox-build": "^7.3.0", "workbox-build": "^7.3.0",
"workbox-window": "^7.3.0" "workbox-window": "^7.3.0"
@@ -90,13 +92,17 @@
}, },
"pnpm": { "pnpm": {
"patchedDependencies": { "patchedDependencies": {
"@rollup/plugin-terser": "patches/@rollup__plugin-terser.patch",
"sql.js": "patches/sql.js.patch" "sql.js": "patches/sql.js.patch"
}, },
"overrides": { "overrides": {
"rollup-plugin-terser": "npm:@rollup/plugin-terser@0.4.3",
"sourcemap-codec": "npm:@jridgewell/sourcemap-codec@1.4.15" "sourcemap-codec": "npm:@jridgewell/sourcemap-codec@1.4.15"
} },
"onlyBuiltDependencies": [
"@swc/core",
"@tailwindcss/oxide",
"esbuild",
"simple-git-hooks"
]
}, },
"packageManager": "pnpm@9.12.1+sha512.e5a7e52a4183a02d5931057f7a0dbff9d5e9ce3161e33fa68ae392125b79282a8a8a470a51dfc8a0ed86221442eb2fb57019b0990ed24fab519bf0e1bc5ccfc4" "packageManager": "pnpm@10.15.1"
} }

2601
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -4,7 +4,7 @@
pushd "$(dirname "${BASH_SOURCE[0]}")/../" pushd "$(dirname "${BASH_SOURCE[0]}")/../"
WRY_VER="0.1.1" WRY_VER="0.1.2"
mkdir -p win64/{deps,dist} mkdir -p win64/{deps,dist}
dl_file() { dl_file() {

View File

@@ -65,3 +65,12 @@ h6 {
opacity: 0.75; opacity: 0.75;
} }
} }
#downloadAll {
position: absolute;
right: 0.5em;
bottom: 72px;
width: 48px;
height: 48px;
margin: 10px;
}

View File

@@ -1,5 +1,4 @@
import { Light as SyntaxHighlighter } from 'react-syntax-highlighter'; import { CodeHighlight } from '../CodeHighlight';
import hljsStyleGitHub from 'react-syntax-highlighter/dist/esm/styles/hljs/github';
import { ExtLink } from '../ExtLink'; import { ExtLink } from '../ExtLink';
import PowerShellAdbDumpCommandTemplate from './adb_dump.ps1?raw'; import PowerShellAdbDumpCommandTemplate from './adb_dump.ps1?raw';
import ShellAdbDumpCommandTemplate from './adb_dump.sh?raw'; import ShellAdbDumpCommandTemplate from './adb_dump.sh?raw';
@@ -45,9 +44,7 @@ export function AdbInstructionTemplate({ dir, file, platform }: AdbInstructionTe
<li></li> <li></li>
<li> <li>
<p> USB </p> <p> USB </p>
<SyntaxHighlighter language={language} style={hljsStyleGitHub}> <CodeHighlight language={language}>{command}</CodeHighlight>
{command}
</SyntaxHighlighter>
<br /> <br />
<ExtLink className="text-nowrap" href="https://g.126.fm/04jewvw"> <ExtLink className="text-nowrap" href="https://g.126.fm/04jewvw">
MuMu MuMu

View File

@@ -9,10 +9,10 @@ export function RootExplorerGuide() {
<div className="flex flex-col items-start gap-4 @md:flex-row"> <div className="flex flex-col items-start gap-4 @md:flex-row">
<div> <div>
<Header5 className="[&]:mt-0 [&]:pt-0">Amaze </Header5> <Header5 className="[&]:mt-0 [&]:pt-0">Amaze </Header5>
<ul className="ml-2 list-disc list-inside"> <ul className="ml-2 list-disc">
<li> <li>
<div className="inline-flex items-center gap-1"> <div className="inline-flex items-center flex-wrap">
<FiMenu /> <FiMenu className="m-1" />
</div> </div>
</li> </li>
<li> <li>
@@ -41,16 +41,16 @@ export function RootExplorerGuide() {
</div> </div>
<div> <div>
<Header5 className="[&]:mt-0 [&]:pt-0">MT </Header5> <Header5 className="[&]:mt-0 [&]:pt-0">MT </Header5>
<ul className="ml-2 list-disc list-inside"> <ul className="ml-2 list-disc">
<li> <li>
<div className="inline-flex items-center gap-1"> <div className="inline-flex items-center flex-wrap">
<FiMenu /> <FiMenu className="m-1" />
</div> </div>
</li> </li>
<li> <li>
<div className="inline-flex items-center"> <div className="inline-flex items-center flex-wrap">
<FiMoreVertical className="ml-1" /> <FiMoreVertical className="ml-1" />
<VQuote></VQuote> <VQuote></VQuote>
</div> </div>
</li> </li>
<li> <li>

View File

@@ -15,7 +15,6 @@ import { Bounce, ToastContainer } from 'react-toastify';
import { SettingsHome } from '~/features/settings/SettingsHome'; import { SettingsHome } from '~/features/settings/SettingsHome';
import { FAQ_PAGES } from '~/faq/FAQPages'; import { FAQ_PAGES } from '~/faq/FAQPages';
import { FaqHome } from '~/faq/FaqHome'; import { FaqHome } from '~/faq/FaqHome';
import { DownloadAll } from '~/components/DownloadAll.tsx';
// Private to this file only. // Private to this file only.
const store = setupStore(); const store = setupStore();
@@ -72,7 +71,6 @@ export function AppRoot() {
transition={Bounce} transition={Bounce}
/> />
<DownloadAll />
<Footer /> <Footer />
</Provider> </Provider>
</BrowserRouter> </BrowserRouter>

View File

@@ -0,0 +1,10 @@
import { Light as SyntaxHighlighter, type SyntaxHighlighterProps } from 'react-syntax-highlighter';
import hljsStyleGitHub from 'react-syntax-highlighter/dist/esm/styles/hljs/github';
export function CodeHighlight({ children, ...props }: SyntaxHighlighterProps) {
return (
<SyntaxHighlighter style={hljsStyleGitHub} {...props}>
{children}
</SyntaxHighlighter>
);
}

View File

@@ -1,49 +1,68 @@
import { DecryptedAudioFile, selectFiles } from '~/features/file-listing/fileListingSlice'; import { DecryptedAudioFile, ProcessState, selectFiles } from '~/features/file-listing/fileListingSlice';
import { FaDownload } from 'react-icons/fa'; import { FaDownload } from 'react-icons/fa';
import { useAppSelector } from '~/hooks'; import { useAppSelector } from '~/hooks';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
export function DownloadAll() { export function DownloadAll() {
const files = useAppSelector(selectFiles); const files = useAppSelector(selectFiles);
const filesLength = Object.keys(files).length;
const onClickDownloadAll = async () => { const onClickDownloadAll = async () => {
console.time('DownloadAll'); //开始计时
const fileCount = Object.keys(files).length;
if (fileCount === 0) {
toast.warning('未添加文件');
return;
}
//判断所有文件是否处理完成
const allComplete = Object.values(files).every((file) => file.state !== ProcessState.PROCESSING);
if (!allComplete) {
toast.warning('请等待所有文件解密完成');
return;
}
//过滤处理失败的文件
const completeFiles = Object.values(files).filter((file) => file.state === ProcessState.COMPLETE);
//开始下载
let dir: FileSystemDirectoryHandle | undefined; let dir: FileSystemDirectoryHandle | undefined;
let success = 0;
try { try {
dir = await window.showDirectoryPicker(); dir = await window.showDirectoryPicker({ mode: 'readwrite' });
} catch (e) { } catch (e) {
console.error(e); console.error(e);
if (e instanceof Error && e.name === 'AbortError') { if (e instanceof Error && e.name === 'AbortError') {
return; return;
} }
} }
for (const [_, file] of Object.entries(files)) { toast.warning('开始下载,请稍候');
const promises = Object.values(completeFiles).map(async (file) => {
console.log(`开始下载: ${file.fileName}`);
try { try {
if (dir) { if (dir) {
await DownloadNew(dir, file); await DownloadNew(dir, file);
} else { } else {
await DownloadOld(file); await DownloadOld(file);
} }
success++; console.log(`成功下载: ${file.fileName}`);
} catch (e) { } catch (e) {
console.error(`下载失败: ${file.fileName}`, e); console.error(`下载失败: ${file.fileName}`, e);
toast.error(`出现错误: ${e}`); toast.error(`出现错误: ${e}`);
throw e;
} }
} });
if (success === filesLength) { await Promise.allSettled(promises).then((f) => {
toast.success(`成功下载: ${success}/${filesLength}`); const success = f.filter((result) => result.status === 'fulfilled').length;
} else { if (success === fileCount) {
toast.error(`成功下载: ${success}/${filesLength}`); toast.success(`成功下载: ${success}/${fileCount}`);
} } else {
toast.warning(`成功下载: ${success}/${fileCount}`);
}
});
console.timeEnd('DownloadAll'); //停止计时
}; };
return ( return (
<button <button className="btn btn-primary" id="downloadAll" onClick={onClickDownloadAll} title="下载全部">
style={{ width: '48px', height: '48px', paddingInline: '0px', margin: '10px', marginLeft: 'auto' }}
className="btn btn-primary"
onClick={onClickDownloadAll}
title="下载全部"
>
<FaDownload /> <FaDownload />
</button> </button>
); );

View File

@@ -0,0 +1,32 @@
import type { ReactNode } from 'react';
import { ExtLink } from './ExtLink';
import { IoMdArchive } from 'react-icons/io';
export type DownloadBase64Props = {
data: string;
filename: string;
mimetype?: string;
className?: string;
icon?: boolean | ReactNode;
children?: ReactNode;
};
export function DownloadBase64({
className,
children,
data,
filename,
icon,
mimetype = 'application/octet-stream',
}: DownloadBase64Props) {
return (
<ExtLink
icon={icon ?? <IoMdArchive className="inline size-sm ml-1" />}
className={className ?? 'link-info mx-1'}
download={filename}
href={`data:${mimetype};base64,${data}`}
>
{children ?? <code>{filename}</code>}
</ExtLink>
);
}

View File

@@ -1,15 +1,15 @@
import type { AnchorHTMLAttributes } from 'react'; import type { AnchorHTMLAttributes, ReactNode } from 'react';
import { FiExternalLink } from 'react-icons/fi'; import { FiExternalLink } from 'react-icons/fi';
export type ExtLinkProps = AnchorHTMLAttributes<HTMLAnchorElement> & { export type ExtLinkProps = AnchorHTMLAttributes<HTMLAnchorElement> & {
icon?: boolean; icon?: boolean | ReactNode;
}; };
export function ExtLink({ className, icon = true, children, ...props }: ExtLinkProps) { export function ExtLink({ className, icon = true, children, ...props }: ExtLinkProps) {
return ( return (
<a rel="noreferrer noopener nofollow" target="_blank" className={`link ${className}`} {...props}> <a rel="noreferrer noopener nofollow" target="_blank" className={`link ${className}`} {...props}>
{children} {children}
{icon && <FiExternalLink className="inline size-sm ml-1" />} {icon === true ? <FiExternalLink className="inline size-sm ml-1" /> : icon}
</a> </a>
); );
} }

View File

@@ -5,25 +5,25 @@ export function Footer() {
const appVersionShort = '__APP_VERSION_SHORT__'; const appVersionShort = '__APP_VERSION_SHORT__';
return ( return (
<footer className="flex flex-col text-center p-4 bg-base-200"> <footer className="flex flex-col text-center p-4 bg-base-200">
<p className="flex flex-row justify-center items-center h-[1em]"> <div className="flex flex-row justify-center items-center h-[1em]">
<a className="link link-info mr-1" href="https://git.unlock-music.dev/um/um-react"> <a className="link link-info mr-1" href="https://git.um-react.app/um/um-react">
</a> </a>
( (
<a <a
title="使用 MIT 授权协议" title="使用 MIT 授权协议"
className="link link-info" className="link link-info"
href="https://git.unlock-music.dev/um/um-react/src/branch/main/LICENSE" href="https://git.um-react.app/um/um-react/src/branch/main/LICENSE"
> >
MIT MIT
</a> </a>
, v{appVersionShort} , v{appVersionShort}
<SDKVersion />) <SDKVersion />)
</p> </div>
<p> <p>
{'© 2019 - '} {'© 2019 - '}
<CurrentYear /> <CurrentYear />
<a className="ml-1 link link-info" href="https://git.unlock-music.dev/um"> <a className="ml-1 link link-info" href="https://git.um-react.app/um">
Unlock Music Unlock Music
</a> </a>
</p> </p>

View File

@@ -1,6 +1,7 @@
import { useEffect, useRef } from 'react'; import { useEffect, useRef } from 'react';
import { FileInput } from '~/components/FileInput'; import { FileInput } from '~/components/FileInput';
import { InSecretImportModalContext } from '~/context/InSecretImportModal';
export interface ImportSecretModalProps { export interface ImportSecretModalProps {
clientName?: React.ReactNode; clientName?: React.ReactNode;
@@ -31,7 +32,7 @@ export function ImportSecretModal({ clientName, children, show, onClose, onImpor
}, [show]); }, [show]);
return ( return (
<dialog ref={refModel} className="modal"> <dialog ref={refModel} className="modal" onClose={onClose}>
<div className="modal-box"> <div className="modal-box">
<form method="dialog" onSubmit={() => onClose()}> <form method="dialog" onSubmit={() => onClose()}>
<button className="btn btn-sm btn-circle btn-ghost absolute right-2 top-2"></button> <button className="btn btn-sm btn-circle btn-ghost absolute right-2 top-2"></button>
@@ -41,7 +42,9 @@ export function ImportSecretModal({ clientName, children, show, onClose, onImpor
<FileInput onReceiveFiles={handleFileReceived}></FileInput> <FileInput onReceiveFiles={handleFileReceived}></FileInput>
<div className="mt-2">{clientName && <>{clientName}</>}</div> <div className="mt-2">{clientName && <>{clientName}</>}</div>
<div>{children}</div> <InSecretImportModalContext.Provider value={true}>
<div>{children}</div>
</InSecretImportModalContext.Provider>
</div> </div>
</div> </div>
</dialog> </dialog>

View File

@@ -7,7 +7,7 @@ export interface ProjectIssueProps {
export function ProjectIssue({ id, title }: ProjectIssueProps) { export function ProjectIssue({ id, title }: ProjectIssueProps) {
return ( return (
<ExtLink target="_blank" href={`https://git.unlock-music.dev/um/um-react/issues/${id}`}> <ExtLink target="_blank" href={`https://git.um-react.app/um/um-react/issues/${id}`}>
{`#${id}`} {`#${id}`}
{title && ` - ${title}`} {title && ` - ${title}`}
</ExtLink> </ExtLink>

View File

@@ -0,0 +1,3 @@
import { createContext } from 'react';
export const InSecretImportModalContext = createContext<boolean>(false);

View File

@@ -25,7 +25,7 @@ export interface DecipherNotOK {
export interface DecipherOK { export interface DecipherOK {
status: Status.OK; status: Status.OK;
message?: string; message?: string;
data: Uint8Array; data: Uint8Array<ArrayBuffer>;
overrideExtension?: string; overrideExtension?: string;
cipherName: string; cipherName: string;
} }

View File

@@ -3,7 +3,7 @@ import { DecipherInstance, DecipherOK, DecipherResult, Status } from '~/decrypt-
export class TransparentDecipher implements DecipherInstance { export class TransparentDecipher implements DecipherInstance {
cipherName = 'none'; cipherName = 'none';
async decrypt(buffer: Uint8Array): Promise<DecipherResult | DecipherOK> { async decrypt(buffer: Uint8Array<ArrayBuffer>): Promise<DecipherResult | DecipherOK> {
return { return {
cipherName: 'None', cipherName: 'None',
status: Status.OK, status: Status.OK,

View File

@@ -24,3 +24,17 @@ export function isDataLooksLikeAudio(buffer: Uint8Array): boolean {
detectResult.free(); detectResult.free();
return ok; return ok;
} }
const AudioMimeType: Record<string, string> = {
mp3: 'audio/mpeg',
flac: 'audio/flac',
m4a: 'audio/mp4',
ogg: 'audio/ogg',
wma: 'audio/x-ms-wma',
wav: 'audio/x-wav',
dff: 'audio/x-dff',
};
export function getMimeTypeFromExt(ext: string) {
return AudioMimeType[ext] || 'application/octet-stream';
}

View File

@@ -1,7 +1,6 @@
export const toArrayBuffer = async (src: Blob | ArrayBuffer | Uint8Array<ArrayBufferLike>) => export const toArrayBuffer = async (src: Blob | BlobPart) => (src instanceof Blob ? await src.arrayBuffer() : src);
src instanceof Blob ? await src.arrayBuffer() : src; export const toBlob = (src: Blob | BlobPart, mimeType?: string) =>
export const toBlob = (src: Blob | ArrayBuffer | Uint8Array<ArrayBufferLike>) => src instanceof Blob ? src : new Blob([src], { type: mimeType ?? 'application/octet-stream' });
src instanceof Blob ? src : new Blob([src]);
export function* chunkBuffer(buffer: Uint8Array, blockLen = 4096): Generator<[Uint8Array, number], void> { export function* chunkBuffer(buffer: Uint8Array, blockLen = 4096): Generator<[Uint8Array, number], void> {
const len = buffer.byteLength; const len = buffer.byteLength;

View File

@@ -6,7 +6,7 @@ import { DecipherFactory, DecipherInstance, Status } from '~/decrypt-worker/Deci
import { UnsupportedSourceFile } from '~/decrypt-worker/util/DecryptError.ts'; import { UnsupportedSourceFile } from '~/decrypt-worker/util/DecryptError.ts';
import { ready as umCryptoReady } from '@unlock-music/crypto'; import { ready as umCryptoReady } from '@unlock-music/crypto';
import { go } from '~/util/go.ts'; import { go } from '~/util/go.ts';
import { detectAudioExtension } from '~/decrypt-worker/util/audioType.ts'; import { getMimeTypeFromExt, detectAudioExtension } from '~/decrypt-worker/util/audioType.ts';
class DecryptCommandHandler { class DecryptCommandHandler {
private readonly label: string; private readonly label: string;
@@ -31,6 +31,7 @@ class DecryptCommandHandler {
const [result, error] = await go(this.tryDecryptWith(decipher)); const [result, error] = await go(this.tryDecryptWith(decipher));
if (!error) { if (!error) {
if (result) { if (result) {
console.debug(`[${decipher.cipherName}] Decryption OK`);
return result; return result;
} }
errors.push(`${decipher.cipherName}: no response`); errors.push(`${decipher.cipherName}: no response`);
@@ -75,7 +76,7 @@ class DecryptCommandHandler {
audioExt = 'm4a'; audioExt = 'm4a';
} }
return { decrypted: URL.createObjectURL(toBlob(result.data)), ext: audioExt }; return { decrypted: URL.createObjectURL(toBlob(result.data, getMimeTypeFromExt(audioExt))), ext: audioExt };
} }
} }

View File

@@ -9,31 +9,31 @@ export function FAQAboutProject() {
<Header3 id="failed">um-react </Header3> <Header3 id="failed">um-react </Header3>
<p> <p>
um-react um-react
<a className="mx-1 link link-info" href="https://git.unlock-music.dev/um"> <a className="mx-1 link link-info" href="https://git.um-react.app/um">
Unlock Music Unlock Music
</a> </a>
React 使 React 使
<a className="mx-1 link link-info" href="https://git.unlock-music.dev/um/um-react/src/branch/main/LICENSE"> <a className="mx-1 link link-info" href="https://git.um-react.app/um/um-react/src/branch/main/LICENSE">
MIT MIT
</a> </a>
</p> </p>
<p> <p>
<FaRust className="inline" /> <FaRust className="inline" />
<a className="mx-1 link link-info" href="https://git.unlock-music.dev/um/lib_um_crypto_rust"> <a className="mx-1 link link-info" href="https://git.um-react.app/um/lib_um_crypto_rust">
<code>lib_um_crypto_rust</code> <code>lib_um_crypto_rust</code>
</a> </a>
使 使
<a <a
className="mx-1 link link-info" className="mx-1 link link-info"
href="https://git.unlock-music.dev/um/lib_um_crypto_rust/src/branch/main/LICENSE_MIT" href="https://git.um-react.app/um/lib_um_crypto_rust/src/branch/main/LICENSE_MIT"
> >
MIT MIT
</a> </a>
+ +
<a <a
className="mx-1 link link-info" className="mx-1 link link-info"
href="https://git.unlock-music.dev/um/lib_um_crypto_rust/src/branch/main/LICENSE_APACHE" href="https://git.um-react.app/um/lib_um_crypto_rust/src/branch/main/LICENSE_APACHE"
> >
Apache Apache
</a> </a>

View File

@@ -1,6 +1,5 @@
import { ExtLink } from '~/components/ExtLink'; import { ExtLink } from '~/components/ExtLink';
import { Header2, Header3, Header4 } from '~/components/HelpText/Headers'; import { Header2, Header3, Header4 } from '~/components/HelpText/Headers';
import { ProjectIssue } from '~/components/ProjectIssue';
import { NavLink } from 'react-router'; import { NavLink } from 'react-router';
@@ -12,21 +11,12 @@ export function OtherFAQ() {
<p></p> <p></p>
<p>使</p> <p>使</p>
<Header3 id="batch-dl"></Header3>
<p>
{'暂时没有实现,不过你可以在 '}
<ProjectIssue id={34} title="[UI] 全部下载功能" />
{' 以及 '}
<ProjectIssue id={43} title="批量下载" />
{' 追踪该问题。'}
</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">
<div> <div>
<Header4></Header4> <Header4></Header4>
<ul className="list-disc list-inside pl-2"> <ul className="list-disc pl-8">
<li>Via </li> <li>Via </li>
<li></li> <li></li>
<li>UC </li> <li>UC </li>
@@ -35,7 +25,7 @@ export function OtherFAQ() {
<div> <div>
<Header4></Header4> <Header4></Header4>
<ul className="list-disc list-inside pl-2"> <ul className="list-disc pl-8">
<li></li> <li></li>
<li></li> <li></li>
<li></li> <li></li>
@@ -77,7 +67,7 @@ export function OtherFAQ() {
</li> </li>
<li> <li>
<p> <p>
<ExtLink className="mr-2" href="https://git.unlock-music.dev/um/um-react-wry"> <ExtLink className="mr-2" href="https://git.um-react.app/um/um-react-wry">
<strong> <strong>
<code>um-react-wry</code> <code>um-react-wry</code>
</strong> </strong>
@@ -89,7 +79,7 @@ export function OtherFAQ() {
<ul className="list-disc pl-6"> <ul className="list-disc pl-6">
<li> <li>
<p> <p>
<ExtLink href="https://git.unlock-music.dev/um/um-react/releases/latest"></ExtLink> <ExtLink href="https://git.um-react.app/um/um-react/releases/latest"></ExtLink>
{' | 寻找文件名为 '} {' | 寻找文件名为 '}
<code>um-react-win64-</code> <code>um-react-win64-</code>
</p> </p>

View File

@@ -1,79 +1,31 @@
import { RiFileCopyLine } from 'react-icons/ri'; import { useId } from 'react';
import { toast } from 'react-toastify'; import { InstructionsMacV8 } from './InstructionsMacV8';
import { ExtLink } from '~/components/ExtLink'; import { InstructionsMacV10 } from './InstructionsMacV10';
import { FilePathBlock } from '~/components/FilePathBlock';
import { VQuote } from '~/components/HelpText/VQuote';
import { MacCommandKey } from '~/components/Key/MacCommandKey';
import { ShiftKey } from '~/components/Key/ShiftKey';
const MAC_CLIENT_URL =
'https://web.archive.org/web/20230903/https://dldir1.qq.com/music/clntupate/mac/QQMusicMac_Mgr.dmg';
const MAC_CLIENT_TG_URL = 'https://t.me/um_lsr_ch/21';
const DB_PATH =
'~/Library/Containers/com.tencent.QQMusicMac/Data/Library/Application Support/QQMusicMac/mmkv/MMKVStreamEncryptId';
export function InstructionsMac() { export function InstructionsMac() {
const copyDbPathToClipboard = () => { const macInstructionId = useId();
navigator.clipboard
.writeText(DB_PATH)
.then(() => {
toast.success('已复制到剪贴板');
})
.catch((err) => {
toast.error(`复制失败,请手动复制\n${err}`);
});
};
return ( return (
<> <>
<p>Mac 使 mmkv </p> <p>Mac 使 mmkv </p>
<p> v8.8.0 </p> <p>使 v8.8.0 v10.7 v8.8.0 </p>
<p className="mt-4"> QQ Mac 8.8.0:</p> <div className="join join-vertical bg-base-100 mt-2 max-w-full">
<ul className="list-disc pl-6"> <div className="collapse collapse-arrow join-item border-base-300 border">
<li> <input type="radio" name={macInstructionId} />
<ExtLink className="link-info" href={MAC_CLIENT_URL}> <div className="collapse-title font-semibold">使 QQ Mac v8.8.0</div>
<code>Archive.org</code> <div className="collapse-content text-sm min-w-0">
</ExtLink> <InstructionsMacV8 />
</li> </div>
<li> </div>
<ExtLink className="link-info" href={MAC_CLIENT_TG_URL}> <div className="collapse collapse-arrow join-item border-base-300 border">
Telegram <input type="radio" name={macInstructionId} />
</ExtLink> <div className="collapse-title font-semibold">使 QQ Mac v10.7.1</div>
</li> <div className="collapse-content text-sm min-w-0">
</ul> <InstructionsMacV10 />
</div>
<p className="mt-4"></p> </div>
<FilePathBlock>{DB_PATH}</FilePathBlock> </div>
<h4 className="font-bold text-lg mt-4"></h4>
<ol className="list-decimal pl-6">
<li>
<button className="btn btn-sm btn-outline btn-accent mr-2" onClick={copyDbPathToClipboard}>
<RiFileCopyLine className="text-xl" />
<span></span>
</button>
<code>MMKVStreamEncryptId</code>
</li>
<li>
<VQuote></VQuote><VQuote></VQuote>
</li>
<li>
<VQuote>
<ShiftKey className="mx-1" />
{'+'}
<MacCommandKey className="mx-1" />
{'+'}
<kbd className="kbd mx-1">G</kbd>
</VQuote>
<VQuote></VQuote>
</li>
<li>
<code>MMKVStreamEncryptId</code>
</li>
<li></li>
</ol>
</> </>
); );
} }

View File

@@ -0,0 +1,65 @@
import { ExtLink } from '~/components/ExtLink';
import {
commandName as DUMP_COMMAND_NAME,
tarName as DUMP_COMMAND_TARBALL_NAME,
tarball as DUMP_COMMAND_BASE64,
} from './assets/qqmusic_v10.7_dump.command?&name=QQ 音乐 Mac v10 密钥提取.command&mac-command';
import { DownloadBase64 } from '~/components/DownloadBase64';
import { VQuote } from '~/components/HelpText/VQuote';
import { InSecretImportModalContext } from '~/context/InSecretImportModal';
import { useContext } from 'react';
const MAC_CLIENT_URL =
'https://c.y.qq.com/cgi-bin/file_redirect.fcg?bid=dldir&file=ecosfile%2Fmusic_clntupate%2Fmac%2Fother%2FQQMusicMac10.7.1Build00.dmg&sign=1-0cb9ee4c40e7447e2113cfdee2dc11c88487b0e31fe37cfe1c59e12c20956dce-689e9373';
const MAC_CLIENT_TG_URL = 'https://t.me/um_lsr_ch/30';
export function InstructionsMacV10() {
const inSecretImportModal = useContext(InSecretImportModalContext);
return (
<>
<p className="mt-4"> QQ Mac 10.7.1:</p>
<ul className="list-disc pl-6">
<li>
<ExtLink className="link-info" href={MAC_CLIENT_URL}>
QQ
</ExtLink>
</li>
<li>
<ExtLink className="link-info" href={MAC_CLIENT_TG_URL}>
Telegram
</ExtLink>
</li>
</ul>
<h4 className="font-bold text-lg mt-4"></h4>
<ol className="list-decimal pl-6">
<li>
<DownloadBase64 data={DUMP_COMMAND_BASE64} filename={DUMP_COMMAND_TARBALL_NAME}></DownloadBase64>
<code>{DUMP_COMMAND_NAME}</code>
</li>
<li>
<p>
<code>{DUMP_COMMAND_NAME}</code>
</p>
<p>
<VQuote></VQuote><VQuote></VQuote>
</p>
</li>
<li>
<code>qqmusic-mac-*.mmkv</code> <code>*</code>
</li>
{inSecretImportModal ? (
<li>
<code>qqmusic-mac-*.mmkv</code> <VQuote></VQuote>
</li>
) : (
<li>
<code>qqmusic-mac-*.mmkv</code>
</li>
)}
</ol>
</>
);
}

View File

@@ -0,0 +1,89 @@
import { RiFileCopyLine } from 'react-icons/ri';
import { ExtLink } from '~/components/ExtLink';
import { FilePathBlock } from '~/components/FilePathBlock';
import { VQuote } from '~/components/HelpText/VQuote';
import { MacCommandKey } from '~/components/Key/MacCommandKey';
import { ShiftKey } from '~/components/Key/ShiftKey';
import { copyToClipboard } from '~/util/clipboard';
import {
commandName as BLOCK_UPDATE_COMAND,
tarName as BLOCK_UPDATE_TAR_NAME,
tarball as BLOCK_UPDATE_BASE64,
} from './assets/qqmusic_v8.8.0_patch_update.command?&name=QQ 音乐 Mac v8.8.0 屏蔽更新.command&mac-command';
import { DownloadBase64 } from '~/components/DownloadBase64';
import { useContext } from 'react';
import { InSecretImportModalContext } from '~/context/InSecretImportModal';
const MAC_CLIENT_URL =
'https://web.archive.org/web/20230903/https://dldir1.qq.com/music/clntupate/mac/QQMusicMac_Mgr.dmg';
const MAC_CLIENT_TG_URL = 'https://t.me/um_lsr_ch/21';
const DB_PATH =
'~/Library/Containers/com.tencent.QQMusicMac/Data/Library/Application Support/QQMusicMac/mmkv/MMKVStreamEncryptId';
export function InstructionsMacV8() {
const inSecretImportModal = useContext(InSecretImportModalContext);
return (
<>
<p className="mt-4"> QQ Mac 8.8.0:</p>
<ul className="list-disc pl-6">
<li>
<ExtLink className="link-info" href={MAC_CLIENT_URL}>
<code>Archive.org</code>
</ExtLink>
</li>
<li>
<ExtLink className="link-info" href={MAC_CLIENT_TG_URL}>
Telegram
</ExtLink>
</li>
</ul>
<p className="mt-4">
<DownloadBase64 filename={BLOCK_UPDATE_TAR_NAME} data={BLOCK_UPDATE_BASE64}></DownloadBase64>
<code>{BLOCK_UPDATE_COMAND}</code>
<span> QQ </span>
</p>
<p>
<VQuote></VQuote><VQuote></VQuote>
</p>
<p className="mt-4"></p>
<FilePathBlock>{DB_PATH}</FilePathBlock>
<h4 className="font-bold text-lg mt-4"></h4>
<ol className="list-decimal pl-6">
<li>
<button className="btn btn-sm btn-outline btn-accent mr-2" onClick={() => copyToClipboard(DB_PATH)}>
<RiFileCopyLine className="text-xl" />
<span></span>
</button>
<code>MMKVStreamEncryptId</code>
</li>
<li>
{inSecretImportModal ? (
<p>
<VQuote></VQuote><VQuote></VQuote>
</p>
) : (
<p></p>
)}
<p>
<VQuote>
<ShiftKey className="mx-1" />
{'+'}
<MacCommandKey className="mx-1" />
{'+'}
<kbd className="kbd mx-1">G</kbd>
</VQuote>
<VQuote></VQuote>
</p>
</li>
</ol>
</>
);
}

View File

@@ -0,0 +1,3 @@
com.tencent.QQMusicMac.plist
iData/
qqmusic-mac-*.mmkv

View File

@@ -0,0 +1,213 @@
#!/usr/bin/env python3
# QQMusic Mac MMKV Decryptor by LSR@Unlock Music
import hashlib
import re
import sys
from argparse import ArgumentParser
from dataclasses import dataclass
from os import PathLike
from os.path import dirname
from pathlib import Path
from struct import pack, unpack
@dataclass
class MMKVDecryptionData:
udid: str
mmkv_path: Path
mmkv_key: str
data: bytes
@property
def mmkv_name(self) -> str:
return self.mmkv_path.name
def _aes_128_cfb_decrypt(key: bytes, iv: bytes, ciphertext: bytes) -> bytes:
"""Decrypt using `Crypto.Cipher.AES` _or_ fallback to `OpenSSL` otherwise"""
try:
from Crypto.Cipher import AES # pyright: ignore[reportMissingImports]
aes = AES.new(key[:16], AES.MODE_CFB, iv=iv, segment_size=128)
return aes.decrypt(ciphertext)
except ImportError:
from subprocess import PIPE, Popen
process = Popen(
["openssl", "enc", "-aes-128-cfb", "-d", "-K", key.hex(), "-iv", iv.hex()],
stdin=PIPE,
stdout=PIPE,
stderr=PIPE,
text=False,
)
stdout, stderr = process.communicate(input=ciphertext)
if process.returncode != 0:
raise RuntimeError(
f"OpenSSL error (install PyCryptodome instead): {stderr.decode()}"
)
return stdout
def _caesar(text: str, shift: int) -> str:
"""A simple Caesar cipher implementation for alphanumeric characters"""
result = ""
for char in text:
if char.isalpha():
base = ord("A") if char.isupper() else ord("a")
result += chr((ord(char) - base + shift) % 26 + base)
elif char.isdigit():
result += chr((ord(char) - ord("0") + shift) % 10 + ord("0"))
else:
result += char
return result
__MMKV_TYPE_STREAM_KEY = 1
def _derive_mmkv_config(udid: str, mmkv_type: int):
"""Derive MMKV name and key from UDID, return (name, key)"""
str1 = _caesar(udid, mmkv_type + 3)
int1 = int(udid[5:7], 16)
int2 = 5 + (int1 + mmkv_type) % 4
mmkv_name = str1[0:int2]
int3 = mmkv_type + 0xA546
str3 = f"{udid}{int3:04x}"
mmkv_key = hashlib.md5(str3.encode()).hexdigest()
return mmkv_name, mmkv_key
def _decrypt_mmkv(path: PathLike, key: bytes):
"""Decrypt MMKV file using the given key, return decrypted data"""
with open(path, "rb") as mmkv, open(str(path) + ".crc", "rb") as crc:
crc.seek(12)
iv = crc.read(16)
(real_size,) = unpack("<I", crc.read(4))
(mmkv_payload_size,) = unpack("<I", mmkv.read(4))
if mmkv_payload_size != real_size:
raise ValueError("MMKV file size mismatch")
decrypted_data = pack("<I", real_size)
decrypted_data += _aes_128_cfb_decrypt(key, iv, mmkv.read(real_size))
return decrypted_data
def _dump_udid(plist_file: PathLike):
"""Extract UDIDs from the given plist file"""
with open(plist_file, "rb") as f:
plist = f.read()
for m in re.finditer(rb"_\x10\(([0-9a-f]{40})_", plist):
yield m.group(1).decode()
def _dump_mmkv(plist_file: PathLike, data_dir: PathLike):
"""Dump all MMKV files from the given plist file and iData directory"""
for udid in _dump_udid(plist_file):
mmkv_name, mmkv_key = _derive_mmkv_config(udid, __MMKV_TYPE_STREAM_KEY)
mmkv_path = Path(data_dir) / mmkv_name
if not mmkv_path.exists() or not mmkv_path.is_file():
print(f"MMKV file not found, skipping (path={mmkv_path})", file=sys.stderr)
continue
try:
decrypted_mmkv = _decrypt_mmkv(mmkv_path, mmkv_key.encode())
except Exception as e:
print(
"Error decrypting mmkv, skipping"
f" (path={mmkv_path}, key={mmkv_key}, error={e})",
file=sys.stderr,
)
continue
yield MMKVDecryptionData(
udid=udid,
mmkv_path=mmkv_path,
mmkv_key=mmkv_key,
data=decrypted_mmkv,
)
def main():
parser = ArgumentParser(
description="QQMusic Mac MMKV Decryptor by LSR@Unlock Music"
)
parser.add_argument(
"-p",
"--plist",
type=str,
nargs="+",
help="Path to com.tencent.QQMusicMac.plist file or files",
default=[],
)
parser.add_argument(
"-i",
"--idata",
type=str,
help="Path to iData directory",
default="",
)
parser.add_argument("-f", "--force", action="store_true", help="Force overwrite")
parser.add_argument(
"-o",
"--output",
type=str,
help="Output directory for decrypted MMKV files (default: script directory)",
default=dirname(__file__),
)
parser.add_argument(
"-v", "--verbose", action="store_true", help="Enable verbose output"
)
args = parser.parse_args()
home_dir = Path.home()
app_sandbox_dir = home_dir / "Library/Containers/com.tencent.QQMusicMac/Data"
idata_dir = app_sandbox_dir / "Library/Application Support/QQMusicMac/iData"
if args.idata:
idata_dir = Path(args.idata)
plists = []
if args.plist:
plists = [Path(p) for p in args.plist]
else:
for base_dir in (home_dir, app_sandbox_dir):
plists.append(base_dir / "Library/Preferences/com.tencent.QQMusicMac.plist")
output_dir = Path(args.output)
output_dir.mkdir(parents=True, exist_ok=True)
force = args.force
verbose = args.verbose
for plist_file in plists:
if plist_file.exists() and plist_file.is_file():
for dump in _dump_mmkv(plist_file, idata_dir):
out_path = output_dir / f"qqmusic-mac-{dump.mmkv_path.name}.mmkv"
if out_path.exists() and not force:
print(f"output exists, skipping (name={out_path.name})")
continue
if verbose:
print("*** MMKV DUMP ENTRY START ***")
print(f"UDID: {dump.udid}")
print(f"MMKV Name: {dump.mmkv_path.name}")
print(f"MMKV Key: {dump.mmkv_key}")
print(f"Output: {out_path.name}")
print("**** MMKV DUMP ENTRY END ****")
else:
print(f"Dumping mmkv: {out_path.name}...")
try:
with open(out_path, "wb") as f:
f.write(dump.data)
except Exception as e:
print(f"Error writing decrypted mmkv: {e}", file=sys.stderr)
continue
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,28 @@
#!/bin/sh
echo '补丁中…'
patch_count=0
patch_qqmusic() {
SUDO="$1"
APP="$2"
if [ ! -d "$APP" ]; then
echo "路径不存在,跳过 $APP..."
return
fi
echo "修补 $APP..."
$SUDO sed -i.bak 's#<string>8.8.0</string>#<string>88.8.0</string>#' \
"$APP/Contents/Info.plist"
$SUDO codesign --force --deep --sign - "$APP"
$SUDO xattr -d com.apple.quarantine "$APP"
patch_count=$((patch_count + 1))
}
patch_qqmusic sudo "/Applications/QQMusic.app"
patch_qqmusic "" "$HOME/Applications/QQMusic.app"
echo "完成,已修补 $patch_count 个 QQ 音乐安装"

View File

@@ -1,5 +1,6 @@
import { RiErrorWarningLine } from 'react-icons/ri'; import { RiErrorWarningLine } from 'react-icons/ri';
import { SelectFile } from '../components/SelectFile'; import { SelectFile } from '../components/SelectFile';
import { DownloadAll } from '~/components/DownloadAll.tsx';
import { FileListing } from '~/features/file-listing/FileListing'; import { FileListing } from '~/features/file-listing/FileListing';
import { useAppDispatch, useAppSelector } from '~/hooks.ts'; import { useAppDispatch, useAppSelector } from '~/hooks.ts';
@@ -39,6 +40,7 @@ export function MainTab() {
<div className="w-full mt-4"> <div className="w-full mt-4">
<FileListing /> <FileListing />
</div> </div>
<DownloadAll />
</div> </div>
</div> </div>
); );

View File

@@ -5,7 +5,7 @@ export class MMKVParser {
private offset = 4; private offset = 4;
private length: number; private length: number;
constructor(private view: DataView) { constructor(private view: DataView<ArrayBuffer>) {
const payloadLength = view.getUint32(0, true); const payloadLength = view.getUint32(0, true);
this.length = 4 + payloadLength; this.length = 4 + payloadLength;

12
src/util/clipboard.ts Normal file
View File

@@ -0,0 +1,12 @@
import { toast } from 'react-toastify';
export const copyToClipboard = (text: string) => {
navigator.clipboard
.writeText(text)
.then(() => {
toast.success('已复制到剪贴板');
})
.catch((err) => {
toast.error(`复制失败,请手动复制\n${err}`);
});
};

View File

@@ -1,7 +1,7 @@
import type { StagingKugouKey } from '~/features/settings/keyFormats'; import type { StagingKugouKey } from '~/features/settings/keyFormats';
import { MMKVParser } from '../MMKVParser'; import { MMKVParser } from '../MMKVParser';
export function parseAndroidKugouMMKV(view: DataView): Omit<StagingKugouKey, 'id'>[] { export function parseAndroidKugouMMKV(view: DataView<ArrayBuffer>): Omit<StagingKugouKey, 'id'>[] {
const mmkv = new MMKVParser(view); const mmkv = new MMKVParser(view);
const result: Omit<StagingKugouKey, 'id'>[] = []; const result: Omit<StagingKugouKey, 'id'>[] = [];
while (!mmkv.eof) { while (!mmkv.eof) {

View File

@@ -1,7 +1,7 @@
import type { StagingKWMv2Key } from '~/features/settings/keyFormats'; import type { StagingKWMv2Key } from '~/features/settings/keyFormats';
import { MMKVParser } from '../MMKVParser'; import { MMKVParser } from '../MMKVParser';
export function parseAndroidKuwoEKey(view: DataView): Omit<StagingKWMv2Key, 'id'>[] { export function parseAndroidKuwoEKey(view: DataView<ArrayBuffer>): Omit<StagingKWMv2Key, 'id'>[] {
const mmkv = new MMKVParser(view); const mmkv = new MMKVParser(view);
const result: Omit<StagingKWMv2Key, 'id'>[] = []; const result: Omit<StagingKWMv2Key, 'id'>[] = [];
while (!mmkv.eof) { while (!mmkv.eof) {
@@ -21,7 +21,7 @@ export function parseAndroidKuwoEKey(view: DataView): Omit<StagingKWMv2Key, 'id'
return result; return result;
} }
export function parseIosKuwoEKey(view: DataView): Omit<StagingKWMv2Key, 'id'>[] { export function parseIosKuwoEKey(view: DataView<ArrayBuffer>): Omit<StagingKWMv2Key, 'id'>[] {
const mmkv = new MMKVParser(view); const mmkv = new MMKVParser(view);
const result: Omit<StagingKWMv2Key, 'id'>[] = []; const result: Omit<StagingKWMv2Key, 'id'>[] = [];
while (!mmkv.eof) { while (!mmkv.eof) {

View File

@@ -1,6 +1,6 @@
import { MMKVParser } from '../MMKVParser'; import { MMKVParser } from '../MMKVParser';
export function parseAndroidQmEKey(view: DataView): Map<string, string> { export function parseAndroidQmEKey(view: DataView<ArrayBuffer>): Map<string, string> {
const mmkv = new MMKVParser(view); const mmkv = new MMKVParser(view);
const result = new Map<string, string>(); const result = new Map<string, string>();
while (!mmkv.eof) { while (!mmkv.eof) {

6
src/vite-env.d.ts vendored
View File

@@ -11,3 +11,9 @@ declare module '*?base64' {
const content: string; const content: string;
export default content; export default content;
} }
declare module '*&mac-command' {
export const tarball: string;
export const commandName: string;
export const tarName: string;
}

View File

@@ -0,0 +1,37 @@
import { basename } from 'node:path';
import { readFile } from 'node:fs/promises';
import { Plugin } from 'vite';
import tar from 'tar-stream';
export const macCommandLoader: Plugin = {
name: 'mac-command-loader',
async transform(_: unknown, id: string) {
const [path, query] = id.split('?');
if (!query || !query.includes('mac-command')) return null;
const params = new URLSearchParams(query);
// Mac .command packer.
// - Create a tarball with the given file (a+x)
// - Encode to base64
const tarball = tar.pack();
const name = params.get('name') || `${basename(path)}.command`;
const data = await readFile(path);
tarball.entry({ name, mode: 0o755 }, data);
tarball.finalize();
const chunks: Buffer[] = [];
for await (const chunk of tarball) {
chunks.push(chunk as Buffer);
}
const dataBuffer = Buffer.concat(chunks);
const base64 = dataBuffer.toString('base64');
return `
export const tarball = ${JSON.stringify(base64)};
export const commandName = ${JSON.stringify(name)};
export const tarName = ${JSON.stringify(`${name}.tar`)};
`;
},
};

View File

@@ -12,6 +12,7 @@ import tailwindcss from '@tailwindcss/vite';
import { tryCommand } from './support/command'; import { tryCommand } from './support/command';
import { base64Loader } from './support/b64-loader'; import { base64Loader } from './support/b64-loader';
import { macCommandLoader } from './support/mac-command-loader';
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'));
@@ -32,6 +33,7 @@ export default defineConfig({
// strict: false, // strict: false,
allow: [ allow: [
'index.html',
'src', 'src',
'node_modules', 'node_modules',
@@ -46,6 +48,7 @@ export default defineConfig({
plugins: [ plugins: [
tailwindcss(), tailwindcss(),
base64Loader, base64Loader,
macCommandLoader,
replace({ replace({
preventAssignment: true, preventAssignment: true,
values: { values: {