54 Commits

Author SHA1 Message Date
鲁树人
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
鲁树人
665524ee34 0.5.0 2025-07-05 04:51:47 +09:00
鲁树人
7c319fa4d9 ci: exclude zip file from gitea artifact 2025-07-05 04:51:47 +09:00
鲁树人
2598b977ab ci: product win64 zip (wry) on build 2025-07-05 04:51:47 +09:00
鲁树人
d81963ddcd Merge pull request '框架迁移: Chakra 迁移到 Daisy UI' (#86) from chakra-to-daisy into main
Reviewed-on: https://git.unlock-music.dev/um/um-react/pulls/86
2025-07-04 16:46:19 +00:00
鲁树人
99bce5c4ef fix: improve dark mode color 2025-07-05 01:13:39 +09:00
鲁树人
3fcbe054b0 chore: bump node types version 2025-07-05 00:55:37 +09:00
鲁树人
67fbe77157 chore: bump minor dep version 2025-07-05 00:53:13 +09:00
鲁树人
d57cdcdb49 fix: reduce footer text, move project information to faq page 2025-06-16 22:31:08 +09:00
鲁树人
ff79b4ce27 chore: fix type issue with filesystem access api 2025-06-16 22:31:03 +09:00
鲁树人
cbb6347251 Merge pull request 'feat: 全部下载' (#91) from awalol/um-react:downloadall-fork-chakra-to-daisy into chakra-to-daisy
Reviewed-on: https://git.unlock-music.dev/um/um-react/pulls/91
Reviewed-by: 鲁树人 <lsr@noreply.unlock-music.dev>
2025-06-16 10:15:13 +00:00
awalol
a5d0ec29a1 增加文件夹选取取消判断
使用 toast 进行提示
使用 react-icons 获取下载图标
使用 pipeTo 方法写出到文件
2025-06-16 00:56:20 +08:00
awalol
fa7292f65b ui: 完善全部下载按钮的样式和位置 2025-06-15 14:05:11 +08:00
awalol
519ced5e88 feat: 全部下载 2025-06-15 04:01:52 +08:00
鲁树人
b33ffa6ca7 build: include _redirects for netlify deployment 2025-05-19 10:00:53 +09:00
鲁树人
09c1bc474e child: remove base 2025-05-19 09:58:13 +09:00
鲁树人
e0b3bd60c2 chore: update wording for settings 2025-05-19 09:51:00 +09:00
鲁树人
6371c58cd5 fix: theme support 2025-05-19 09:50:52 +09:00
鲁树人
98f1be9ac7 fix: default sub-nav to align top on large screen 2025-05-19 09:26:54 +09:00
鲁树人
3541af7a96 refactor: deep link for faq 2025-05-19 09:23:14 +09:00
鲁树人
3ab73d8369 adjust dark mode and layout 2025-05-18 23:18:01 +09:00
鲁树人
6cb1f9f87f fix: adjust layout for settings 2025-05-18 11:05:50 +09:00
鲁树人
9518b813bd refactor: batch 4 2025-05-18 09:58:34 +09:00
鲁树人
2e4e57be45 refactor: batch 3 2025-05-18 02:41:20 +09:00
鲁树人
75b43e1e84 refactor: batch 2 2025-05-17 11:20:52 +09:00
鲁树人
246ba48135 fix: build/test issue 2025-05-17 06:09:00 +09:00
鲁树人
13c669b4ea refactor: batch 1 2025-05-17 05:59:39 +09:00
鲁树人
089d66cbf4 feat: add daisy ui 2025-05-17 01:44:55 +09:00
鲁树人
33a5f277fa chore: bump major versions 2025-05-17 01:42:58 +09:00
鲁树人
8a77cb0dc8 chore: update packages 2025-05-17 01:39:01 +09:00
鲁树人
0a820b620b docs: use gitea actions badge 2025-05-08 07:22:52 +09:00
鲁树人
721d947fdb docs: add link to lib_um_crypto_rust 2025-05-06 10:28:22 +09:00
鲁树人
1880220aaa docs: add issue template 2025-05-04 22:13:25 +09:00
鲁树人
d91e2fffe4 chore: bump version to v0.4.7; upgrade deps 2025-03-31 09:37:58 +09:00
鲁树人
88cfbcd337 build: retrive git hash 2025-03-31 04:03:27 +09:00
127 changed files with 17381 additions and 5287 deletions

View File

@@ -1,3 +0,0 @@
dist/
node_modules/
coverage/

View File

@@ -1,27 +0,0 @@
/* eslint-env node */
module.exports = {
root: true,
env: { browser: true, es2020: true },
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:react-hooks/recommended',
'prettier',
],
parser: '@typescript-eslint/parser',
parserOptions: { ecmaVersion: 'latest', sourceType: 'module' },
plugins: ['react-refresh'],
rules: {
'react-refresh/only-export-components': 'warn',
'@typescript-eslint/no-unused-vars': [
'error',
{
varsIgnorePattern: '^_',
argsIgnorePattern: '^_',
destructuredArrayIgnorePattern: '^_',
ignoreRestSiblings: true,
},
],
},
};

View File

@@ -0,0 +1,46 @@
name: QQ 音乐 (安卓)
about: 解密使用「QQ 音乐」的「安卓」客户端下载的文件失败时选择该项。
title: '[QQ音乐]: 请填写标题'
body:
- type: markdown
attributes:
value: |
### 安卓客户端
你的手机需要 root 权限。注意 root 可能会导致手机保修失效,操作不当可能会导致手机变砖。
请参考 [um-react](https://um-react.netlify.app/) 上方的答疑区域获取说明。
此外请使用 Chrome 或 Firefox 浏览器,而非系统自带的浏览器或轻量浏览器,如 Via 浏览器。
---
如果你确定你的客户端版本符合上述描述,并遇到了问题,请继续填写下面的表单。
- type: textarea
id: what-happened
attributes:
label: 错误描述
description: |
请详细描述你遇到的问题,例如下载使用的客户端、提供下载后的文件、操作步骤、错误提示等你认为会帮助修正错误的信息。
validations:
required: true
- type: textarea
id: logs
attributes:
label: 相关日志
description: 如果有相关日志,请附上。浏览器日志可以通过 F12 打开开发者工具,在 Console 选项卡中查看。
- type: checkboxes
id: terms
attributes:
label: 检查清单
description: 请检查下方的快速检查清单,确保你已经完成了所有步骤。
options:
- label: 我有填写一个简单易懂的标题
required: true
- label: 我有阅读上方的说明
required: true
- label: 我有阅读 um-react 的答疑部分
required: true
- label: 我的安卓设备已获得 root 权限
required: true

View File

@@ -0,0 +1,68 @@
name: QQ 音乐 (Windows/Mac)
about: 解密使用「QQ 音乐」的「Windows」或「Mac」客户端下载的文件失败时选择该项。
title: '[QQ音乐]: 请填写标题'
body:
- type: markdown
attributes:
value: |
### Windows 客户端
目前 Windows 客户端仅支持 19.51 或更低版本下载的歌曲文件。
* [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/6)
### Mac 客户端
目前 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)
安装好客户端后可以加装更新屏蔽更新:
* [屏蔽更新](https://t.me/um_lsr_ch/29)
---
如果你确定你的客户端版本符合上述描述,并遇到了问题,请继续填写下面的表单。
- type: dropdown
id: platform
attributes:
label: 平台
description: 你使用的客户端平台是…
options:
- Windows
- Mac
validations:
required: true
- type: textarea
id: what-happened
attributes:
label: 错误描述
description: |
请详细描述你遇到的问题,例如下载使用的客户端、提供下载后的文件、操作步骤、错误提示等你认为会帮助修正错误的信息。
validations:
required: true
- type: textarea
id: logs
attributes:
label: 相关日志
description: 如果有相关日志,请附上。浏览器日志可以通过 F12 打开开发者工具,在 Console 选项卡中查看。
- type: checkboxes
id: terms
attributes:
label: 检查清单
description: 请检查下方的快速检查清单,确保你已经完成了所有步骤。
options:
- label: 我有填写一个简单易懂的标题
required: true
- label: 我有阅读上方的说明
required: true
- label: 我有阅读 um-react 的答疑部分
required: true

View File

@@ -0,0 +1,51 @@
name: 酷我音乐 (安卓)
about: 解密使用「酷我音乐」的「安卓」客户端下载的文件失败时选择该项。
title: '[酷我音乐]: 请填写标题'
body:
- type: markdown
attributes:
value: |
### 酷我音乐 - 安卓客户端
你的手机需要 root 权限。注意 root 可能会导致手机保修失效,操作不当可能会导致手机变砖。
请参考 [um-react](https://um-react.netlify.app/) 上方的答疑区域获取说明。
此外请使用 Chrome 或 Firefox 浏览器,而非系统自带的浏览器或轻量浏览器,如 Via 浏览器。
※ 如果你使用酷我音乐的是所谓“破解版”,你的问题会被忽略。
已知部分“第三方魔改”会破坏密钥写出过程,请从官方渠道安装酷我音乐。
---
如果你确定你的客户端版本符合上述描述,并遇到了问题,请继续填写下面的表单。
- type: textarea
id: what-happened
attributes:
label: 错误描述
description: |
请详细描述你遇到的问题,例如下载使用的客户端、提供下载后的文件、操作步骤、错误提示等你认为会帮助修正错误的信息。
validations:
required: true
- type: textarea
id: logs
attributes:
label: 相关日志
description: 如果有相关日志,请附上。浏览器日志可以通过 F12 打开开发者工具,在 Console 选项卡中查看。
- type: checkboxes
id: terms
attributes:
label: 检查清单
description: 请检查下方的快速检查清单,确保你已经完成了所有步骤。
options:
- label: 我有填写一个简单易懂的标题
required: true
- label: 我有阅读上方的说明
required: true
- label: 我有阅读 um-react 的答疑部分
required: true
- label: 我的安卓设备已获得 root 权限
required: true
- label: 我能使用官方渠道下载的酷我音乐客户端复现该问题
required: true

View File

@@ -0,0 +1,4 @@
---
name: '其它'
about: '如果你遇到的问题不符合上述模板的描述,请选择此项。'
---

View File

@@ -0,0 +1 @@
blank_issues_enabled: false

View File

@@ -17,16 +17,20 @@ jobs:
- args: [--frozen-lockfile, --strict-peer-dependencies] - args: [--frozen-lockfile, --strict-peer-dependencies]
- name: Build - name: Build
run: pnpm build run: pnpm build
- name: Prepare for deployment - name: Pack Win64
run: | run: |
python3 -m zipfile -c um-react.zip dist/. python3 -m zipfile -c um-react.zip dist/.
cp um-react.zip dist/"release-${GITHUB_SHA}.zip" ./scripts/make-win64.sh
python3 -m zipfile -c um-react-site.zip dist/.
- name: Publish Artifact - name: Publish Artifact
uses: christopherhx/gitea-upload-artifact@v4 uses: christopherhx/gitea-upload-artifact@v4
with: with:
name: site name: site
path: dist/ path: dist/
- name: Prepare for deployment
run: |
cp um-react.zip dist/"release-${GITHUB_SHA}.zip"
cp win64/dist/*.zip dist/"release-${GITHUB_SHA}-win64.zip"
python3 -m zipfile -c um-react-site.zip dist/.
- name: Deploy - name: Deploy
env: env:
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }} NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }}

4
.gitignore vendored
View File

@@ -32,3 +32,7 @@ dist-ssr
/um-react*.exe /um-react*.exe
/win64/ /win64/
# Python
*.py[cod]
__pycache__

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,7 +1,8 @@
{ {
"recommendations": [ "recommendations": [
"editorconfig.editorconfig", "bradlc.vscode-tailwindcss",
"dbaeumer.vscode-eslint", "dbaeumer.vscode-eslint",
"editorconfig.editorconfig",
"esbenp.prettier-vscode", "esbenp.prettier-vscode",
"foxundermoon.shell-format" "foxundermoon.shell-format"
] ]

View File

@@ -1,6 +1,6 @@
# Unlock Music 音乐解锁 (React) # Unlock Music 音乐解锁 (React)
[![Build Status](https://ci.unlock-music.dev/api/badges/um/um-react/status.svg)](https://ci.unlock-music.dev/um/um-react) [![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 [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,13 +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.um-react.app/um/lib_um_crypto_rust) - 项目引入的解密算法实现
- [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),一起探讨。

43
eslint.config.mjs Normal file
View File

@@ -0,0 +1,43 @@
import eslint from '@eslint/js';
import tseslint from 'typescript-eslint';
import reactRefresh from 'eslint-plugin-react-refresh';
import reactHooks from 'eslint-plugin-react-hooks';
import eslintConfigPrettier from 'eslint-config-prettier/flat';
import globals from 'globals';
export default tseslint.config(
eslint.configs.recommended,
tseslint.configs.recommended,
reactRefresh.configs.recommended,
reactHooks.configs['recommended-latest'],
eslintConfigPrettier,
{
rules: {
'react-refresh/only-export-components': 'warn',
'@typescript-eslint/no-unused-vars': [
'error',
{
varsIgnorePattern: '^_',
argsIgnorePattern: '^_',
destructuredArrayIgnorePattern: '^_',
ignoreRestSiblings: true,
},
],
},
},
{
ignores: ['**/dist/', '**/node_modules/', '**/coverage/'],
},
{
files: ['scripts/*.mjs'],
languageOptions: {
globals: {
...globals.node,
},
},
},
);

View File

@@ -1,8 +1,8 @@
<!DOCTYPE html> <!doctype html>
<html lang="zh-cmn-Hans-CN"> <html lang="zh-cmn-Hans-CN">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<title>音乐解锁 - Unlock Music</title> <title>音乐解锁 - Unlock Music</title>
<meta name="description" content="音乐解锁 - Unlock Music" /> <meta name="description" content="音乐解锁 - Unlock Music" />
@@ -10,6 +10,7 @@
<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" />
</head> </head>
<body> <body>
<main id="root"></main> <main id="root"></main>
<script type="module" src="/src/main.tsx"></script> <script type="module" src="/src/main.tsx"></script>

11870
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,12 +1,12 @@
{ {
"name": "um-react", "name": "um-react",
"private": true, "private": true,
"version": "0.4.2", "version": "0.5.2",
"type": "module", "type": "module",
"scripts": { "scripts": {
"start": "vite", "start": "vite",
"build": "tsc -p tsconfig.prod.json && vite build && pnpm build:finalize", "build": "tsc -p tsconfig.prod.json && vite build && pnpm build:finalize",
"build:finalize": "node scripts/write-version.mjs && node scripts/minify-mjs.mjs", "build:finalize": "node scripts/write-version.mjs",
"lint": "eslint src --ext ts,tsx --report-unused-disable-directives --max-warnings 0", "lint": "eslint src --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"format": "prettier -w .", "format": "prettier -w .",
"test": "vitest run", "test": "vitest run",
@@ -17,58 +17,62 @@
"prepare": "simple-git-hooks" "prepare": "simple-git-hooks"
}, },
"dependencies": { "dependencies": {
"@chakra-ui/anatomy": "^2.3.4", "@reduxjs/toolkit": "^2.9.0",
"@chakra-ui/icons": "^2.2.4", "@unlock-music/crypto": "^0.1.12",
"@chakra-ui/react": "^2.10.4", "classnames": "^2.5.1",
"@emotion/react": "^11.14.0", "nanoid": "^5.1.5",
"@emotion/styled": "^11.14.0", "radash": "^12.1.1",
"@reduxjs/toolkit": "^2.5.0", "react": "^19.1.1",
"@unlock-music/crypto": "0.1.6", "react-dom": "^19.1.1",
"framer-motion": "^11.14.4", "react-dropzone": "^14.3.8",
"nanoid": "^5.0.9", "react-icons": "^5.5.0",
"radash": "^12.1.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-dropzone": "^14.3.5",
"react-icons": "^5.4.0",
"react-promise-suspense": "^0.3.4",
"react-redux": "^9.2.0", "react-redux": "^9.2.0",
"react-syntax-highlighter": "^15.6.1", "react-router": "^7.8.2",
"sass": "^1.83.0", "react-syntax-highlighter": "^15.6.6",
"sql.js": "^1.12.0" "react-toastify": "^11.0.5",
"sql.js": "^1.13.0"
}, },
"devDependencies": { "devDependencies": {
"@rollup/plugin-replace": "^6.0.1", "@eslint/js": "^9.35.0",
"@testing-library/jest-dom": "^6.6.3", "@rollup/plugin-replace": "^6.0.2",
"@testing-library/react": "^16.1.0", "@tailwindcss/vite": "^4.1.13",
"@testing-library/user-event": "^14.5.2", "@testing-library/jest-dom": "^6.8.0",
"@types/node": "^22.10.2", "@testing-library/react": "^16.3.0",
"@types/react": "^18.3.16", "@testing-library/user-event": "^14.6.1",
"@types/react-dom": "^18.3.5", "@types/node": "^24.3.1",
"@types/react": "^19.1.12",
"@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",
"@typescript-eslint/eslint-plugin": "^8.18.0", "@types/wicg-file-system-access": "^2023.10.6",
"@typescript-eslint/parser": "^8.18.0", "@typescript-eslint/eslint-plugin": "^8.42.0",
"@vitejs/plugin-react": "^4.3.4", "@typescript-eslint/parser": "^8.42.0",
"@vitest/coverage-v8": "^2.1.8", "@vitejs/plugin-react": "^5.0.2",
"@vitest/ui": "^2.1.8", "@vitest/coverage-v8": "^3.2.4",
"eslint": "^8.57.1", "@vitest/ui": "^3.2.4",
"eslint-config-prettier": "^9.1.0", "daisyui": "^5.1.8",
"eslint-plugin-react-hooks": "^4.6.2", "eslint": "^9.35.0",
"eslint-plugin-react-refresh": "^0.4.16", "eslint-config-prettier": "^10.1.8",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20",
"globals": "^16.3.0",
"husky": "^9.1.7", "husky": "^9.1.7",
"jsdom": "^25.0.1", "jsdom": "^26.1.0",
"lint-staged": "^15.2.11", "lint-staged": "^16.1.6",
"prettier": "^3.4.2", "prettier": "^3.6.2",
"rollup": "^4.28.1", "rollup": "^4.50.1",
"simple-git-hooks": "^2.11.1", "sass": "^1.92.1",
"terser": "^5.37.0", "simple-git-hooks": "^2.13.1",
"typescript": "^5.7.2", "tailwindcss": "^4.1.13",
"vite": "^5.4.11", "terser": "^5.44.0",
"vite-plugin-pwa": "^0.20.5", "typescript": "^5.9.2",
"vite-plugin-top-level-await": "^1.4.4", "typescript-eslint": "^8.42.0",
"vite-plugin-wasm": "^3.3.0", "vite": "^7.1.5",
"vitest": "^2.1.8", "vite-plugin-pwa": "^1.0.3",
"vite-plugin-top-level-await": "^1.6.0",
"vite-plugin-wasm": "^3.5.0",
"vitest": "^3.2.4",
"workbox-build": "^7.3.0",
"workbox-window": "^7.3.0" "workbox-window": "^7.3.0"
}, },
"lint-staged": { "lint-staged": {
@@ -86,13 +90,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"
}
}, },
"packageManager": "pnpm@9.12.1+sha512.e5a7e52a4183a02d5931057f7a0dbff9d5e9ce3161e33fa68ae392125b79282a8a8a470a51dfc8a0ed86221442eb2fb57019b0990ed24fab519bf0e1bc5ccfc4" "onlyBuiltDependencies": [
"@swc/core",
"@tailwindcss/oxide",
"esbuild",
"simple-git-hooks"
]
},
"packageManager": "pnpm@10.15.1"
} }

View File

