63 Commits

Author SHA1 Message Date
鲁树人
c1e17992e9 0.2.8 2024-09-12 22:59:19 +01:00
鲁树人
f478ca8818 fix: upgrade libprarkeet to v0.4.5 2024-09-12 22:56:13 +01:00
鲁树人
8e4367fbf9 build: minify final mjs 2024-01-18 00:59:11 +00:00
鲁树人
1ae2f93e99 chore: make win64 build to its own dir 2024-01-18 00:38:01 +00:00
鲁树人
741e302ea7 ci: publish site deployment to netlify as well 2024-01-18 00:29:37 +00:00
鲁树人
f09aa84984 0.2.7 2023-12-28 23:31:28 +00:00
鲁树人
1d2296a02a docs: add wry project to docs and app 2023-12-28 23:31:23 +00:00
鲁树人
f6703160e7 docs: update note on WebView2 2023-12-28 23:14:48 +00:00
鲁树人
bea2f4b7d4 build: make zip archive of final zip 2023-12-28 23:09:56 +00:00
鲁树人
0a3dac9d3d docs: add note about win64 build 2023-12-28 23:00:41 +00:00
鲁树人
602d6865f5 0.2.6 2023-12-28 20:39:50 +00:00
鲁树人
8d4194772e build: include commit hash in version.txt 2023-12-28 20:38:45 +00:00
鲁树人
1ef1db30ab build: include version.txt in dist 2023-12-28 20:36:29 +00:00
鲁树人
80fc595833 chore: add related projects 2023-12-28 20:30:10 +00:00
鲁树人
be9a1b6724 docs: make note about metadata editor 2023-12-28 14:18:11 +00:00
鲁树人
6cccb722ce docs: update warning about android emu 2023-12-25 20:02:55 +01:00
鲁树人
2f9cfaa763 0.2.5 2023-12-25 18:15:56 +01:00
鲁树人
5d7f5b76ef fix: qmcv2 db name matching when musicex was not found 2023-12-25 18:15:51 +01:00
鲁树人
2d50a45ef2 0.2.4 2023-12-24 12:16:08 +01:00
鲁树人
fcc4b14211 feat: support for qmcv2 musicex tail 2023-12-24 12:15:56 +01:00
鲁树人
6c21150fc8 0.2.3 2023-12-23 19:39:46 +00:00
鲁树人
e98470cb70 Merge branch 'docs/android-emu-root' 2023-12-23 19:38:45 +00:00
鲁树人
bb8f69f137 Merge pull request '安卓 root 相关说明' (#63) from docs/android-emu-root into main
Reviewed-on: https://git.unlock-music.dev/um/um-react/pulls/63
2023-12-23 16:04:50 +00:00
鲁树人
f194dfd135 build: cache webp images 2023-12-23 15:59:28 +00:00
鲁树人
7e741412a8 docs: typo 2023-12-23 15:54:22 +00:00
鲁树人
fe39ac6604 docs: re-order faq sections 2023-12-23 15:48:51 +00:00
鲁树人
d781767dd0 docs: update in-app-faq about broken android browsers 2023-12-23 15:47:35 +00:00
鲁树人
a7158a75e9 docs: update list of issues with broken android browsers 2023-12-23 15:45:02 +00:00
鲁树人
2bd35f899d docs: format supported format, add qtfm, added warn about android browser 2023-12-23 15:44:45 +00:00
鲁树人
bb37da5066 chore: bump node to v20.10.0 2023-12-23 15:41:13 +00:00
鲁树人
27a91a67bb docs: document broken browsers 2023-12-23 11:20:31 +00:00
鲁树人
067ad6e40b docs: update offline faq with android emu root notes #62 2023-12-23 11:15:32 +00:00
鲁树人
c95bcd7eda docs: update offline md faq with updated notes 2023-12-23 11:13:23 +00:00
鲁树人
caed717755 add notes about android emu for root #62 2023-12-23 11:11:38 +00:00
鲁树人
16bb7cb0fb docs: update faq 2023-12-22 11:02:56 +00:00
鲁树人
24fc661953 0.2.2 2023-12-22 10:48:18 +00:00
鲁树人
6b3ce9b031 ci: disable publish to gitea as it is still broken 2023-12-22 10:39:08 +00:00
鲁树人
b2528969a0 Merge pull request '#58 蜻蜓FM 安卓端支援' (#59) from feat/qingting-fm into main
Reviewed-on: https://git.unlock-music.dev/um/um-react/pulls/59
2023-12-22 10:35:17 +00:00
鲁树人
c89801a950 ci: enable publish to gitea 2023-12-22 10:34:47 +00:00
鲁树人
314e87f448 Merge remote-tracking branch 'origin/main' into feat/qingting-fm 2023-12-22 10:34:12 +00:00
鲁树人
935d6a776a feat: qtfm instructions & notes 2023-12-22 10:33:12 +00:00
鲁树人
6f733864b7 fix: mono font styling 2023-12-22 10:32:26 +00:00
鲁树人
e8d1dba0e2 chore: typo 2023-12-21 22:57:59 +00:00
鲁树人
9f1c8877db build: remove workaround for test 2023-12-21 22:51:44 +00:00
鲁树人
889a6cbcd0 build: fix build issue with vite/vite-pwa/parakeet; upgrade deps 2023-12-21 22:49:12 +00:00
鲁树人
83b06dbe60 feat: add basic instruction to paste device key from qtfm-device-id 2023-12-21 19:34:37 +00:00
鲁树人
e2b7a753dc ci: don't publish to gitea as it is broken 2023-11-29 23:53:46 +00:00
鲁树人
4498ab6592 Merge remote-tracking branch 'origin/main' into feat/qingting-fm 2023-11-29 23:49:59 +00:00
鲁树人
da853ba6e0 Merge pull request '酷我 iOS 数据库支持' (#60) from feat/kwm-ios-support into main
Reviewed-on: https://git.unlock-music.dev/um/um-react/pulls/60
2023-11-29 23:49:43 +00:00
鲁树人
18d02a906b feat: initial implementation of qtfm android 2023-11-29 23:45:56 +00:00
鲁树人
06f257d3bb fix: fix bad refactor 2023-11-08 20:49:13 +00:00
鲁树人
ecc34aaf44 feat: add support for kuwo ios ekey db 2023-11-08 20:40:41 +00:00
鲁树人
85ab69d41d docs: readme for supported version 2023-11-03 00:07:49 +00:00
鲁树人
fbf7749e55 Merge commit 'e678e40b86a398a095cec88283c86aa3e8e2f096' 2023-11-02 23:58:33 +00:00
鲁树人
a0bab29966 docs: update faq to include valid version of qmpc #52 2023-11-02 23:57:54 +00:00
鲁树人
e678e40b86 feat: experimental support for douban key import 2023-10-19 02:58:50 +01:00
鲁树人
c39d2edce7 test: fix type for test 2023-10-19 02:39:30 +01:00
鲁树人
31420ac515 chore: text update for faq 2023-10-19 02:16:21 +01:00
鲁树人
82f15e204c chore: readme update 2023-10-19 02:16:12 +01:00
鲁树人
a1044b0c3a chore: prefer node v20.8.1 over v18 2023-10-19 02:05:23 +01:00
鲁树人
bf7fc908bc chore: update readme with link to old project 2023-10-11 23:12:00 +01:00
鲁树人
4905511d71 chore: fix pwa build 2023-10-11 23:08:27 +01:00
鲁树人
022c9d1eac chore: pwa update prompt 2023-10-11 23:06:09 +01:00
71 changed files with 6755 additions and 4977 deletions

View File

@@ -5,7 +5,7 @@ name: default
steps: steps:
- name: test & build - name: test & build
image: node:18.16.1-bookworm image: node:20.10.0-bookworm
commands: commands:
# - git config --global --add safe.directory "/drone/src" # - git config --global --add safe.directory "/drone/src"
- corepack enable - corepack enable
@@ -17,7 +17,7 @@ steps:
npm_config_registry: https://registry.npmmirror.com npm_config_registry: https://registry.npmmirror.com
- name: publish - name: publish
image: node:18.16.1-bookworm image: node:20.10.0-bookworm
environment: environment:
DRONE_GITEA_SERVER: https://git.unlock-music.dev DRONE_GITEA_SERVER: https://git.unlock-music.dev
GITEA_API_KEY: GITEA_API_KEY:
@@ -27,7 +27,9 @@ steps:
NETLIFY_API_KEY: NETLIFY_API_KEY:
from_secret: NETLIFY_API_KEY from_secret: NETLIFY_API_KEY
commands: commands:
# - git config --global --add safe.directory "/drone/src" - |
- python3 -m zipfile -c um-react.zip dist/. python3 -m zipfile -c um-react.zip dist/.
- ./scripts/publish.sh cp um-react.zip dist/release-"${DRONE_COMMIT_SHA}".zip
python3 -m zipfile -c um-react-site.zip dist/.
# - ./scripts/publish.sh
- ./scripts/deploy.sh - ./scripts/deploy.sh

5
.gitignore vendored
View File

@@ -27,3 +27,8 @@ dist-ssr
# Files created when running "drone exec" locally # Files created when running "drone exec" locally
/.pnpm-store/ /.pnpm-store/
/*.zip /*.zip
/um-react-wry-*
/um-react*.exe
/win64/

5
.npmrc
View File

@@ -1,3 +1,4 @@
use-node-version=18.16.0 use-node-version=20.10.0
node-version=18.16.0 node-version=20.10.0
engine-strict=true engine-strict=true
@um:registry=https://git.unlock-music.dev/api/packages/um/npm/

View File

@@ -3,6 +3,7 @@
[![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://ci.unlock-music.dev/api/badges/um/um-react/status.svg)](https://ci.unlock-music.dev/um/um-react)
- 在浏览器中解锁加密的音乐文件。 Unlock encrypted music file in the browser. - 在浏览器中解锁加密的音乐文件。 Unlock encrypted music file in the browser.
- 查看[原基于 Vue 的 Unlock Music 项目][um-vue]
- Unlock Music 项目是以学习和技术研究的初衷创建的,修改、再分发时请遵循[授权协议]。 - Unlock Music 项目是以学习和技术研究的初衷创建的,修改、再分发时请遵循[授权协议]。
- Unlock Music 的 CLI 版本可以在 [unlock-music/cli] 找到,大批量转换建议使用 CLI 版本。 - Unlock Music 的 CLI 版本可以在 [unlock-music/cli] 找到,大批量转换建议使用 CLI 版本。
- 我们新建了 Telegram 群组 [`@unlock_music_chat`] ,欢迎加入! - 我们新建了 Telegram 群组 [`@unlock_music_chat`] ,欢迎加入!
@@ -10,21 +11,34 @@
- [常见问题参考](./docs/faq_zh-hans.md) - [常见问题参考](./docs/faq_zh-hans.md)
[授权协议]: https://git.unlock-music.dev/um/um-react/src/branch/main/LICENSE [授权协议]: https://git.unlock-music.dev/um/um-react/src/branch/main/LICENSE
[um-vue]: https://git.unlock-music.dev/um/web
[unlock-music/cli]: https://git.unlock-music.dev/um/cli [unlock-music/cli]: https://git.unlock-music.dev/um/cli
[`@unlock_music_chat`]: https://t.me/unlock_music_chat [`@unlock_music_chat`]: https://t.me/unlock_music_chat
[um-react-packages]: https://git.unlock-music.dev/um/-/packages/generic/um-react/ [um-react-packages]: https://git.unlock-music.dev/um/-/packages/generic/um-react/
⚠️ 手机端浏览器支持有限,请使用最新版本的 Chrome 或 Firefox 官方浏览器。
## 支持的格式 ## 支持的格式
- [x] QQ 音乐 QMCv1 (.qmc0/.qmc2/.qmc3/.qmcflac/.qmcogg/.tkm) - [x] QQ 音乐 QMCv1 (`.qmc3` / `.qmcflac` 等)
- [x] QQ 音乐 QMCv2 PC 端 (.mflac/.mgg/.mflac0/.mgg1/.mggl) - [x] QQ 音乐 QMCv2
- [x] 网易云音乐 (.ncm) - PC 客户端 (`.mflac` / `.mgg` 等) [^qm-key-pc]
- [x] 虾米音乐 (.xm) - 安卓客户端 (`.mflac0` / `.mgg1` / `.mggl` 等) [^qm-key-android]
- [x] 酷我音乐 (.kwm) - iOS 客户端 (`.mgalaxy` 等) [^qm-key-ios]
- [x] 酷狗音乐 (.kgm/.vpr) - Mac 客户端 (`.mflach` 等) [^qm-key-mac]
- [x] 喜马拉雅 Android 端 (.x2m/.x3m) - [x] 网易云音乐 (`.ncm`)
- [x] 咪咕音乐格式 (.mg3d) - [x] 虾米音乐 (`.xm`)
- [ ] ~~<ruby>QQ 音乐海外版<rt>JOOX Music</rt></ruby> (.ofl_en)~~ - [x] 酷我音乐 (`.kwm`)
- [x] 酷狗音乐 (`.kgm` / `.vpr`)
- [x] 喜马拉雅 Android 端 (`.x2m` / `.x3m`)
- [x] 咪咕音乐格式 (`.mg3d`)
- [x] 蜻蜓 FM (`.qta`)
- [ ] ~~<ruby>QQ 音乐海外版<rt>JOOX Music</rt></ruby> (`.ofl_en`)~~
[^qm-key-pc]: PC 客户端仅支持 v19.43 或更低版本。
[^qm-key-android]: 需要获取超级管理员权限后提取密钥数据库,并导入后使用。
[^qm-key-ios]: 需要越狱获取密钥数据库,或对设备进行完整备份后提取密钥数据库,并导入后使用。
[^qm-key-mac]: 需要导入密钥数据库。
不支持的格式?请提交样本(加密文件)与客户端信息(或一并上传其安装包)到[仓库的问题追踪区][project-issues]。如果文件太大,请上传到不需要登入下载的网盘,如 [mega.nz](https://mega.nz)、[OneDrive](https://www.onedrive.com/) 等。 不支持的格式?请提交样本(加密文件)与客户端信息(或一并上传其安装包)到[仓库的问题追踪区][project-issues]。如果文件太大,请上传到不需要登入下载的网盘,如 [mega.nz](https://mega.nz)、[OneDrive](https://www.onedrive.com/) 等。
@@ -59,12 +73,25 @@
满足上述条件后发起 Pull Request仓库管理员审阅后将合并到主分支。 满足上述条件后发起 Pull Request仓库管理员审阅后将合并到主分支。
## 相关项目
- [Unlock Music (Web)](https://git.unlock-music.dev/um/web) - 原始项目
- [Unlock Music (Cli)](https://git.unlock-music.dev/um/cli) - 命令行批量处理版
- [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)
- [um-react-wry](https://git.unlock-music.dev/um/um-react-wry) - 使用 WRY 框架封装的 Win64 单文件 (需要[安装 Edge WebView2 运行时][webview2_redist]Win10+ 操作系统自带)
- [本地下载](https://git.unlock-music.dev/um/um-react/releases/latest) | 寻找文件名为 `um-react-win64-` 开头的附件
[webview2_redist]: https://go.microsoft.com/fwlink/p/?LinkId=2124703
有新的项目提交?欢迎[提交 issue][project-issues],请带上项目名称和链接。
## TODO ## TODO
- 待定 - 待定
- [ ] 各类算法 [追踪 `crypto` 标签](https://git.unlock-music.dev/um/um-react/issues?labels=67) - [ ] 各类算法 [追踪 `crypto` 标签](https://git.unlock-music.dev/um/um-react/issues?labels=67)
- [ ] #7 简易元数据编辑器
- 完成 - 完成
- [x] #7 ~~简易元数据编辑器~~ 放弃
- [x] #8 ~~添加单元测试~~ 框架加上了,以后慢慢添加更多测试即可。 - [x] #8 ~~添加单元测试~~ 框架加上了,以后慢慢添加更多测试即可。
- [x] #2 解密内容探测 (解密过程) - [x] #2 解密内容探测 (解密过程)
- [x] #6 文件拖放 (利用 `react-dropzone`?) - [x] #6 文件拖放 (利用 `react-dropzone`?)

View File

@@ -10,7 +10,7 @@
#### 2、检查您的平台。 #### 2、检查您的平台。
日前,<mark>仅 Windows 客户端</mark>下载的歌曲无需密钥,其余平台的官方正式版本均需要提取密钥。 日前,<mark>仅 Windows 客户端 v19.43 或以下版本</mark>下载的歌曲无需密钥,其余平台的官方正式版本均需要提取密钥。
> iOS 用户提取歌曲困难建议换用电脑操作Android 用户提取密钥需要 root也建议用电脑操作。 > iOS 用户提取歌曲困难建议换用电脑操作Android 用户提取密钥需要 root也建议用电脑操作。
@@ -34,6 +34,8 @@
日前,<mark>仅手机客户端</mark>下载的歌曲**至臻全景声**及**至臻母带**为新版加密手机平台的其他音质暂时不需要提取密钥PC 平台暂未推出使用新版加密的音质。 日前,<mark>仅手机客户端</mark>下载的歌曲**至臻全景声**及**至臻母带**为新版加密手机平台的其他音质暂时不需要提取密钥PC 平台暂未推出使用新版加密的音质。
※ 已知部分第三方修改版会破坏密钥写出功能,导致无法导入密钥。请使用官方版本。
> Android 用户提取密钥需要 root或者注入文件提供器。 > Android 用户提取密钥需要 root或者注入文件提供器。
提取密钥教程请访问[新版解锁网站](https://um-react.netlify.app/),前往网站内的设置 →<mark>切换密钥为 KWMv2 密钥</mark>→“添加一条密钥”旁的<mark>**下拉按钮**</mark>→ 从文件导入密钥…→ 选择您对应的平台查看具体教程。 提取密钥教程请访问[新版解锁网站](https://um-react.netlify.app/),前往网站内的设置 →<mark>切换密钥为 KWMv2 密钥</mark>→“添加一条密钥”旁的<mark>**下拉按钮**</mark>→ 从文件导入密钥…→ 选择您对应的平台查看具体教程。
@@ -54,6 +56,37 @@
目前新版没有做歌曲信息匹配与编辑,所以歌曲如果自己没有写入歌曲信息,解出来就是没有的。 目前新版没有做歌曲信息匹配与编辑,所以歌曲如果自己没有写入歌曲信息,解出来就是没有的。
### 安卓 root 相关
对安卓设备获取 root 特权通常会破坏系统的完整性并导致部分功能无法使用。
例如部分厂商的安卓设备会在解锁后丧失保修资格,或导致无法使用 NFC 移动支付功能等限制。
如果希望不破坏系统完整性,你可以考虑使用模拟器。
**注意**:根据应用厂商的风控策略,使用模拟器登录的账号**有可能会被封锁**;使用前请自行评估风险。
目前常见的带有 root 特权支持的的安卓模拟器方案,分别是雷电模拟器(※ 官方版有内置广告)和微软在 Windows 11 开始支援的适用于 Android™ 的 Windows 子系统 (WSA)。
- WSA 可以参考 [MagiskOnWSALocal](https://github.com/LSPosed/MagiskOnWSALocal) 的说明操作。
- 雷电模拟器可以在「模拟器设置」 → 「其他设置」中启用 root 特权。
![雷电模拟器 其他设置](../src/faq/assets/ld_settings_misc.webp)
### Via 等浏览器无法正常解密/下载
⚠️ 手机端浏览器支持有限,请使用最新版本的 Chrome 或 Firefox 官方浏览器。
已知有问题的浏览器:
- Via 浏览器
- 夸克浏览器
- UC 浏览器
可能会遇到的问题包括:
- 网页白屏
- 无法下载解密后内容
- 下载的文件名错误
### 新版解锁网站没有批量下载 ### 新版解锁网站没有批量下载
目前没有做。抱歉。 目前没有做。抱歉。

View File

@@ -33,3 +33,31 @@ pnpm build
如果需要预览构建版本,运行 `pnpm preview` 然后打开[项目预览页面][vite-preview-url]即可。 如果需要预览构建版本,运行 `pnpm preview` 然后打开[项目预览页面][vite-preview-url]即可。
[vite-preview-url]: http://localhost:4173/ [vite-preview-url]: http://localhost:4173/
## 打包 `.zip`
建议在 Linux 环境下执行,可参考 `.drone.yml` CI 文件。
1. 确保上述的构建步骤已完成。
2. 确保 `python3` 已安装。
3. 执行下述代码
```sh
python3 -m zipfile -c um-react.zip dist/.
```
## 打包 win64 单文件
利用 Windows 系统自带的 [Edge WebView2 组件](https://learn.microsoft.com/zh-cn/microsoft-edge/webview2/)
和 [wry](https://github.com/tauri-apps/wry) 进行一个单文件的打包。
大部分 Windows 10 或以上版本的操作系统已经集成了 WebView2 运行时。若无法正常启动,请[下载并安装 Edge WebView2 运行时](https://go.microsoft.com/fwlink/p/?LinkId=2124703)。
其它系统兼容性未知。
1. 确保你现在在 `linux-amd64` 环境下。
2. 确保上述的 `um-react.zip` 构建已完成。
3. 执行下述代码
```sh
./scripts/make-win64.sh
```
4. 等待提示 `[Build OK]` 即可。

View File

@@ -1,11 +1,12 @@
{ {
"name": "um-react", "name": "um-react",
"private": true, "private": true,
"version": "0.2.1", "version": "0.2.8",
"type": "module", "type": "module",
"scripts": { "scripts": {
"start": "vite", "start": "vite",
"build": "tsc -p tsconfig.prod.json && vite build", "build": "tsc -p tsconfig.prod.json && vite build && pnpm build:finalize",
"build:finalize": "node scripts/write-version.mjs && node scripts/minify-mjs.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",
@@ -16,56 +17,57 @@
"prepare": "husky install" "prepare": "husky install"
}, },
"dependencies": { "dependencies": {
"@chakra-ui/anatomy": "^2.2.1", "@chakra-ui/anatomy": "^2.2.2",
"@chakra-ui/icons": "^2.1.1", "@chakra-ui/icons": "^2.1.1",
"@chakra-ui/react": "^2.8.1", "@chakra-ui/react": "^2.8.2",
"@emotion/react": "^11.11.1", "@emotion/react": "^11.11.1",
"@emotion/styled": "^11.11.0", "@emotion/styled": "^11.11.0",
"@jixun/libparakeet": "0.3.0", "@reduxjs/toolkit": "^2.0.1",
"@reduxjs/toolkit": "^1.9.7", "@um/libparakeet": "0.4.5",
"framer-motion": "^10.16.4", "framer-motion": "^10.16.16",
"immer": "^10.0.3", "nanoid": "^5.0.4",
"nanoid": "^5.0.1",
"radash": "^11.0.0", "radash": "^11.0.0",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-dropzone": "^14.2.3", "react-dropzone": "^14.2.3",
"react-icons": "^4.11.0", "react-icons": "^4.12.0",
"react-promise-suspense": "^0.3.4", "react-promise-suspense": "^0.3.4",
"react-redux": "^8.1.3", "react-redux": "^9.0.4",
"react-syntax-highlighter": "^15.5.0", "react-syntax-highlighter": "^15.5.0",
"sass": "^1.69.2", "sass": "^1.69.5",
"sql.js": "^1.8.0" "sql.js": "^1.9.0"
}, },
"devDependencies": { "devDependencies": {
"@rollup/plugin-replace": "^5.0.3", "@rollup/plugin-replace": "^5.0.5",
"@testing-library/jest-dom": "^6.1.3", "@testing-library/jest-dom": "^6.1.5",
"@testing-library/react": "^14.0.0", "@testing-library/react": "^14.1.2",
"@testing-library/user-event": "^14.5.1", "@testing-library/user-event": "^14.5.1",
"@types/node": "^20.8.4", "@types/node": "^20.10.5",
"@types/react": "^18.2.28", "@types/react": "^18.2.45",
"@types/react-dom": "^18.2.13", "@types/react-dom": "^18.2.18",
"@types/react-syntax-highlighter": "^15.5.8", "@types/react-syntax-highlighter": "^15.5.11",
"@types/sql.js": "^1.4.5", "@types/sql.js": "^1.4.9",
"@typescript-eslint/eslint-plugin": "^6.7.5", "@typescript-eslint/eslint-plugin": "^6.15.0",
"@typescript-eslint/parser": "^6.7.5", "@typescript-eslint/parser": "^6.15.0",
"@vitejs/plugin-react": "^4.1.0", "@vitejs/plugin-react": "^4.2.1",
"@vitest/coverage-v8": "^0.34.6", "@vitest/coverage-v8": "^1.1.0",
"@vitest/ui": "^0.34.6", "@vitest/ui": "^1.1.0",
"eslint": "^8.51.0", "eslint": "^8.56.0",
"eslint-config-prettier": "^9.0.0", "eslint-config-prettier": "^9.1.0",
"eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.3", "eslint-plugin-react-refresh": "^0.4.5",
"husky": "^8.0.3", "husky": "^8.0.3",
"jsdom": "^22.1.0", "jsdom": "^23.0.1",
"lint-staged": "^14.0.1", "lint-staged": "^15.2.0",
"prettier": "^3.0.3", "prettier": "^3.1.1",
"typescript": "^5.2.2", "terser": "^5.27.0",
"vite": "^4.4.11", "typescript": "^5.3.3",
"vite-plugin-pwa": "^0.16.5", "vite": "^5.0.10",
"vite-plugin-top-level-await": "^1.3.1", "vite-plugin-pwa": "^0.17.4",
"vite-plugin-wasm": "^3.2.2", "vite-plugin-top-level-await": "^1.4.1",
"vitest": "^0.34.6" "vite-plugin-wasm": "^3.3.0",
"vitest": "^1.1.0",
"workbox-window": "^7.0.0"
}, },
"lint-staged": { "lint-staged": {
"*": "prettier --write --ignore-unknown", "*": "prettier --write --ignore-unknown",
@@ -79,7 +81,7 @@
"pnpm": { "pnpm": {
"patchedDependencies": { "patchedDependencies": {
"@rollup/plugin-terser@0.4.3": "patches/@rollup__plugin-terser@0.4.3.patch", "@rollup/plugin-terser@0.4.3": "patches/@rollup__plugin-terser@0.4.3.patch",
"sql.js@1.8.0": "patches/sql.js@1.8.0.patch" "sql.js@1.9.0": "patches/sql.js@1.9.0.patch"
}, },
"overrides": { "overrides": {
"rollup-plugin-terser": "npm:@rollup/plugin-terser@0.4.3", "rollup-plugin-terser": "npm:@rollup/plugin-terser@0.4.3",

View File

@@ -1,12 +1,11 @@
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 e0da60ba096433d9af1c7025d2ffb9c521f190ed..89a5da6af23e1a644106d38dafe7cfa85500a8c4 100644 index d29af3624109025e59966cf25cb357111bb459de..1b028e3d91ec37108f775627f31f1134aec47476 100644
--- a/dist/sql-wasm.js --- a/dist/sql-wasm.js
+++ b/dist/sql-wasm.js +++ b/dist/sql-wasm.js
@@ -192,3 +192,7 @@ else if (typeof define === 'function' && define['amd']) { @@ -190,3 +190,6 @@ else if (typeof define === 'function' && define['amd']) {
else if (typeof exports === 'object'){ else if (typeof exports === 'object'){
exports["Module"] = initSqlJs; exports["Module"] = initSqlJs;
} }
+ +
+var module; +var module;
+export default initSqlJs; +export default initSqlJs;
+

10354
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -104,7 +104,7 @@ deploy_netlify() {
# For deployment, we care a bit less # For deployment, we care a bit less
if [[ -n "${NETLIFY_API_KEY}" && -n "${NETLIFY_SITE_ID}" ]]; then if [[ -n "${NETLIFY_API_KEY}" && -n "${NETLIFY_SITE_ID}" ]]; then
echo "Deploy to netlify..." echo "Deploy to netlify..."
deploy_netlify um-react.zip deploy_netlify um-react-site.zip
else else
echo "skip netlify deployment." echo "skip netlify deployment."
fi fi

33
scripts/make-win64.sh Executable file
View File

@@ -0,0 +1,33 @@
#!/bin/bash
# sudo apt install -y jq zip
pushd "$(dirname "${BASH_SOURCE[0]}")/../"
WRY_VER="0.1.1"
mkdir -p win64/{deps,dist}
dl_file() {
local FILE="$1"
if [[ ! -f "win64/deps/$FILE" ]]; then
curl -fsL "https://um-react.app/files/${FILE}.gz" | gzip -d >"win64/deps/${FILE}"
fi
}
dl_file "um-react-wry-builder-${WRY_VER}-linux-amd64"
dl_file "um-react-wry-stub-${WRY_VER}-win64.exe"
chmod a+x win64/deps/um-react-wry-builder-${WRY_VER}-linux-amd64
APP_VERSION="$(jq -r '.version' <package.json)"
EXE_NAME="um-react-win64-${APP_VERSION}.exe"
ZIP_NAME="um-react-win64-${APP_VERSION}.zip"
"./win64/deps/um-react-wry-builder-${WRY_VER}-linux-amd64" \
-t "win64/deps/um-react-wry-stub-${WRY_VER}-win64.exe" \
-r um-react.zip \
-o "win64/dist/${EXE_NAME}"
touch -d 1970-01-01T00:00:00Z "win64/dist/${EXE_NAME}"
zip -9oX "win64/dist/${ZIP_NAME}" -- "win64/dist/${EXE_NAME}"
echo "[Build OK] 'win64/dist/${ZIP_NAME}'."
popd

19
scripts/minify-mjs.mjs Normal file
View File

@@ -0,0 +1,19 @@
import { minify } from 'terser';
import { readFileSync, writeFileSync, readdirSync } from 'fs';
for (const file of readdirSync('dist/assets')) {
if (!/\.(mjs|js)$/.test(file)) {
continue;
}
console.log(`minifying ${file}...`);
const isModule = /\.mjs$/.test(file);
const output = await minify(readFileSync(`dist/assets/${file}`, 'utf-8'), {
compress: true,
mangle: true,
module: isModule,
});
writeFileSync(`dist/assets/${file}`, output.code);
}

14
scripts/write-version.mjs Normal file
View File

@@ -0,0 +1,14 @@
/* eslint-env node */
import { readFileSync, writeFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname } from 'node:path';
import { execSync } from 'node:child_process';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const commitHash = execSync('git rev-parse --short HEAD').toString('utf-8').trim();
const pkgJson = JSON.parse(readFileSync(__dirname + '/../package.json', 'utf-8'));
const pkgVer = `${pkgJson.version ?? 'unknown'}-${commitHash ?? 'unknown'}` + '\n';
writeFileSync(__dirname + '/../dist/version.txt', pkgVer, 'utf-8');

View File

@@ -7,7 +7,6 @@ import {
Box, Box,
Code, Code,
Heading, Heading,
Link,
ListItem, ListItem,
OrderedList, OrderedList,
Text, Text,
@@ -19,6 +18,7 @@ import hljsStyleGitHub from 'react-syntax-highlighter/dist/esm/styles/hljs/githu
import PowerShellAdbDumpCommandTemplate from './adb_dump.ps1?raw'; import PowerShellAdbDumpCommandTemplate from './adb_dump.ps1?raw';
import ShellAdbDumpCommandTemplate from './adb_dump.sh?raw'; import ShellAdbDumpCommandTemplate from './adb_dump.sh?raw';
import { ExtLink } from '../ExtLink';
const applyTemplate = (tpl: string, values: Record<string, unknown>) => { const applyTemplate = (tpl: string, values: Record<string, unknown>) => {
return tpl.replace(/\{\{\s*(\w+)\s*\}\}/g, (_, key) => (Object.hasOwn(values, key) ? String(values[key]) : '<nil>')); return tpl.replace(/\{\{\s*(\w+)\s*\}\}/g, (_, key) => (Object.hasOwn(values, key) ? String(values[key]) : '<nil>'));
@@ -36,10 +36,19 @@ export function AndroidADBPullInstruction({ dir, file }: AndroidADBPullInstructi
return ( return (
<> <>
<Text> <Text>
<code>root</code> 访访
<ruby>
<rp> (</rp>
<rt>
<code>root</code>
</rt>
<rp>)</rp>
</ruby>
访访
</Text> </Text>
<Text> <Text>
<code>root</code>
<chakra.span color="red.400"></chakra.span> <chakra.span color="red.400"></chakra.span>
</Text> </Text>
@@ -92,13 +101,13 @@ export function AndroidADBPullInstruction({ dir, file }: AndroidADBPullInstructi
<OrderedList> <OrderedList>
<ListItem> <ListItem>
<Text> <Text>
<code>adb</code> <Code>adb</Code>
</Text> </Text>
<Text> <Text>
💡 💡
<Link href="https://scoop.sh/#/apps?q=adb" isExternal> <ExtLink href="https://scoop.sh/#/apps?q=adb">
使 Scoop <ExternalLinkIcon /> 使 Scoop <ExternalLinkIcon />
</Link> </ExtLink>
</Text> </Text>
</ListItem> </ListItem>
@@ -134,6 +143,11 @@ export function AndroidADBPullInstruction({ dir, file }: AndroidADBPullInstructi
</Heading> </Heading>
<AccordionPanel pb={4}> <AccordionPanel pb={4}>
<OrderedList> <OrderedList>
<ListItem>
<Text>
<Code>adb</Code>
</Text>
</ListItem>
<ListItem> <ListItem>
<Text></Text> <Text></Text>
</ListItem> </ListItem>

View File

@@ -33,7 +33,7 @@ export function AppRoot() {
</Tab> </Tab>
<Tab> <Tab>
<Icon as={MdQuestionAnswer} mr="1" /> <Icon as={MdQuestionAnswer} mr="1" />
<chakra.span></chakra.span> <chakra.span></chakra.span>
</Tab> </Tab>
</TabList> </TabList>

View File

@@ -0,0 +1,12 @@
import type { AnchorHTMLAttributes } from 'react';
import { ExternalLinkIcon } from '@chakra-ui/icons';
import { Link } from '@chakra-ui/react';
export function ExtLink({ children, ...props }: AnchorHTMLAttributes<HTMLAnchorElement>) {
return (
<Link isExternal {...props} rel="noreferrer noopener nofollow">
{children}
<ExternalLinkIcon />
</Link>
);
}

View File

@@ -35,7 +35,9 @@ export function ImportSecretModal({ clientName, children, show, onClose, onImpor
<FileInput onReceiveFiles={handleFileReceived}></FileInput> <FileInput onReceiveFiles={handleFileReceived}></FileInput>
</Center> </Center>
<Text mt={2}>{clientName && <>{clientName}</>}</Text> <Text as="div" mt={2}>
{clientName && <>{clientName}</>}
</Text>
<Flex as={Tabs} variant="enclosed" flexDir="column" flex={1} minH={0}> <Flex as={Tabs} variant="enclosed" flexDir="column" flex={1} minH={0}>
{children} {children}
</Flex> </Flex>

View File

@@ -7,9 +7,11 @@ export interface KuwoHeader {
quality: string; quality: string;
} }
const KUWO_MAGIC_HDRS = new Set(['yeelion-kuwo\x00\x00\x00\x00', 'yeelion-kuwo-tme']);
export function parseKuwoHeader(view: DataView): KuwoHeader | null { export function parseKuwoHeader(view: DataView): KuwoHeader | null {
const magic = view.buffer.slice(view.byteOffset, view.byteOffset + 0x10); const magic = view.buffer.slice(view.byteOffset, view.byteOffset + 0x10);
if (bytesToUTF8String(magic) !== 'yeelion-kuwo-tme') { if (!KUWO_MAGIC_HDRS.has(bytesToUTF8String(magic))) {
return null; // not kuwo-encrypted file return null; // not kuwo-encrypted file
} }

View File

@@ -1,5 +1,6 @@
export enum DECRYPTION_WORKER_ACTION_NAME { export enum DECRYPTION_WORKER_ACTION_NAME {
DECRYPT = 'DECRYPT', DECRYPT = 'DECRYPT',
FIND_QMC_MUSICEX_NAME = 'FIND_QMC_MUSICEX_NAME',
VERSION = 'VERSION', VERSION = 'VERSION',
} }

View File

@@ -9,6 +9,7 @@ import { XimalayaAndroidCrypto } from './xmly/xmly_android';
import { KWMCrypto } from './kwm/kwm'; import { KWMCrypto } from './kwm/kwm';
import { MiguCrypto } from './migu/migu3d_keyless'; import { MiguCrypto } from './migu/migu3d_keyless';
import { TransparentCrypto } from './transparent/transparent'; import { TransparentCrypto } from './transparent/transparent';
import { QingTingFM$Device } from './qtfm/qtfm_device';
export const allCryptoFactories: CryptoFactory[] = [ export const allCryptoFactories: CryptoFactory[] = [
// Xiami (*.xm) // Xiami (*.xm)
@@ -40,6 +41,9 @@ export const allCryptoFactories: CryptoFactory[] = [
XimalayaAndroidCrypto.makeX2M, XimalayaAndroidCrypto.makeX2M,
XimalayaAndroidCrypto.makeX3M, XimalayaAndroidCrypto.makeX3M,
// QingTingFM (Android)
QingTingFM$Device.make,
// Transparent crypto (not encrypted) // Transparent crypto (not encrypted)
TransparentCrypto.make, TransparentCrypto.make,
]; ];

View File

@@ -3,7 +3,7 @@ import type { CryptoBase } from '../CryptoBase';
import { KWM_KEY } from './kwm.key'; import { KWM_KEY } from './kwm.key';
import { DecryptCommandOptions } from '~/decrypt-worker/types'; import { DecryptCommandOptions } from '~/decrypt-worker/types';
import { makeQMCv2KeyCrypto } from '~/decrypt-worker/util/qmc2KeyCrypto'; import { makeQMCv2KeyCrypto } from '~/decrypt-worker/util/qmc2KeyCrypto';
import { fetchParakeet } from '@jixun/libparakeet'; import { fetchParakeet } from '@um/libparakeet';
import { stringToUTF8Bytes } from '~/decrypt-worker/util/utf8Encoder'; import { stringToUTF8Bytes } from '~/decrypt-worker/util/utf8Encoder';
// v1 only // v1 only

View File

@@ -1,10 +1,9 @@
import { transformBlob } from '~/decrypt-worker/util/transformBlob'; import { transformBlob } from '~/decrypt-worker/util/transformBlob';
import type { CryptoBase } from '../CryptoBase'; import type { CryptoBase } from '../CryptoBase';
import type { DecryptCommandOptions } from '~/decrypt-worker/types.ts'; import type { DecryptCommandOptions } from '~/decrypt-worker/types.ts';
import { SEED, ENC_V2_KEY_1, ENC_V2_KEY_2 } from './qmc_v2.key.ts'; import { fetchParakeet } from '@um/libparakeet';
import { fetchParakeet } from '@jixun/libparakeet';
import { stringToUTF8Bytes } from '~/decrypt-worker/util/utf8Encoder.ts'; import { stringToUTF8Bytes } from '~/decrypt-worker/util/utf8Encoder.ts';
import { makeQMCv2KeyCrypto } from '~/decrypt-worker/util/qmc2KeyCrypto.ts'; import { makeQMCv2FooterParser, makeQMCv2KeyCrypto } from '~/decrypt-worker/util/qmc2KeyCrypto.ts';
export class QMC2Crypto implements CryptoBase { export class QMC2Crypto implements CryptoBase {
cryptoName = 'QMC/v2'; cryptoName = 'QMC/v2';
@@ -12,7 +11,7 @@ export class QMC2Crypto implements CryptoBase {
async decrypt(buffer: ArrayBuffer): Promise<Blob> { async decrypt(buffer: ArrayBuffer): Promise<Blob> {
const parakeet = await fetchParakeet(); const parakeet = await fetchParakeet();
const footerParser = parakeet.make.QMCv2FooterParser(SEED, ENC_V2_KEY_1, ENC_V2_KEY_2); const footerParser = makeQMCv2FooterParser(parakeet);
return transformBlob(buffer, (p) => p.make.QMCv2(footerParser), { return transformBlob(buffer, (p) => p.make.QMCv2(footerParser), {
parakeet, parakeet,
cleanup: () => footerParser.delete(), cleanup: () => footerParser.delete(),

View File

@@ -0,0 +1,25 @@
import { transformBlob } from '~/decrypt-worker/util/transformBlob';
import type { CryptoBase } from '../CryptoBase';
import { DecryptCommandOptions } from '~/decrypt-worker/types';
export class QingTingFM$Device implements CryptoBase {
cryptoName = 'QingTing FM/Device ID';
checkByDecryptHeader = false;
async checkBySignature(_buffer: ArrayBuffer, options: DecryptCommandOptions) {
return Boolean(/^\.p~?!.*\.qta$/.test(options.fileName) && options.qingTingAndroidKey);
}
async decrypt(buffer: ArrayBuffer, options: DecryptCommandOptions): Promise<Blob> {
const { fileName: name, qingTingAndroidKey } = options;
if (!qingTingAndroidKey) {
throw new Error('QingTingFM Android Device Key was not provided');
}
return transformBlob(buffer, (p) => p.make.QingTingFM(name, qingTingAndroidKey));
}
public static make() {
return new QingTingFM$Device();
}
}

View File

@@ -1,6 +1,8 @@
export interface DecryptCommandOptions { export interface DecryptCommandOptions {
fileName: string;
qmc2Key?: string; qmc2Key?: string;
kwm2key?: string; kwm2key?: string;
qingTingAndroidKey?: string;
} }
export interface DecryptCommandPayload { export interface DecryptCommandPayload {
@@ -8,3 +10,8 @@ export interface DecryptCommandPayload {
blobURI: string; blobURI: string;
options: DecryptCommandOptions; options: DecryptCommandOptions;
} }
export interface FetchMusicExNamePayload {
id: string;
blobURI: string;
}

View File

@@ -1,4 +1,5 @@
import type { Parakeet } from '@jixun/libparakeet'; import type { Parakeet } from '@um/libparakeet';
import { SEED, ENC_V2_KEY_1, ENC_V2_KEY_2 } from '../crypto/qmc/qmc_v2.key'; import { SEED, ENC_V2_KEY_1, ENC_V2_KEY_2 } from '../crypto/qmc/qmc_v2.key';
export const makeQMCv2KeyCrypto = (p: Parakeet) => p.make.QMCv2KeyCrypto(SEED, ENC_V2_KEY_1, ENC_V2_KEY_2); export const makeQMCv2KeyCrypto = (p: Parakeet) => p.make.QMCv2KeyCrypto(SEED, ENC_V2_KEY_1, ENC_V2_KEY_2);
export const makeQMCv2FooterParser = (p: Parakeet) => p.make.QMCv2FooterParser(SEED, ENC_V2_KEY_1, ENC_V2_KEY_2);

View File

@@ -1,11 +1,11 @@
import { Transformer, Parakeet, TransformResult, fetchParakeet } from '@jixun/libparakeet'; import { Transformer, Parakeet, TransformResult, fetchParakeet } from '@um/libparakeet';
import { toArrayBuffer } from './buffer'; import { toArrayBuffer } from './buffer';
import { UnsupportedSourceFile } from './DecryptError'; import { UnsupportedSourceFile } from './DecryptError';
export async function transformBlob( export async function transformBlob(
blob: Blob | ArrayBuffer, blob: Blob | ArrayBuffer,
transformerFactory: (p: Parakeet) => Transformer | Promise<Transformer>, transformerFactory: (p: Parakeet) => Transformer | Promise<Transformer>,
{ cleanup, parakeet }: { cleanup?: () => void; parakeet?: Parakeet } = {} { cleanup, parakeet }: { cleanup?: () => void; parakeet?: Parakeet } = {},
) { ) {
const registeredCleanupFns: (() => void)[] = []; const registeredCleanupFns: (() => void)[] = [];
if (cleanup) { if (cleanup) {

View File

@@ -1,12 +1,14 @@
import { WorkerServerBus } from '~/util/WorkerEventBus'; import { WorkerServerBus } from '~/util/WorkerEventBus';
import { DECRYPTION_WORKER_ACTION_NAME } from './constants'; import { DECRYPTION_WORKER_ACTION_NAME } from './constants';
import { getSDKVersion } from '@jixun/libparakeet'; import { getSDKVersion } from '@um/libparakeet';
import { workerDecryptHandler } from './worker/handler/decrypt'; import { workerDecryptHandler } from './worker/handler/decrypt';
import { workerParseMusicExMediaName } from './worker/handler/qmcv2_parser';
const bus = new WorkerServerBus(); const bus = new WorkerServerBus();
onmessage = bus.onmessage; onmessage = bus.onmessage;
bus.addEventHandler(DECRYPTION_WORKER_ACTION_NAME.DECRYPT, workerDecryptHandler); bus.addEventHandler(DECRYPTION_WORKER_ACTION_NAME.DECRYPT, workerDecryptHandler);
bus.addEventHandler(DECRYPTION_WORKER_ACTION_NAME.FIND_QMC_MUSICEX_NAME, workerParseMusicExMediaName);
bus.addEventHandler(DECRYPTION_WORKER_ACTION_NAME.VERSION, getSDKVersion); bus.addEventHandler(DECRYPTION_WORKER_ACTION_NAME.VERSION, getSDKVersion);

View File

@@ -1,4 +1,4 @@
import { Parakeet, fetchParakeet } from '@jixun/libparakeet'; import { Parakeet, fetchParakeet } from '@um/libparakeet';
import { timedLogger, withGroupedLogs as withTimeGroupedLogs } from '~/util/logUtils'; import { timedLogger, withGroupedLogs as withTimeGroupedLogs } from '~/util/logUtils';
import type { DecryptCommandOptions, DecryptCommandPayload } from '~/decrypt-worker/types'; import type { DecryptCommandOptions, DecryptCommandPayload } from '~/decrypt-worker/types';
import { allCryptoFactories } from '../../crypto/CryptoFactory'; import { allCryptoFactories } from '../../crypto/CryptoFactory';

View File

@@ -0,0 +1,30 @@
import { fetchParakeet, FooterParserState } from '@um/libparakeet';
import type { FetchMusicExNamePayload } from '~/decrypt-worker/types';
import { makeQMCv2FooterParser } from '~/decrypt-worker/util/qmc2KeyCrypto';
import { timedLogger, withGroupedLogs as withTimeGroupedLogs } from '~/util/logUtils';
export const workerParseMusicExMediaName = async ({ id, blobURI }: FetchMusicExNamePayload) => {
const label = `decrypt(${id})`;
return withTimeGroupedLogs(label, async () => {
const parakeet = await timedLogger(`${label}/init`, fetchParakeet);
const blob = await timedLogger(`${label}/fetch-src`, async () =>
fetch(blobURI, { headers: { Range: 'bytes=-1024' } }).then((r) => r.blob()),
);
const buffer = await timedLogger(`${label}/read-src`, async () => {
// Firefox: the range header does not work...?
const blobBuffer = await blob.arrayBuffer();
if (blobBuffer.byteLength > 1024) {
return blobBuffer.slice(-1024);
}
return blobBuffer;
});
const parsed = makeQMCv2FooterParser(parakeet).parse(buffer);
if (parsed.state === FooterParserState.OK) {
return parsed.mediaName;
}
return null;
});
};

View File

@@ -1,2 +1,5 @@
// This is a dummy module for vite/rollup to resolve. // This is a dummy module for vite/rollup to resolve.
Object.defineProperty(Object.create(null), { sideEffects: true }); export function createRequire() {
import('radash'); // we need to import something, so vite don't complain on build
throw new Error('this is a dummy module. Do not use');
}

View File

@@ -37,7 +37,12 @@ export function KuwoFAQ() {
<AlertIcon /> <AlertIcon />
<Flex flexDir="column"> <Flex flexDir="column">
<Text> root </Text> <Text> root </Text>
<Text>使使</Text> <Text>
<strong></strong>
</Text>
<Text>
<strong></strong>使使
</Text>
</Flex> </Flex>
</Alert> </Alert>
</Container> </Container>

View File

@@ -1,7 +1,9 @@
import { ExternalLinkIcon } from '@chakra-ui/icons'; import { Alert, AlertIcon, Code, Container, Flex, Img, ListItem, Text, UnorderedList } from '@chakra-ui/react';
import { Link, Text } from '@chakra-ui/react'; import { ExtLink } from '~/components/ExtLink';
import { Header4 } from '~/components/HelpText/Header4'; import { Header4 } from '~/components/HelpText/Header4';
import { VQuote } from '~/components/HelpText/VQuote';
import { ProjectIssue } from '~/components/ProjectIssue'; import { ProjectIssue } from '~/components/ProjectIssue';
import LdPlayerSettingsScreen from './assets/ld_settings_misc.webp';
export function OtherFAQ() { export function OtherFAQ() {
return ( return (
@@ -9,18 +11,127 @@ export function OtherFAQ() {
<Header4></Header4> <Header4></Header4>
<Text></Text> <Text></Text>
<Text>使</Text> <Text>使</Text>
<Header4></Header4>
<Header4></Header4>
<Text> <Text>
<ProjectIssue id={34} title="[UI] 全部下载功能" /> {' '} {'暂时没有实现,不过你可以在 '}
<ProjectIssue id={43} title="批量下载" /> <ProjectIssue id={34} title="[UI] 全部下载功能" />
{' 以及 '}
<ProjectIssue id={43} title="批量下载" />
{' 追踪该问题。'}
</Text> </Text>
<Header4>安卓: 浏览器支持说明</Header4>
<Text> 使 Chrome Firefox </Text>
<Text></Text>
<UnorderedList>
<ListItem>Via </ListItem>
<ListItem></ListItem>
<ListItem>UC </ListItem>
</UnorderedList>
<Text></Text>
<UnorderedList>
<ListItem></ListItem>
<ListItem></ListItem>
<ListItem></ListItem>
</UnorderedList>
<Header4>安卓: root </Header4>
<Text>
root 使
使 NFC
</Text>
<Text>使</Text>
<Text>
root 广 Windows 11
<ExtLink href="https://learn.microsoft.com/zh-cn/windows/android/wsa/">
<ruby>
Android Windows (WSA)
<rp> (</rp>
<rt>
<code>Windows Subsystem for Android</code>
</rt>
<rp>)</rp>
</ruby>
</ExtLink>
</Text>
<Container p={2}>
<Alert status="warning" borderRadius={5}>
<AlertIcon />
<Flex flexDir="column">
<Text>
<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>
<Code>um-react-electron</Code>
</strong>
</ExtLink>
Electron WindowsLinux Mac
</Text>
<UnorderedList>
<ListItem>
<Text>
<ExtLink href="https://github.com/CarlGao4/um-react-electron/releases/latest">GitHub </ExtLink>
</Text>
</ListItem>
</UnorderedList>
</ListItem>
<ListItem>
<Text>
<ExtLink href="https://git.unlock-music.dev/um/um-react-wry">
<strong>
<Code>um-react-wry</Code>
</strong>
</ExtLink>
: 使 WRY Win64
<ExtLink href="https://go.microsoft.com/fwlink/p/?LinkId=2124703"> Edge WebView2 </ExtLink>
{'Win10+ 操作系统自带)'}
</Text>
<UnorderedList>
<ListItem>
<Text>
<ExtLink href="https://git.unlock-music.dev/um/um-react/releases/latest"></ExtLink>
{' | 寻找文件名为 '}
<Code>um-react-win64-</Code>
</Text>
</ListItem>
</UnorderedList>
</ListItem>
</UnorderedList>
<Header4></Header4> <Header4></Header4>
<Text> <Text>
{'欢迎进入 '} {'欢迎进入 '}
<Link href={'https://t.me/unlock_music_chat'} isExternal> <ExtLink href={'https://t.me/unlock_music_chat'}>Telegram - </ExtLink>
Telegram -
<ExternalLinkIcon />
</Link>
{' 一起探讨。'} {' 一起探讨。'}
</Text> </Text>
</> </>

View File

@@ -1,8 +1,9 @@
import { Alert, AlertIcon, Container, Flex, List, ListItem, Text, chakra } from '@chakra-ui/react'; import { Alert, AlertIcon, Container, Flex, List, ListItem, Text, UnorderedList, chakra } from '@chakra-ui/react';
import { Header4 } from '~/components/HelpText/Header4'; import { Header4 } from '~/components/HelpText/Header4';
import { SegmentTryOfficialPlayer } from './SegmentTryOfficialPlayer'; import { SegmentTryOfficialPlayer } from './SegmentTryOfficialPlayer';
import { QMCv2AllInstructions } from '~/features/settings/panels/QMCv2/QMCv2AllInstructions'; import { QMCv2QQMusicAllInstructions } from '~/features/settings/panels/QMCv2/QMCv2QQMusicAllInstructions';
import { SegmentKeyImportInstructions } from './SegmentKeyImportInstructions'; import { SegmentKeyImportInstructions } from './SegmentKeyImportInstructions';
import { ExtLink } from '~/components/ExtLink';
export function QQMusicFAQ() { export function QQMusicFAQ() {
return ( return (
@@ -16,7 +17,26 @@ export function QQMusicFAQ() {
<Text> <Text>
<chakra.strong>2</chakra.strong> <chakra.strong>2</chakra.strong>
</Text> </Text>
<Text>Windows客户端下载的歌曲无需密钥</Text> <Text>
Windows 19.43
QQ Windows v19.43
</Text>
<UnorderedList pl={3}>
<ListItem>
<Text>
<ExtLink href="https://dldir1v6.qq.com/music/clntupate/QQMusic_Setup_1943.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_1943.exe">
<code>Archive.org</code>
</ExtLink>
</Text>
</ListItem>
</UnorderedList>
<Container p={2}> <Container p={2}>
<Alert status="warning" borderRadius={5}> <Alert status="warning" borderRadius={5}>
@@ -35,7 +55,7 @@ export function QQMusicFAQ() {
</Alert> </Alert>
</Container> </Container>
<SegmentKeyImportInstructions tab="QMCv2 密钥" clientInstructions={<QMCv2AllInstructions />} /> <SegmentKeyImportInstructions tab="QMCv2 密钥" clientInstructions={<QMCv2QQMusicAllInstructions />} />
</ListItem> </ListItem>
</List> </List>
</> </>

View File

@@ -16,9 +16,8 @@ export function SegmentAddKeyDropdown() {
ml="2" ml="2"
borderTopLeftRadius={0} borderTopLeftRadius={0}
borderBottomLeftRadius={0} borderBottomLeftRadius={0}
isDisabled pointerEvents="none"
css={{ ':disabled': { opacity: 1 } }} aria-label="下拉按钮"
aria-label="示例按钮"
/> />
</Flex> </Flex>
); );

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

View File

@@ -92,7 +92,7 @@ export function FileRow({ id, file }: FileRowProps) {
</WrapItem> </WrapItem>
<WrapItem> <WrapItem>
{file.decrypted && ( {file.decrypted && (
<Link isExternal href={file.decrypted} download={decryptedName}> <Link href={file.decrypted} download={decryptedName}>
<Button as="span"></Button> <Button as="span"></Button>
</Link> </Link>
)} )}

View File

@@ -2,11 +2,11 @@ import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
import type { PayloadAction } from '@reduxjs/toolkit'; import type { PayloadAction } from '@reduxjs/toolkit';
import type { RootState } from '~/store'; import type { RootState } from '~/store';
import type { DecryptionResult } from '~/decrypt-worker/constants'; import { DECRYPTION_WORKER_ACTION_NAME, type DecryptionResult } from '~/decrypt-worker/constants';
import type { DecryptCommandOptions } from '~/decrypt-worker/types'; import type { DecryptCommandOptions, FetchMusicExNamePayload } from '~/decrypt-worker/types';
import { decryptionQueue } 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';
import { selectQMCv2KeyByFileName, selectKWMv2Key } from '../settings/settingsSelector'; import { selectQMCv2KeyByFileName, selectKWMv2Key, selectQtfmAndroidKey } from '../settings/settingsSelector';
export enum ProcessState { export enum ProcessState {
QUEUED = 'QUEUED', QUEUED = 'QUEUED',
@@ -44,7 +44,7 @@ export interface FileListingState {
displayMode: ListingMode; displayMode: ListingMode;
} }
const initialState: FileListingState = { const initialState: FileListingState = {
files: Object.create(null), files: {},
displayMode: ListingMode.LIST, displayMode: ListingMode.LIST,
}; };
@@ -64,17 +64,29 @@ export const processFile = createAsyncThunk<
thunkAPI.dispatch(setFileAsProcessing({ id: fileId })); thunkAPI.dispatch(setFileAsProcessing({ id: fileId }));
}; };
const fileHeader = await fetch(file.raw, { const fileHeader = await fetch(file.raw, { headers: { Range: 'bytes=0-1023' } })
headers: {
Range: 'bytes=0-1023',
},
})
.then((r) => r.blob()) .then((r) => r.blob())
.then((r) => r.arrayBuffer()); .then((r) => r.arrayBuffer())
.then((r) => {
if (r.byteLength > 1024) {
return r.slice(0, 1024);
}
return r;
});
const qmcv2MusicExMediaFile = await workerClientBus.request<string, FetchMusicExNamePayload>(
DECRYPTION_WORKER_ACTION_NAME.FIND_QMC_MUSICEX_NAME,
{
id: fileId,
blobURI: file.raw,
},
);
const options: DecryptCommandOptions = { const options: DecryptCommandOptions = {
qmc2Key: selectQMCv2KeyByFileName(state, file.fileName), fileName: file.fileName,
qmc2Key: selectQMCv2KeyByFileName(state, qmcv2MusicExMediaFile || file.fileName),
kwm2key: selectKWMv2Key(state, new DataView(fileHeader)), kwm2key: selectKWMv2Key(state, new DataView(fileHeader)),
qingTingAndroidKey: selectQtfmAndroidKey(state),
}; };
return decryptionQueue.add({ id: fileId, blobURI: file.raw, options }, onPreProcess); return decryptionQueue.add({ id: fileId, blobURI: file.raw, options }, onPreProcess);
}); });

View File

@@ -30,10 +30,12 @@ import { useAppDispatch, useAppSelector } from '~/hooks';
import { commitStagingChange, discardStagingChanges } from './settingsSlice'; import { commitStagingChange, discardStagingChanges } from './settingsSlice';
import { PanelKWMv2Key } from './panels/PanelKWMv2Key'; import { PanelKWMv2Key } from './panels/PanelKWMv2Key';
import { selectIsSettingsNotSaved } from './settingsSelector'; import { selectIsSettingsNotSaved } from './settingsSelector';
import { PanelQingTing } from './panels/PanelQingTing';
const TABS: { name: string; Tab: () => JSX.Element }[] = [ const TABS: { name: string; Tab: () => JSX.Element }[] = [
{ name: 'QMCv2 密钥', Tab: PanelQMCv2Key }, { name: 'QMCv2 密钥', Tab: PanelQMCv2Key },
{ name: 'KWMv2 密钥', Tab: PanelKWMv2Key }, { name: 'KWMv2 密钥', Tab: PanelKWMv2Key },
{ name: '蜻蜓 FM', Tab: PanelQingTing },
{ {
name: '其它/待定', name: '其它/待定',
Tab: () => <Text></Text>, Tab: () => <Text></Text>,

View File

@@ -3,7 +3,7 @@ import { objectify } from 'radash';
export function productionKeyToStaging<S, P extends Record<string, unknown>>( export function productionKeyToStaging<S, P extends Record<string, unknown>>(
src: P, src: P,
make: (k: keyof P, v: P[keyof P]) => null | S make: (k: keyof P, v: P[keyof P]) => null | S,
): S[] { ): S[] {
const result: S[] = []; const result: S[] = [];
for (const [key, value] of Object.entries(src)) { for (const [key, value] of Object.entries(src)) {
@@ -31,7 +31,7 @@ export const qmc2StagingToProductionKey = (key: StagingQMCv2Key) => key.name.nor
export const qmc2StagingToProductionValue = (key: StagingQMCv2Key) => key.ekey.trim(); export const qmc2StagingToProductionValue = (key: StagingQMCv2Key) => key.ekey.trim();
export const qmc2ProductionToStaging = ( export const qmc2ProductionToStaging = (
key: keyof ProductionQMCv2Keys, key: keyof ProductionQMCv2Keys,
value: ProductionQMCv2Keys[keyof ProductionQMCv2Keys] value: ProductionQMCv2Keys[keyof ProductionQMCv2Keys],
): StagingQMCv2Key => { ): StagingQMCv2Key => {
return { return {
id: nanoid(), id: nanoid(),
@@ -44,7 +44,13 @@ export const qmc2ProductionToStaging = (
export interface StagingKWMv2Key { export interface StagingKWMv2Key {
id: string; id: string;
/**
* Resource ID
*/
rid: string; rid: string;
/**
* Quality String
*/
quality: string; quality: string;
ekey: string; ekey: string;
} }
@@ -58,16 +64,17 @@ export const parseKwm2ProductionKey = (key: string): null | { rid: string; quali
return { rid, quality }; return { rid, quality };
}; };
export const kwm2StagingToProductionKey = (key: StagingKWMv2Key) => `${key.rid}-${key.quality}`; export const kwm2StagingToProductionKey = (key: StagingKWMv2Key) => `${key.rid}-${key.quality.replace(/[\D]/g, '')}`;
export const kwm2StagingToProductionValue = (key: StagingKWMv2Key) => key.ekey; export const kwm2StagingToProductionValue = (key: StagingKWMv2Key) => key.ekey;
export const kwm2ProductionToStaging = ( export const kwm2ProductionToStaging = (
key: keyof ProductionKWMv2Keys, key: keyof ProductionKWMv2Keys,
value: ProductionKWMv2Keys[keyof ProductionKWMv2Keys] value: ProductionKWMv2Keys[keyof ProductionKWMv2Keys],
): null | StagingKWMv2Key => { ): null | StagingKWMv2Key => {
if (typeof value !== 'string') return null; if (typeof value !== 'string') return null;
const parsed = parseKwm2ProductionKey(key); const parsed = parseKwm2ProductionKey(key);
if (!parsed) return null; if (!parsed) return null;
const { quality, rid } = parsed;
return { id: nanoid(), rid: parsed.rid, quality: parsed.quality, ekey: value }; return { id: nanoid(), rid, quality, ekey: value };
}; };

View File

@@ -0,0 +1,33 @@
import { Code, ListItem, OrderedList, Text, chakra } from '@chakra-ui/react';
const KUWO_IOS_DIR = '/var/mobile/Containers/Data/Application/<酷我数据目录>/mmkv';
export function InstructionsIOS() {
return (
<>
<Text>访 iOS </Text>
<Text>
<chakra.span color="red.400"></chakra.span>
</Text>
<OrderedList>
<ListItem>
<Text>
访
<Code wordBreak="break-word">{KUWO_IOS_DIR}</Code>
</Text>
</ListItem>
<ListItem>
<Text>
<Code>kw_ekey</Code> 访
</Text>
</ListItem>
<ListItem>
<Text>
<Code>kw_ekey</Code>
</Text>
</ListItem>
</OrderedList>
</>
);
}

View File

@@ -1,12 +1,14 @@
import { Tab, TabList, TabPanel, TabPanels } from '@chakra-ui/react'; 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';
export function KWMv2AllInstructions() { export function KWMv2AllInstructions() {
return ( return (
<> <>
<TabList> <TabList>
<Tab></Tab> <Tab></Tab>
<Tab>iOS</Tab>
<Tab>Windows</Tab> <Tab>Windows</Tab>
</TabList> </TabList>
<TabPanels flex={1} overflow="auto"> <TabPanels flex={1} overflow="auto">
@@ -16,6 +18,9 @@ export function KWMv2AllInstructions() {
file="cn.kuwo.player.mmkv.defaultconfig" file="cn.kuwo.player.mmkv.defaultconfig"
/> />
</TabPanel> </TabPanel>
<TabPanel>
<InstructionsIOS />
</TabPanel>
<TabPanel> <TabPanel>
<InstructionsPC /> <InstructionsPC />
</TabPanel> </TabPanel>

View File

@@ -22,7 +22,7 @@ import { useDispatch, useSelector } from 'react-redux';
import { MdAdd, MdDeleteForever, MdExpandMore, MdFileUpload } from 'react-icons/md'; import { MdAdd, MdDeleteForever, MdExpandMore, MdFileUpload } from 'react-icons/md';
import { ImportSecretModal } from '~/components/ImportSecretModal'; import { ImportSecretModal } from '~/components/ImportSecretModal';
import { MMKVParser } from '~/util/MMKVParser'; import { parseAndroidKuwoEKey, parseIosKuwoEKey } from '~/util/mmkv/kuwo';
import { kwm2AddKey, kwm2ClearKeys, kwm2ImportKeys } from '../settingsSlice'; import { kwm2AddKey, kwm2ClearKeys, kwm2ImportKeys } from '../settingsSlice';
import { selectStagingKWMv2Keys } from '../settingsSelector'; import { selectStagingKWMv2Keys } from '../settingsSelector';
@@ -41,9 +41,11 @@ export function PanelKWMv2Key() {
const handleSecretImport = async (file: File) => { const handleSecretImport = async (file: File) => {
let keys: Omit<StagingKWMv2Key, 'id'>[] | null = null; let keys: Omit<StagingKWMv2Key, 'id'>[] | null = null;
if (/cn\.kuwo\.player\.mmkv/i.test(file.name)) { if (/cn\.kuwo\.player\.mmkv/i.test(file.name)) {
const fileBuffer = await file.arrayBuffer(); keys = parseAndroidKuwoEKey(new DataView(await file.arrayBuffer()));
keys = MMKVParser.parseKuwoEKey(new DataView(fileBuffer)); } else if (/kw_ekey/.test(file.name)) {
keys = parseIosKuwoEKey(new DataView(await file.arrayBuffer()));
} }
if (keys?.length === 0) { if (keys?.length === 0) {
toast({ toast({
title: '未导入密钥', title: '未导入密钥',

View File

@@ -14,6 +14,7 @@ import {
MenuDivider, MenuDivider,
MenuItem, MenuItem,
MenuList, MenuList,
Select,
Text, Text,
Tooltip, Tooltip,
useToast, useToast,
@@ -28,15 +29,17 @@ 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';
import { MMKVParser } from '~/util/MMKVParser'; import { parseAndroidQmEKey } from '~/util/mmkv/qm';
import { getFileName } from '~/util/pathHelper'; import { getFileName } from '~/util/pathHelper';
import { QMCv2AllInstructions } from './QMCv2/QMCv2AllInstructions'; import { QMCv2QQMusicAllInstructions } from './QMCv2/QMCv2QQMusicAllInstructions';
import { QMCv2DoubanAllInstructions } from './QMCv2/QMCv2DoubanAllInstructions';
export function PanelQMCv2Key() { export function PanelQMCv2Key() {
const toast = useToast(); 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);
const [secretType, setSecretType] = useState<'qm' | 'douban'>('qm');
const addKey = () => dispatch(qmc2AddKey()); const addKey = () => dispatch(qmc2AddKey());
const clearAll = () => dispatch(qmc2ClearKeys()); const clearAll = () => dispatch(qmc2ClearKeys());
@@ -51,16 +54,16 @@ export function PanelQMCv2Key() {
let qmc2Keys: null | Omit<StagingQMCv2Key, 'id'>[] = null; let qmc2Keys: null | Omit<StagingQMCv2Key, 'id'>[] = null;
if (/[_.]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.extractQmAndroidDbKeys(fileBuffer); qmc2Keys = extractor.extractQmcV2KeysFromSqliteDb(fileBuffer);
if (!qmc2Keys) { if (!qmc2Keys) {
alert(`不是支持的 SQLite 数据库文件。\n表名${qmc2Keys}`); alert(`不是支持的 SQLite 数据库文件。`);
return; return;
} }
} else if (/MMKVStreamEncryptId|filenameEkeyMap/i.test(file.name)) { } else if (/MMKVStreamEncryptId|filenameEkeyMap|qmpc-mmkv-v1/i.test(file.name)) {
const fileBuffer = await file.arrayBuffer(); const fileBuffer = await file.arrayBuffer();
const map = MMKVParser.toStringMap(new DataView(fileBuffer)); const map = parseAndroidQmEKey(new DataView(fileBuffer));
qmc2Keys = Array.from(map.entries(), ([name, ekey]) => ({ name: getFileName(name), ekey })); qmc2Keys = Array.from(map.entries(), ([name, ekey]) => ({ name: getFileName(name), ekey }));
} }
@@ -96,8 +99,8 @@ export function PanelQMCv2Key() {
</Heading> </Heading>
<Text> <Text>
QQ QMCv2使QQ Mac iOS QQ FM QMCv2使QQ Mac iOS 使
线 FM线
</Text> </Text>
<HStack pb={2} pt={2}> <HStack pb={2} pt={2}>
@@ -155,16 +158,28 @@ export function PanelQMCv2Key() {
<QMCv2EKeyItem key={id} id={id} ekey={ekey} name={name} i={i} /> <QMCv2EKeyItem key={id} id={id} ekey={ekey} name={name} i={i} />
))} ))}
</List> </List>
{qmc2Keys.length === 0 && <Text></Text>} {qmc2Keys.length === 0 && <Text></Text>}
</Box> </Box>
<ImportSecretModal <ImportSecretModal
clientName="QQ 音乐" clientName={
<Select
value={secretType}
onChange={(e) => setSecretType(e.target.value as 'qm' | 'douban')}
variant="flushed"
display="inline"
css={{ paddingLeft: '0.75rem', width: 'auto' }}
>
<option value="qm">QQ </option>
<option value="douban"> FM</option>
</Select>
}
show={showImportModal} show={showImportModal}
onClose={() => setShowImportModal(false)} onClose={() => setShowImportModal(false)}
onImport={handleSecretImport} onImport={handleSecretImport}
> >
<QMCv2AllInstructions /> {secretType === 'qm' && <QMCv2QQMusicAllInstructions />}
{secretType === 'douban' && <QMCv2DoubanAllInstructions />}
</ImportSecretModal> </ImportSecretModal>
</Flex> </Flex>
); );

View File

@@ -0,0 +1,133 @@
import {
Box,
Code,
Flex,
FormControl,
FormHelperText,
FormLabel,
Heading,
Input,
ListItem,
Text,
UnorderedList,
} from '@chakra-ui/react';
import { useAppDispatch, useAppSelector } from '~/hooks';
import { fetchParakeet } from '@um/libparakeet';
import { ExtLink } from '~/components/ExtLink';
import { ChangeEvent, ClipboardEvent } from 'react';
import { VQuote } from '~/components/HelpText/VQuote';
import { selectStagingQtfmAndroidKey } from '../settingsSelector';
import { qtfmAndroidUpdateKey } from '../settingsSlice';
const QTFM_DEVICE_ID_URL = 'https://github.com/parakeet-rs/qtfm-device-id/releases/latest';
export function PanelQingTing() {
const dispatch = useAppDispatch();
const secretKey = useAppSelector(selectStagingQtfmAndroidKey);
const setSecretKey = (secretKey: string) => {
dispatch(qtfmAndroidUpdateKey({ deviceKey: secretKey }));
};
const handleDataPaste = (e: ClipboardEvent<HTMLInputElement>) => {
const plainText = e.clipboardData.getData('text/plain');
const matchDeviceSecret = plainText.match(/^DEVICE_SECRET: ([0-9a-fA-F]+)/m);
if (matchDeviceSecret) {
e.preventDefault();
setSecretKey(matchDeviceSecret[1]);
return;
}
const dataMap = new Map();
for (const [_unused, key, value] of plainText.matchAll(
/^(PRODUCT|DEVICE|MANUFACTURER|BRAND|BOARD|MODEL): (.+)/gim,
)) {
dataMap.set(key.toLowerCase(), value);
}
const product = dataMap.get('product') ?? null;
const device = dataMap.get('device') ?? null;
const manufacturer = dataMap.get('manufacturer') ?? null;
const brand = dataMap.get('brand') ?? null;
const board = dataMap.get('board') ?? null;
const model = dataMap.get('model') ?? null;
if (
product !== null &&
device !== null &&
manufacturer !== null &&
brand !== null &&
board !== null &&
model !== null
) {
e.preventDefault();
fetchParakeet().then((parakeet) => {
setSecretKey(parakeet.qtfm.createDeviceKey(product, device, manufacturer, brand, board, model));
});
}
};
const handleDataInput = (e: ChangeEvent<HTMLInputElement>) => {
setSecretKey(e.target.value);
};
return (
<Flex minH={0} flexDir="column" flex={1}>
<Heading as="h2" size="lg">
<VQuote> FM</VQuote>
</Heading>
<Text>
<VQuote> FM</VQuote>
</Text>
<Box mt={3} mb={3}>
<FormControl>
<FormLabel></FormLabel>
<Input type="text" onPaste={handleDataPaste} value={secretKey} onChange={handleDataInput} />
<FormHelperText>
{'粘贴含有设备密钥的信息的内容时将自动提取密钥(如通过 '}
<ExtLink href={QTFM_DEVICE_ID_URL}>
<Code>qtfm-device-id</Code>
</ExtLink>
{' 获取的设备信息)。'}
</FormHelperText>
</FormControl>
</Box>
<Heading as="h3" size="md" pt={3} pb={2}>
</Heading>
<UnorderedList>
<ListItem>
<Text>
<Code>[]/Android/data/fm.qingting.qtradio/files/Music/</Code>
</Text>
<UnorderedList>
<ListItem>
<Text>
使
<ruby>
<rp> (</rp>
<rt>root</rt>
<rp>)</rp>
</ruby>
访
</Text>
</ListItem>
</UnorderedList>
</ListItem>
<ListItem>
<Text>
<Code>.p~!</Code>
</Text>
</ListItem>
<ListItem>
<Text></Text>
</ListItem>
</UnorderedList>
</Flex>
);
}

View File

@@ -3,7 +3,8 @@ import { Text } from '@chakra-ui/react';
export function InstructionsPC() { export function InstructionsPC() {
return ( return (
<> <>
<Text>使 Windows </Text> <Text>使 Windows 19.43 </Text>
<Text>使 Windows 19.51 </Text>
</> </>
); );
} }

View File

@@ -0,0 +1,17 @@
import { Tab, TabList, TabPanel, TabPanels } from '@chakra-ui/react';
import { AndroidADBPullInstruction } from '~/components/AndroidADBPullInstruction/AndroidADBPullInstruction';
export function QMCv2DoubanAllInstructions() {
return (
<>
<TabList>
<Tab></Tab>
</TabList>
<TabPanels flex={1} overflow="auto">
<TabPanel>
<AndroidADBPullInstruction dir="/data/data/com.douban.radio/databases" file="music_audio_play" />
</TabPanel>
</TabPanels>
</>
);
}

View File

@@ -4,7 +4,7 @@ import { InstructionsIOS } from './InstructionsIOS';
import { InstructionsMac } from './InstructionsMac'; import { InstructionsMac } from './InstructionsMac';
import { InstructionsPC } from './InstructionsPC'; import { InstructionsPC } from './InstructionsPC';
export function QMCv2AllInstructions() { export function QMCv2QQMusicAllInstructions() {
return ( return (
<> <>
<TabList> <TabList>

View File

@@ -0,0 +1 @@
// TODO: Popup dialog for QingTing instructions

View File

@@ -1,16 +1,16 @@
import { debounce } from 'radash'; import { debounce } from 'radash';
import { produce } from 'immer';
import type { AppStore } from '~/store'; import type { AppStore } from '~/store';
import { settingsSlice, setProductionChanges, ProductionSettings } from './settingsSlice'; import { settingsSlice, setProductionChanges, ProductionSettings } from './settingsSlice';
import { enumObject } from '~/util/objects'; import { enumObject } from '~/util/objects';
import { getLogger } from '~/util/logUtils'; import { getLogger } from '~/util/logUtils';
import { parseKwm2ProductionKey } from './keyFormats'; import { parseKwm2ProductionKey } from './keyFormats';
import { deepClone } from '~/util/deepClone';
const DEFAULT_STORAGE_KEY = 'um-react-settings'; const DEFAULT_STORAGE_KEY = 'um-react-settings';
function mergeSettings(settings: ProductionSettings): ProductionSettings { function mergeSettings(settings: ProductionSettings): ProductionSettings {
return produce(settingsSlice.getInitialState().production, (draft) => { const draft = deepClone(settingsSlice.getInitialState().production);
if (settings?.qmc2) { if (settings?.qmc2) {
const { allowFuzzyNameSearch, keys } = settings.qmc2; const { allowFuzzyNameSearch, keys } = settings.qmc2;
for (const [k, v] of enumObject(keys)) { for (const [k, v] of enumObject(keys)) {
@@ -33,7 +33,12 @@ function mergeSettings(settings: ProductionSettings): ProductionSettings {
} }
} }
} }
});
if (typeof settings?.qtfm?.android === 'string') {
draft.qtfm.android = settings.qtfm.android.replace(/[^0-9a-fA-F]/g, '');
}
return draft;
} }
export function persistSettings(store: AppStore, storageKey = DEFAULT_STORAGE_KEY) { export function persistSettings(store: AppStore, storageKey = DEFAULT_STORAGE_KEY) {
@@ -58,6 +63,6 @@ export function persistSettings(store: AppStore, storageKey = DEFAULT_STORAGE_KE
localStorage.setItem(storageKey, JSON.stringify(currentSettings)); localStorage.setItem(storageKey, JSON.stringify(currentSettings));
getLogger().debug('settings saved'); getLogger().debug('settings saved');
} }
}) }),
); );
} }

View File

@@ -47,3 +47,6 @@ export const selectKWMv2Key = (state: RootState, headerView: DataView): string |
return ekey; return ekey;
}; };
export const selectStagingQtfmAndroidKey = (state: RootState) => state.settings.staging.qtfm.android;
export const selectQtfmAndroidKey = (state: RootState) => state.settings.production.qtfm.android;

View File

@@ -24,6 +24,9 @@ export interface StagingSettings {
kwm2: { kwm2: {
keys: StagingKWMv2Key[]; keys: StagingKWMv2Key[];
}; };
qtfm: {
android: string;
};
} }
export interface ProductionSettings { export interface ProductionSettings {
@@ -34,6 +37,9 @@ export interface ProductionSettings {
kwm2: { kwm2: {
keys: ProductionKWMv2Keys; // { [`${rid}-${quality}`]: ekey } keys: ProductionKWMv2Keys; // { [`${rid}-${quality}`]: ekey }
}; };
qtfm: {
android: string;
};
} }
export interface SettingsState { export interface SettingsState {
@@ -46,10 +52,12 @@ const initialState: SettingsState = {
staging: { staging: {
qmc2: { allowFuzzyNameSearch: true, keys: [] }, qmc2: { allowFuzzyNameSearch: true, keys: [] },
kwm2: { keys: [] }, kwm2: { keys: [] },
qtfm: { android: '' },
}, },
production: { production: {
qmc2: { allowFuzzyNameSearch: true, keys: {} }, qmc2: { allowFuzzyNameSearch: true, keys: {} },
kwm2: { keys: {} }, kwm2: { keys: {} },
qtfm: { android: '' },
}, },
}; };
@@ -61,6 +69,7 @@ const stagingToProduction = (staging: StagingSettings): ProductionSettings => ({
kwm2: { kwm2: {
keys: stagingKeyToProduction(staging.kwm2.keys, kwm2StagingToProductionKey, kwm2StagingToProductionValue), keys: stagingKeyToProduction(staging.kwm2.keys, kwm2StagingToProductionKey, kwm2StagingToProductionValue),
}, },
qtfm: staging.qtfm,
}); });
const productionToStaging = (production: ProductionSettings): StagingSettings => ({ const productionToStaging = (production: ProductionSettings): StagingSettings => ({
@@ -71,6 +80,7 @@ const productionToStaging = (production: ProductionSettings): StagingSettings =>
kwm2: { kwm2: {
keys: productionKeyToStaging(production.kwm2.keys, kwm2ProductionToStaging), keys: productionKeyToStaging(production.kwm2.keys, kwm2ProductionToStaging),
}, },
qtfm: production.qtfm,
}); });
export const settingsSlice = createSlice({ export const settingsSlice = createSlice({
@@ -101,7 +111,7 @@ export const settingsSlice = createSlice({
}, },
qmc2UpdateKey( qmc2UpdateKey(
state, state,
{ payload: { id, field, value } }: PayloadAction<{ id: string; field: keyof StagingQMCv2Key; value: string }> { payload: { id, field, value } }: PayloadAction<{ id: string; field: keyof StagingQMCv2Key; value: string }>,
) { ) {
const keyItem = state.staging.qmc2.keys.find((item) => item.id === id); const keyItem = state.staging.qmc2.keys.find((item) => item.id === id);
if (keyItem) { if (keyItem) {
@@ -134,7 +144,7 @@ export const settingsSlice = createSlice({
}, },
kwm2UpdateKey( kwm2UpdateKey(
state, state,
{ payload: { id, field, value } }: PayloadAction<{ id: string; field: keyof StagingKWMv2Key; value: string }> { payload: { id, field, value } }: PayloadAction<{ id: string; field: keyof StagingKWMv2Key; value: string }>,
) { ) {
const keyItem = state.staging.kwm2.keys.find((item) => item.id === id); const keyItem = state.staging.kwm2.keys.find((item) => item.id === id);
if (keyItem) { if (keyItem) {
@@ -142,6 +152,10 @@ export const settingsSlice = createSlice({
state.dirty = true; state.dirty = true;
} }
}, },
qtfmAndroidUpdateKey(state, { payload: { deviceKey } }: PayloadAction<{ deviceKey: string }>) {
state.staging.qtfm.android = deviceKey;
state.dirty = true;
},
kwm2ClearKeys(state) { kwm2ClearKeys(state) {
state.staging.kwm2.keys = []; state.staging.kwm2.keys = [];
state.dirty = true; state.dirty = true;
@@ -183,6 +197,8 @@ export const {
kwm2ClearKeys, kwm2ClearKeys,
kwm2ImportKeys, kwm2ImportKeys,
qtfmAndroidUpdateKey,
commitStagingChange, commitStagingChange,
discardStagingChanges, discardStagingChanges,
} = settingsSlice.actions; } = settingsSlice.actions;

View File

@@ -1,3 +1,4 @@
import './pwa';
import React from 'react'; import React from 'react';
import ReactDOM from 'react-dom/client'; import ReactDOM from 'react-dom/client';
@@ -13,5 +14,5 @@ SyntaxHighlighter.registerLanguage('bash', hljsSyntaxBash);
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render( ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
<React.StrictMode> <React.StrictMode>
<AppRoot /> <AppRoot />
</React.StrictMode> </React.StrictMode>,
); );

10
src/pwa.ts Normal file
View File

@@ -0,0 +1,10 @@
import { registerSW } from 'virtual:pwa-register';
const updateSW = registerSW({
onNeedRefresh() {
if (confirm('应用程序已更新,是否刷新?')) {
updateSW();
}
},
onOfflineReady() {},
});

View File

@@ -1,4 +1,4 @@
import { PreloadedState, combineReducers, configureStore } from '@reduxjs/toolkit'; import { combineReducers, configureStore } from '@reduxjs/toolkit';
import fileListingReducer from './features/file-listing/fileListingSlice'; import fileListingReducer from './features/file-listing/fileListingSlice';
import settingsReducer from './features/settings/settingsSlice'; import settingsReducer from './features/settings/settingsSlice';
@@ -7,12 +7,13 @@ const rootReducer = combineReducers({
settings: settingsReducer, settings: settingsReducer,
}); });
export const setupStore = (preloadedState?: PreloadedState<RootState>) => export type RootState = ReturnType<typeof rootReducer>;
export const setupStore = (preloadedState?: Partial<RootState>) =>
configureStore({ configureStore({
reducer: rootReducer, reducer: rootReducer,
preloadedState, preloadedState,
}); });
export type RootState = ReturnType<typeof rootReducer>;
export type AppStore = ReturnType<typeof setupStore>; export type AppStore = ReturnType<typeof setupStore>;
export type AppDispatch = AppStore['dispatch']; export type AppDispatch = AppStore['dispatch'];

View File

@@ -1,4 +1,3 @@
import { PreloadedState } from '@reduxjs/toolkit';
import { RenderOptions, render } from '@testing-library/react'; import { RenderOptions, render } from '@testing-library/react';
import { PropsWithChildren } from 'react'; import { PropsWithChildren } from 'react';
import { Provider } from 'react-redux'; import { Provider } from 'react-redux';
@@ -10,13 +9,13 @@ import { AppStore, RootState, setupStore } from '~/store';
export * from '@testing-library/react'; export * from '@testing-library/react';
export interface ExtendedRenderOptions extends RenderOptions { export interface ExtendedRenderOptions extends RenderOptions {
preloadedState?: PreloadedState<RootState>; preloadedState?: Partial<RootState>;
store?: AppStore; store?: AppStore;
} }
export function renderWithProviders( export function renderWithProviders(
ui: React.ReactElement, ui: React.ReactElement,
{ preloadedState = {}, store = setupStore(preloadedState), ...renderOptions }: ExtendedRenderOptions = {} { preloadedState = {}, store = setupStore(preloadedState), ...renderOptions }: ExtendedRenderOptions = {},
) { ) {
function Wrapper({ children }: PropsWithChildren<unknown>): JSX.Element { function Wrapper({ children }: PropsWithChildren<unknown>): JSX.Element {
return <Provider store={store}>{children}</Provider>; return <Provider store={store}>{children}</Provider>;

View File

@@ -9,6 +9,12 @@ export const theme = extendTheme({
'Segoe UI,Helvetica,Arial,sans-serif', 'Segoe UI,Helvetica,Arial,sans-serif',
'Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol', 'Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol',
].join(','), ].join(','),
mono: [
'SFMono-Regular,Menlo,Monaco',
'"Sarasa Mono CJK SC"',
'Consolas,"Liberation Mono","Courier New",monospace',
'"Microsoft YaHei UI"',
].join(','),
}, },
components: { components: {
Button: { Button: {
@@ -25,6 +31,16 @@ export const theme = extendTheme({
color: 'blue.600', color: 'blue.600',
}, },
}, },
Text: {
baseStyle: {
mt: 1,
},
},
Header: {
baseStyle: {
mt: 3,
},
},
}, },
styles: { styles: {
global: { global: {

View File

@@ -23,16 +23,21 @@ export class DatabaseKeyExtractor {
return tables.includes(name); return tables.includes(name);
} }
extractQmAndroidDbKeys(buffer: ArrayBuffer): null | QMAndroidKeyEntry[] { extractQmcV2KeysFromSqliteDb(buffer: ArrayBuffer): null | QMAndroidKeyEntry[] {
let db: SQLDatabase | null = null; let db: SQLDatabase | null = null;
try { try {
db = new this.SQL.Database(new Uint8Array(buffer)); db = new this.SQL.Database(new Uint8Array(buffer));
if (!this.hasTable(db, 'audio_file_ekey_table')) {
return null;
}
const result = db.exec('select file_path, ekey from audio_file_ekey_table'); let sql: undefined | string;
if (this.hasTable(db, 'audio_file_ekey_table')) {
sql = 'select file_path, ekey from audio_file_ekey_table';
} else if (this.hasTable(db, 'EKeyFileInfo')) {
sql = 'select filePath, eKey from EKeyFileInfo';
}
if (!sql) return null;
const result = db.exec(sql);
if (result.length === 0) { if (result.length === 0) {
return []; return [];
} }

View File

@@ -1,4 +1,3 @@
import type { StagingKWMv2Key } from '~/features/settings/keyFormats';
import { bytesToUTF8String } from '~/decrypt-worker/util/utf8Encoder'; import { bytesToUTF8String } from '~/decrypt-worker/util/utf8Encoder';
import { formatHex } from './formatHex'; import { formatHex } from './formatHex';
@@ -69,7 +68,11 @@ export class MMKVParser {
return bytesToUTF8String(data).normalize(); return bytesToUTF8String(data).normalize();
} }
public readOptionalString() { public readKey() {
return this.readString();
}
public readStringValue(): string | null {
// Container [ // Container [
// len: int, // len: int,
// data: variant // data: variant
@@ -96,37 +99,4 @@ export class MMKVParser {
const containerLen = this.readInt(); const containerLen = this.readInt();
this.offset += containerLen; this.offset += containerLen;
} }
public static toStringMap(view: DataView): Map<string, string> {
const mmkv = new MMKVParser(view);
const result = new Map<string, string>();
while (!mmkv.eof) {
const key = mmkv.readString();
const value = mmkv.readOptionalString();
if (value) {
result.set(key, value);
}
}
return result;
}
public static parseKuwoEKey(view: DataView): Omit<StagingKWMv2Key, 'id'>[] {
const mmkv = new MMKVParser(view);
const result: Omit<StagingKWMv2Key, 'id'>[] = [];
while (!mmkv.eof) {
const key = mmkv.readString();
const idMatch = key.match(/^sec_ekey#(\d+)-(.+)/);
if (!idMatch) {
mmkv.skipContainer();
continue;
}
const [_, rid, quality] = idMatch;
const ekey = mmkv.readOptionalString();
if (ekey) {
result.push({ rid, quality, ekey });
}
}
return result;
}
} }

View File

@@ -18,15 +18,18 @@ test('should be able to forward request to worker client bus', async () => {
vi.spyOn(bus, 'request').mockImplementation( vi.spyOn(bus, 'request').mockImplementation(
async (actionName: DECRYPTION_WORKER_ACTION_NAME, payload: unknown): Promise<unknown> => { async (actionName: DECRYPTION_WORKER_ACTION_NAME, payload: unknown): Promise<unknown> => {
return { actionName, payload }; return { actionName, payload };
} },
); );
const queue = new DecryptionQueue(bus, 1); const queue = new DecryptionQueue(bus, 1);
await expect(queue.add({ id: 'file://1', blobURI: 'blob://mock-file' })).resolves.toEqual({ await expect(
queue.add({ id: 'file://1', blobURI: 'blob://mock-file', options: { fileName: 'test.bin' } }),
).resolves.toEqual({
actionName: DECRYPTION_WORKER_ACTION_NAME.DECRYPT, actionName: DECRYPTION_WORKER_ACTION_NAME.DECRYPT,
payload: { payload: {
blobURI: 'blob://mock-file', blobURI: 'blob://mock-file',
id: 'file://1', id: 'file://1',
options: { fileName: 'test.bin' },
}, },
}); });
}); });

View File

@@ -1,46 +1,28 @@
import { MMKVParser } from '../MMKVParser'; import { MMKVParser } from '../MMKVParser';
import { readFileSync } from 'node:fs';
const makeViewFromBuffer = (buff: Buffer) => const makeViewFromBuffer = (buff: Buffer) =>
new DataView(buff.buffer.slice(buff.byteOffset, buff.byteOffset + buff.byteLength)); new DataView(buff.buffer.slice(buff.byteOffset, buff.byteOffset + buff.byteLength));
test('parse qm mmkv file', () => {
const view = makeViewFromBuffer(readFileSync(__dirname + '/__fixture__/qm.mmkv'));
expect(Object.fromEntries(MMKVParser.toStringMap(view).entries())).toMatchInlineSnapshot(`
{
"Lorem Ipsum": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vestibulum congue volutpat metus non molestie. Quisque id est sapien. Fusce eget tristique sem. Donec tellus lacus, viverra sed lectus eget, elementum ultrices dolor. Integer non urna justo.",
"key": "value",
}
`);
});
test('parse qm mmkv file with optional str', () => {
const view = makeViewFromBuffer(readFileSync(__dirname + '/__fixture__/qm_optional.mmkv'));
expect(Object.fromEntries(MMKVParser.toStringMap(view).entries())).toMatchInlineSnapshot(`
{
"key": "value",
"key2": "value2",
}
`);
});
test('parse kuwo mmkv file', () => {
const view = makeViewFromBuffer(readFileSync(__dirname + '/__fixture__/kuwo.mmkv'));
expect(MMKVParser.parseKuwoEKey(view)).toMatchInlineSnapshot(`
[
{
"ekey": "xyz123",
"quality": "20201kmflac",
"rid": "1234567",
},
]
`);
});
test('throw error on broken file', () => { test('throw error on broken file', () => {
const view = makeViewFromBuffer( const view = makeViewFromBuffer(
Buffer.from([0x27, 0x00, 0x00, 0x00, 0x7f, 0x03, 0x6b, 0x65, 0x79, 0x06, 0x07, 0x62, 0x61, 0x64, 0xff, 0xff]), Buffer.from([0x27, 0x00, 0x00, 0x00, 0x7f, 0x03, 0x6b, 0x65, 0x79, 0x06, 0x07, 0x62, 0x61, 0x64, 0xff, 0xff]),
); );
expect(() => Object.fromEntries(MMKVParser.toStringMap(view).entries())).toThrow(/offset mismatch/i); expect(() => {
const parser = new MMKVParser(view);
parser.readKey();
parser.readStringValue();
}).toThrow(/offset mismatch/i);
});
test('able to handle empty value', () => {
const view = makeViewFromBuffer(
Buffer.from([0x0b, 0x00, 0x00, 0x00, 0x7f, 0x03, 0x6b, 0x65, 0x79, 0x00, 0x01, 0x31, 0x02, 0x01, 0x32, 0xff]),
);
const parser = new MMKVParser(view);
expect(parser.readKey()).toEqual('key');
expect(parser.readStringValue()).toEqual(null);
expect(parser.readKey()).toEqual('1');
expect(parser.readStringValue()).toEqual('2');
}); });

3
src/util/deepClone.ts Normal file
View File

@@ -0,0 +1,3 @@
export function deepClone<T>(obj: T): T {
return JSON.parse(JSON.stringify(obj));
}

Binary file not shown.

View File

@@ -0,0 +1,31 @@
import { readFileSync } from 'node:fs';
import { parseAndroidKuwoEKey, parseIosKuwoEKey } from '../kuwo';
const makeViewFromBuffer = (buff: Buffer) =>
new DataView(buff.buffer.slice(buff.byteOffset, buff.byteOffset + buff.byteLength));
test('parse kuwo android ekey mmkv file "cn.kuwo.player.mmkv.defaultconfig"', () => {
const view = makeViewFromBuffer(readFileSync(__dirname + '/__fixture__/kuwo_android.mmkv'));
expect(parseAndroidKuwoEKey(view)).toMatchInlineSnapshot(`
[
{
"ekey": "xyz123",
"quality": "20201kmflac",
"rid": "1234567",
},
]
`);
});
test('parse kuwo ios ekey mmkv file "kw_ekey"', () => {
const view = makeViewFromBuffer(readFileSync(__dirname + '/__fixture__/kuwo_ios.mmkv'));
expect(parseIosKuwoEKey(view)).toMatchInlineSnapshot(`
[
{
"ekey": "xyz123",
"quality": "20201",
"rid": "1234567",
},
]
`);
});

View File

@@ -0,0 +1,15 @@
import { readFileSync } from 'node:fs';
import { parseAndroidQmEKey } from '../qm';
const makeViewFromBuffer = (buff: Buffer) =>
new DataView(buff.buffer.slice(buff.byteOffset, buff.byteOffset + buff.byteLength));
test('parse qm mmkv file', () => {
const view = makeViewFromBuffer(readFileSync(__dirname + '/__fixture__/qm.mmkv'));
expect(Object.fromEntries(parseAndroidQmEKey(view).entries())).toMatchInlineSnapshot(`
{
"Lorem Ipsum": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vestibulum congue volutpat metus non molestie. Quisque id est sapien. Fusce eget tristique sem. Donec tellus lacus, viverra sed lectus eget, elementum ultrices dolor. Integer non urna justo.",
"key": "value",
}
`);
});

41
src/util/mmkv/kuwo.ts Normal file
View File

@@ -0,0 +1,41 @@
import type { StagingKWMv2Key } from '~/features/settings/keyFormats';
import { MMKVParser } from '../MMKVParser';
export function parseAndroidKuwoEKey(view: DataView): Omit<StagingKWMv2Key, 'id'>[] {
const mmkv = new MMKVParser(view);
const result: Omit<StagingKWMv2Key, 'id'>[] = [];
while (!mmkv.eof) {
const key = mmkv.readString();
const idMatch = key.match(/^sec_ekey#(\d+)-(\w+)$/);
if (!idMatch) {
mmkv.skipContainer();
continue;
}
const [_, rid, quality] = idMatch;
const ekey = mmkv.readStringValue();
if (ekey) {
result.push({ rid, quality, ekey });
}
}
return result;
}
export function parseIosKuwoEKey(view: DataView): Omit<StagingKWMv2Key, 'id'>[] {
const mmkv = new MMKVParser(view);
const result: Omit<StagingKWMv2Key, 'id'>[] = [];
while (!mmkv.eof) {
const key = mmkv.readKey();
const idMatch = key.match(/^(\d+)_(\d+)$/);
if (!idMatch) {
mmkv.skipContainer();
continue;
}
const [_, rid, quality] = idMatch;
const ekey = mmkv.readStringValue();
if (ekey) {
result.push({ rid, quality, ekey });
}
}
return result;
}

14
src/util/mmkv/qm.ts Normal file
View File

@@ -0,0 +1,14 @@
import { MMKVParser } from '../MMKVParser';
export function parseAndroidQmEKey(view: DataView): Map<string, string> {
const mmkv = new MMKVParser(view);
const result = new Map<string, string>();
while (!mmkv.eof) {
const key = mmkv.readString();
const value = mmkv.readStringValue();
if (value) {
result.set(key, value);
}
}
return result;
}

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

@@ -1 +1,9 @@
/// <reference types="vite/client" /> /// <reference types="vite/client" />
module 'virtual:pwa-register' {
/**
* See: {@link https://vite-pwa-org.netlify.app/guide/prompt-for-update.html}
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
declare function registerSW(opts: unknown): () => void;
}

View File

@@ -40,7 +40,7 @@ export default defineConfig({
}, },
base: './', base: './',
optimizeDeps: { optimizeDeps: {
exclude: ['@jixun/libparakeet', 'sql.js'], exclude: ['@um/libparakeet', 'sql.js'],
}, },
plugins: [ plugins: [
replace({ replace({
@@ -57,7 +57,7 @@ export default defineConfig({
registerType: 'prompt', registerType: 'prompt',
workbox: { workbox: {
// Cache everything from dist // Cache everything from dist
globPatterns: ['**/*.{js,css,html,ico,png,svg,wasm}'], globPatterns: ['**/*.{js,css,html,ico,png,svg,wasm,webp}'],
}, },
manifest: { manifest: {
display: 'standalone', display: 'standalone',
@@ -86,6 +86,8 @@ export default defineConfig({
alias: { alias: {
'~': path.resolve(__dirname, 'src'), '~': path.resolve(__dirname, 'src'),
'@nm': path.resolve(__dirname, 'node_modules'), '@nm': path.resolve(__dirname, 'node_modules'),
// workaround for vite, workbox (PWA) and Emscripten transpiled parakeet lib (use of `import("module")`)
module: path.resolve(__dirname, 'src', 'dummy.mjs'), module: path.resolve(__dirname, 'src', 'dummy.mjs'),
}, },
}, },
@@ -96,7 +98,7 @@ export default defineConfig({
reacts: ['react', 'react-dom', 'react-dropzone', 'react-promise-suspense', 'react-redux', '@reduxjs/toolkit'], reacts: ['react', 'react-dom', 'react-dropzone', 'react-promise-suspense', 'react-redux', '@reduxjs/toolkit'],
chakra: ['@chakra-ui/react', '@emotion/react', '@emotion/styled', 'framer-motion'], chakra: ['@chakra-ui/react', '@emotion/react', '@emotion/styled', 'framer-motion'],
icons: ['react-icons', '@chakra-ui/icons'], icons: ['react-icons', '@chakra-ui/icons'],
utility: ['radash', 'nanoid', 'immer', 'react-syntax-highlighter'], utility: ['radash', 'nanoid', 'react-syntax-highlighter'],
}, },
}, },
}, },
@@ -106,15 +108,8 @@ export default defineConfig({
mockReset: true, mockReset: true,
environment: 'jsdom', environment: 'jsdom',
setupFiles: ['src/test-utils/setup-jest.ts'], setupFiles: ['src/test-utils/setup-jest.ts'],
alias: [
{
find: /^~\/(.*)/,
replacement: 'src/$1',
},
],
// workaround: sql.js is not ESModule friendly, yet... // workaround: sql.js is not ESModule friendly, yet...
deps: { deps: {
// inline: ['sql.js'],
optimizer: { optimizer: {
web: { web: {
include: ['sql.js'], include: ['sql.js'],