@@ -1,11 +1,23 @@
diff --git a/dist/sql-wasm.js b/dist/sql-wasm.js diff --git a/dist/sql-wasm.js b/dist/sql-wasm.js
index b16cee5c3cbdf523f9beae920258094ae7fcbd0f..ae67be7145625c60995c5044860e87d6144a8837 100644 index 6ef6c1f54a368431b22dea4da123f1341b4a1780..8dd8a53cefa7779f0556eb1038be35ad547beabb 100644
--- a/dist/sql-wasm.js --- a/dist/sql-wasm.js
+++ b/dist/sql-wasm.js +++ b/dist/sql-wasm.js
@@ -187,3 +187,6 @@ else if (typeof define === 'function' && define['amd']) { @@ -173,16 +173,5 @@ if(0<K)Sa=Yc;else{if(f.preRun)for("function"==typeof f.preRun&&(f.preRun=[f.preR
else if (typeof exports === 'object'){ return initSqlJsPromise;
exports["Module"] = initSqlJs; } // The end of our initSqlJs function
}
+ -// This bit below is copied almost exactly from what you get when you use the MODULARIZE=1 flag with emcc
-// However, we don't want to use the emcc modularization. See shell-pre.js
-if (typeof exports === 'object' && typeof module === 'object'){
- module.exports = initSqlJs;
- // This will allow the module to be used in ES6 or CommonJS
- module.exports.default = initSqlJs;
-}
-else if (typeof define === 'function' && define['amd']) {
- define([], function() { return initSqlJs; });
-}
-else if (typeof exports === 'object'){
- exports["Module"] = initSqlJs;
-}
+var module; +var module;
+export default initSqlJs; +export default initSqlJs;

6014
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

2
public/_redirects Normal file
View File

@@ -0,0 +1,2 @@
# Support SPA routing in Netlify
/* /index.html 200

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() {
@@ -26,8 +26,8 @@ ZIP_NAME="um-react-win64-${APP_VERSION}.zip"
-r um-react.zip \ -r um-react.zip \
-o "win64/dist/${EXE_NAME}" -o "win64/dist/${EXE_NAME}"
touch -d 1970-01-01T00:00:00Z "win64/dist/${EXE_NAME}" touch -d 1980-01-01T00:00:00Z "win64/dist/${EXE_NAME}"
zip -9oX "win64/dist/${ZIP_NAME}" -- "win64/dist/${EXE_NAME}" zip -9oXj "win64/dist/${ZIP_NAME}" -- "win64/dist/${EXE_NAME}"
echo "[Build OK] 'win64/dist/${ZIP_NAME}'." echo "[Build OK] 'win64/dist/${ZIP_NAME}'."
popd popd

View File

@@ -9,7 +9,7 @@ const __dirname = dirname(__filename);
let commitHash = process.env.GIT_COMMIT || 'unknown'; let commitHash = process.env.GIT_COMMIT || 'unknown';
try { try {
execSync('git rev-parse --short HEAD').toString('utf-8').trim(); commitHash = execSync('git rev-parse --short HEAD').toString('utf-8').trim();
} catch (e) { } catch (e) {
console.error('Failed to get commit hash:', e); console.error('Failed to get commit hash:', e);
} }

76
src/App.css Normal file
View File

@@ -0,0 +1,76 @@
@import 'tailwindcss';
@plugin "daisyui" {
themes: dracula --prefersdark;
}
/* theme: https://daisyui.com/theme-generator/#theme=eJx1kuFupCAUhV-FmGyym1TCBQHt2zB67Zg6MAFNu2323ZdhGkdH_Annu9dzjnwX1lyweC1OZjS2xa54KcrSm26YQxlwxHZyPsqMSo-XtdgPOHZZ5eQ-t_dh-MLtMr7VHrsewsn5Dm80XD_TRYfX6Xw7p5N1Q8CfU-tG58vQnjFFGYe385Sgu3AyAUtgLErufWzPv5v6F2GE_Xlm-JpRkaGMAeG1okLLHS3WNL_TinBVUSlgR7fOTminZYKznQeL8-TNuCBCHyG7bZlEg-3dolfJHzRN9CepEvWaDHPbYggLLO-wrAjIGEo1a_jDeDvYtwVWqSfQDZE1FbBZjN6n__2z9haH8koSrqng8tnsPlQaYFARLiuqWJUxfTTEgYBSFEBkzOfri0OcNJJyrXchDr0JAjHMJvXVDxfj_z4aYqkhqAnUFdVPMTBu7jI4j0-JA9V8E8DE0CsXElKl0Tco2giZsXGUNbYKNYv7s24Op-IDYozWOuNqPyPSjOC3jjSkVjvszTxGZvIzvhRXjz36ED_5Xrz2Zgz47z_whE0z */
@plugin "daisyui/theme" {
name: 'balanced';
default: true;
prefersdark: false;
color-scheme: 'light';
--color-base-100: oklch(98% 0 0);
--color-base-200: oklch(96% 0.001 286.375);
--color-base-300: oklch(92% 0.006 264.531);
--color-base-content: oklch(20% 0 0);
--color-primary: oklch(60% 0.118 184.704);
--color-primary-content: oklch(98% 0.014 180.72);
--color-secondary: oklch(60% 0.126 221.723);
--color-secondary-content: oklch(98% 0.019 200.873);
--color-accent: oklch(51% 0.222 16.935);
--color-accent-content: oklch(93% 0.032 17.717);
--color-neutral: oklch(37% 0 0);
--color-neutral-content: oklch(98% 0 0);
--color-info: oklch(42% 0.199 265.638);
--color-info-content: oklch(97% 0.014 254.604);
--color-success: oklch(52% 0.154 150.069);
--color-success-content: oklch(97% 0.021 166.113);
--color-warning: oklch(66% 0.179 58.318);
--color-warning-content: oklch(98% 0.022 95.277);
--color-error: oklch(57% 0.245 27.325);
--color-error-content: oklch(97% 0.013 17.38);
--radius-selector: 0.5rem;
--radius-field: 0.5rem;
--radius-box: 0.5rem;
--size-selector: 0.25rem;
--size-field: 0.25rem;
--border: 1px;
--depth: 1;
--noise: 1;
}
@theme {
--font-display:
system-ui, Sarasa UI SC, Source Han Sans CN, Noto Sans CJK SC, sans-serif, Apple Color Emoji, Segoe UI Emoji;
--font-mono:
ui-monospace, Consolas, Sarasa Mono CJK SC, Sarasa UI SC, Source Han Sans CN, Noto Sans CJK SC, Microsoft YaHei UI,
monospace, Apple Color Emoji, Segoe UI Emoji;
}
#root {
display: flex;
flex-direction: column;
height: 100vh;
display: flex;
flex-direction: column;
}
h1,
h2,
h3,
h4,
h5,
h6 {
&:hover > a[data-anchor] {
opacity: 0.75;
}
}
#downloadAll {
position: absolute;
right: 0.5em;
bottom: 72px;
width: 48px;
height: 48px;
margin: 10px;
}

View File

@@ -16,6 +16,7 @@ test('should be able to render App', async () => {
await waitFor(() => screen.getByTestId('sdk-version')); await waitFor(() => screen.getByTestId('sdk-version'));
// Quick sanity check of known strings. // Quick sanity check of known strings.
expect(screen.getByText(/在浏览器内对文件进行解锁/i)).toBeInTheDocument(); expect(screen.getByText(/音乐解锁/i)).toBeInTheDocument();
expect(screen.getByText(/UnlockMusic 团队/i)).toBeInTheDocument(); expect(screen.getByText(/Unlock Music/i)).toBeInTheDocument();
expect(screen.getByText(/MIT/i)).toBeInTheDocument();
}); });

View File

@@ -1,47 +1,49 @@
import { import type { RefObject } from 'react';
Button, import { MdAdd, MdDeleteForever, MdFileUpload } from 'react-icons/md';
ButtonGroup,
HStack,
Icon,
IconButton,
Menu,
MenuButton,
MenuDivider,
MenuItem,
MenuList,
} from '@chakra-ui/react';
import { MdAdd, MdDeleteForever, MdExpandMore, MdFileUpload } from 'react-icons/md';
export interface AddKeyProps { export interface AddKeyProps {
addKey: () => void; addKey: () => void;
importKeyFromFile?: () => void; importKeyFromFile?: () => void;
clearKeys?: () => void; clearKeys?: () => void;
refContainer?: RefObject<HTMLElement | null>;
} }
export function AddKey({ addKey, importKeyFromFile, clearKeys }: AddKeyProps) { export function AddKey({ addKey, refContainer, importKeyFromFile, clearKeys }: AddKeyProps) {
const scrollToLastKey = () => {
const container = refContainer?.current;
if (container) {
const inputs = container.querySelectorAll('input[data-name="key-input--name"]');
const lastInput = inputs[inputs.length - 1] as HTMLInputElement | null;
if (lastInput) {
lastInput.focus({ preventScroll: true });
lastInput.scrollIntoView({
behavior: 'smooth',
block: 'center',
});
}
}
};
const handleAddKey = () => {
addKey();
setTimeout(scrollToLastKey);
};
return ( return (
<HStack pb={2} pt={2}> <div className="flex flex-row justify-between items-center">
<ButtonGroup isAttached colorScheme="purple" variant="outline"> <div className="join">
<Button onClick={addKey} leftIcon={<Icon as={MdAdd} />}> <button type="button" className="join-item btn flex items-center gap-2" onClick={handleAddKey}>
<MdAdd className="text-lg" />
</Button> </button>
<Menu> <button type="button" className="join-item btn flex items-center gap-2" onClick={importKeyFromFile}>
<MenuButton as={IconButton} icon={<MdExpandMore />}></MenuButton> <MdFileUpload className="text-lg" />
<MenuList>
{importKeyFromFile && ( </button>
<MenuItem onClick={importKeyFromFile} icon={<Icon as={MdFileUpload} boxSize={5} />}> <button type="button" className="join-item btn flex items-center gap-2 btn-error" onClick={clearKeys}>
<MdDeleteForever className="text-lg" />
</MenuItem>
)}
{importKeyFromFile && clearKeys && <MenuDivider />}
{clearKeys && (
<MenuItem color="red" onClick={clearKeys} icon={<Icon as={MdDeleteForever} boxSize={5} />}>
</MenuItem> </button>
)} </div>
</MenuList> </div>
</Menu>
</ButtonGroup>
</HStack>
); );
} }

View File

@@ -0,0 +1,59 @@
import { CodeHighlight } from '../CodeHighlight';
import { ExtLink } from '../ExtLink';
import PowerShellAdbDumpCommandTemplate from './adb_dump.ps1?raw';
import ShellAdbDumpCommandTemplate from './adb_dump.sh?raw';
import { applyTemplate } from '~/util/applyTemplate';
export interface AdbInstructionTemplateProps {
dir: string;
file: string;
platform: 'win32' | 'linux';
}
const URL_USB_DEBUGGING = 'https://developer.android.com/studio/debug/dev-options?hl=zh-cn#Enable-debugging';
const LANGUAGE_MAP = {
win32: { language: 'ps1', template: PowerShellAdbDumpCommandTemplate },
linux: { language: 'sh', template: ShellAdbDumpCommandTemplate },
};
export function AdbInstructionTemplate({ dir, file, platform }: AdbInstructionTemplateProps) {
const { language, template } = LANGUAGE_MAP[platform];
const command = applyTemplate(template, { dir, file });
return (
<ol className="list-decimal pl-4">
<li>
<p>
<code>adb</code>
</p>
{platform === 'win32' && (
<div>
<span>
💡
<ExtLink href="https://scoop.sh/#/apps?q=adb">使 Scoop </ExtLink>
</span>
</div>
)}
</li>
<li> PowerShell </li>
<li>
<ExtLink href={URL_USB_DEBUGGING}> USB </ExtLink>
</li>
<li></li>
<li>
<p> USB </p>
<CodeHighlight language={language}>{command}</CodeHighlight>
<br />
<ExtLink className="text-nowrap" href="https://g.126.fm/04jewvw">
MuMu
</ExtLink>
使 <code>adb connect ...</code>
</li>
<li>
<code>{file}</code>
</li>
</ol>
);
}

View File

@@ -1,171 +1,73 @@
import {
Accordion,
AccordionButton,
AccordionIcon,
AccordionItem,
AccordionPanel,
Box,
Code,
Heading,
ListItem,
OrderedList,
Text,
chakra,
} from '@chakra-ui/react';
import { ExternalLinkIcon } from '@chakra-ui/icons';
import { Light as SyntaxHighlighter } from 'react-syntax-highlighter';
import hljsStyleGitHub from 'react-syntax-highlighter/dist/esm/styles/hljs/github';
import PowerShellAdbDumpCommandTemplate from './adb_dump.ps1?raw';
import ShellAdbDumpCommandTemplate from './adb_dump.sh?raw';
import { ExtLink } from '../ExtLink'; import { ExtLink } from '../ExtLink';
import { Ruby } from '../Ruby';
const applyTemplate = (tpl: string, values: Record<string, unknown>) => { import { useId } from 'react';
return tpl.replace(/\{\{\s*(\w+)\s*\}\}/g, (_, key) => (Object.hasOwn(values, key) ? String(values[key]) : '<nil>')); import { RootExplorerGuide } from './RootExplorerGuide';
}; import { AdbInstructionTemplate } from './AdbInstructionTemplate';
import { HiWord } from '../HelpText/HiWord';
export interface AndroidADBPullInstructionProps { export interface AndroidADBPullInstructionProps {
dir: string; dir: string;
file: string; file: string;
} }
const URL_AMAZE = 'https://github.com/TeamAmaze/AmazeFileManager/releases/latest';
const URL_MT2 = 'https://mt2.cn/download/';
export function AndroidADBPullInstruction({ dir, file }: AndroidADBPullInstructionProps) { export function AndroidADBPullInstruction({ dir, file }: AndroidADBPullInstructionProps) {
const psAdbDumpCommand = applyTemplate(PowerShellAdbDumpCommandTemplate, { dir, file }); const androidInstructionId = useId();
const shAdbDumpCommand = applyTemplate(ShellAdbDumpCommandTemplate, { dir, file });
return ( return (
<> <>
<Text> <p>
<Ruby caption="root"></Ruby>访访
<ruby> </p>
<p>
<rp> (</rp>
<rt>
<code>root</code>
</rt>
<rp>)</rp>
</ruby>
访访
</Text>
<Text>
<chakra.span color="red.400"></chakra.span> <HiWord></HiWord>
</Text> </p>
<Accordion allowToggle mt="2"> <div className="join join-vertical bg-base-100 mt-2 max-w-full">
<AccordionItem> <div className="collapse collapse-arrow join-item border-base-300 border">
<Heading as="h3" size="md"> <input type="radio" name={androidInstructionId} />
<AccordionButton> <div className="collapse-title font-semibold"></div>
<Box as="span" flex="1" textAlign="left"> <div className="collapse-content text-sm min-w-0">
<ol className="list-decimal pl-4">
</Box> <li>
<AccordionIcon /> <code>root</code> <ExtLink href={URL_AMAZE}>Amaze </ExtLink>
</AccordionButton> <ExtLink href={URL_MT2}>MT </ExtLink>
</Heading> </li>
<AccordionPanel pb={4}> <li>
<OrderedList> root
<ListItem> <RootExplorerGuide />
<Text> </li>
<Code>root</Code> <li>
</Text> <p>
</ListItem> 访 <code>{dir}/</code>
<ListItem> </p>
<Text> <p> </p>
访 <Code>{dir}/</Code> </li>
</Text> <li>
</ListItem> <code>{file}</code> 访
<ListItem> </li>
<Text> <li></li>
<Code>{file}</Code> 访 </ol>
<br /> </div>
</div>
</Text> <div className="collapse collapse-arrow join-item border-base-300 border">
</ListItem> <input type="radio" name={androidInstructionId} />
<ListItem> <div className="collapse-title font-semibold"> PC 使 ADB / PowerShell</div>
<Text></Text> <div className="collapse-content text-sm min-w-0">
</ListItem> <AdbInstructionTemplate dir={dir} file={file} platform="win32" />
</OrderedList> </div>
</AccordionPanel> </div>
</AccordionItem> <div className="collapse collapse-arrow join-item border-base-300 border">
<input type="radio" name={androidInstructionId} />
<AccordionItem> <div className="collapse-title font-semibold"> Linux / Mac 使 ADB / Shell</div>
<Heading as="h3" size="md"> <div className="collapse-content text-sm min-w-0">
<AccordionButton> <AdbInstructionTemplate dir={dir} file={file} platform="linux" />
<Box as="span" flex="1" textAlign="left"> </div>
PC ADB / PowerShell </div>
</Box> </div>
<AccordionIcon />
</AccordionButton>
</Heading>
<AccordionPanel pb={4}>
<OrderedList>
<ListItem>
<Text>
<Code>adb</Code>
</Text>
<Text>
💡
<ExtLink href="https://scoop.sh/#/apps?q=adb">
使 Scoop <ExternalLinkIcon />
</ExtLink>
</Text>
</ListItem>
<ListItem>
<Text> PowerShell 7 </Text>
</ListItem>
<ListItem>
<Text></Text>
</ListItem>
<ListItem>
<Text></Text>
<SyntaxHighlighter language="ps1" style={hljsStyleGitHub}>
{psAdbDumpCommand}
</SyntaxHighlighter>
</ListItem>
<ListItem>
<Text>
<Code>{file}</Code>
</Text>
</ListItem>
</OrderedList>
</AccordionPanel>
</AccordionItem>
<AccordionItem>
<Heading as="h3" size="md">
<AccordionButton>
<Box as="span" flex="1" textAlign="left">
Linux / Mac ADB / Shell
</Box>
<AccordionIcon />
</AccordionButton>
</Heading>
<AccordionPanel pb={4}>
<OrderedList>
<ListItem>
<Text>
<Code>adb</Code>
</Text>
</ListItem>
<ListItem>
<Text></Text>
</ListItem>
<ListItem>
<Text></Text>
<SyntaxHighlighter language="bash" style={hljsStyleGitHub}>
{shAdbDumpCommand}
</SyntaxHighlighter>
</ListItem>
<ListItem>
<Text>
<Code>{file}</Code>
</Text>
</ListItem>
</OrderedList>
</AccordionPanel>
</AccordionItem>
</Accordion>
</> </>
); );
} }

View File

@@ -0,0 +1,64 @@
import { FiMenu, FiMoreVertical } from 'react-icons/fi';
import { Header5 } from '../HelpText/Headers';
import { Ruby } from '../Ruby';
import { VQuote } from '../HelpText/VQuote';
export function RootExplorerGuide() {
return (
<div className="@container inline-flex flex-col items-start w-full pl-4">
<div className="flex flex-col items-start gap-4 @md:flex-row">
<div>
<Header5 className="[&]:mt-0 [&]:pt-0">Amaze </Header5>
<ul className="ml-2 list-disc list-inside">
<li>
<div className="inline-flex items-center gap-1">
<FiMenu />
</div>
</li>
<li>
<VQuote>
<Ruby caption="Settings"></Ruby>
</VQuote>
</li>
<li>
<VQuote>
<Ruby caption="Behaviour"></Ruby>
</VQuote>
</li>
<li>
<VQuote>
<Ruby caption="Advanced"></Ruby>
</VQuote>
<VQuote>
<Ruby caption="Root Explorer"></Ruby>
</VQuote>
</li>
</ul>
</div>
<div>
<Header5 className="[&]:mt-0 [&]:pt-0">MT </Header5>
<ul className="ml-2 list-disc list-inside">
<li>
<div className="inline-flex items-center gap-1">
<FiMenu />
</div>
</li>
<li>
<div className="inline-flex items-center">
<FiMoreVertical className="ml-1" />
<VQuote></VQuote>
</div>
</li>
<li>
<VQuote> Root </VQuote>
</li>
</ul>
</div>
</div>
</div>
);
}

View File

@@ -1,57 +1,78 @@
import { useEffect } from 'react'; import { useEffect } from 'react';
import { BrowserRouter, NavLink, Route, Routes } from 'react-router';
import { MdSettings, MdHome, MdQuestionAnswer } from 'react-icons/md'; import { MdSettings, MdHome, MdQuestionAnswer } from 'react-icons/md';
import { ChakraProvider, Tabs, TabList, TabPanels, Tab, TabPanel, Icon, chakra } from '@chakra-ui/react';
import { MainTab } from '~/tabs/MainTab'; import { MainTab } from '~/tabs/MainTab';
import { SettingsTab } from '~/tabs/SettingsTab'; import { SettingsTab } from '~/tabs/SettingsTab';
import { Provider } from 'react-redux'; import { Provider } from 'react-redux';
import { theme } from '~/theme';
import { persistSettings } from '~/features/settings/persistSettings'; import { persistSettings } from '~/features/settings/persistSettings';
import { setupStore } from '~/store'; import { setupStore } from '~/store';
import { Footer } from '~/components/Footer'; import { Footer } from '~/components/Footer';
import { FaqTab } from '~/tabs/FaqTab'; import { FaqTab } from '~/tabs/FaqTab';
import { SETTINGS_TABS } from '~/features/settings/settingsTabs';
import { Bounce, ToastContainer } from 'react-toastify';
import { SettingsHome } from '~/features/settings/SettingsHome';
import { FAQ_PAGES } from '~/faq/FAQPages';
import { FaqHome } from '~/faq/FaqHome';
// Private to this file only. // Private to this file only.
const store = setupStore(); const store = setupStore();
const tabClassNames = ({ isActive }: { isActive: boolean }) => `mb-[-2px] tab ${isActive ? 'tab-active' : ''}`;
export function AppRoot() { export function AppRoot() {
useEffect(() => persistSettings(store), []); useEffect(() => persistSettings(store), []);
return ( return (
<ChakraProvider theme={theme}> <BrowserRouter>
<Provider store={store}> <Provider store={store}>
<Tabs flex={1} minH={0} display="flex" flexDir="column"> <div role="tablist" className="tabs tabs-border w-full justify-center border-b-2 border-base-200 box-content">
<TabList justifyContent="center"> <NavLink to="/" role="tab" className={tabClassNames}>
<Tab> <MdHome />
<Icon as={MdHome} mr="1" />
<chakra.span></chakra.span> </NavLink>
</Tab> <NavLink to="/settings" role="tab" className={tabClassNames}>
<Tab> <MdSettings />
<Icon as={MdSettings} mr="1" />
<chakra.span></chakra.span> </NavLink>
</Tab> <NavLink to="/questions" role="tab" className={tabClassNames}>
<Tab> <MdQuestionAnswer />
<Icon as={MdQuestionAnswer} mr="1" />
<chakra.span></chakra.span> </NavLink>
</Tab> </div>
</TabList>
<TabPanels overflow="auto" minW={0} flexDir="column" flex={1} display="flex"> <main className="flex-1 flex justify-center min-h-0 overflow-auto">
<TabPanel> <Routes>
<MainTab /> <Route path="/" Component={MainTab} />
</TabPanel> <Route path="/settings" Component={SettingsTab}>
<TabPanel flex={1} display="flex"> <Route index Component={SettingsHome} />
<SettingsTab /> {Object.entries(SETTINGS_TABS).map(([key, { Tab }]) => (
</TabPanel> <Route key={key} path={key} Component={Tab} />
<TabPanel> ))}
<FaqTab /> </Route>
</TabPanel> <Route path="/questions" Component={FaqTab}>
</TabPanels> <Route index Component={FaqHome} />
</Tabs> {FAQ_PAGES.map(({ id, Component }) => (
<Route key={id} path={id} Component={Component} />
))}
</Route>
</Routes>
</main>
<ToastContainer
position="bottom-center"
autoClose={5000}
newestOnTop
closeOnClick={false}
pauseOnFocusLoss
draggable
theme="colored"
transition={Bounce}
/>
<Footer /> <Footer />
</Provider> </Provider>
</ChakraProvider> </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>
);
}

40
src/components/Dialog.tsx Normal file
View File

@@ -0,0 +1,40 @@
import { useEffect, useRef } from 'react';
export interface DialogProps {
closeButton?: boolean;
backdropClose?: boolean;
title?: React.ReactNode;
children: React.ReactNode;
show: boolean;
onClose: () => void;
}
export function Dialog({ closeButton, backdropClose, title, children, show, onClose }: DialogProps) {
const refModel = useRef<HTMLDialogElement>(null);
useEffect(() => {
if (show) {
refModel.current?.showModal();
} else {
refModel.current?.close();
}
}, [show]);
return (
<dialog ref={refModel} className="modal">
<div className="modal-box">
{closeButton && (
<form method="dialog" onSubmit={onClose}>
<button className="btn btn-sm btn-circle btn-ghost absolute right-2 top-2"></button>
</form>
)}
<h3 className="font-bold text-lg pb-3">{title}</h3>
{children}
</div>
{backdropClose && (
<form method="dialog" className="modal-backdrop" onSubmit={onClose}>
<button>close</button>
</form>
)}
</dialog>
);
}

View File

@@ -0,0 +1,84 @@
import { DecryptedAudioFile, ProcessState, selectFiles } from '~/features/file-listing/fileListingSlice';
import { FaDownload } from 'react-icons/fa';
import { useAppSelector } from '~/hooks';
import { toast } from 'react-toastify';
export function DownloadAll() {
const files = useAppSelector(selectFiles);
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;
try {
dir = await window.showDirectoryPicker({ mode: 'readwrite' });
} catch (e) {
console.error(e);
if (e instanceof Error && e.name === 'AbortError') {
return;
}
}
toast.warning('开始下载,请稍候');
const promises = Object.values(completeFiles).map(async (file) => {
console.log(`开始下载: ${file.fileName}`);
try {
if (dir) {
await DownloadNew(dir, file);
} else {
await DownloadOld(file);
}
console.log(`成功下载: ${file.fileName}`);
} catch (e) {
console.error(`下载失败: ${file.fileName}`, e);
toast.error(`出现错误: ${e}`);
throw e;
}
});
await Promise.allSettled(promises).then((f) => {
const success = f.filter((result) => result.status === 'fulfilled').length;
if (success === fileCount) {
toast.success(`成功下载: ${success}/${fileCount}`);
} else {
toast.warning(`成功下载: ${success}/${fileCount}`);
}
});
console.timeEnd('DownloadAll'); //停止计时
};
return (
<button className="btn btn-primary" id="downloadAll" onClick={onClickDownloadAll} title="下载全部">
<FaDownload />
</button>
);
}
async function DownloadNew(dir: FileSystemDirectoryHandle, file: DecryptedAudioFile) {
const fileHandle = await dir.getFileHandle(file.cleanName + '.' + file.ext, { create: true });
const writable = await fileHandle.createWritable();
await fetch(file.decrypted).then((res) => res.body?.pipeTo(writable));
}
async function DownloadOld(file: DecryptedAudioFile) {
const a = document.createElement('a');
a.href = file.decrypted;
a.download = file.cleanName + '.' + file.ext;
document.body.append(a);
a.click();
a.remove();
}

View File

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

View File

@@ -1,5 +1,5 @@
import classnames from 'classnames';
import { useDropzone } from 'react-dropzone'; import { useDropzone } from 'react-dropzone';
import { Box } from '@chakra-ui/react';
export interface FileInputProps { export interface FileInputProps {
onReceiveFiles: (files: File[]) => void; onReceiveFiles: (files: File[]) => void;
@@ -14,30 +14,19 @@ export function FileInput({ children, onReceiveFiles }: FileInputProps) {
}); });
return ( return (
<Box <div
{...getRootProps()} {...getRootProps()}
w="100%" className={classnames(
maxW={480} 'w-full max-w-xl border rounded-lg transition duration-500 p-6 border-base-300 mx-auto',
borderWidth="1px" 'cursor-pointer flex flex-col items-center bg-base-200 hover:border-gray-400 hover:bg-gray-50 hover:dark:bg-gray-800',
borderRadius="lg" {
transitionDuration="0.5s" 'bg-blue-50 dark:bg-blue-900 border-blue-700': isDragActive,
p="6" },
cursor="pointer" )}
display="flex" tabIndex={0}
flexDir="column"
alignItems="center"
_hover={{
borderColor: 'gray.400',
bg: 'gray.50',
}}
{...(isDragActive && {
bg: 'blue.50',
borderColor: 'blue.700',
})}
> >
<input {...getInputProps()} /> <input {...getInputProps()} />
{children} {children}
</Box> </div>
); );
} }

View File

@@ -1,10 +1,9 @@
import { Code, Text } from '@chakra-ui/react'; import type { ReactNode } from 'react';
import React from 'react';
export function FilePathBlock({ children }: { children: React.ReactNode }) { export function FilePathBlock({ children }: { children: ReactNode }) {
return ( return (
<Text as="pre" whiteSpace="pre-wrap" wordBreak="break-all"> <pre className="whitespace-pre-wrap break-all">
<Code>{children}</Code> <code>{children}</code>
</Text> </pre>
); );
} }

View File

@@ -1,45 +1,32 @@
import { Center, Flex, Link, Text } from '@chakra-ui/react';
import { Suspense } from 'react';
import { SDKVersion } from './SDKVersion'; import { SDKVersion } from './SDKVersion';
import { CurrentYear } from './CurrentYear'; import { CurrentYear } from './CurrentYear';
export function Footer() { export function Footer() {
const appVersionShort = '__APP_VERSION_SHORT__';
return ( return (
<Center <footer className="flex flex-col text-center p-4 bg-base-200">
fontSize="sm" <p className="flex flex-row justify-center items-center h-[1em]">
textAlign="center" <a className="link link-info mr-1" href="https://git.um-react.app/um/um-react">
bottom="0"
w="full"
pt="3"
pb="3"
borderTop="1px solid"
borderColor="gray.300"
bg="gray.100"
color="gray.800"
flexDir="column"
flexShrink={0}
>
<Flex as={Text}>
<Link href="https://git.unlock-music.dev/um/um-react" isExternal>
</Link> </a>
{' (__APP_VERSION_SHORT__'} (
<Suspense> <a
<SDKVersion /> title="使用 MIT 授权协议"
</Suspense> className="link link-info"
{') - 移除已购音乐的加密保护。'} href="https://git.um-react.app/um/um-react/src/branch/main/LICENSE"
</Flex> >
<Text> MIT
</a>
, v{appVersionShort}
<SDKVersion />)
</p>
<p>
{'© 2019 - '} {'© 2019 - '}
<CurrentYear />{' '} <CurrentYear />
<Link href="https://git.unlock-music.dev/um" isExternal> <a className="ml-1 link link-info" href="https://git.um-react.app/um">
UnlockMusic Unlock Music
</Link> </a>
{' | '} </p>
<Link href="https://git.unlock-music.dev/um/um-react/src/branch/main/LICENSE" isExternal> </footer>
使 MIT
</Link>
</Text>
</Center>
); );
} }

View File

@@ -0,0 +1,9 @@
import { RiLink } from 'react-icons/ri';
export function HeaderAnchor({ id }: { id: string }) {
return (
<a href={`#${id}`} data-anchor={id} className="absolute -left-6 opacity-10 transition-opacity duration-200">
<RiLink className="max-h-[.75em]" />
</a>
);
}

View File

@@ -1,5 +1,5 @@
import { Heading } from '@chakra-ui/react';
import React from 'react'; import React from 'react';
import { HeaderAnchor } from './HeaderAnchor';
export interface HeaderProps { export interface HeaderProps {
children: React.ReactNode; children: React.ReactNode;
@@ -7,36 +7,39 @@ export interface HeaderProps {
className?: string; className?: string;
} }
const commonHeaderClasses = 'relative flex items-center pt-3 pb-1 font-bold';
export function Header2({ children, className, id }: HeaderProps) {
return (
<h2 id={id} className={`${commonHeaderClasses} text-3xl border-b border-base-300 ${className}`}>
{id && <HeaderAnchor id={id} />}
{children}
</h2>
);
}
export function Header3({ children, className, id }: HeaderProps) { export function Header3({ children, className, id }: HeaderProps) {
return ( return (
<Heading <h3 id={id} className={`${commonHeaderClasses} text-2xl border-b border-base-300 ${className}`}>
as="h3" {id && <HeaderAnchor id={id} />}
id={id}
className={className}
pt={3}
pb={1}
borderBottom={'1px solid'}
borderColor="gray.300"
color="gray.800"
size="lg"
>
{children} {children}
</Heading> </h3>
); );
} }
export function Header4({ children, className, id }: HeaderProps) { export function Header4({ children, className, id }: HeaderProps) {
return ( return (
<Heading as="h4" id={id} className={className} pt={3} pb={1} color="gray.700" size="md"> <h4 id={id} className={`${commonHeaderClasses} text-xl ${className}`}>
{id && <HeaderAnchor id={id} />}
{children} {children}
</Heading> </h4>
); );
} }
export function Header5({ children, className, id }: HeaderProps) { export function Header5({ children, className, id }: HeaderProps) {
return ( return (
<Heading as="h5" id={id} className={className} pt={3} pb={1} color="gray.700" size="sm"> <h5 id={id} className={`${commonHeaderClasses} text-lg ${className}`}>
{id && <HeaderAnchor id={id} />}
{children} {children}
</Heading> </h5>
); );
} }

View File

@@ -1,9 +1,3 @@
import { Mark } from '@chakra-ui/react'; export function HiWord({ className = '', children }: { className?: string; children: React.ReactNode }) {
return <mark className={`bg-orange-100 rounded-md px-2 mx-1 ${className}`}>{children}</mark>;
export function HiWord({ children }: { children: React.ReactNode }) {
return (
<Mark bg="orange.100" borderRadius={5} px={2} mx={1}>
{children}
</Mark>
);
} }

View File

@@ -1,13 +1,9 @@
import { chakra, css } from '@chakra-ui/react';
const cssUnselectable = css({ pointerEvents: 'none', userSelect: 'none' });
export function VQuote({ children }: { children: React.ReactNode }) { export function VQuote({ children }: { children: React.ReactNode }) {
return ( return (
<> <>
<chakra.span css={cssUnselectable}></chakra.span> <span className="select-none"></span>
{children} {children}
<chakra.span css={cssUnselectable}></chakra.span> <span className="select-none"></span>
</> </>
); );
} }

View File

@@ -0,0 +1,43 @@
import classNames from 'classnames';
import { useRef } from 'react';
export interface ImageFigureProps {
srcSet: string;
alt: string;
className?: string;
loading?: 'lazy' | 'eager';
children?: React.ReactNode;
}
export function ImageFigure({ alt, srcSet, children, className, loading }: ImageFigureProps) {
const refDialog = useRef<HTMLDialogElement>(null);
return (
<figure className={classNames(className, 'inline-flex flex-col items-center')}>
<img
className={`rounded-md cursor-pointer border border-base-300 max-h-48`}
loading={loading}
srcSet={srcSet}
alt={alt}
onClick={() => refDialog?.current?.showModal()}
/>
{children && <figcaption className="text-sm text-base-content/70">{children}</figcaption>}
<dialog ref={refDialog} className="modal text-left">
<div className="modal-box max-w-[50vw]">
<form method="dialog">
<button className="btn btn-sm btn-circle btn-ghost absolute right-2 top-2"></button>
</form>
<h3 className="font-bold text-lg"></h3>
<figure className="flex flex-col justify-center text-center">
<img srcSet={srcSet} alt={alt} />
{children && <figcaption className="text-sm text-base-content/70">{children}</figcaption>}
</figure>
</div>
<form method="dialog" className="modal-backdrop">
<button></button>
</form>
</dialog>
</figure>
);
}

View File

@@ -1,15 +1,4 @@
import { import { useEffect, useRef } from 'react';
Center,
Flex,
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalHeader,
ModalOverlay,
Tabs,
Text,
} from '@chakra-ui/react';
import { FileInput } from '~/components/FileInput'; import { FileInput } from '~/components/FileInput';
@@ -18,39 +7,43 @@ export interface ImportSecretModalProps {
children: React.ReactNode; children: React.ReactNode;
show: boolean; show: boolean;
onClose: () => void; onClose: () => void;
onImport: (file: File) => void|Promise<void>; onImport: (file: File) => void | Promise<void>;
} }
export function ImportSecretModal({ clientName, children, show, onClose, onImport }: ImportSecretModalProps) { export function ImportSecretModal({ clientName, children, show, onClose, onImport }: ImportSecretModalProps) {
const handleFileReceived = (files: File[]) => { const handleFileReceived = (files: File[]) => {
const promise = onImport(files[0]); const promise = onImport(files[0]);
if (promise instanceof Promise) { if (promise instanceof Promise) {
promise.catch(err => { promise.catch((err) => {
console.error('could not import: ', err); console.error('could not import: ', err);
}); });
} }
return promise; return promise;
}; };
return ( const refModel = useRef<HTMLDialogElement>(null);
<Modal isOpen={show} onClose={onClose} closeOnOverlayClick={false} scrollBehavior="inside" size="xl"> useEffect(() => {
<ModalOverlay /> if (show) {
<ModalContent> refModel.current?.showModal();
<ModalHeader></ModalHeader> } else {
<ModalCloseButton /> refModel.current?.close();
<Flex as={ModalBody} gap={2} flexDir="column" flex={1}> }
<Center> }, [show]);
<FileInput onReceiveFiles={handleFileReceived}></FileInput>
</Center>
<Text as="div" mt={2}> return (
{clientName && <>{clientName}</>} <dialog ref={refModel} className="modal">
</Text> <div className="modal-box">
<Flex as={Tabs} variant="enclosed" flexDir="column" flex={1} minH={0}> <form method="dialog" onSubmit={() => onClose()}>
{children} <button className="btn btn-sm btn-circle btn-ghost absolute right-2 top-2"></button>
</Flex> </form>
</Flex> <h3 className="font-bold text-lg"></h3>
</ModalContent> <div className="py-4 flex flex-col gap-2 flex-1">
</Modal> <FileInput onReceiveFiles={handleFileReceived}></FileInput>
<div className="mt-2">{clientName && <>{clientName}</>}</div>
<div>{children}</div>
</div>
</div>
</dialog>
); );
} }

View File

@@ -0,0 +1,25 @@
import { Dialog } from '~/components/Dialog.tsx';
import React, { useState } from 'react';
interface InfoModalProps {
title?: React.ReactNode;
description?: React.ReactNode;
children?: React.ReactNode;
}
export function InfoModal(props: InfoModalProps) {
const { title, description, children } = props;
const [showModal, setShowModal] = useState(false);
return (
<div>
<button className="btn btn-info btn-sm" type="button" onClick={() => setShowModal(true)}>
{children || '这是什么?'}
</button>
<Dialog closeButton backdropClose show={showModal} onClose={() => setShowModal(false)} title={title}>
{description}
</Dialog>
</div>
);
}

View File

@@ -0,0 +1,39 @@
import classNames from 'classnames';
import React, { Fragment, useId } from 'react';
export type InstructionTab = {
id: string | number;
label: React.ReactNode;
content: React.ReactNode;
};
export interface InstructionsTabsProps {
tabs: InstructionTab[];
limitHeight?: boolean;
}
export function InstructionsTabs({ limitHeight = false, tabs }: InstructionsTabsProps) {
const id = useId();
return (
<div className={classNames('tabs tabs-lift pb-4 mt-2', { 'max-h-[32rem]': limitHeight })}>
{tabs.map(({ id: _tabId, label, content }, index) => (
<Fragment key={_tabId}>
<label className="tab dark:[--tab-border-color:#555]">
<input type="radio" name={id} defaultChecked={index === 0} />
{label}
</label>
<div
className={classNames(
'tab-content border-base-300 dark:border-[#555] bg-base-100 px-4 py-2 overflow-y-auto',
{
'max-h-[30rem]': limitHeight,
},
)}
>
{content}
</div>
</Fragment>
))}
</div>
);
}

View File

@@ -1,15 +1,12 @@
import { Icon, Kbd } from '@chakra-ui/react';
import { BsCommand } from 'react-icons/bs'; import { BsCommand } from 'react-icons/bs';
import { Ruby } from '../Ruby';
export function MacCommandKey() { export function MacCommandKey({ className }: { className?: string }) {
return ( return (
<ruby> <Ruby caption="command" className={className}>
<Kbd> <kbd className="kbd">
<Icon as={BsCommand} /> <BsCommand className="text-sm" />
</Kbd> </kbd>
<rp> (</rp> </Ruby>
<rt>command</rt>
<rp>)</rp>
</ruby>
); );
} }

View File

@@ -1,15 +1,12 @@
import { Icon, Kbd } from '@chakra-ui/react';
import { BsShift } from 'react-icons/bs'; import { BsShift } from 'react-icons/bs';
import { Ruby } from '../Ruby';
export function ShiftKey() { export function ShiftKey({ className }: { className?: string }) {
return ( return (
<ruby> <Ruby caption="shift" className={className}>
<Kbd> <kbd className="kbd">
<Icon as={BsShift} /> <BsShift className="text-sm" />
</Kbd> </kbd>
<rp> (</rp> </Ruby>
<rt>shift</rt>
<rp>)</rp>
</ruby>
); );
} }

112
src/components/KeyInput.tsx Normal file
View File

@@ -0,0 +1,112 @@
import { PiFileAudio } from 'react-icons/pi';
import { MdDelete, MdVpnKey } from 'react-icons/md';
import type { ReactNode } from 'react';
export interface KeyInputProps {
sequence: number;
name: string;
value: string;
isValidKey?: boolean;
onSetName: (name: string) => void;
onSetValue: (value: string) => void;
onDelete: () => void;
quality?: string;
onSetQuality?: (quality: string) => void;
nameLabel?: ReactNode;
valueLabel?: ReactNode;
qualityLabel?: ReactNode;
namePlaceholder?: string;
valuePlaceholder?: string;
qualityPlaceholder?: string;
}
export function KeyInput(props: KeyInputProps) {
const {
nameLabel,
valueLabel,
qualityLabel,
namePlaceholder,
qualityPlaceholder,
valuePlaceholder,
sequence,
name,
quality,
value,
onSetName,
onSetValue,
onDelete,
onSetQuality,
isValidKey,
} = props;
return (
<li className="list-row items-center">
<div className="flex items-center justify-center w-8 h-8 text-sm font-bold text-gray-500 bg-gray-200 rounded-full">
{sequence}
</div>
<div className="join join-vertical flex-1">
<div className="flex">
<label className="input w-full rounded-tl-md last:rounded-tr-md">
<span className="cucursor-default inline-flex items-center gap-1 select-none">
{nameLabel || (
<>
<PiFileAudio />
</>
)}
</span>
<input
type="text"
className="font-mono"
placeholder={namePlaceholder}
value={name}
onChange={(e) => onSetName(e.target.value)}
data-name="key-input--name"
/>
</label>
{onSetQuality && (
<label className="input min-w-0 max-w-[10rem] ml-[-1px] rounded-tr-md">
<span className="cucursor-default inline-flex items-center gap-1 select-none">
{qualityLabel || '音质'}
</span>
<input
type="text"
className="font-mono"
placeholder={qualityPlaceholder}
value={quality}
onChange={(e) => onSetQuality(e.target.value)}
/>
</label>
)}
</div>
<label className="input w-full rounded-bl-md rounded-br-md mt-[-1px]">
<span className="cursor-default inline-flex items-center gap-1 select-none">
{valueLabel || (
<>
<MdVpnKey />
</>
)}
</span>
<input
type="text"
className="font-mono"
placeholder={valuePlaceholder}
value={value}
onChange={(e) => onSetValue(e.target.value)}
/>
<span className={isValidKey ? 'text-green-600' : 'text-red-600'}>
<code>{value.length || '?'}</code>
</span>
</label>
</div>
<button type="button" className="btn btn-error btn-sm px-1 btn-outline" onClick={onDelete}>
<MdDelete className="size-6" />
</button>
</li>
);
}

View File

@@ -0,0 +1,21 @@
import type { ReactNode, RefObject } from 'react';
export interface KeyListContainerProps {
keys: unknown[];
children?: ReactNode;
ref?: RefObject<HTMLDivElement | null>;
}
export function KeyListContainer({ keys, children, ref }: KeyListContainerProps) {
const count = keys.length;
return (
<div ref={ref} className="flex grow min-h-0 pr-4 pt-3">
{count > 0 && (
<ul className="list bg-base-100 rounded-box shadow-sm border border-base-300 w-full min-h-0 overflow-auto">
{children}
</ul>
)}
{count === 0 && <p></p>}
</div>
);
}

View File

@@ -1,4 +1,4 @@
import { Link } from '@chakra-ui/react'; import { ExtLink } from './ExtLink';
export interface ProjectIssueProps { export interface ProjectIssueProps {
id: number | string; id: number | string;
@@ -7,9 +7,9 @@ export interface ProjectIssueProps {
export function ProjectIssue({ id, title }: ProjectIssueProps) { export function ProjectIssue({ id, title }: ProjectIssueProps) {
return ( return (
<Link isExternal 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}`}
</Link> </ExtLink>
); );
} }

20
src/components/Ruby.tsx Normal file
View File

@@ -0,0 +1,20 @@
import React from 'react';
export interface RubyProps {
caption: React.ReactNode;
children: React.ReactNode;
className?: string;
}
export function Ruby(props: RubyProps) {
const { caption, children, ...rest } = props;
return (
<ruby {...rest}>
{children}
<rp>(</rp>
<rt>{caption}</rt>
<rp>)</rp>
</ruby>
);
}

View File

@@ -1,33 +1,41 @@
import { InfoOutlineIcon } from '@chakra-ui/icons'; import { MdInfoOutline } from 'react-icons/md';
import { Tooltip, VStack, Text, Flex } from '@chakra-ui/react';
import { workerClientBus } from '~/decrypt-worker/client'; import { workerClientBus } from '~/decrypt-worker/client';
import { DECRYPTION_WORKER_ACTION_NAME } from '~/decrypt-worker/constants'; import { DECRYPTION_WORKER_ACTION_NAME } from '~/decrypt-worker/constants';
import { useEffect, useRef, useState } from 'react';
import usePromise from 'react-promise-suspense';
const getSDKVersion = async (): Promise<string> => { const getSDKVersion = async (): Promise<string> => {
return workerClientBus.request(DECRYPTION_WORKER_ACTION_NAME.VERSION, null); return workerClientBus.request(DECRYPTION_WORKER_ACTION_NAME.VERSION, null);
}; };
export function SDKVersion() { export function SDKVersion() {
const sdkVersion = usePromise(getSDKVersion, []); const refDialog = useRef<HTMLDialogElement>(null);
const [sdkVersion, setSdkVersion] = useState('...');
useEffect(() => {
getSDKVersion().then(setSdkVersion);
}, []);
return ( return (
<Flex as="span" pl="1" alignItems="center" data-testid="sdk-version"> <>
<Tooltip <span className="btn btn-ghost inline-flex p-0" onClick={() => refDialog.current?.showModal()}>
hasArrow <MdInfoOutline />
placement="top" </span>
label={
<VStack> <dialog ref={refDialog} className="modal text-left">
<Text>App: __APP_VERSION__</Text> <div className="modal-box">
<Text>SDK: {sdkVersion}</Text> <form method="dialog">
</VStack> <button className="btn btn-sm btn-circle btn-ghost absolute right-2 top-2"></button>
} </form>
bg="gray.300" <h3 className="font-bold text-lg"></h3>
color="black"
> <p>App: __APP_VERSION__</p>
<InfoOutlineIcon /> <p>
</Tooltip> SDK: <span data-testid="sdk-version">{sdkVersion}</span>
</Flex> </p>
</div>
<form method="dialog" className="modal-backdrop">
<button></button>
</form>
</dialog>
</>
); );
} }

View File

@@ -1,5 +1,4 @@
import { Box, Text } from '@chakra-ui/react'; import { FiUnlock } from 'react-icons/fi';
import { UnlockIcon } from '@chakra-ui/icons';
import { useAppDispatch } from '~/hooks'; import { useAppDispatch } from '~/hooks';
import { addNewFile, processFile } from '~/features/file-listing/fileListingSlice'; import { addNewFile, processFile } from '~/features/file-listing/fileListingSlice';
@@ -12,7 +11,7 @@ export function SelectFile() {
console.debug( console.debug(
'react-dropzone/onDropAccepted(%o, %o)', 'react-dropzone/onDropAccepted(%o, %o)',
files.length, files.length,
files.map((x) => x.name) files.map((x) => x.name),
); );
for (const file of files) { for (const file of files) {
@@ -26,7 +25,7 @@ export function SelectFile() {
id: fileId, id: fileId,
blobURI, blobURI,
fileName, fileName,
}) }),
); );
dispatch(processFile({ fileId })); dispatch(processFile({ fileId }));
} }
@@ -34,19 +33,13 @@ export function SelectFile() {
return ( return (
<FileInput multiple onReceiveFiles={handleFileReceived}> <FileInput multiple onReceiveFiles={handleFileReceived}>
<Box pb={3}> <FiUnlock className="size-8 mb-4" />
<UnlockIcon boxSize={8} /> <p className="text-center">
</Box>
<Text as="div" textAlign="center">
<Text as="span" color="teal.400"> <span className="text-teal-700 font-semibold"></span>
</Text>
<Text fontSize="sm" opacity="50%"> </p>
<p className="text-sm opacity-50 m-0"></p>
</Text>
</Text>
</FileInput> </FileInput>
); );
} }

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

@@ -0,0 +1,75 @@
import { ExtLink } from '~/components/ExtLink';
import { Header2, Header3, Header4 } from '~/components/HelpText/Headers';
import { VQuote } from '~/components/HelpText/VQuote';
import { RiErrorWarningLine } from 'react-icons/ri';
import LdPlayerSettingsMisc2x from './assets/ld_settings_misc@2x.webp';
import MumuSettingsMisc2x from './assets/mumu_settings_misc@2x.webp';
import { ImageFigure } from '~/components/ImageFigure';
export function AndroidEmulatorFAQ() {
return (
<>
<Header2></Header2>
<p className="mb-2">便 root </p>
<ul className="list-disc pl-6 mb-2">
<li>
<ExtLink href="https://mumu.163.com/"> MuMu 12</ExtLink> - Hyper-V
</li>
<li>
<ExtLink href="https://www.ldmnq.com/"> 9</ExtLink>
</li>
</ul>
<p className="mb-2">广使</p>
<div className="my-2 alert alert-warning">
<RiErrorWarningLine className="text-lg" />
<p>
使<strong></strong>
</p>
</div>
<p className="mb-2">使</p>
<Header3 id="enable-root"> root</Header3>
<p className="mb-2"> root </p>
<Header4 id="root-mumu"> MuMu </Header4>
<ul className="list-disc pl-6">
<li>
<VQuote></VQuote>
</li>
<li>
<VQuote></VQuote>
</li>
<li>
<VQuote>Root权限</VQuote>
</li>
</ul>
<div>
<ImageFigure className="ml-2" alt="网易木木模拟器设置界面" loading="lazy" srcSet={`${MumuSettingsMisc2x} 2x`}>
</ImageFigure>
</div>
<Header4 id="root-ld"></Header4>
<ul className="list-disc pl-6">
<li>
<VQuote></VQuote>
</li>
<li>
<VQuote></VQuote>
</li>
<li>
<VQuote>ROOT </VQuote><VQuote></VQuote>
</li>
</ul>
<div>
<ImageFigure className="ml-2" alt="雷电模拟器设置界面" loading="lazy" srcSet={`${LdPlayerSettingsMisc2x} 2x`}>
</ImageFigure>
</div>
</>
);
}

45
src/faq/FAQAbout.tsx Normal file
View File

@@ -0,0 +1,45 @@
import { Header2, Header3 } from '~/components/HelpText/Headers';
import { FaRust } from 'react-icons/fa';
export function FAQAboutProject() {
return (
<div className="flex flex-col gap-4">
<Header2></Header2>
<Header3 id="failed">um-react </Header3>
<p>
um-react
<a className="mx-1 link link-info" href="https://git.um-react.app/um">
Unlock Music
</a>
React 使
<a className="mx-1 link link-info" href="https://git.um-react.app/um/um-react/src/branch/main/LICENSE">
MIT
</a>
</p>
<p>
<FaRust className="inline" />
<a className="mx-1 link link-info" href="https://git.um-react.app/um/lib_um_crypto_rust">
<code>lib_um_crypto_rust</code>
</a>
使
<a
className="mx-1 link link-info"
href="https://git.um-react.app/um/lib_um_crypto_rust/src/branch/main/LICENSE_MIT"
>
MIT
</a>
+
<a
className="mx-1 link link-info"
href="https://git.um-react.app/um/lib_um_crypto_rust/src/branch/main/LICENSE_APACHE"
>
Apache
</a>
</p>
<p>使</p>
</div>
);
}

22
src/faq/FAQPages.tsx Normal file
View File

@@ -0,0 +1,22 @@
import type { ComponentType } from 'react';
import { QQMusicFAQ } from './QQMusicFAQ';
import { KuwoFAQ } from './KuwoFAQ';
import { KugouFAQ } from './KugouFAQ';
import { OtherFAQ } from './OtherFAQ';
import { AndroidEmulatorFAQ } from './AndroidEmulatorFAQ';
import { FAQAboutProject } from './FAQAbout';
export type FAQEntry = {
id: string;
name: string;
Component: ComponentType;
};
export const FAQ_PAGES: FAQEntry[] = [
{ id: 'qqmusic', name: 'QQ 音乐', Component: QQMusicFAQ },
{ id: 'kuwo', name: '酷我音乐', Component: KuwoFAQ },
{ id: 'kugou', name: '酷狗音乐', Component: KugouFAQ },
{ id: 'android-emu', name: '安卓模拟器', Component: AndroidEmulatorFAQ },
{ id: 'other', name: '其它问题', Component: OtherFAQ },
{ id: 'about', name: '关于项目', Component: FAQAboutProject },
];

15
src/faq/FaqHome.tsx Normal file
View File

@@ -0,0 +1,15 @@
import { ExtLink } from '~/components/ExtLink';
import { Header2 } from '~/components/HelpText/Headers';
export function FaqHome() {
return (
<div className="flex flex-col gap-4">
<Header2></Header2>
<p></p>
<p>
访
<ExtLink href={'https://t.me/unlock_music_chat'}>- </ExtLink>
</p>
</div>
);
}

View File

@@ -1,31 +1,25 @@
import { Alert, AlertIcon, Container, Flex, List, ListItem, Text } from '@chakra-ui/react'; import { Header2, Header3 } from '~/components/HelpText/Headers';
import { Header4 } from '~/components/HelpText/Headers';
import { SegmentKeyImportInstructions } from './SegmentKeyImportInstructions'; import { SegmentKeyImportInstructions } from './SegmentKeyImportInstructions';
import { KugouAllInstructions } from '~/features/settings/panels/Kugou/KugouAllInstructions.tsx'; import { KugouAllInstructions } from '~/features/settings/panels/Kugou/KugouAllInstructions.tsx';
import { RiErrorWarningLine } from 'react-icons/ri';
export function KugouFAQ() { export function KugouFAQ() {
return ( return (
<> <>
<Header4></Header4> <Header2></Header2>
<List spacing={2}> <Header3 id="failed"></Header3>
<ListItem> <p className="mb-2">
<Text>
<code>kgg</code> Windows <code>kgg</code> Windows
</Text> </p>
<Text></Text> <p className="mb-2"></p>
<Container p={2}> <div className="alert alert-warning mb-2">
<Alert status="warning" borderRadius={5}> <RiErrorWarningLine className="size-6" />
<AlertIcon /> <p> root </p>
<Flex flexDir="column"> </div>
<Text> root </Text>
</Flex>
</Alert>
</Container>
<Header3 id="keys"></Header3>
<SegmentKeyImportInstructions tab="酷狗密钥" clientInstructions={<KugouAllInstructions />} /> <SegmentKeyImportInstructions tab="酷狗密钥" clientInstructions={<KugouAllInstructions />} />
</ListItem>
</List>
</> </>
); );
} }

View File

@@ -1,21 +1,18 @@
import { Alert, AlertIcon, Container, Flex, List, ListItem, Text } from '@chakra-ui/react'; import { Header2, Header3 } from '~/components/HelpText/Headers';
import { Header4 } from '~/components/HelpText/Headers';
import { VQuote } from '~/components/HelpText/VQuote'; import { VQuote } from '~/components/HelpText/VQuote';
import { SegmentTryOfficialPlayer } from './SegmentTryOfficialPlayer'; import { SegmentTryOfficialPlayer } from './SegmentTryOfficialPlayer';
import { HiWord } from '~/components/HelpText/HiWord'; import { HiWord } from '~/components/HelpText/HiWord';
import { KWMv2AllInstructions } from '~/features/settings/panels/KWMv2/KWMv2AllInstructions'; import { KWMv2AllInstructions } from '~/features/settings/panels/KWMv2/KWMv2AllInstructions';
import { SegmentKeyImportInstructions } from './SegmentKeyImportInstructions'; import { SegmentKeyImportInstructions } from './SegmentKeyImportInstructions';
import { RiErrorWarningLine } from 'react-icons/ri';
export function KuwoFAQ() { export function KuwoFAQ() {
return ( return (
<> <>
<Header4></Header4> <Header2></Header2>
<List spacing={2}> <Header3 id="failed"></Header3>
<ListItem>
<SegmentTryOfficialPlayer /> <SegmentTryOfficialPlayer />
</ListItem> <p className="mb-2">
<ListItem>
<Text>
<HiWord></HiWord> <HiWord></HiWord>
<VQuote> <VQuote>
<strong></strong> <strong></strong>
@@ -24,29 +21,27 @@ export function KuwoFAQ() {
<VQuote> <VQuote>
<strong></strong> <strong></strong>
</VQuote> </VQuote>
{'音质的音乐文件采用新版加密。'}
</Text> </p>
<Text></Text> <p className="mb-2"></p>
<Text>PC平台暂未推出使用新版加密的音质</Text> <p className="mb-2">PC平台暂未推出使用新版加密的音质</p>
<Container p={2}> <Header3 id="keys"></Header3>
<Alert status="warning" borderRadius={5}> <div className="alert alert-warning my-2">
<AlertIcon /> <RiErrorWarningLine className="text-2xl" />
<Flex flexDir="column"> <div>
<Text> root </Text> <p> root </p>
<Text> <p>
<strong></strong> <strong className="pr-2"></strong>
</Text> </p>
<Text> <p>
<strong></strong>使使 <strong className="pr-2"></strong>
</Text> 使使
</Flex> </p>
</Alert> </div>
</Container> </div>
<SegmentKeyImportInstructions tab="KWMv2 密钥" clientInstructions={<KWMv2AllInstructions />} /> <SegmentKeyImportInstructions tab="KWMv2 密钥" clientInstructions={<KWMv2AllInstructions />} />
</ListItem>
</List>
</> </>
); );
} }

View File

@@ -1,139 +1,99 @@
import { Alert, AlertIcon, Code, Container, Flex, Img, ListItem, Text, UnorderedList } from '@chakra-ui/react';
import { ExtLink } from '~/components/ExtLink'; import { ExtLink } from '~/components/ExtLink';
import { Header4 } from '~/components/HelpText/Headers'; import { Header2, Header3, Header4 } from '~/components/HelpText/Headers';
import { VQuote } from '~/components/HelpText/VQuote';
import { ProjectIssue } from '~/components/ProjectIssue'; import { NavLink } from 'react-router';
import LdPlayerSettingsScreen from './assets/ld_settings_misc.webp';
export function OtherFAQ() { export function OtherFAQ() {
return ( return (
<> <>
<Header4></Header4> <Header2></Header2>
<Text></Text> <Header3 id="metadata"></Header3>
<Text>使</Text> <p></p>
<p>使</p>
<Header4></Header4> <Header3 id="android-browsers">安卓: 浏览器支持说明</Header3>
<Text> <p> 使 Chrome Firefox </p>
{'暂时没有实现,不过你可以在 '} <div className="flex flex-col md:flex-row gap-2 md:gap-8">
<ProjectIssue id={34} title="[UI] 全部下载功能" /> <div>
{' 以及 '} <Header4></Header4>
<ProjectIssue id={43} title="批量下载" /> <ul className="list-disc list-inside pl-2">
{' 追踪该问题。'} <li>Via </li>
</Text> <li></li>
<li>UC </li>
</ul>
</div>
<Header4>安卓: 浏览器支持说明</Header4> <div>
<Text> 使 Chrome Firefox </Text> <Header4></Header4>
<Text></Text> <ul className="list-disc list-inside pl-2">
<UnorderedList> <li></li>
<ListItem>Via </ListItem> <li></li>
<ListItem></ListItem> <li></li>
<ListItem>UC </ListItem> </ul>
</UnorderedList> </div>
<Text></Text> </div>
<UnorderedList>
<ListItem></ListItem>
<ListItem></ListItem>
<ListItem></ListItem>
</UnorderedList>
<Header4>安卓: root </Header4> <Header3 id="android-root"> root</Header3>
<Text> <p>
root 使 root 使
使 NFC 使 NFC
</Text> </p>
<Text>使</Text> <p className="my-2">
<Text> 使
root 广 Windows 11 <NavLink className="link link-info" to="/questions/android-emu">
<ExtLink href="https://learn.microsoft.com/zh-cn/windows/android/wsa/"> </NavLink>
<ruby>
Android Windows (WSA)
<rp> (</rp>
<rt>
<code>Windows Subsystem for Android</code>
</rt>
<rp>)</rp>
</ruby>
</ExtLink>
</Text> </p>
<Container p={2}> <Header3 id="projects"></Header3>
<Alert status="warning" borderRadius={5}> <ul className="list-disc pl-6">
<AlertIcon /> <li>
<Flex flexDir="column"> <p>
<Text> <ExtLink className="mr-2" href="https://github.com/CarlGao4/um-react-electron">
<strong></strong>使<strong></strong>
{';使用前请自行评估风险。'}
</Text>
</Flex>
</Alert>
</Container>
<UnorderedList>
<ListItem>
<Text>
{'WSA 可以参考 '}
<ExtLink href="https://github.com/LSPosed/MagiskOnWSALocal">MagiskOnWSALocal</ExtLink>
{' 的说明操作。'}
</Text>
</ListItem>
<ListItem>
<Text>
<VQuote></VQuote> <VQuote></VQuote> root
</Text>
<Img borderRadius={5} border="1px solid #ccc" src={LdPlayerSettingsScreen}></Img>
</ListItem>
</UnorderedList>
<Header4></Header4>
<UnorderedList>
<ListItem>
<Text>
<ExtLink href="https://github.com/CarlGao4/um-react-electron">
<strong> <strong>
<Code>um-react-electron</Code> <code>um-react-electron</code>
</strong> </strong>
</ExtLink> </ExtLink>
Electron WindowsLinux Mac Electron WindowsLinux Mac
</Text> </p>
<UnorderedList> <ul className="list-disc pl-6">
<ListItem> <li>
<Text> <p>
<ExtLink href="https://github.com/CarlGao4/um-react-electron/releases/latest">GitHub </ExtLink> <ExtLink href="https://github.com/CarlGao4/um-react-electron/releases/latest">GitHub </ExtLink>
</Text> </p>
</ListItem> </li>
</UnorderedList> </ul>
</ListItem> </li>
<ListItem> <li>
<Text> <p>
<ExtLink 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>
</ExtLink> </ExtLink>
: 使 WRY Win64 使 WRY Win64
<ExtLink href="https://go.microsoft.com/fwlink/p/?LinkId=2124703"> Edge WebView2 </ExtLink> <ExtLink href="https://go.microsoft.com/fwlink/p/?LinkId=2124703"> Edge WebView2 </ExtLink>
{'Win10+ 操作系统自带)'} {'Win10+ 操作系统自带)'}
</Text> </p>
<UnorderedList> <ul className="list-disc pl-6">
<ListItem> <li>
<Text> <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>
</Text> </p>
</ListItem> </li>
</UnorderedList> </ul>
</ListItem> </li>
</UnorderedList> </ul>
<Header4></Header4> <Header3 id="more-questions"></Header3>
<Text> <p className="flex flex-row gap-1">
{'欢迎进入 '}
<ExtLink href={'https://t.me/unlock_music_chat'}>Telegram - </ExtLink> <ExtLink href={'https://t.me/unlock_music_chat'}>- </ExtLink>
{' 一起探讨。'}
</Text> </p>
</> </>
); );
} }

View File

@@ -1,159 +1,25 @@
import { Accordion, AccordionButton, AccordionIcon, AccordionItem, AccordionPanel, Box } from '@chakra-ui/react'; import { Header2, Header3 } from '~/components/HelpText/Headers';
import { Alert, AlertIcon, Container, Flex, ListItem, Text, UnorderedList } from '@chakra-ui/react';
import { Header4 } from '~/components/HelpText/Headers';
import { SegmentTryOfficialPlayer } from './SegmentTryOfficialPlayer'; import { SegmentTryOfficialPlayer } from './SegmentTryOfficialPlayer';
import { SegmentKeyImportInstructions } from './SegmentKeyImportInstructions'; import { QMCv2QQMusicAllInstructions } from '~/features/settings/panels/QMCv2/QMCv2QQMusicAllInstructions';
import { ExtLink } from '~/components/ExtLink';
import { AndroidADBPullInstruction } from '~/components/AndroidADBPullInstruction/AndroidADBPullInstruction';
import { InstructionsIOS } from '~/features/settings/panels/QMCv2/InstructionsIOS';
import { InstructionsMac } from '~/features/settings/panels/QMCv2/InstructionsMac';
export function QQMusicFAQ() { export function QQMusicFAQ() {
return ( return (
<> <>
<Header4></Header4> <Header2>QQ </Header2>
<Header3 id="failed"></Header3>
<SegmentTryOfficialPlayer /> <SegmentTryOfficialPlayer />
<Text></Text> <p className="mb-2"> QQ </p>
<Text> <p className="mb-2"></p>
<p className="mb-2"></p>
<Header3 id="about-download"></Header3>
<p></p>
<p className="my-2">
<strong></strong>使 <strong></strong>使
</Text> </p>
<Accordion allowToggle my={2}>
<AccordionItem>
<h2>
<AccordionButton>
<Box as="span" flex="1" textAlign="left">
Windows
</Box>
<AccordionIcon />
</AccordionButton>
</h2>
<AccordionPanel pb={4}>
<Text>
Windows 19.51
</Text>
<Text> QQ Windows v19.51 </Text>
<UnorderedList pl={3}>
<ListItem>
<Text>
<ExtLink href="https://dldir1v6.qq.com/music/clntupate/QQMusic_Setup_1951.exe">
<code>qq.com</code>
</ExtLink>
</Text>
</ListItem>
<ListItem>
<Text>
<ExtLink href="https://web.archive.org/web/2023/https://dldir1v6.qq.com/music/clntupate/QQMusic_Setup_1951.exe">
<code>Archive.org</code>
</ExtLink>
</Text>
</ListItem>
</UnorderedList>
</AccordionPanel>
</AccordionItem>
<AccordionItem> <Header3 id="keys-or-downgrade"></Header3>
<h2> <QMCv2QQMusicAllInstructions />
<AccordionButton>
<Box as="span" flex="1" textAlign="left">
Mac
</Box>
<AccordionIcon />
</AccordionButton>
</h2>
<AccordionPanel pb={4}>
<Container p={2}>
<Alert status="warning" borderRadius={5}>
<AlertIcon />
<Flex flexDir="column">
<Text>Mac 8.8.0 </Text>
<Text>
<ExtLink href="https://web.archive.org/web/20230903/https://dldir1.qq.com/music/clntupate/mac/QQMusicMac_Mgr.dmg">
<code>Archive.org</code>
</ExtLink>
</Text>
</Flex>
</Alert>
</Container>
<SegmentKeyImportInstructions
tab="QMCv2 密钥"
keyInstructionText="查看密钥提取说明:"
clientInstructions={
<Box p={2}>
<InstructionsMac />
</Box>
}
/>
</AccordionPanel>
</AccordionItem>
<AccordionItem>
<h2>
<AccordionButton>
<Box as="span" flex="1" textAlign="left">
(Android)
</Box>
<AccordionIcon />
</AccordionButton>
</h2>
<AccordionPanel pb={4}>
<Container p={2}>
<Alert status="warning" borderRadius={5}>
<AlertIcon />
<Flex flexDir="column">
<Text> root </Text>
</Flex>
</Alert>
</Container>
<Text>QQ </Text>
<Text>
使QQ OEM
</Text>
<SegmentKeyImportInstructions
tab="QMCv2 密钥"
keyInstructionText="查看密钥提取说明:"
clientInstructions={
<Box p={2}>
<AndroidADBPullInstruction dir="/data/data/com.tencent.qqmusic/databases" file="player_process_db" />
</Box>
}
/>
</AccordionPanel>
</AccordionItem>
<AccordionItem>
<h2>
<AccordionButton>
<Box as="span" flex="1" textAlign="left">
iOS (iPhone, iPad)
</Box>
<AccordionIcon />
</AccordionButton>
</h2>
<AccordionPanel pb={4}>
<Container p={2}>
<Alert status="warning" borderRadius={5}>
<AlertIcon />
<Flex flexDir="column">
<Text>iOS </Text>
</Flex>
</Alert>
</Container>
<SegmentKeyImportInstructions
tab="QMCv2 密钥"
keyInstructionText="查看密钥提取说明:"
clientInstructions={
<Box p={2}>
<InstructionsIOS />
</Box>
}
/>
</AccordionPanel>
</AccordionItem>
</Accordion>
</> </>
); );
} }

View File

@@ -1,24 +1,13 @@
import { Flex, IconButton } from '@chakra-ui/react'; import { MdFileUpload } from 'react-icons/md';
import { MdExpandMore } from 'react-icons/md';
import { HiWord } from '~/components/HelpText/HiWord';
import { VQuote } from '~/components/HelpText/VQuote';
export function SegmentAddKeyDropdown() { export function SegmentAddKeyDropdown() {
return ( return (
<Flex as="span" alignItems="center" flexWrap="wrap"> <span className="inline-flex items-center flex-wrap">
<VQuote></VQuote>
<HiWord></HiWord> <button type="button" className="btn flex items-center gap-2">
<IconButton <MdFileUpload className="text-lg" />
colorScheme="purple"
variant="outline" </button>
size="sm" </span>
icon={<MdExpandMore />}
ml="2"
borderTopLeftRadius={0}
borderBottomLeftRadius={0}
pointerEvents="none"
aria-label="下拉按钮"
/>
</Flex>
); );
} }

View File

@@ -1,9 +1,7 @@
import { Flex, Icon, ListItem, OrderedList, Tabs, Text } from '@chakra-ui/react';
import { SegmentTopNavSettings } from './SegmentTopNavSettings'; import { SegmentTopNavSettings } from './SegmentTopNavSettings';
import { VQuote } from '~/components/HelpText/VQuote'; import { VQuote } from '~/components/HelpText/VQuote';
import { SegmentAddKeyDropdown } from './SegmentAddKeyDropdown'; import { SegmentAddKeyDropdown } from './SegmentAddKeyDropdown';
import React from 'react'; import React from 'react';
import { MdFileUpload } from 'react-icons/md';
export interface SegmentKeyImportInstructionsProps { export interface SegmentKeyImportInstructionsProps {
clientInstructions: React.ReactNode; clientInstructions: React.ReactNode;
@@ -18,32 +16,22 @@ export function SegmentKeyImportInstructions({
}: SegmentKeyImportInstructionsProps) { }: SegmentKeyImportInstructionsProps) {
return ( return (
<> <>
<Text></Text> <p className="mt-2"></p>
<OrderedList> <ol className="list-decimal pl-5">
<ListItem> <li>
<SegmentTopNavSettings /> <SegmentTopNavSettings />
</ListItem> </li>
<ListItem> <li>
<VQuote>{tab}</VQuote> <VQuote>{tab}</VQuote>
</ListItem> </li>
<ListItem> <li>
<SegmentAddKeyDropdown /> <SegmentAddKeyDropdown />
</ListItem> </li>
<ListItem> <li>
<Flex flexDir="row" alignItems="center"> <p className="mb-2">{keyInstructionText}</p>
{'选择 '}
<VQuote>
<Icon as={MdFileUpload} boxSize={5} mr={2} />
</VQuote>
</Flex>
</ListItem>
<ListItem>
<Text>{keyInstructionText}</Text>
<Tabs display="flex" flexDir="column" border="1px solid" borderColor="gray.300" borderRadius={5}>
{clientInstructions} {clientInstructions}
</Tabs> </li>
</ListItem> </ol>
</OrderedList>
</> </>
); );
} }

View File

@@ -1,12 +1,10 @@
import { Alert, AlertIcon, Container } from '@chakra-ui/react'; import { RiErrorWarningLine } from 'react-icons/ri';
export function SegmentTryOfficialPlayer() { export function SegmentTryOfficialPlayer({ className = '' }: { className?: string }) {
return ( return (
<Container p={2} my={2} pt={0}> <div className={`alert alert-warning my-2 ${className}`}>
<Alert status="info" borderRadius={5}> <RiErrorWarningLine className="text-2xl" />
<AlertIcon /> <p></p>
</div>
</Alert>
</Container>
); );
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

View File

@@ -1,24 +0,0 @@
import { Box, Image } from '@chakra-ui/react';
import noCoverFallbackImageURL from '~/assets/no-cover.svg';
interface AlbumImageProps {
url?: string;
name?: string;
}
export function AlbumImage({ name, url }: AlbumImageProps) {
const coverAlternativeText = name ? `${name} 的专辑封面` : '专辑封面';
return (
<Box w="160px" h="160px" m="auto">
<Image
border="2px solid"
borderColor="gray.400"
borderRadius="50%"
objectFit="cover"
src={url || noCoverFallbackImageURL}
alt={coverAlternativeText}
/>
</Box>
);
}

View File

@@ -1,5 +1,6 @@
import { Box, Button, chakra, Collapse, Text, useDisclosure } from '@chakra-ui/react'; import { toast } from 'react-toastify';
import { DecryptErrorType } from '~/decrypt-worker/util/DecryptError'; import { DecryptErrorType } from '~/decrypt-worker/util/DecryptError';
import { applyTemplate } from '~/util/applyTemplate';
export interface FileErrorProps { export interface FileErrorProps {
error: null | string; error: null | string;
@@ -7,33 +8,55 @@ export interface FileErrorProps {
} }
const errorMap = new Map<string | null | DecryptErrorType, string>([ const errorMap = new Map<string | null | DecryptErrorType, string>([
[DecryptErrorType.UNSUPPORTED_FILE, '尚未支持的文件格式'], [DecryptErrorType.UNSUPPORTED_FILE, '支持的文件类型'],
]); ]);
const ERROR_TEMPLATE = `解密错误:{{summary}}
详细错误信息:
\`\`\`text
{{error}}
\`\`\`
<!-- 报告错误时请提交上述【全部内容】 -->
`;
export function FileError({ error, code }: FileErrorProps) { export function FileError({ error, code }: FileErrorProps) {
const { isOpen, onToggle } = useDisclosure(); const summary = errorMap.get(code) ?? '未知错误';
const errorSummary = errorMap.get(code) ?? '未知错误';
const copyError = () => {
if (error) {
navigator.clipboard
.writeText(applyTemplate(ERROR_TEMPLATE, { summary, error }))
.then(() => {
toast.success('错误信息已复制到剪贴板');
})
.catch((e) => {
toast.error(`复制错误信息失败: ${e}`);
});
}
};
return ( return (
<Box> <>
<Text> <p>
<chakra.span>
<chakra.span color="red.700">{errorSummary}</chakra.span> <span className="text-red-600">{summary}</span>
</chakra.span> </p>
{error && ( {error && (
<Button ml="2" onClick={onToggle} type="button"> <div className="collapse border-error border w-full text-left my-2 py-0">
<input className="[&&&]:py-2 [&&&]:min-h-[1.5rem]" type="checkbox" />
</Button> <div className="collapse-title font-semibold text-center [&&&]:min-h-[1.5rem] [&&&]:py-2"></div>
<div className="collapse-content text-sm overflow-hidden">
<pre className="overflow-x-auto w-full">{error}</pre>
<p className="mt-2 text-center">
<button type="button" className="btn btn-secondary" onClick={copyError}>
</button>
</p>
</div>
</div>
)} )}
</Text> </>
{error && (
<Collapse in={isOpen} animateOpacity>
<Box as="pre" display="inline-block" mt="2" px="4" py="2" bg="red.100" color="red.800" rounded="md">
{error}
</Box>
</Collapse>
)}
</Box>
); );
} }

View File

@@ -1,5 +1,3 @@
import { VStack } from '@chakra-ui/react';
import { selectFiles } from './fileListingSlice'; import { selectFiles } from './fileListingSlice';
import { useAppSelector } from '~/hooks'; import { useAppSelector } from '~/hooks';
import { FileRow } from './FileRow'; import { FileRow } from './FileRow';
@@ -8,10 +6,10 @@ export function FileListing() {
const files = useAppSelector(selectFiles); const files = useAppSelector(selectFiles);
return ( return (
<VStack> <div className="flex flex-row flex-wrap gap-8">
{Object.entries(files).map(([id, file]) => ( {Object.entries(files).map(([id, file]) => (
<FileRow key={id} id={id} file={file} /> <FileRow key={id} id={id} file={file} />
))} ))}
</VStack> </div>
); );
} }

View File

@@ -1,24 +1,7 @@
import { useRef } from 'react';
import {
Box,
Button,
Card,
CardBody,
Collapse,
GridItem,
Link,
VStack,
Wrap,
WrapItem,
useDisclosure,
} from '@chakra-ui/react';
import { FileRowResponsiveGrid } from './FileRowResponsiveGrid';
import { DecryptedAudioFile, deleteFile, ProcessState } from './fileListingSlice'; import { DecryptedAudioFile, deleteFile, ProcessState } from './fileListingSlice';
import { useAppDispatch } from '~/hooks'; import { useAppDispatch } from '~/hooks';
import { AnimationDefinition } from 'framer-motion';
import { AlbumImage } from './AlbumImage';
import { SongMetadata } from './SongMetadata';
import { FileError } from './FileError'; import { FileError } from './FileError';
import classNames from 'classnames';
interface FileRowProps { interface FileRowProps {
id: string; id: string;
@@ -26,90 +9,46 @@ interface FileRowProps {
} }
export function FileRow({ id, file }: FileRowProps) { export function FileRow({ id, file }: FileRowProps) {
const { isOpen, onClose } = useDisclosure({ defaultIsOpen: true });
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const isDecrypted = file.state === ProcessState.COMPLETE; const isDecrypted = file.state === ProcessState.COMPLETE;
const metadata = file.metadata;
const nameWithoutExt = file.fileName.replace(/\.[a-z\d]{3,6}$/, ''); const decryptedName = file.cleanName + '.' + file.ext;
const decryptedName = nameWithoutExt + '.' + file.ext;
const audioPlayerRef = useRef<HTMLAudioElement>(null);
const togglePlay = () => {
const player = audioPlayerRef.current;
if (!player) {
return;
}
if (player.paused) {
player.play();
} else {
player.pause();
}
};
const onCollapseAnimationComplete = (definition: AnimationDefinition) => {
if (definition === 'exit') {
dispatch(deleteFile({ id }));
}
};
return ( return (
<Collapse <div className="card bg-base-100 dark:bg-gray-700 shadow-sm w-full md:w-[30%] " data-testid="file-row">
in={isOpen} <div className="card-body items-center text-center px-2">
animateOpacity <h2 className="card-title max-w-full whitespace-nowrap flex gap-0" data-testid="audio-meta-song-name">
unmountOnExit <span className="grow overflow-hidden text-ellipsis" title={decryptedName}>
startingHeight={0} {file.cleanName}
onAnimationComplete={onCollapseAnimationComplete} </span>
style={{ width: '100%', padding: '0.25rem' }} {isDecrypted && file.ext && <div className="ml-2 badge badge-accent">{file.ext}</div>}
> </h2>
<Card w="full" data-testid="file-row">
<CardBody>
<FileRowResponsiveGrid>
<GridItem area="cover">
<AlbumImage name={metadata?.album} url={metadata?.cover} />
</GridItem>
<GridItem area="title">
<Box w="full" as="h4" fontWeight="semibold" mt="1" textAlign={{ base: 'center', md: 'left' }}>
<span data-testid="audio-meta-song-name">{metadata?.name ?? nameWithoutExt}</span>
</Box>
</GridItem>
<GridItem area="meta">
{isDecrypted && metadata && <SongMetadata metadata={metadata} />}
{file.state === ProcessState.ERROR && <FileError error={file.errorMessage} code={file.errorCode} />}
</GridItem>
<GridItem area="action" alignSelf="center">
<VStack>
{file.decrypted && <audio controls autoPlay={false} src={file.decrypted} ref={audioPlayerRef} />}
<Wrap> <div className="w-full grow">
{file.state === ProcessState.ERROR && <FileError error={file.errorMessage} code={file.errorCode} />}
{isDecrypted && ( {isDecrypted && (
<> <audio className="w-full" aria-disabled={!file.decrypted} controls autoPlay={false} src={file.decrypted} />
<WrapItem>
<Button type="button" onClick={togglePlay}>
/
</Button>
</WrapItem>
<WrapItem>
{file.decrypted && (
<Link href={file.decrypted} download={decryptedName}>
<Button as="span"></Button>
</Link>
)} )}
</WrapItem> </div>
</>
)} <div className="card-actions justify-end">
<WrapItem> <a
<Button type="button" onClick={onClose}> href={file.decrypted}
download={decryptedName}
title={`下载: ${decryptedName}`}
className={classNames('btn', {
'btn-primary': file.decrypted,
'cursor-not-allowed pointer-events-none': !file.decrypted,
})}
data-testid="audio-download"
>
</a>
<button type="button" className="btn btn-error" onClick={() => dispatch(deleteFile({ id }))}>
</Button> </button>
</WrapItem> </div>
</Wrap> </div>
</VStack> </div>
</GridItem>
</FileRowResponsiveGrid>
</CardBody>
</Card>
</Collapse>
); );
} }

View File

@@ -1,27 +0,0 @@
import { Grid, chakra } from '@chakra-ui/react';
export const FileRowResponsiveGrid = chakra(Grid, {
baseStyle: {
gridTemplateAreas: {
base: `
"cover"
"title"
"meta"
"action"
`,
md: `
"cover title action"
"cover meta action"
`,
},
gridTemplateRows: {
base: 'repeat(auto-fill)',
md: 'min-content 1fr',
},
gridTemplateColumns: {
base: '1fr',
md: '160px 1fr',
},
gap: 3,
},
});

View File

@@ -1,22 +0,0 @@
import { Box, Text } from '@chakra-ui/react';
import type { AudioMetadata } from './fileListingSlice';
export interface SongMetadataProps {
metadata: AudioMetadata;
}
export function SongMetadata({ metadata }: SongMetadataProps) {
return (
<Box>
<Text>
: <span data-testid="audio-meta-album-name">{metadata.album}</span>
</Text>
<Text>
: <span data-testid="audio-meta-song-artist">{metadata.artist}</span>
</Text>
<Text>
: <span data-testid="audio-meta-album-artist">{metadata.albumArtist}</span>
</Text>
</Box>
);
}

View File

@@ -14,5 +14,5 @@ test('should be able to render a list of 3 items', () => {
}); });
expect(screen.getAllByTestId('file-row')).toHaveLength(3); expect(screen.getAllByTestId('file-row')).toHaveLength(3);
expect(screen.getByText('Für Alice')).toBeInTheDocument(); expect(screen.getByText('ready')).toBeInTheDocument();
}); });

View File

@@ -3,22 +3,15 @@ import { untouchedFile } from './__fixture__/file-list';
import { FileRow } from '../FileRow'; import { FileRow } from '../FileRow';
import { completedFile } from './__fixture__/file-list'; import { completedFile } from './__fixture__/file-list';
test('should render no metadata when unavailable', () => { test('should render basic title (ready)', () => {
renderWithProviders(<FileRow id="file://ready" file={untouchedFile} />); renderWithProviders(<FileRow id="file://ready" file={untouchedFile} />);
expect(screen.getAllByTestId('file-row')).toHaveLength(1); expect(screen.getAllByTestId('file-row')).toHaveLength(1);
expect(screen.getByTestId('audio-meta-song-name')).toHaveTextContent('ready'); expect(screen.getByTestId('audio-meta-song-name')).toHaveTextContent('ready');
expect(screen.queryByTestId('audio-meta-album-name')).toBeFalsy();
expect(screen.queryByTestId('audio-meta-song-artist')).toBeFalsy();
expect(screen.queryByTestId('audio-meta-album-artist')).toBeFalsy();
}); });
test('should render metadata when file has been processed', () => { test('should render basic title (done)', () => {
renderWithProviders(<FileRow id="file://done" file={completedFile} />); renderWithProviders(<FileRow id="file://done" file={completedFile} />);
expect(screen.getAllByTestId('file-row')).toHaveLength(1); expect(screen.getAllByTestId('file-row')).toHaveLength(1);
expect(screen.getByTestId('audio-meta-song-name')).toHaveTextContent('Für Alice');
expect(screen.getByTestId('audio-meta-album-name')).toHaveTextContent("NOW That's What I Call Cryptography 2023");
expect(screen.getByTestId('audio-meta-song-artist')).toHaveTextContent('Jixun');
expect(screen.getByTestId('audio-meta-album-artist')).toHaveTextContent('Cipher Lovers');
}); });

View File

@@ -1,6 +1,7 @@
import { DecryptedAudioFile, ProcessState } from '../../fileListingSlice'; import { DecryptedAudioFile, ProcessState } from '../../fileListingSlice';
export const untouchedFile: DecryptedAudioFile = { export const untouchedFile: DecryptedAudioFile = {
cleanName: 'ready',
fileName: 'ready.bin', fileName: 'ready.bin',
raw: 'blob://localhost/file-a', raw: 'blob://localhost/file-a',
decrypted: '', decrypted: '',
@@ -13,6 +14,7 @@ export const untouchedFile: DecryptedAudioFile = {
export const completedFile: DecryptedAudioFile = { export const completedFile: DecryptedAudioFile = {
fileName: 'hello-b.bin', fileName: 'hello-b.bin',
cleanName: 'hello-b',
raw: 'blob://localhost/file-b', raw: 'blob://localhost/file-b',
decrypted: 'blob://localhost/file-b-decrypted', decrypted: 'blob://localhost/file-b-decrypted',
ext: 'flac', ext: 'flac',
@@ -30,6 +32,7 @@ export const completedFile: DecryptedAudioFile = {
export const fileWithError: DecryptedAudioFile = { export const fileWithError: DecryptedAudioFile = {
fileName: 'hello-c.bin', fileName: 'hello-c.bin',
cleanName: 'hello-c',
raw: 'blob://localhost/file-c', raw: 'blob://localhost/file-c',
decrypted: 'blob://localhost/file-c-decrypted', decrypted: 'blob://localhost/file-c-decrypted',
ext: 'flac', ext: 'flac',

View File

@@ -5,9 +5,11 @@ import type { RootState } from '~/store';
import { DECRYPTION_WORKER_ACTION_NAME, type DecryptionResult } from '~/decrypt-worker/constants'; import { DECRYPTION_WORKER_ACTION_NAME, type DecryptionResult } from '~/decrypt-worker/constants';
import type { import type {
DecryptCommandOptions, DecryptCommandOptions,
FetchMusicExNamePayload, ParseKugouHeaderPayload, ParseKugouHeaderResponse, FetchMusicExNamePayload,
ParseKugouHeaderPayload,
ParseKugouHeaderResponse,
ParseKuwoHeaderPayload, ParseKuwoHeaderPayload,
ParseKuwoHeaderResponse ParseKuwoHeaderResponse,
} from '~/decrypt-worker/types'; } from '~/decrypt-worker/types';
import { decryptionQueue, workerClientBus } from '~/decrypt-worker/client'; import { decryptionQueue, workerClientBus } from '~/decrypt-worker/client';
import { DecryptErrorType } from '~/decrypt-worker/util/DecryptError'; import { DecryptErrorType } from '~/decrypt-worker/util/DecryptError';
@@ -15,8 +17,9 @@ import {
selectKugouKey, selectKugouKey,
selectKWMv2Key, selectKWMv2Key,
selectQMCv2KeyByFileName, selectQMCv2KeyByFileName,
selectQtfmAndroidKey selectQtfmAndroidKey,
} from '../settings/settingsSelector'; } from '../settings/settingsSelector';
import { cleanFilename } from '~/util/cleanFilename';
export enum ProcessState { export enum ProcessState {
QUEUED = 'QUEUED', QUEUED = 'QUEUED',
@@ -40,6 +43,7 @@ export interface AudioMetadata {
export interface DecryptedAudioFile { export interface DecryptedAudioFile {
fileName: string; fileName: string;
cleanName: string;
raw: string; // blob uri raw: string; // blob uri
ext: string; ext: string;
decrypted: string; // blob uri decrypted: string; // blob uri
@@ -106,6 +110,7 @@ export const fileListingSlice = createSlice({
addNewFile: (state, { payload }: PayloadAction<{ id: string; fileName: string; blobURI: string }>) => { addNewFile: (state, { payload }: PayloadAction<{ id: string; fileName: string; blobURI: string }>) => {
state.files[payload.id] = { state.files[payload.id] = {
fileName: payload.fileName, fileName: payload.fileName,
cleanName: cleanFilename(payload.fileName),
raw: payload.blobURI, raw: payload.blobURI,
decrypted: '', decrypted: '',
ext: '', ext: '',

View File

@@ -0,0 +1,29 @@
export interface ResponsiveNavProps {
navigationClassName?: string;
navigation?: React.ReactNode;
className?: string;
contentClassName?: string;
children?: React.ReactNode;
}
export function ResponsiveNav({
className = '',
navigationClassName = '',
contentClassName = '',
children,
navigation,
}: ResponsiveNavProps) {
return (
<div
className={`@container/nav grow grid grid-cols-1 grid-rows-[auto_1fr] md:grid-rows-1 md:grid-cols-[10rem_1fr] ${className}`}
>
{/* Sidebar */}
<aside className={`bg-base-100 md:p-4 md:block ${navigationClassName}`}>{navigation}</aside>
{/* Main content */}
<div className={`p-4 grow ${contentClassName}`}>{children}</div>
</div>
);
}

View File

@@ -0,0 +1,21 @@
import classNames from 'classnames';
import type { RefAttributes } from 'react';
import { NavLink, type NavLinkProps } from 'react-router';
const tabClassNames = ({ isActive }: { isActive: boolean }) =>
classNames(
'link inline-flex text-nowrap mb-[-2px] no-underline w-full',
'border-b-2 md:border-b-0 md:border-r-2',
'tab md:grow',
{
'tab-active bg-accent/10 border-accent': isActive,
},
);
export function TabNavLink({ children, ...props }: NavLinkProps & RefAttributes<HTMLAnchorElement>) {
return (
<NavLink className={tabClassNames} role="tab" {...props}>
{children}
</NavLink>
);
}

View File

@@ -1,163 +1,75 @@
import {
Box,
Button,
Center,
chakra,
Flex,
HStack,
Icon,
IconButton,
Menu,
MenuButton,
MenuItem,
MenuList,
Portal,
Spacer,
Tab,
TabList,
TabPanel,
TabPanels,
Tabs,
Text,
useBreakpointValue,
useToast,
VStack,
} from '@chakra-ui/react';
import { PanelQMCv2Key } from './panels/PanelQMCv2Key';
import { useState } from 'react';
import { MdExpandMore, MdMenu, MdOutlineSettingsBackupRestore } from 'react-icons/md';
import { useAppDispatch, useAppSelector } from '~/hooks'; import { useAppDispatch, useAppSelector } from '~/hooks';
import { commitStagingChange, discardStagingChanges } from './settingsSlice'; import { commitStagingChange, discardStagingChanges } from './settingsSlice';
import { PanelKWMv2Key } from './panels/PanelKWMv2Key';
import { selectIsSettingsNotSaved } from './settingsSelector'; import { selectIsSettingsNotSaved } from './settingsSelector';
import { PanelQingTing } from './panels/PanelQingTing'; import { Outlet } from 'react-router';
import { PanelKGGKey } from '~/features/settings/panels/PanelKGGKey.tsx'; import { SETTINGS_TABS } from '~/features/settings/settingsTabs.tsx';
import { MdOutlineSettingsBackupRestore } from 'react-icons/md';
const TABS: { name: string; Tab: () => JSX.Element }[] = [ import { toast } from 'react-toastify';
{ name: 'QMCv2 密钥', Tab: PanelQMCv2Key }, import { ResponsiveNav } from '../nav/ResponsiveNav';
{ name: 'KWMv2 密钥', Tab: PanelKWMv2Key }, import { TabNavLink } from '../nav/TabNavLink';
{ name: 'KGG 密钥', Tab: PanelKGGKey },
{ name: '蜻蜓 FM', Tab: PanelQingTing },
{
name: '其它/待定',
Tab: () => <Text></Text>,
},
];
export function Settings() { export function Settings() {
const toast = useToast();
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const isLargeWidthDevice =
useBreakpointValue({
base: false,
lg: true,
}) ?? false;
const [tabIndex, setTabIndex] = useState(0);
const handleTabChange = (idx: number) => {
setTabIndex(idx);
};
const handleResetSettings = () => { const handleResetSettings = () => {
dispatch(discardStagingChanges()); dispatch(discardStagingChanges());
toast({ toast.info(() => (
status: 'info', <div>
title: '未储存的设定已舍弃', <h3 className="text-lg font-bold"></h3>
description: '已还原到更改前的状态。', <p className="text-sm"></p>
isClosable: true, </div>
}); ));
}; };
const handleApplySettings = () => { const handleApplySettings = () => {
dispatch(commitStagingChange()); dispatch(commitStagingChange());
toast({ toast.success('设定已应用');
status: 'success',
title: '设定已应用',
isClosable: true,
});
}; };
const isSettingsNotSaved = useAppSelector(selectIsSettingsNotSaved); const isSettingsNotSaved = useAppSelector(selectIsSettingsNotSaved);
return ( return (
<Flex flexDir="column" flex={1}> <div className="flex flex-col flex-1 container w-full">
<Menu> <ResponsiveNav
<MenuButton className="grow h-full overflow-auto"
as={Button} contentClassName="flex flex-col overflow-auto"
leftIcon={<MdMenu />} navigationClassName="overflow-x-auto pb-[2px] md:pb-0 h-full items-start [&]:md:flex"
rightIcon={<MdExpandMore />} navigation={
colorScheme="gray" <div role="tablist" className="tabs gap-1 flex-nowrap md:flex-col grow items-center">
variant="outline" {Object.entries(SETTINGS_TABS).map(([id, { name }]) => (
w="full" <TabNavLink key={id} to={`/settings/${id}`}>
flexShrink={0}
hidden={isLargeWidthDevice}
mb="4"
>
{TABS[tabIndex].name}
</MenuButton>
<Portal>
<MenuList w="100px">
{TABS.map(({ name }, i) => (
<MenuItem key={name} onClick={() => setTabIndex(i)}>
{name} {name}
</MenuItem> </TabNavLink>
))} ))}
</MenuList> </div>
</Portal> }
</Menu>
<Tabs
orientation={isLargeWidthDevice ? 'vertical' : 'horizontal'}
align="start"
variant="line-i"
display="flex"
flex={1}
index={tabIndex}
onChange={handleTabChange}
> >
<TabList hidden={!isLargeWidthDevice} minW="8em" width="8em" textAlign="right" justifyContent="center"> <Outlet />
{TABS.map(({ name }) => ( </ResponsiveNav>
<Tab key={name}>{name}</Tab>
))}
</TabList>
<TabPanels> <footer className="flex flex-row gap-2 w-full p-2 border-t border-base-200 bg-base-100">
{TABS.map(({ name, Tab }) => ( <div className="grow inline-flex items-center">
<Flex as={TabPanel} flex={1} flexDir="column" h="100%" key={name}>
<Flex h="100%" flex={1} minH={0}>
<Tab />
</Flex>
<VStack mt="4" alignItems="flex-start" w="full">
<Flex flexDir="row" gap="2" w="full">
<Center>
{isSettingsNotSaved ? ( {isSettingsNotSaved ? (
<Box color="gray"> <span>
{' '} <span className="text-red-600"></span>
<chakra.span color="red" wordBreak="keep-all"> </span>
</chakra.span>
</Box>
) : ( ) : (
<Box color="gray"></Box> <span className="text-base-700"></span>
)} )}
</Center> </div>
<Spacer />
<HStack gap="2" justifyContent="flex-end"> <div className="flex flex-row gap-2">
<IconButton <button
icon={<Icon as={MdOutlineSettingsBackupRestore} />} className="btn btn-sm btn-ghost text-error"
onClick={handleResetSettings} onClick={handleResetSettings}
colorScheme="red"
variant="ghost"
title="放弃未储存的更改,将设定还原未储存前的状态。" title="放弃未储存的更改,将设定还原未储存前的状态。"
aria-label="放弃未储存的更改" >
/> <MdOutlineSettingsBackupRestore className="size-4" />
<Button onClick={handleApplySettings}></Button> </button>
</HStack> <button className="btn btn-sm btn-primary" onClick={handleApplySettings}>
</Flex>
</VStack> </button>
</Flex> </div>
))} </footer>
</TabPanels> </div>
</Tabs>
</Flex>
); );
} }

View File

@@ -0,0 +1,8 @@
export function SettingsHome() {
return (
<div className="flex flex-col gap-4">
<h1 className="text-2xl font-bold"></h1>
<p></p>
</div>
);
}

View File

@@ -1,33 +1,28 @@
import { Code, ListItem, OrderedList, Text, chakra } from '@chakra-ui/react'; import { HiWord } from '~/components/HelpText/HiWord';
const KUWO_IOS_DIR = '/var/mobile/Containers/Data/Application/<酷我数据目录>/mmkv';
export function InstructionsIOS() { export function InstructionsIOS() {
return ( return (
<> <>
<Text>访 iOS </Text> <p>访 iOS </p>
<Text> <p>
<chakra.span color="red.400"></chakra.span> <span className="text-red-600"></span>
</Text> </p>
<OrderedList> <ol className="list-decimal pl-6">
<ListItem> <li>
<Text>
访 访
<Code wordBreak="break-word">{KUWO_IOS_DIR}</Code> <br />
</Text> <code className="break-words">
</ListItem> /var/mobile/Containers/Data/Application/<HiWord className="text-nowrap">{'<>'}</HiWord>/mmkv
<ListItem> </code>
<Text> </li>
<Code>kw_ekey</Code> 访 <li>
</Text> <code>kw_ekey</code> 访
</ListItem> </li>
<ListItem> <li>
<Text> <code>kw_ekey</code>
<Code>kw_ekey</Code> </li>
</Text> </ol>
</ListItem>
</OrderedList>
</> </>
); );
} }

View File

@@ -1,9 +1,3 @@
import { Text } from '@chakra-ui/react';
export function InstructionsPC() { export function InstructionsPC() {
return ( return <p>使 Windows </p>;
<>
<Text>使 Windows </Text>
</>
);
} }

View File

@@ -1,30 +1,16 @@
import { Tab, TabList, TabPanel, TabPanels } from '@chakra-ui/react';
import { AndroidADBPullInstruction } from '~/components/AndroidADBPullInstruction/AndroidADBPullInstruction'; import { AndroidADBPullInstruction } from '~/components/AndroidADBPullInstruction/AndroidADBPullInstruction';
import { InstructionsPC } from './InstructionsPC'; import { InstructionsPC } from './InstructionsPC';
import { InstructionsIOS } from './InstructionsIOS'; import { InstructionsIOS } from './InstructionsIOS';
import { InstructionsTabs, InstructionTab } from '~/components/InstructionsTabs';
export function KWMv2AllInstructions() { export function KWMv2AllInstructions() {
return ( const ANDROID_DIR = '/data/data/cn.kuwo.player/files/mmkv';
<> const ANDROID_FILE = 'cn.kuwo.player.mmkv.defaultconfig';
<TabList> const tabs: InstructionTab[] = [
<Tab></Tab> { id: 'android', label: '安卓', content: <AndroidADBPullInstruction dir={ANDROID_DIR} file={ANDROID_FILE} /> },
<Tab>iOS</Tab> { id: 'ios', label: 'iOS', content: <InstructionsIOS /> },
<Tab>Windows</Tab> { id: 'windows', label: 'Windows', content: <InstructionsPC /> },
</TabList> ];
<TabPanels flex={1} overflow="auto">
<TabPanel> return <InstructionsTabs tabs={tabs} />;
<AndroidADBPullInstruction
dir="/data/data/cn.kuwo.player/files/mmkv"
file="cn.kuwo.player.mmkv.defaultconfig"
/>
</TabPanel>
<TabPanel>
<InstructionsIOS />
</TabPanel>
<TabPanel>
<InstructionsPC />
</TabPanel>
</TabPanels>
</>
);
} }

View File

@@ -1,81 +1,41 @@
import { import { PiFileAudio, PiHash } from 'react-icons/pi';
HStack,
Icon,
IconButton,
Input,
InputGroup,
InputLeftElement,
InputRightElement,
ListItem,
Text,
VStack,
} from '@chakra-ui/react';
import { MdDelete, MdVpnKey } from 'react-icons/md';
import { kwm2DeleteKey, kwm2UpdateKey } from '../../settingsSlice'; import { kwm2DeleteKey, kwm2UpdateKey } from '../../settingsSlice';
import { useAppDispatch } from '~/hooks'; import { useAppDispatch } from '~/hooks';
import { memo } from 'react'; import { memo } from 'react';
import { StagingKWMv2Key } from '../../keyFormats'; import { StagingKWMv2Key } from '../../keyFormats';
import { KeyInput } from '~/components/KeyInput';
export const KWMv2EKeyItem = memo(({ id, ekey, quality, rid, i }: StagingKWMv2Key & { i: number }) => { export const KWMv2EKeyItem = memo(({ id, ekey, quality, rid, i }: StagingKWMv2Key & { i: number }) => {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const updateKey = (prop: keyof StagingKWMv2Key, e: React.ChangeEvent<HTMLInputElement>) => const ekeyLen = ekey.length;
dispatch(kwm2UpdateKey({ id, field: prop, value: e.target.value })); const isValidEKey = ekeyLen === 364 || ekeyLen === 704;
const deleteKey = () => dispatch(kwm2DeleteKey({ id }));
return ( return (
<ListItem mt={0} pt={2} pb={2} _even={{ bg: 'gray.50' }}> <KeyInput
<HStack> name={rid}
<Text w="2em" textAlign="center"> quality={quality}
{i + 1}
</Text>
<VStack flex={1}>
<HStack flex={1} w="full">
<Input
variant="flushed"
placeholder="资源 ID"
value={rid}
onChange={(e) => updateKey('rid', e)}
type="number"
maxW="8em"
/>
<Input
variant="flushed"
placeholder="音质格式"
value={quality}
onChange={(e) => updateKey('quality', e)}
flex={1}
/>
</HStack>
<InputGroup size="xs">
<InputLeftElement pr="2">
<Icon as={MdVpnKey} />
</InputLeftElement>
<Input
variant="flushed"
placeholder="密钥,通常包含 364 或 704 位字符,没有空格。"
value={ekey} value={ekey}
onChange={(e) => updateKey('ekey', e)} isValidKey={isValidEKey}
onSetName={(value) => dispatch(kwm2UpdateKey({ id, field: 'rid', value }))}
onSetQuality={(value) => dispatch(kwm2UpdateKey({ id, field: 'quality', value }))}
onSetValue={(value) => dispatch(kwm2UpdateKey({ id, field: 'ekey', value }))}
onDelete={() => dispatch(kwm2DeleteKey({ id }))}
sequence={i + 1}
nameLabel={
<>
ID
<PiHash className="hidden md:inline-block" />
</>
}
qualityLabel={
<>
<PiFileAudio className="hidden md:inline-block" />
</>
}
namePlaceholder="音频哈希。不建议手动填写。"
qualityPlaceholder="比特率 ID"
valuePlaceholder="密钥,通常包含 364 或 704 位字符,没有空格。"
/> />
<InputRightElement>
<Text pl="2" color={ekey.length ? 'green.500' : 'red.500'}>
<code>{ekey.length || '?'}</code>
</Text>
</InputRightElement>
</InputGroup>
</VStack>
<IconButton
aria-label="删除该密钥"
icon={<Icon as={MdDelete} boxSize={6} />}
variant="ghost"
colorScheme="red"
type="button"
onClick={deleteKey}
/>
</HStack>
</ListItem>
); );
}); });

View File

@@ -1,34 +1,48 @@
import { Code, Heading, ListItem, OrderedList, Text } from '@chakra-ui/react'; import { RiFileCopyLine } from 'react-icons/ri';
import { toast } from 'react-toastify';
import { ExtLink } from '~/components/ExtLink';
import { FilePathBlock } from '~/components/FilePathBlock.tsx'; import { FilePathBlock } from '~/components/FilePathBlock.tsx';
export function InstructionsPC() { export function InstructionsPC() {
const DB_PATH = '%APPDATA%\\KuGou8\\KGMusicV3.db';
const copyDbPathToClipboard = () => {
navigator.clipboard
.writeText(DB_PATH)
.then(() => {
toast.success('已复制到剪贴板');
})
.catch((err) => {
toast.error(`复制失败,请手动复制\n${err}`);
});
};
return ( return (
<> <>
<Text> Windows 使 <abbr title="SQLite w/ SQLCipher">SQLite</abbr> </Text> <p>
<Text></Text> Windows 使
<FilePathBlock>%APPDATA%\KuGou8\KGMusicV3.db</FilePathBlock> <ExtLink className="link-info px-1" href="https://www.zetetic.net/sqlcipher/">
SQLCipher
</ExtLink>
</p>
<p></p>
<FilePathBlock>{DB_PATH}</FilePathBlock>
<Heading as="h3" size="md" mt="4"> <h3 className="font-bold text-xl mt-4"></h3>
<ol className="list-decimal pl-6">
</Heading> <li>
<OrderedList> <button className="btn btn-sm btn-outline btn-accent mr-2" onClick={copyDbPathToClipboard}>
<ListItem> <RiFileCopyLine className="text-xl" />
<Text> <span></span>
<Code>KGMusicV3.db</Code> </button>
</Text> <code>KGMusicV3.db</code>
</ListItem> </li>
<ListItem> <li></li>
<Text></Text> <li>
</ListItem> <code>KGMusicV3.db</code>
<ListItem> </li>
<Text> <li></li>
<Code>KGMusicV3.db</Code> </ol>
</Text>
</ListItem>
<ListItem>
<Text></Text>
</ListItem>
</OrderedList>
</> </>
); );
} }

View File

@@ -1,25 +1,14 @@
import { Tab, TabList, TabPanel, TabPanels } from '@chakra-ui/react';
import { AndroidADBPullInstruction } from '~/components/AndroidADBPullInstruction/AndroidADBPullInstruction'; import { AndroidADBPullInstruction } from '~/components/AndroidADBPullInstruction/AndroidADBPullInstruction';
import { InstructionsPC } from './InstructionsPC'; import { InstructionsPC } from './InstructionsPC';
import { InstructionsTabs, InstructionTab } from '~/components/InstructionsTabs';
export function KugouAllInstructions() { export function KugouAllInstructions() {
return ( const ANDROID_DIR = '/data/data/com.kugou.android/files/mmkv';
<> const ANDROID_FILE = 'mggkey_multi_process';
<TabList> const tabs: InstructionTab[] = [
<Tab></Tab> { id: 'android', label: '安卓', content: <AndroidADBPullInstruction dir={ANDROID_DIR} file={ANDROID_FILE} /> },
<Tab>Windows</Tab> { id: 'windows', label: 'Windows', content: <InstructionsPC /> },
</TabList> ];
<TabPanels flex={1} overflow="auto">
<TabPanel> return <InstructionsTabs tabs={tabs} />;
<AndroidADBPullInstruction
dir="/data/data/com.kugou.android/files/mmkv"
file="mggkey_multi_process"
/>
</TabPanel>
<TabPanel>
<InstructionsPC />
</TabPanel>
</TabPanels>
</>
);
} }

View File

@@ -1,72 +1,26 @@
import {
HStack,
Icon,
IconButton,
Input,
InputGroup,
InputLeftElement,
InputRightElement,
ListItem,
Text,
VStack,
} from '@chakra-ui/react';
import { MdDelete, MdVpnKey } from 'react-icons/md';
import { kugouDeleteKey, kugouUpdateKey } from '../../settingsSlice'; import { kugouDeleteKey, kugouUpdateKey } from '../../settingsSlice';
import { useAppDispatch } from '~/hooks'; import { useAppDispatch } from '~/hooks';
import { memo } from 'react'; import { memo } from 'react';
import { StagingKugouKey } from '../../keyFormats'; import { StagingKugouKey } from '../../keyFormats';
import { KeyInput } from '~/components/KeyInput';
export const KugouEKeyItem = memo(({ id, ekey, audioHash, i }: StagingKugouKey & { i: number }) => { export const KugouEKeyItem = memo(({ id, ekey, audioHash, i }: StagingKugouKey & { i: number }) => {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const updateKey = (prop: keyof StagingKugouKey, e: React.ChangeEvent<HTMLInputElement>) => const ekeyLen = ekey.length;
dispatch(kugouUpdateKey({ id, field: prop, value: e.target.value })); const isValidEKey = ekeyLen === 364 || ekeyLen === 704;
const deleteKey = () => dispatch(kugouDeleteKey({ id }));
return ( return (
<ListItem mt={0} pt={2} pb={2} _even={{ bg: 'gray.50' }}> <KeyInput
<HStack> name={audioHash}
<Text w="2em" textAlign="center">
{i + 1}
</Text>
<VStack flex={1}>
<HStack flex={1} w="full">
<Input
variant="flushed"
placeholder="音频哈希。不建议手动填写。"
value={audioHash}
onChange={(e) => updateKey('audioHash', e)}
/>
</HStack>
<InputGroup size="xs">
<InputLeftElement pr="2">
<Icon as={MdVpnKey} />
</InputLeftElement>
<Input
variant="flushed"
placeholder="密钥,通常包含 364 或 704 位字符,没有空格。"
value={ekey} value={ekey}
onChange={(e) => updateKey('ekey', e)} isValidKey={isValidEKey}
onSetName={(value) => dispatch(kugouUpdateKey({ id, field: 'audioHash', value }))}
onSetValue={(value) => dispatch(kugouUpdateKey({ id, field: 'ekey', value }))}
onDelete={() => dispatch(kugouDeleteKey({ id }))}
sequence={i + 1}
namePlaceholder="音频哈希。不建议手动填写。"
valuePlaceholder="密钥,通常包含 364 或 704 位字符,没有空格。"
/> />
<InputRightElement>
<Text pl="2" color={ekey.length ? 'green.500' : 'red.500'}>
<code>{ekey.length || '?'}</code>
</Text>
</InputRightElement>
</InputGroup>
</VStack>
<IconButton
aria-label="删除该密钥"
icon={<Icon as={MdDelete} boxSize={6} />}
variant="ghost"
colorScheme="red"
type="button"
onClick={deleteKey}
/>
</HStack>
</ListItem>
); );
}); });

View File

@@ -1,5 +1,4 @@
import { Box, Flex, Heading, List, Text, useToast } from '@chakra-ui/react'; import { useRef, useState } from 'react';
import { useState } from 'react';
import { useDispatch, useSelector } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import { ImportSecretModal } from '~/components/ImportSecretModal'; import { ImportSecretModal } from '~/components/ImportSecretModal';
@@ -12,9 +11,10 @@ import { KugouEKeyItem } from '~/features/settings/panels/Kugou/KugouEKeyItem.ts
import { KugouAllInstructions } from '~/features/settings/panels/Kugou/KugouAllInstructions.tsx'; import { KugouAllInstructions } from '~/features/settings/panels/Kugou/KugouAllInstructions.tsx';
import { parseAndroidKugouMMKV } from '~/util/mmkv/kugou.ts'; import { parseAndroidKugouMMKV } from '~/util/mmkv/kugou.ts';
import { DatabaseKeyExtractor } from '~/util/DatabaseKeyExtractor.ts'; import { DatabaseKeyExtractor } from '~/util/DatabaseKeyExtractor.ts';
import { KeyListContainer } from '~/components/KeyListContainer';
import { toastImportResult } from '~/util/toastImportResult';
export function PanelKGGKey() { export function PanelKGGKey() {
const toast = useToast();
const dispatch = useDispatch(); const dispatch = useDispatch();
const kugouKeys = useSelector(selectStagingKugouV5Keys); const kugouKeys = useSelector(selectStagingKugouV5Keys);
const [showImportModal, setShowImportModal] = useState(false); const [showImportModal, setShowImportModal] = useState(false);
@@ -30,49 +30,34 @@ export function PanelKGGKey() {
keys = extractor.extractKugouKeyFromEncryptedDb(await file.arrayBuffer()); keys = extractor.extractKugouKeyFromEncryptedDb(await file.arrayBuffer());
} }
if (keys?.length === 0) { if (keys && keys.length > 0) {
toast({
title: '未导入密钥',
description: '选择的密钥数据库文件未发现任何可用的密钥。',
isClosable: true,
status: 'warning',
});
} else if (keys) {
dispatch(kugouImportKeys(keys)); dispatch(kugouImportKeys(keys));
setShowImportModal(false); setShowImportModal(false);
toast({
title: `导入完成,共导入了 ${keys.length} 个密钥。`,
description: '记得按下「保存」来应用。',
isClosable: true,
status: 'success',
});
} else {
toast({
title: `不支持的文件:${file.name}`,
isClosable: true,
status: 'error',
});
} }
toastImportResult(file.name, keys);
}; };
const refKeyContainer = useRef<HTMLDivElement>(null);
return ( return (
<Flex minH={0} flexDir="column" flex={1}> <div className="container flex flex-col grow min-h-0 w-full">
<Heading as="h2" size="lg"> <h2 className="text-2xl font-bold"> (KGG / KGM v5)</h2>
(KGG / KGM v5)
</Heading>
<Text>使 KGG / KGM v5 </Text> <p>使 KGG / KGM v5 </p>
<AddKey addKey={addKey} importKeyFromFile={() => setShowImportModal(true)} clearKeys={clearAll} /> <h3 className="mt-2 text-xl font-bold"></h3>
<AddKey
addKey={addKey}
refContainer={refKeyContainer}
importKeyFromFile={() => setShowImportModal(true)}
clearKeys={clearAll}
/>
<Box flex={1} minH={0} overflow="auto" pr="4"> <KeyListContainer ref={refKeyContainer} keys={kugouKeys}>
<List spacing={3}>
{kugouKeys.map(({ id, audioHash, ekey }, i) => ( {kugouKeys.map(({ id, audioHash, ekey }, i) => (
<KugouEKeyItem key={id} id={id} ekey={ekey} audioHash={audioHash} i={i} /> <KugouEKeyItem key={id} id={id} ekey={ekey} audioHash={audioHash} i={i} />
))} ))}
</List> </KeyListContainer>
{kugouKeys.length === 0 && <Text></Text>}
</Box>
<ImportSecretModal <ImportSecretModal
clientName="酷狗音乐" clientName="酷狗音乐"
@@ -82,6 +67,6 @@ export function PanelKGGKey() {
> >
<KugouAllInstructions /> <KugouAllInstructions />
</ImportSecretModal> </ImportSecretModal>
</Flex> </div>
); );
} }

View File

@@ -1,25 +1,5 @@
import { import { useRef, useState } from 'react';
Box,
Button,
ButtonGroup,
Code,
Flex,
HStack,
Heading,
Icon,
IconButton,
List,
Menu,
MenuButton,
MenuDivider,
MenuItem,
MenuList,
Text,
useToast,
} from '@chakra-ui/react';
import { useState } from 'react';
import { useDispatch, useSelector } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import { MdAdd, MdDeleteForever, MdExpandMore, MdFileUpload } from 'react-icons/md';
import { ImportSecretModal } from '~/components/ImportSecretModal'; import { ImportSecretModal } from '~/components/ImportSecretModal';
import { parseAndroidKuwoEKey, parseIosKuwoEKey } from '~/util/mmkv/kuwo'; import { parseAndroidKuwoEKey, parseIosKuwoEKey } from '~/util/mmkv/kuwo';
@@ -29,9 +9,11 @@ import { selectStagingKWMv2Keys } from '../settingsSelector';
import { KWMv2EKeyItem } from './KWMv2/KWMv2EKeyItem'; import { KWMv2EKeyItem } from './KWMv2/KWMv2EKeyItem';
import type { StagingKWMv2Key } from '../keyFormats'; import type { StagingKWMv2Key } from '../keyFormats';
import { KWMv2AllInstructions } from './KWMv2/KWMv2AllInstructions'; import { KWMv2AllInstructions } from './KWMv2/KWMv2AllInstructions';
import { AddKey } from '~/components/AddKey';
import { KeyListContainer } from '~/components/KeyListContainer';
import { toastImportResult } from '~/util/toastImportResult';
export function PanelKWMv2Key() { export function PanelKWMv2Key() {
const toast = useToast();
const dispatch = useDispatch(); const dispatch = useDispatch();
const kwm2Keys = useSelector(selectStagingKWMv2Keys); const kwm2Keys = useSelector(selectStagingKWMv2Keys);
const [showImportModal, setShowImportModal] = useState(false); const [showImportModal, setShowImportModal] = useState(false);
@@ -46,70 +28,35 @@ export function PanelKWMv2Key() {
keys = parseIosKuwoEKey(new DataView(await file.arrayBuffer())); keys = parseIosKuwoEKey(new DataView(await file.arrayBuffer()));
} }
if (keys?.length === 0) { if (keys && keys.length > 0) {
toast({
title: '未导入密钥',
description: '选择的密钥数据库文件未发现任何可用的密钥。',
isClosable: true,
status: 'warning',
});
} else if (keys) {
dispatch(kwm2ImportKeys(keys)); dispatch(kwm2ImportKeys(keys));
setShowImportModal(false); setShowImportModal(false);
toast({
title: `导入完成,共导入了 ${keys.length} 个密钥。`,
description: '记得按下「保存」来应用。',
isClosable: true,
status: 'success',
});
} else {
toast({
title: `不支持的文件:${file.name}`,
isClosable: true,
status: 'error',
});
} }
toastImportResult(file.name, keys);
}; };
const refKeyContainer = useRef<HTMLDivElement>(null);
return ( return (
<Flex minH={0} flexDir="column" flex={1}> <div className="container flex flex-col grow min-h-0 w-full">
<Heading as="h2" size="lg"> <h2 className="text-2xl font-bold">KwmV2</h2>
KwmV2 <p>
</Heading> V2 <code>mflac</code> <code>mgg</code>
</p>
<p></p>
<Text> <h3 className="mt-2 text-xl font-bold"></h3>
V2 <Code>mflac</Code> 沿 <Code>kwm</Code>{''} <AddKey
addKey={addKey}
</Text> refContainer={refKeyContainer}
importKeyFromFile={() => setShowImportModal(true)}
clearKeys={clearAll}
/>
<HStack pb={2} pt={2}> <KeyListContainer ref={refKeyContainer} keys={kwm2Keys}>
<ButtonGroup isAttached colorScheme="purple" variant="outline">
<Button onClick={addKey} leftIcon={<Icon as={MdAdd} />}>
</Button>
<Menu>
<MenuButton as={IconButton} icon={<MdExpandMore />}></MenuButton>
<MenuList>
<MenuItem onClick={() => setShowImportModal(true)} icon={<Icon as={MdFileUpload} boxSize={5} />}>
</MenuItem>
<MenuDivider />
<MenuItem color="red" onClick={clearAll} icon={<Icon as={MdDeleteForever} boxSize={5} />}>
</MenuItem>
</MenuList>
</Menu>
</ButtonGroup>
</HStack>
<Box flex={1} minH={0} overflow="auto" pr="4">
<List spacing={3}>
{kwm2Keys.map(({ id, ekey, quality, rid }, i) => ( {kwm2Keys.map(({ id, ekey, quality, rid }, i) => (
<KWMv2EKeyItem key={id} id={id} ekey={ekey} quality={quality} rid={rid} i={i} /> <KWMv2EKeyItem key={id} id={id} ekey={ekey} quality={quality} rid={rid} i={i} />
))} ))}
</List> </KeyListContainer>
{kwm2Keys.length === 0 && <Text></Text>}
</Box>
<ImportSecretModal <ImportSecretModal
clientName="酷我音乐" clientName="酷我音乐"
@@ -119,6 +66,6 @@ export function PanelKWMv2Key() {
> >
<KWMv2AllInstructions /> <KWMv2AllInstructions />
</ImportSecretModal> </ImportSecretModal>
</Flex> </div>
); );
} }

View File

@@ -1,31 +1,8 @@
import {
Box,
Button,
ButtonGroup,
Checkbox,
Flex,
Heading,
HStack,
Icon,
IconButton,
List,
Menu,
MenuButton,
MenuDivider,
MenuItem,
MenuList,
Select,
Text,
Tooltip,
useToast,
} from '@chakra-ui/react';
import { useDispatch, useSelector } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import { qmc2AddKey, qmc2AllowFuzzyNameSearch, qmc2ClearKeys, qmc2ImportKeys } from '../settingsSlice'; import { qmc2AddKey, qmc2AllowFuzzyNameSearch, qmc2ClearKeys, qmc2ImportKeys } from '../settingsSlice';
import { selectStagingQMCv2Settings } from '../settingsSelector'; import { selectStagingQMCv2Settings } from '../settingsSelector';
import React, { useState } from 'react'; import React, { useRef, useState } from 'react';
import { MdAdd, MdDeleteForever, MdExpandMore, MdFileUpload } from 'react-icons/md';
import { QMCv2EKeyItem } from './QMCv2/QMCv2EKeyItem'; import { QMCv2EKeyItem } from './QMCv2/QMCv2EKeyItem';
import { InfoOutlineIcon } from '@chakra-ui/icons';
import { ImportSecretModal } from '~/components/ImportSecretModal'; import { ImportSecretModal } from '~/components/ImportSecretModal';
import { StagingQMCv2Key } from '../keyFormats'; import { StagingQMCv2Key } from '../keyFormats';
import { DatabaseKeyExtractor } from '~/util/DatabaseKeyExtractor'; import { DatabaseKeyExtractor } from '~/util/DatabaseKeyExtractor';
@@ -33,9 +10,14 @@ import { parseAndroidQmEKey } from '~/util/mmkv/qm';
import { getFileName } from '~/util/pathHelper'; import { getFileName } from '~/util/pathHelper';
import { QMCv2QQMusicAllInstructions } from './QMCv2/QMCv2QQMusicAllInstructions'; import { QMCv2QQMusicAllInstructions } from './QMCv2/QMCv2QQMusicAllInstructions';
import { QMCv2DoubanAllInstructions } from './QMCv2/QMCv2DoubanAllInstructions'; import { QMCv2DoubanAllInstructions } from './QMCv2/QMCv2DoubanAllInstructions';
import { AddKey } from '~/components/AddKey';
import { InfoModal } from '~/components/InfoModal.tsx';
import { Ruby } from '~/components/Ruby.tsx';
import { ExtLink } from '~/components/ExtLink.tsx';
import { KeyListContainer } from '~/components/KeyListContainer';
import { toastImportResult } from '~/util/toastImportResult';
export function PanelQMCv2Key() { export function PanelQMCv2Key() {
const toast = useToast();
const dispatch = useDispatch(); const dispatch = useDispatch();
const { keys: qmc2Keys, allowFuzzyNameSearch } = useSelector(selectStagingQMCv2Settings); const { keys: qmc2Keys, allowFuzzyNameSearch } = useSelector(selectStagingQMCv2Settings);
const [showImportModal, setShowImportModal] = useState(false); const [showImportModal, setShowImportModal] = useState(false);
@@ -52,127 +34,93 @@ export function PanelQMCv2Key() {
try { try {
const fileBuffer = await file.arrayBuffer(); const fileBuffer = await file.arrayBuffer();
let qmc2Keys: null | Omit<StagingQMCv2Key, 'id'>[] = null; let keys: null | Omit<StagingQMCv2Key, 'id'>[] = null;
if (/(player_process[_.]db|music_audio_play)(\.db)?$/i.test(file.name)) { if (/(player_process[_.]db|music_audio_play)(\.db)?$/i.test(file.name)) {
const extractor = await DatabaseKeyExtractor.getInstance(); const extractor = await DatabaseKeyExtractor.getInstance();
qmc2Keys = extractor.extractQmcV2KeysFromSqliteDb(fileBuffer); keys = extractor.extractQmcV2KeysFromSqliteDb(fileBuffer);
if (!qmc2Keys) {
alert(`不是支持的 SQLite 数据库文件。`);
return;
}
} else if (/MMKVStreamEncryptId|filenameEkeyMap|qmpc-mmkv-v1|(\.mmkv$)/i.test(file.name)) { } else if (/MMKVStreamEncryptId|filenameEkeyMap|qmpc-mmkv-v1|(\.mmkv$)/i.test(file.name)) {
const fileBuffer = await file.arrayBuffer(); const fileBuffer = await file.arrayBuffer();
const map = parseAndroidQmEKey(new DataView(fileBuffer)); const map = parseAndroidQmEKey(new DataView(fileBuffer));
qmc2Keys = Array.from(map.entries(), ([name, ekey]) => ({ name: getFileName(name), ekey })); keys = Array.from(map.entries(), ([name, ekey]) => ({ name: getFileName(name), ekey }));
} }
if (qmc2Keys?.length === 0) { if (keys && keys.length > 0) {
toast({ dispatch(qmc2ImportKeys(keys));
title: '未导入密钥',
description: '选择的密钥数据库文件未发现任何可用的密钥。',
isClosable: true,
status: 'warning',
});
} else if (qmc2Keys) {
dispatch(qmc2ImportKeys(qmc2Keys));
setShowImportModal(false); setShowImportModal(false);
toast({
title: `导入成功 (${qmc2Keys.length})`,
description: '记得保存更改来应用。',
isClosable: true,
status: 'success',
});
} else {
alert(`不支持的文件:${file.name}`);
} }
toastImportResult(file.name, keys);
} catch (e) { } catch (e) {
console.error('error during import: ', e); console.error('error during import: ', e);
alert(`导入数据库时发生错误:${e}`); alert(`导入数据库时发生错误:${e}`);
} }
}; };
const refKeyContainer = useRef<HTMLDivElement>(null);
return ( return (
<Flex minH={0} flexDir="column" flex={1}> <>
<Heading as="h2" size="lg"> <h2 className="text-2xl font-bold">QMCv2 </h2>
QMCv2
</Heading>
<Text> <p>QQ FM QMCv2</p>
QQ FM QMCv2使QQ Mac iOS 使 <p>QQ Mac iOS FM</p>
FM线
</Text>
<HStack pb={2} pt={2}> <div className="flex flex-row gap-2 items-center my-2">
<ButtonGroup isAttached colorScheme="purple" variant="outline"> <label className="label">
<Button onClick={addKey} leftIcon={<Icon as={MdAdd} />}> <input
className="checkbox"
</Button> type="checkbox"
<Menu> checked={allowFuzzyNameSearch}
<MenuButton as={IconButton} icon={<MdExpandMore />}></MenuButton> onChange={handleAllowFuzzyNameSearchCheckbox}
<MenuList> />
<MenuItem onClick={() => setShowImportModal(true)} icon={<Icon as={MdFileUpload} boxSize={5} />}>
</label>
</MenuItem> <InfoModal
<MenuDivider /> title="莱文斯坦距离"
<MenuItem color="red" onClick={clearAll} icon={<Icon as={MdDeleteForever} boxSize={5} />}> description={
<div>
</MenuItem> <p>使</p>
</MenuList> <p>
</Menu> 使
</ButtonGroup> <ExtLink href="https://zh.wikipedia.org/zh-cn/%E8%90%8A%E6%96%87%E6%96%AF%E5%9D%A6%E8%B7%9D%E9%9B%A2">
<Ruby caption="Levenshtein distance"></Ruby>
<HStack> </ExtLink>
<Checkbox isChecked={allowFuzzyNameSearch} onChange={handleAllowFuzzyNameSearchCheckbox}>
<Text></Text> </p>
</Checkbox> <p></p>
<Tooltip <p></p>
hasArrow </div>
closeOnClick={false}
label={
<Box>
<Text>使</Text>
<Text>
使
<ruby>
<rp> (</rp>
<rt>Levenshtein distance</rt>
<rp>)</rp>
</ruby>
</Text>
<Text></Text>
<Text></Text>
</Box>
} }
> >
<InfoOutlineIcon /> ?
</Tooltip> </InfoModal>
</HStack> </div>
</HStack>
<Box flex={1} minH={0} overflow="auto" pr="4"> <h3 className="mt-2 text-xl font-bold"></h3>
<List spacing={3}> <AddKey
addKey={addKey}
refContainer={refKeyContainer}
importKeyFromFile={() => setShowImportModal(true)}
clearKeys={clearAll}
/>
<KeyListContainer ref={refKeyContainer} keys={qmc2Keys}>
{qmc2Keys.map(({ id, ekey, name }, i) => ( {qmc2Keys.map(({ id, ekey, name }, i) => (
<QMCv2EKeyItem key={id} id={id} ekey={ekey} name={name} i={i} /> <QMCv2EKeyItem key={id} id={id} ekey={ekey} name={name} i={i} />
))} ))}
</List> </KeyListContainer>
{qmc2Keys.length === 0 && <Text></Text>}
</Box>
<ImportSecretModal <ImportSecretModal
clientName={ clientName={
<Select <select
value={secretType} value={secretType}
onChange={(e) => setSecretType(e.target.value as 'qm' | 'douban')} onChange={(e) => setSecretType(e.target.value as 'qm' | 'douban')}
variant="flushed" className="inline mx-1 px-1 border-b border-accent/50 bg-base-100"
display="inline"
css={{ paddingLeft: '0.75rem', width: 'auto' }}
> >
<option value="qm">QQ </option> <option value="qm">QQ </option>
<option value="douban"> FM</option> <option value="douban"> FM</option>
</Select> </select>
} }
show={showImportModal} show={showImportModal}
onClose={() => setShowImportModal(false)} onClose={() => setShowImportModal(false)}
@@ -181,6 +129,6 @@ export function PanelQMCv2Key() {
{secretType === 'qm' && <QMCv2QQMusicAllInstructions />} {secretType === 'qm' && <QMCv2QQMusicAllInstructions />}
{secretType === 'douban' && <QMCv2DoubanAllInstructions />} {secretType === 'douban' && <QMCv2DoubanAllInstructions />}
</ImportSecretModal> </ImportSecretModal>
</Flex> </>
); );
} }

View File

@@ -1,26 +1,14 @@
import {
Box,
Code,
Flex,
FormControl,
FormHelperText,
FormLabel,
Heading,
Input,
ListItem,
Text,
UnorderedList,
} from '@chakra-ui/react';
import { useAppDispatch, useAppSelector } from '~/hooks'; import { useAppDispatch, useAppSelector } from '~/hooks';
import { ExtLink } from '~/components/ExtLink'; import { ExtLink } from '~/components/ExtLink';
import { ChangeEvent, ClipboardEvent } from 'react'; import { ChangeEvent, ClipboardEvent, useId } from 'react';
import { VQuote } from '~/components/HelpText/VQuote'; import { VQuote } from '~/components/HelpText/VQuote';
import { selectStagingQtfmAndroidKey } from '../settingsSelector'; import { selectStagingQtfmAndroidKey } from '../settingsSelector';
import { qtfmAndroidUpdateKey } from '../settingsSlice'; import { qtfmAndroidUpdateKey } from '../settingsSlice';
import { workerClientBus } from '~/decrypt-worker/client.ts'; import { workerClientBus } from '~/decrypt-worker/client.ts';
import { GetQingTingFMDeviceKeyPayload } from '~/decrypt-worker/types.ts'; import { GetQingTingFMDeviceKeyPayload } from '~/decrypt-worker/types.ts';
import { DECRYPTION_WORKER_ACTION_NAME } from '~/decrypt-worker/constants.ts'; import { DECRYPTION_WORKER_ACTION_NAME } from '~/decrypt-worker/constants.ts';
import { Ruby } from '~/components/Ruby';
import { HiWord } from '~/components/HelpText/HiWord';
const QTFM_DEVICE_ID_URL = 'https://github.com/parakeet-rs/qtfm-device-id/releases/latest'; const QTFM_DEVICE_ID_URL = 'https://github.com/parakeet-rs/qtfm-device-id/releases/latest';
@@ -64,64 +52,76 @@ export function PanelQingTing() {
setSecretKey(e.target.value); setSecretKey(e.target.value);
}; };
const idSecretKey = useId();
return ( return (
<Flex minH={0} flexDir="column" flex={1}> <div className="min-h-0 flex-col grow px-1">
<Heading as="h2" size="lg"> <h2 className="text-2xl font-bold mb-4"> FM</h2>
<VQuote> FM</VQuote>
</Heading>
<Text> <p>
<VQuote> FM</VQuote> <VQuote> FM</VQuote>
</Text> </p>
<Box mt={3} mb={3}>
<FormControl> <div className="my-4">
<FormLabel></FormLabel> <fieldset className="fieldset">
<Input type="text" onPaste={handleDataPaste} value={secretKey} onChange={handleDataInput} /> <legend className="fieldset-legend text-lg">
<FormHelperText> <label htmlFor={idSecretKey}></label>
{'粘贴含有设备密钥的信息的内容时将自动提取密钥(如通过 '} </legend>
<input
id={idSecretKey}
type="text"
className="input font-mono"
onPaste={handleDataPaste}
value={secretKey}
onChange={handleDataInput}
/>
<p className="label flex-wrap">
<ExtLink href={QTFM_DEVICE_ID_URL}> <ExtLink href={QTFM_DEVICE_ID_URL}>
<Code>qtfm-device-id</Code> <code>qtfm-device-id</code>
</ExtLink> </ExtLink>
{' 获取的设备信息)。'} root
</FormHelperText> </p>
</FormControl> </fieldset>
</Box> </div>
<Heading as="h3" size="md" pt={3} pb={2}> <h3 className="text-xl font-bold my-2"></h3>
<ul className="list-disc pl-6">
</Heading> <li>
<UnorderedList> <p>
<ListItem>
<Text>
<Code>[]/Android/data/fm.qingting.qtradio/files/Music/</Code> <VQuote>
</Text> <code className="break-words">
<HiWord>[]</HiWord>/<wbr />
<UnorderedList> Android/
<ListItem> <wbr />
<Text> data/
<wbr />
fm.qingting.qtradio/
<wbr />
files/Music/
</code>
</VQuote>
</p>
<ul className="list-disc pl-6">
<li>
<p>
使 使
<ruby> <Ruby caption="root"></Ruby>
<rp> (</rp>
<rt>root</rt>
<rp>)</rp>
</ruby>
访 访
</Text> </p>
</ListItem> </li>
</UnorderedList> </ul>
</ListItem> </li>
<ListItem> <li>
<Text> <p>
<Code>.p~!</Code> <code>.p~!</code>
</Text> </p>
</ListItem> </li>
<ListItem> <li>
<Text></Text> <p></p>
</ListItem> </li>
</UnorderedList> </ul>
</Flex> </div>
); );
} }

View File

@@ -1,51 +1,36 @@
import {
Accordion,
AccordionButton,
AccordionIcon,
AccordionItem,
AccordionPanel,
Box,
Heading,
Text,
} from '@chakra-ui/react';
import { InstructionsIOSCondition } from './InstructionsIOSCondition'; import { InstructionsIOSCondition } from './InstructionsIOSCondition';
import { useId } from 'react';
export function InstructionsIOS() { export function InstructionsIOS() {
const iosInstructionId = useId();
return ( return (
<> <>
<Box> <div>
<Text>iOS 使 PC Mac iOS </Text> <p>iOS 使 PC Mac iOS </p>
<Text> PC Mac </Text> <p> PC Mac </p>
</Box> </div>
<Accordion allowToggle mt="2">
<AccordionItem>
<Heading as="h3" size="md">
<AccordionButton>
<Box as="span" flex="1" textAlign="left">
iOS
</Box>
<AccordionIcon />
</AccordionButton>
</Heading>
<AccordionPanel pb={4}>
<InstructionsIOSCondition jailbreak={true} />
</AccordionPanel>
</AccordionItem>
<AccordionItem> <div className="join join-vertical bg-base-100 mt-2 max-w-full">
<Heading as="h3" size="md"> <div className="collapse collapse-arrow join-item border-base-300 border">
<AccordionButton> <input type="radio" name={iosInstructionId} />
<Box as="span" flex="1" textAlign="left"> <div className="collapse-title font-semibold">
iOS iOS <strong></strong>{' '}
</Box> </div>
<AccordionIcon /> <div className="collapse-content text-sm min-w-0">
</AccordionButton> <InstructionsIOSCondition jailbreak={true} />
</Heading> </div>
<AccordionPanel pb={4}> </div>
<div className="collapse collapse-arrow join-item border-base-300 border">
<input type="radio" name={iosInstructionId} />
<div className="collapse-title font-semibold">
iOS <strong></strong>
</div>
<div className="collapse-content text-sm min-w-0">
<InstructionsIOSCondition jailbreak={false} /> <InstructionsIOSCondition jailbreak={false} />
</AccordionPanel> </div>
</AccordionItem> </div>
</Accordion> </div>
</> </>
); );
} }

View File

@@ -1,6 +1,6 @@
import { Box, Code, Heading, Image, ListItem, OrderedList, Text } from '@chakra-ui/react';
import iosAllowBackup from './iosAllowBackup.webp'; import iosAllowBackup from './iosAllowBackup.webp';
import { FilePathBlock } from '~/components/FilePathBlock'; import { FilePathBlock } from '~/components/FilePathBlock';
import { HiWord } from '~/components/HelpText/HiWord';
const EXAMPLE_MEDIA_ID = '0011wjLv1bIkvv'; const EXAMPLE_MEDIA_ID = '0011wjLv1bIkvv';
const EXAMPLE_NAME_IOS = '333407709-0011wjLv1bIkvv-1.mgalaxy'; const EXAMPLE_NAME_IOS = '333407709-0011wjLv1bIkvv-1.mgalaxy';
@@ -10,92 +10,77 @@ export function InstructionsIOSCondition({ jailbreak }: { jailbreak: boolean })
const useJailbreak = jailbreak; const useJailbreak = jailbreak;
const useBackup = !jailbreak; const useBackup = !jailbreak;
const pathPrefix = jailbreak ? '/var/mobile/Containers/Data/Application/<随机>/' : '/AppDomain-'; const pathPrefix = jailbreak ? (
<>
/var/mobile/Containers/Data/Application/<HiWord className="text-nowrap">[]</HiWord>/
</>
) : (
'/AppDomain-'
);
return ( return (
<> <>
<Heading as="h3" size="md"> <h4 className="text-lg font-semibold"></h4>
<ol className="list-decimal pl-4">
</Heading>
<OrderedList>
{useBackup && ( {useBackup && (
<ListItem> <li>
<Text> iOS </Text> iOS
<Image src={iosAllowBackup}></Image> <br />
</ListItem> <img src={iosAllowBackup}></img>
</li>
)} )}
{useBackup && ( {useBackup && <li>使 iOS </li>}
<ListItem> <li>
<Text>使 iOS </Text> {useBackup && <span></span>}
</ListItem> {useJailbreak && <span>访</span>}
)}
<ListItem>
{useBackup && <Text></Text>}
{useJailbreak && <Text>访</Text>}
<FilePathBlock>{pathPrefix}com.tencent.QQMusic/Documents/mmkv/</FilePathBlock> <FilePathBlock>{pathPrefix}com.tencent.QQMusic/Documents/mmkv/</FilePathBlock>
</ListItem> </li>
<ListItem> <li>
<Text> <code>filenameEkeyMap</code>
<Code>filenameEkeyMap</Code> </li>
</Text> <li>
</ListItem> <code>filenameEkeyMap</code>
<ListItem> </li>
<Text> <li></li>
<Code>filenameEkeyMap</Code> </ol>
</Text>
</ListItem>
<ListItem>
<Text></Text>
</ListItem>
</OrderedList>
<Heading as="h3" size="md" mt="3"> <h3 className="text-lg font-semibold mt-3">线</h3>
线 <section>
</Heading> <p>访</p>
<Box>
<Text>访</Text>
<FilePathBlock> <FilePathBlock>
{pathPrefix}com.tencent.QQMusic/Library/Application Support/com.tencent.QQMusic/iData/iMusic {pathPrefix}com.tencent.QQMusic/Library/Application Support/com.tencent.QQMusic/iData/iMusic
</FilePathBlock> </FilePathBlock>
<Text> <p>
<Code>[].m[]</Code> <code>[].m[]</code>
</Text> </p>
<Text> <p>
<Code>[song_id]-[mid]-[].m[]</Code> <code>[song_id]-[mid]-[].m[]</code>
</Text> </p>
<Text> <p>
&#x3000;<Code>{EXAMPLE_NAME_IOS}</Code> &#x3000;<code>{EXAMPLE_NAME_IOS}</code>
</Text> </p>
</Box> </section>
<Heading as="h3" size="md" mt="3"> <h4 className="text-lg font-semibold mt-3">线</h4>
线 <p>使</p>
</Heading> <p> </p>
<Text>使</Text> <ol className="list-decimal pl-4 mt-1">
<Text> </Text> <li>
<OrderedList> <code>[mid]</code> <code>{EXAMPLE_MEDIA_ID}</code>
<ListItem> </li>
<Text> <li>
<Code>[mid]</Code> <Code>{EXAMPLE_MEDIA_ID}</Code> <code>{EXAMPLE_NAME_DB}</code>
</Text> </li>
</ListItem> <li>
<ListItem>
<Text> <br />
<Code>{EXAMPLE_NAME_DB}</Code> <code>{EXAMPLE_NAME_IOS}</code>
</Text> <br /> <code>{EXAMPLE_NAME_DB}</code>
</ListItem> </li>
<ListItem> <li>
<Text> <code>{EXAMPLE_NAME_DB}</code>
<Code display="inline">{EXAMPLE_NAME_IOS}</Code> </li>
<Code display="inline">{EXAMPLE_NAME_DB}</Code> </ol>
</Text>
</ListItem>
<ListItem>
<Text>
<Code>{EXAMPLE_NAME_DB}</Code>
</Text>
</ListItem>
</OrderedList>
</> </>
); );
} }

View File

@@ -1,59 +1,94 @@
import { Heading, Text, Code, Kbd, OrderedList, ListItem, Link } from '@chakra-ui/react'; import { RiFileCopyLine } from 'react-icons/ri';
import { toast } from 'react-toastify';
import { ExtLink } from '~/components/ExtLink';
import { FilePathBlock } from '~/components/FilePathBlock'; import { FilePathBlock } from '~/components/FilePathBlock';
import { VQuote } from '~/components/HelpText/VQuote';
import { MacCommandKey } from '~/components/Key/MacCommandKey'; import { MacCommandKey } from '~/components/Key/MacCommandKey';
import { ShiftKey } from '~/components/Key/ShiftKey'; import { ShiftKey } from '~/components/Key/ShiftKey';
import BlockUpdateScript from './assets/QQ 音乐 Mac 屏蔽升级.tar.gz?base64';
const MAC_CLIENT_URL = const MAC_CLIENT_URL =
'https://web.archive.org/web/20230903/https://dldir1.qq.com/music/clntupate/mac/QQMusicMac_Mgr.dmg'; '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 = () => {
navigator.clipboard
.writeText(DB_PATH)
.then(() => {
toast.success('已复制到剪贴板');
})
.catch((err) => {
toast.error(`复制失败,请手动复制\n${err}`);
});
};
return ( return (
<> <>
<Text>Mac 使 mmkv </Text> <p>Mac 使 mmkv </p>
<Text> <p> v8.8.0 </p>
{'此外,你需要降级到 '}
<Link isExternal href={MAC_CLIENT_URL}>
2023.09.03
</Link>
{'。'}
mmkv
</Text>
<Text></Text>
<FilePathBlock>
~/Library/Containers/com.tencent.QQMusicMac/Data/Library/Application Support/QQMusicMac/mmkv/MMKVStreamEncryptId
</FilePathBlock>
<Heading as="h3" size="md" mt="4"> <p className="mt-4"> QQ Mac 8.8.0:</p>
<ul className="list-disc pl-6">
</Heading> <li>
<OrderedList> <ExtLink className="link-info" href={MAC_CLIENT_URL}>
<ListItem> <code>Archive.org</code>
<Text> </ExtLink>
<Code>MMKVStreamEncryptId</Code> </li>
</Text> <li>
</ListItem> <ExtLink className="link-info" href={MAC_CLIENT_TG_URL}>
<ListItem> Telegram
<Text></Text> </ExtLink>
</ListItem> </li>
<ListItem> </ul>
<Text>
<p className="mt-4">
<ShiftKey />
{' + '} <ExtLink
<MacCommandKey /> className="link-info mx-1"
{' + '} download="QQ 音乐 Mac 屏蔽升级.tar.gz"
<Kbd>{'G'}</Kbd> href={`data:application/gzip;base64,${BlockUpdateScript}`}
</Text> >
</ListItem> QQ Mac .tar.gz
<ListItem> </ExtLink>
<Text> <code>QQ Mac .command</code> QQ
<Code>MMKVStreamEncryptId</Code>
</Text> </p>
</ListItem>
<ListItem> <p className="mt-4"></p>
<Text></Text> <FilePathBlock>{DB_PATH}</FilePathBlock>
</ListItem>
</OrderedList> <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>
</> </>
); );
} }

Some files were not shown because too many files have changed in this diff Show More