update
9
.editorconfig
Normal file
@ -0,0 +1,9 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
4
.eslintignore
Normal file
@ -0,0 +1,4 @@
|
||||
node_modules
|
||||
dist
|
||||
out
|
||||
.gitignore
|
3
.eslintrc.js
Normal file
@ -0,0 +1,3 @@
|
||||
module.exports = {
|
||||
extends: ['eslint:recommended', '@electron-toolkit', '@electron-toolkit/eslint-config-prettier']
|
||||
}
|
43
.gitignore
vendored
Normal file
@ -0,0 +1,43 @@
|
||||
# Logs
|
||||
logs/
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules/
|
||||
coverage/
|
||||
dist/
|
||||
dist-ssr/
|
||||
release/**/*
|
||||
*.local
|
||||
src/renderer/**/*
|
||||
src/default/**/*
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/
|
||||
!.vscode/extensions.json
|
||||
.idea/
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
# Ignore the same patterns in the "um-react" directory
|
||||
um-react/logs/
|
||||
um-react/*.log
|
||||
um-react/npm-debug.log*
|
||||
um-react/yarn-debug.log*
|
||||
um-react/yarn-error.log*
|
||||
um-react/pnpm-debug.log*
|
||||
um-react/lerna-debug.log*
|
||||
|
||||
um-react/node_modules/
|
||||
um-react/coverage/
|
||||
um-react/dist/
|
||||
um-react/dist-ssr/
|
||||
um-react/*.local
|
6
.prettierignore
Normal file
@ -0,0 +1,6 @@
|
||||
out
|
||||
dist
|
||||
pnpm-lock.yaml
|
||||
LICENSE.md
|
||||
tsconfig.json
|
||||
tsconfig.*.json
|
4
.prettierrc.yaml
Normal file
@ -0,0 +1,4 @@
|
||||
singleQuote: true
|
||||
semi: false
|
||||
printWidth: 100
|
||||
trailingComma: none
|
104
README.MD
Normal file
@ -0,0 +1,104 @@
|
||||
# 关于仓库官方
|
||||
|
||||
本仓库原始地址(已 DMCA):https://github.com/unlock-music/unlock-music
|
||||
|
||||
本仓库目前官方地址:https://git.unlock-music.dev/um/um-react
|
||||
|
||||
你所看到的这个仓库是依照 MIT 协议授权的,除electron部分(不限于electron构建)外,代码与[本人](https://github.com/dfvips)无关。
|
||||
|
||||
# Unlock Music 音乐解锁 Electron 应用程序
|
||||
|
||||
[![Build Status](https://ci.unlock-music.dev/api/badges/um/web/status.svg)](https://github.com/dfvips/unlock-music-react-electron)
|
||||
- 在Electron应用程序中解锁加密的音乐文件。 Unlock encrypted music file in the Electron App.
|
||||
- 无需依赖浏览器,开箱即可使用。
|
||||
|
||||
## 自行构建Electron应用程序
|
||||
|
||||
- 环境要求
|
||||
- Linux、MacOs、Windows
|
||||
- python3、nodejs、npm
|
||||
|
||||
- 开发环境
|
||||
```sh
|
||||
cd um-react && npm install --frozen-lockfile & npm install
|
||||
npm run start / npm run dev
|
||||
```
|
||||
- 打包
|
||||
```sh
|
||||
npm run build / npm run build:win / npm run build:mac
|
||||
```
|
||||
- 前端静态页面构建后的产物可以在 `src/renderer` 目录找到。
|
||||
- Electron构建后的产物可以在 `release` 目录找到。
|
||||
|
||||
## 开箱即用版本
|
||||
|
||||
https://github.com/dfvips/unlock-music-electron/releases
|
||||
# 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)
|
||||
|
||||
- 在浏览器中解锁加密的音乐文件。 Unlock encrypted music file in the browser.
|
||||
- Unlock Music 项目是以学习和技术研究的初衷创建的,修改、再分发时请遵循[授权协议]。
|
||||
- Unlock Music 的 CLI 版本可以在 [unlock-music/cli] 找到,大批量转换建议使用 CLI 版本。
|
||||
- 我们新建了 Telegram 群组 [`@unlock_music_chat`] ,欢迎加入!
|
||||
- CI 自动构建已经部署,可以在 [Packages][um-react-packages] 下载。
|
||||
|
||||
[授权协议]: https://git.unlock-music.dev/um/um-react/src/branch/main/LICENSE
|
||||
[unlock-music/cli]: https://git.unlock-music.dev/um/cli
|
||||
[`@unlock_music_chat`]: https://t.me/unlock_music_chat
|
||||
[um-react-packages]: https://git.unlock-music.dev/um/-/packages/generic/um-react/
|
||||
|
||||
## 支持的格式
|
||||
|
||||
- [x] QQ 音乐 QMCv1 (.qmc0/.qmc2/.qmc3/.qmcflac/.qmcogg/.tkm)
|
||||
- [x] QQ 音乐 QMCv2 PC 端 (.mflac/.mgg/.mflac0/.mgg1/.mggl)
|
||||
- [x] 网易云音乐 (.ncm)
|
||||
- [x] 虾米音乐 (.xm)
|
||||
- [x] 酷我音乐 (.kwm)
|
||||
- [x] 酷狗音乐 (.kgm/.vpr)
|
||||
- [x] 喜马拉雅 Android 端 (.x2m/.x3m)
|
||||
- [x] 咪咕音乐格式 (.mg3d)
|
||||
- [ ] ~~<ruby>QQ 音乐海外版<rt>JOOX Music</rt></ruby> (.ofl_en)~~
|
||||
|
||||
不支持的格式?请提交样本(加密文件)与客户端信息(或一并上传其安装包)到[仓库的问题追踪区][project-issues]。如果文件太大,请上传到不需要登入下载的网盘,如 [mega.nz](https://mega.nz)、[OneDrive](https://www.onedrive.com/) 等。
|
||||
|
||||
如果遇到解密出错的情况,请一并携带错误信息并简单描述错误的重现过程。
|
||||
|
||||
[project-issues]: https://git.unlock-music.dev/um/um-react/issues/new
|
||||
|
||||
## 开发相关
|
||||
|
||||
从源码运行或编译生产版本,请参考文档「[新手上路](./docs/getting-started.zh.md)」。
|
||||
|
||||
### 面向 libparakeet SDK 开发
|
||||
|
||||
⚠️ 如果只是进行前端方面的更改,你可以跳过该节。
|
||||
|
||||
请参考文档「[面向 `libparakeet-js` 开发](./docs/develop-with-libparakeet.zh.md)」。
|
||||
|
||||
### 架构
|
||||
|
||||
- 浏览器主线程: 渲染界面,处理 UI 更新
|
||||
- Web Worker: 负责计算方面的内容,如内容解密。
|
||||
|
||||
数据传输: 生成 blob url (`URL.createObjectURL`) 然后透过 `postMessage` 传递给线程,线程利用 `fetch` API 来获取文件信息。
|
||||
|
||||
### 贡献代码
|
||||
|
||||
欢迎贡献代码。请确保:
|
||||
|
||||
- 单元测试的覆盖率不低于主分支的覆盖率;
|
||||
- 提交前请使用 Prettier 对代码进行格式化;
|
||||
- 提交前请确保 ESLint 不会报告 warning / error;
|
||||
|
||||
满足上述条件后发起 Pull Request,仓库管理员审阅后将合并到主分支。
|
||||
|
||||
## TODO
|
||||
|
||||
- 待定
|
||||
- [ ] 各类算法 [追踪 `crypto` 标签](https://git.unlock-music.dev/um/um-react/issues?labels=67)
|
||||
- [ ] #7 简易元数据编辑器
|
||||
- 完成
|
||||
- [x] #8 ~~添加单元测试~~ 框架加上了,以后慢慢添加更多测试即可。
|
||||
- [x] #2 解密内容探测 (解密过程)
|
||||
- [x] #6 文件拖放 (利用 `react-dropzone`?)
|
BIN
build/icon.icns
Normal file
BIN
build/icon.ico
Executable file
After Width: | Height: | Size: 43 KiB |
BIN
build/icon.png
Normal file
After Width: | Height: | Size: 126 KiB |
57
electron-builder.json5
Executable file
@ -0,0 +1,57 @@
|
||||
/**
|
||||
* @see https://www.electron.build/configuration/configuration
|
||||
*/
|
||||
{
|
||||
"$schema": "https://github.com/dfvips/iqiyi-parser",
|
||||
"appId": "com.dreamfly.um",
|
||||
"productName": "音乐解锁 React",
|
||||
"asar": true,
|
||||
"directories": {
|
||||
"output": "release/${version}"
|
||||
},
|
||||
"asarUnpack":[
|
||||
"resources/**"
|
||||
],
|
||||
"mac": {
|
||||
"icon": "build/icon.icns",
|
||||
"artifactName": "${name}-${version}-osx64.${ext}",
|
||||
"target": [
|
||||
"dmg"
|
||||
],
|
||||
"files": [
|
||||
"!**/.vscode/*",
|
||||
"!{.eslintignore,.eslintrc.js,.prettierignore,.prettierrc.yaml,dev-app-update.yml,CHANGELOG.md,README.md}"
|
||||
]
|
||||
},
|
||||
"compression": "maximum",
|
||||
"win": {
|
||||
"icon": "build/icon.ico",
|
||||
"target": [
|
||||
{
|
||||
"target": "nsis",
|
||||
"arch": [
|
||||
"x64"
|
||||
]
|
||||
},
|
||||
{
|
||||
"target": "7z"
|
||||
}
|
||||
],
|
||||
"artifactName": "${name}-${version}-winx64.${ext}",
|
||||
"files": [
|
||||
"!**/.vscode/*",
|
||||
"!{.eslintignore,.eslintrc.js,.prettierignore,.prettierrc.yaml,dev-app-update.yml,CHANGELOG.md,README.md}"
|
||||
]
|
||||
},
|
||||
"nsis": {
|
||||
"oneClick": false,
|
||||
"perMachine": false,
|
||||
"allowToChangeInstallationDirectory": true,
|
||||
"deleteAppDataOnUninstall": true,
|
||||
"installerIcon": "build/icon.ico",
|
||||
"uninstallerIcon": "build/icon.ico",
|
||||
"uninstallDisplayName": "${name}-${version}",
|
||||
"artifactName": "${name}-${version}-winx64-setup.${ext}",
|
||||
"shortcutName": "${productName}"
|
||||
}
|
||||
}
|
4383
package-lock.json
generated
Normal file
30
package.json
Normal file
@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "um-react-electron",
|
||||
"version": "1.0.0",
|
||||
"description": "Unlock Music 音乐解锁 (React) Electron App",
|
||||
"main": "./src/main.js",
|
||||
"author": "dreamfly",
|
||||
"homepage": "https://git.unlock-music.dev/um/um-react",
|
||||
"scripts": {
|
||||
"format": "prettier --write .",
|
||||
"lint": "eslint --ext .js .",
|
||||
"dev": "cd um-react && npm run start & electron .",
|
||||
"start": "cd um-react && npm run start & electron .",
|
||||
"postinstall": "electron-builder install-app-deps",
|
||||
"build:win": "cd um-react && npm run build && cd ../ && electron-builder --win --config",
|
||||
"build:mac": "cd um-react && npm run build && cd ../ && electron-builder --mac --config",
|
||||
"build:linux": "cd um-react && npm run build && cd ../ && electron-builder --linux --config"
|
||||
},
|
||||
"dependencies": {
|
||||
"@electron-toolkit/preload": "^2.0.0",
|
||||
"@electron-toolkit/utils": "^2.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@electron-toolkit/eslint-config": "^1.0.1",
|
||||
"@electron-toolkit/eslint-config-prettier": "^1.0.1",
|
||||
"electron": "^25.6.0",
|
||||
"electron-builder": "^24.6.3",
|
||||
"eslint": "^8.47.0",
|
||||
"prettier": "^3.0.2"
|
||||
}
|
||||
}
|
BIN
resources/icon.ico
Executable file
After Width: | Height: | Size: 43 KiB |
BIN
resources/icon.png
Normal file
After Width: | Height: | Size: 126 KiB |
32
src/icons.svg
Normal file
@ -0,0 +1,32 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 900 300">
|
||||
<g fill="none" fill-rule="evenodd">
|
||||
<g class="hero-apps" style="fill: #71abb7;">
|
||||
<path d="M15 138l-4.9-.64L8 133l-2.1 4.36L1 138l3.6 3.26-.93 4.74L8 143.67l4.33 2.33-.93-4.74z"></path>
|
||||
<path d="M897.2 114.0912l-5.2 3.63v-2.72c0-.55-.45-1-1-1h-8c-.55 0-1 .45-1 1v9c0 .55.45 1 1 1h8c.55 0 1-.45 1-1v-2.72l5.2 3.63c.33.23.8 0 .8-.41v-10c0-.41-.47-.64-.8-.41z"></path>
|
||||
<path d="M65.4 188.625h-1.6c.88 0 1.6-.7313 1.6-1.625v-1.625c0-.8937-.72-1.625-1.6-1.625h-1.6c-.88 0-1.6.7313-1.6 1.625V187c0 .8937.72 1.625 1.6 1.625h-1.6c-.88 0-1.6.7313-1.6 1.625v3.25h1.6v4.875c0 .8937.72 1.625 1.6 1.625h1.6c.88 0 1.6-.7313 1.6-1.625V193.5H67v-3.25c0-.8937-.72-1.625-1.6-1.625zm-3.2-3.25h1.6V187h-1.6v-1.625zm3.2 6.5h-1.6v6.5h-1.6v-6.5h-1.6v-1.625h4.8v1.625zm3.344-5.6875c0-3.2175-2.576-5.8337-5.744-5.8337-3.168 0-5.744 2.6162-5.744 5.8337 0 .455.048.8937.144 1.3162v3.2175c-.976-1.2512-1.6-2.8112-1.6-4.55 0-4.03 3.232-7.3125 7.2-7.3125s7.2 3.2825 7.2 7.3125c0 1.7225-.624 3.2988-1.6 4.55v-3.2175c.096-.4387.144-.8612.144-1.3162zm6.256 0c0 4.68-2.608 8.7425-6.4 10.7738v-1.7063c2.976-1.885 4.944-5.2325 4.944-9.0675 0-5.915-4.72-10.7087-10.544-10.7087-5.824 0-10.544 4.7937-10.544 10.7087 0 3.835 1.968 7.1825 4.944 9.0675v1.7063c-3.792-2.0313-6.4-6.0938-6.4-10.7738C51 179.46 56.376 174 63 174s12 5.46 12 12.1875z"></path>
|
||||
<path d="M830.7143 142.3333c-.8643 0-1.5714.7125-1.5714 1.5834v3.1666c0 .871.707 1.5834 1.5713 1.5834h12.5714c.8643 0 1.5714-.7125 1.5714-1.5834v-3.1666c0-.871-.707-1.5834-1.5713-1.5834h-12.5714zm12.5714 2.771l-1.9643 1.979h-2.357L837 145.1043l-1.9643 1.979h-2.357l-1.9644-1.979v-1.1876h1.1786l1.964 1.979 1.9644-1.979h2.3572l1.9643 1.979 1.964-1.979h1.1787v1.1875zm-9.4286 5.1457h6.286v1.5833h-6.286V150.25zM837 136c-6.0657 0-11 4.6075-11 10.2917v7.125c0 .8708.707 1.5833 1.5714 1.5833h18.8572c.8643 0 1.5714-.7125 1.5714-1.5833v-7.125C848 140.6075 843.0657 136 837 136zm9.4286 17.4167h-18.8572v-7.125c0-4.8925 4.1486-8.851 9.4286-8.851 5.28 0 9.4286 3.9585 9.4286 8.851v7.125z"></path>
|
||||
<path d="M75 91.8065V96h4.1935L90.376 84.8174l-4.1934-4.1935L75 91.8064zm4.1935 2.7957h-2.7957v-2.7957h1.398v1.3978h1.3977v1.398zM93.591 81.6024l-1.817 1.817-4.1935-4.1934 1.817-1.817c.5453-.5453 1.426-.5453 1.971 0l2.2226 2.2224c.5453.5452.5453 1.4258 0 1.971z"></path>
|
||||
<path d="M797 187h4v4h-4v-4zm12-1v19c0 1.1-.9 2-2 2h-20c-1.1 0-2-.9-2-2v-24c0-1.1.9-2 2-2h15l7 7zm-2 1l-6-6h-14v22l6-10 4 8 4-4 6 6v-16z"></path>
|
||||
<path d="M138 125c-6.62 0-12 5-12 11 0 9.04 12 21 12 21s12-11.96 12-21c0-6-5.38-11-12-11zm0 29.1c-3.72-4.06-10-12.22-10-18.1 0-4.96 4.5-9 10-9 2.68 0 5.22.96 7.12 2.72 1.84 1.72 2.88 3.94 2.88 6.28 0 5.88-6.28 14.04-10 18.1zm4-18.1c0 2.22-1.78 4-4 4-2.22 0-4-1.78-4-4 0-2.22 1.78-4 4-4 2.22 0 4 1.78 4 4z"></path>
|
||||
<path d="M771 82h8v2h-8v-2zm0 6h8v-2h-8v2zm0 4h8v-2h-8v2zm22-10h-8v2h8v-2zm0 4h-8v2h8v-2zm0 4h-8v2h8v-2zm4-12v18c0 1.1-.9 2-2 2h-11l-2 2-2-2h-11c-1.1 0-2-.9-2-2V78c0-1.1.9-2 2-2h11l2 2 2-2h11c1.1 0 2 .9 2 2zm-16 1l-1-1h-11v18h12V79zm14-1h-11l-1 1v17h12V78z"></path>
|
||||
<path d="M176 203h-24c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h4v7l7-7h13c1.1 0 2-.9 2-2v-16c0-1.1-.9-2-2-2zm0 18h-14l-4 4v-4h-6v-16h24v16z"></path>
|
||||
<path d="M673 88.921c0 2.18-.9 4.18-2.34 5.66l-1.34-1.34c1.1-1.12 1.78-2.62 1.78-4.32 0-1.7-.68-3.22-1.78-4.32l1.34-1.34c1.44 1.44 2.34 3.44 2.34 5.66zm-8.56-11.48l-7.44 7.44h-4c-1.1 0-2 .9-2 2v4c0 1.1.9 2 2 2h4l7.44 7.44c.94.94 2.56.28 2.56-1.06v-20.76c0-1.34-1.62-2-2.56-1.06zm11.88.16l-1.34 1.34c2.56 2.56 4.12 6.06 4.12 9.96 0 3.88-1.56 7.4-4.12 9.96l1.34 1.34c2.9-2.9 4.68-6.9 4.68-11.32 0-4.44-1.78-8.44-4.68-11.32v.04zm-2.82 2.82l-1.38 1.34c1.84 1.84 2.96 4.38 2.96 7.16 0 2.78-1.12 5.32-2.96 7.12l1.38 1.34c2.16-2.16 3.5-5.16 3.5-8.46 0-3.3-1.34-6.32-3.5-8.5z"></path>
|
||||
<path d="M226 79h-16c0-1.1-.9-2-2-2h-8c-1.1 0-2 .9-2 2-1.1 0-2 .9-2 2v18c0 1.1.9 2 2 2h28c1.1 0 2-.9 2-2V81c0-1.1-.9-2-2-2zm-18 4h-8v-2h8v2zm9 14c-3.88 0-7-3.12-7-7s3.12-7 7-7 7 3.12 7 7-3.12 7-7 7zm5-7c0 2.76-2.26 5-5 5s-5-2.26-5-5 2.26-5 5-5 5 2.26 5 5z"></path>
|
||||
<path d="M725.8393 157h-15.6498c-1.1807 0-1.1807-.82-1.1807-2 0-1.18 0-2 1.1807-2h15.6298C727 153 727 153.82 727 155c0 1.18 0 2-1.1807 2h.02zm-11.6473-10c-1.1807 0-1.1807-.82-1.1807-2 0-1.18 0-2 1.1807-2h11.6273C727 143 727 143.82 727 145c0 1.18 0 2-1.1807 2H714.192zM695 146.82l2.8218-2.6 3.182 3.18 8.185-8.4 2.8218 2.82-11.0068 11-6.0038-6zM710.1895 163h15.6298C727 163 727 163.82 727 165c0 1.18 0 2-1.1807 2h-15.6298c-1.1807 0-1.1807-.82-1.1807-2 0-1.18 0-2 1.1807-2z"></path>
|
||||
<path d="M223 152v24c0 1.65 1.35 3 3 3h36c1.65 0 3-1.35 3-3v-24c0-1.65-1.35-3-3-3h-36c-1.65 0-3 1.35-3 3zm39 0l-18 15-18-15h36zm-36 4.5l12 9-12 9v-18zm3 19.5l10.5-9 4.5 4.5 4.5-4.5 10.5 9h-30zm33-1.5l-12-9 12-9v18z"></path>
|
||||
<path d="M648 182h-3v4.5c0 .84-.66 1.5-1.5 1.5h-6c-.84 0-1.5-.66-1.5-1.5V182h-9v4.5c0 .84-.66 1.5-1.5 1.5h-6c-.84 0-1.5-.66-1.5-1.5V182h-3c-1.65 0-3 1.35-3 3v33c0 1.65 1.35 3 3 3h33c1.65 0 3-1.35 3-3v-33c0-1.65-1.35-3-3-3zm0 36h-33v-27h33v27zm-24-33h-3v-6h3v6zm18 0h-3v-6h3v6zm-15 12h-3v-3h3v3zm6 0h-3v-3h3v3zm6 0h-3v-3h3v3zm6 0h-3v-3h3v3zm-24 6h-3v-3h3v3zm6 0h-3v-3h3v3zm6 0h-3v-3h3v3zm6 0h-3v-3h3v3zm6 0h-3v-3h3v3zm-24 6h-3v-3h3v3zm6 0h-3v-3h3v3zm6 0h-3v-3h3v3zm6 0h-3v-3h3v3zm6 0h-3v-3h3v3zm-24 6h-3v-3h3v3zm6 0h-3v-3h3v3zm6 0h-3v-3h3v3zm6 0h-3v-3h3v3z"></path>
|
||||
</g>
|
||||
<g class="hero-icons" style="fill: #c2f5ff;">
|
||||
<path d="M441.1132 69.724c7.681 0 13.9075-6.207 13.9075-13.8636 0-7.6565-6.2266-13.8634-13.9075-13.8634-7.681 0-13.9076 6.207-13.9076 13.8634 0 7.6566 6.2266 13.8635 13.9076 13.8635zm0-5.7932c-4.4713 0-8.096-3.6132-8.096-8.0704 0-4.457 3.6247-8.0703 8.096-8.0703 4.4712 0 8.096 3.6133 8.096 8.0704 0 4.4572-3.6248 8.0704-8.096 8.0704z"></path>
|
||||
<path d="M354.8995 220.2693c7.681 0 13.9075-6.207 13.9075-13.8635s-6.2266-13.8634-13.9075-13.8634c-7.681 0-13.9075 6.207-13.9075 13.8634 0 7.6566 6.2266 13.8635 13.9075 13.8635zm0-5.793c-4.4713 0-8.096-3.6133-8.096-8.0705 0-4.457 3.6247-8.0703 8.096-8.0703s8.096 3.6132 8.096 8.0703c0 4.4572-3.6247 8.0704-8.096 8.0704z"></path>
|
||||
<path d="M541.0343 206.4058c0-7.6565-6.2266-13.8634-13.9075-13.8634-7.681 0-13.9075 6.207-13.9075 13.8634 0 7.6566 6.2266 13.8635 13.9075 13.8635 7.681 0 13.9075-6.207 13.9075-13.8635zm-5.8115 0c0 4.4572-3.6247 8.0704-8.096 8.0704s-8.096-3.6132-8.096-8.0704c0-4.457 3.6247-8.0703 8.096-8.0703s8.096 3.6132 8.096 8.0703z"></path>
|
||||
<path d="M397.6943 214.5258c9.7012 27.0033 25.5723 43.629 43.419 43.629 13.0157 0 25.0578-8.8443 34.4482-24.4154.827-1.371.3822-3.1507-.9932-3.975-1.3755-.824-3.1607-.3808-3.9876.9902-8.439 13.9938-18.8052 21.6072-29.4675 21.6072-14.8247 0-28.9803-14.8288-37.9476-39.7892-.541-1.506-2.2044-2.2897-3.7153-1.7504-1.511.5394-2.297 2.1975-1.756 3.7036z"></path>
|
||||
<path d="M514.124 163.4733c18.5545-21.85 25.033-43.826 16.122-59.2117-6.557-11.321-20.419-17.2982-38.841-17.537-1.6047-.021-2.9225 1.259-2.9434 2.8586-.0208 1.5996 1.263 2.9132 2.8678 2.934 16.5683.2148 28.5106 5.3642 33.8836 14.641 7.4018 12.7797 1.6243 32.3774-15.5247 52.5722-1.037 1.221-.8844 3.0487.3405 4.0822 1.2248 1.0336 3.0584.8817 4.0952-.3393z"></path>
|
||||
<path d="M411.5672 88.457c-28.3373-5.1448-50.7424.24-59.672 15.6575-6.6635 11.505-4.7588 26.7585 4.6193 43.0637.7982 1.3878 2.574 1.8678 3.966 1.072 1.3923-.7956 1.874-2.5656 1.0756-3.9534-8.4477-14.688-10.0915-27.8524-4.628-37.2857 7.418-12.8074 27.403-17.6105 53.5978-12.8546 1.579.2866 3.092-.7568 3.3794-2.3307.2876-1.5738-.7592-3.082-2.338-3.3687z"></path>
|
||||
<path d="M486.3075 209.2436c5.022-15.998 7.7194-34.453 7.7194-53.6842 0-47.9875-16.849-89.3545-40.8478-99.977-1.4667-.649-3.1837.0098-3.835 1.472-.6512 1.462.01 3.1735 1.4766 3.8227 21.404 9.474 37.3945 48.7337 37.3945 94.6824 0 18.6574-2.612 36.5297-7.454 51.954-.4794 1.5268.3736 3.1518 1.9052 3.6295s3.1617-.3727 3.641-1.8994z"></path>
|
||||
<path d="M466.439 89.4215c-16.7763 3.583-34.6332 10.5886-51.7827 20.4585-42.434 24.4216-70.1147 60.4323-66.2703 86.5432.233 1.5828 1.709 2.6776 3.297 2.4453 1.5877-.2323 2.686-1.7037 2.453-3.2865-3.4135-23.1838 22.825-57.3183 63.426-80.685 16.6365-9.5746 33.9267-16.3578 50.0946-19.811 1.5692-.335 2.5687-1.8748 2.2325-3.439-.336-1.5642-1.8807-2.5606-3.45-2.2255z"></path>
|
||||
<path d="M371.2508 166.997c11.458 12.5516 26.3438 24.3243 43.3203 34.0947 41.106 23.6572 84.866 29.9805 106.4328 15.3217 1.326-.9013 1.668-2.7033.7638-4.025-.904-1.3217-2.712-1.6626-4.0378-.7614-19.302 13.1195-60.871 7.1128-100.253-15.5523-16.469-9.4783-30.8834-20.8782-41.9277-32.9767-1.08-1.1832-2.9178-1.2695-4.1048-.1928-1.187 1.0766-1.2735 2.9086-.1934 4.0918z"></path>
|
||||
<path d="M443.2374 165.3634c-5.432 1.17-10.7838-2.2712-11.9598-7.686-1.1714-5.415 2.2785-10.7498 7.7106-11.922 5.432-1.17 10.7838 2.2712 11.9598 7.686 1.1737 5.415-2.2785 10.7498-7.7106 11.922z"></path>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 8.8 KiB |
55
src/index.html
Normal file
@ -0,0 +1,55 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>Electron</title>
|
||||
<!-- https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP -->
|
||||
<meta
|
||||
http-equiv="Content-Security-Policy"
|
||||
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'"
|
||||
/>
|
||||
|
||||
<link href="./style.css" type="text/css" rel="stylesheet" />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="container">
|
||||
<ul class="versions">
|
||||
<li class="electron-version"></li>
|
||||
<li class="chrome-version"></li>
|
||||
<li class="node-version"></li>
|
||||
<li class="v8-version"></li>
|
||||
</ul>
|
||||
|
||||
<img class="hero-logo" src="./icons.svg" />
|
||||
<h2 class="hero-text">You've successfully created an Electron project</h2>
|
||||
<p class="hero-tagline">Please try pressing <code>F12</code> to open the devTool</p>
|
||||
<p class="hero-tagline">This is the simplest starter for Electron without any build tools</p>
|
||||
<p class="hero-tagline">
|
||||
You can check out the links to learn more about Electron build tools
|
||||
</p>
|
||||
|
||||
<div class="links">
|
||||
<div class="link-item">
|
||||
<a target="_blank" href="https://electron-vite.org">electron-vite</a>
|
||||
</div>
|
||||
<div class="link-item link-dot">•</div>
|
||||
<div class="link-item">
|
||||
<a
|
||||
target="_blank"
|
||||
href="https://github.com/alex8088/quick-start/tree/master/packages/create-electron"
|
||||
>
|
||||
create-electron
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="hero-tagline">More templates:</p>
|
||||
<p class="hero-tagline line-spacing">
|
||||
<code>vanilla</code>, <code>vanilla-ts</code>, <code>vue</code>, <code>vue-ts</code>,
|
||||
<code>react</code>, <code>react-ts</code>, <code>svelte</code>, <code>svelte-ts</code>
|
||||
</p>
|
||||
</div>
|
||||
<script src="./renderer.js"></script>
|
||||
</body>
|
||||
</html>
|
90
src/main.js
Normal file
@ -0,0 +1,90 @@
|
||||
// Modules to control application life and create native browser window
|
||||
const { app, shell, BrowserWindow } = require('electron')
|
||||
const path = require('path')
|
||||
const { electronApp, optimizer } = require('@electron-toolkit/utils')
|
||||
|
||||
function createWindow() {
|
||||
// Create the browser window.
|
||||
const mainWindow = new BrowserWindow({
|
||||
width: 900,
|
||||
height: 670,
|
||||
show: false,
|
||||
autoHideMenuBar: true,
|
||||
// frame: false, // 隐藏标题栏
|
||||
...(process.platform === 'linux' || process.platform === 'win32'
|
||||
? {
|
||||
icon: path.join(__dirname, '../resources/icon.png')
|
||||
}
|
||||
: {}),
|
||||
...(process.platform === 'darwin'
|
||||
? {
|
||||
titleBarStyle: 'hidden' // 隐藏 macOS 的默认标题栏
|
||||
}
|
||||
: {}),
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, 'preload.js'),
|
||||
sandbox: false,
|
||||
nodeIntegration: true
|
||||
}
|
||||
})
|
||||
|
||||
mainWindow.on('ready-to-show', () => {
|
||||
mainWindow.show()
|
||||
})
|
||||
|
||||
mainWindow.webContents.setWindowOpenHandler((details) => {
|
||||
shell.openExternal(details.url)
|
||||
return { action: 'deny' }
|
||||
})
|
||||
|
||||
// and load the index.html of the app.
|
||||
console.log(app.isPackaged)
|
||||
if (!app.isPackaged) {
|
||||
mainWindow.loadURL('http://localhost:5173')
|
||||
} else {
|
||||
mainWindow.loadFile(path.join(__dirname, 'renderer/index.html'))
|
||||
}
|
||||
|
||||
mainWindow.on('closed', () => {
|
||||
// 在窗口关闭时触发before-quit事件以结束进程
|
||||
app.quit()
|
||||
})
|
||||
}
|
||||
|
||||
// This method will be called when Electron has finished
|
||||
// initialization and is ready to create browser windows.
|
||||
// Some APIs can only be used after this event occurs.
|
||||
app.whenReady().then(() => {
|
||||
// Set app user model id for windows
|
||||
electronApp.setAppUserModelId('com.dreamfly.um')
|
||||
|
||||
// Default open or close DevTools by F12 in development
|
||||
// and ignore CommandOrControl + R in production.
|
||||
// see https://github.com/alex8088/electron-toolkit/tree/master/packages/utils
|
||||
app.on('browser-window-created', (_, window) => {
|
||||
optimizer.watchWindowShortcuts(window)
|
||||
})
|
||||
|
||||
app.on('NSApplicationDelegate.applicationSupportsSecureRestorableState', () => true)
|
||||
|
||||
createWindow()
|
||||
|
||||
app.on('activate', function () {
|
||||
// On macOS it's common to re-create a window in the app when the
|
||||
// dock icon is clicked and there are no other windows open.
|
||||
if (BrowserWindow.getAllWindows().length === 0) createWindow()
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
// Quit when all windows are closed, except on macOS. There, it's common
|
||||
// for applications and their menu bar to stay active until the user quits
|
||||
// explicitly with Cmd + Q.
|
||||
app.on('window-all-closed', function () {
|
||||
if (process.platform !== 'darwin') {
|
||||
app.quit()
|
||||
}
|
||||
})
|
||||
|
||||
// In this file you can include the rest of your app's specific main process
|
||||
// code. You can also put them in separate files and require them here.
|
22
src/preload.js
Normal file
@ -0,0 +1,22 @@
|
||||
// All of the Node.js APIs are available in the preload process.
|
||||
// It has the same sandbox as a Chrome extension.
|
||||
const { contextBridge } = require('electron')
|
||||
const { electronAPI } = require('@electron-toolkit/preload')
|
||||
|
||||
// Custom APIs for renderer
|
||||
const api = {}
|
||||
|
||||
// Use `contextBridge` APIs to expose Electron APIs to
|
||||
// renderer only if context isolation is enabled, otherwise
|
||||
// just add to the DOM global.
|
||||
if (process.contextIsolated) {
|
||||
try {
|
||||
contextBridge.exposeInMainWorld('electron', electronAPI)
|
||||
contextBridge.exposeInMainWorld('api', api)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
} else {
|
||||
window.electron = electronAPI
|
||||
window.api = api
|
||||
}
|
26
src/renderer.js
Normal file
@ -0,0 +1,26 @@
|
||||
// This file is required by the index.html file and will
|
||||
// be executed in the renderer process for that window.
|
||||
// No Node.js APIs are available in this process because
|
||||
// `nodeIntegration` is turned off. Use `preload.js` to
|
||||
// selectively enable features needed in the rendering
|
||||
// process.
|
||||
;(async () => {
|
||||
window.addEventListener('DOMContentLoaded', () => {
|
||||
doAThing()
|
||||
})
|
||||
|
||||
function doAThing() {
|
||||
const versions = window.electron.process.versions
|
||||
replaceText('.electron-version', `Electron v${versions.electron}`)
|
||||
replaceText('.chrome-version', `Chromium v${versions.chrome}`)
|
||||
replaceText('.node-version', `Node v${versions.node}`)
|
||||
replaceText('.v8-version', `V8 v${versions.v8}`)
|
||||
}
|
||||
|
||||
function replaceText(selector, text) {
|
||||
const element = document.querySelector(selector)
|
||||
if (element) {
|
||||
element.innerText = text
|
||||
}
|
||||
}
|
||||
})()
|
165
src/style.css
Normal file
@ -0,0 +1,165 @@
|
||||
/* Add styles here to customize the appearance of your app */
|
||||
body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-family:
|
||||
Roboto,
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
'Helvetica Neue',
|
||||
'Segoe UI',
|
||||
'Oxygen',
|
||||
'Ubuntu',
|
||||
'Cantarell',
|
||||
'Open Sans',
|
||||
sans-serif;
|
||||
color: #86a5b1;
|
||||
background-color: #2f3241;
|
||||
}
|
||||
|
||||
* {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
ul {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
code {
|
||||
font-weight: 600;
|
||||
padding: 3px 5px;
|
||||
border-radius: 2px;
|
||||
background-color: #26282e;
|
||||
font-family:
|
||||
ui-monospace,
|
||||
SFMono-Regular,
|
||||
SF Mono,
|
||||
Menlo,
|
||||
Consolas,
|
||||
Liberation Mono,
|
||||
monospace;
|
||||
font-size: 85%;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #9feaf9;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
border-bottom: 1px solid;
|
||||
}
|
||||
|
||||
.container {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
padding: 15px 30px 0 30px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
@media (min-width: 840px) {
|
||||
.container {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.versions {
|
||||
margin: 0 auto;
|
||||
float: none;
|
||||
clear: both;
|
||||
overflow: hidden;
|
||||
font-family: 'Menlo', 'Lucida Console', monospace;
|
||||
color: #c2f5ff;
|
||||
line-height: 1;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.versions li {
|
||||
display: block;
|
||||
float: left;
|
||||
border-right: 1px solid rgba(194, 245, 255, 0.4);
|
||||
padding: 0 20px;
|
||||
font-size: 13px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.versions li:last-child {
|
||||
border: none;
|
||||
}
|
||||
|
||||
.hero-logo {
|
||||
margin-top: -0.4rem;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
@media (max-width: 840px) {
|
||||
.versions {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.hero-logo {
|
||||
margin-top: -1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.hero-text {
|
||||
font-weight: 400;
|
||||
color: #c2f5ff;
|
||||
text-align: center;
|
||||
margin-top: -0.5rem;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
@media (max-width: 660px) {
|
||||
.hero-logo {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.hero-text {
|
||||
margin-top: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.hero-tagline {
|
||||
text-align: center;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.links {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 24px;
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.links a {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.links .link-item {
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.links {
|
||||
flex-direction: column;
|
||||
line-height: 32px;
|
||||
}
|
||||
|
||||
.links .link-dot {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.line-spacing {
|
||||
line-height: 2;
|
||||
}
|
33
um-react/.drone.yml
Normal file
@ -0,0 +1,33 @@
|
||||
---
|
||||
kind: pipeline
|
||||
type: docker
|
||||
name: default
|
||||
|
||||
steps:
|
||||
- name: test & build
|
||||
image: node:18.16.1-bookworm
|
||||
commands:
|
||||
# - git config --global --add safe.directory "/drone/src"
|
||||
- corepack enable
|
||||
- corepack prepare pnpm@latest --activate
|
||||
- pnpm i --frozen-lockfile
|
||||
- pnpm build
|
||||
environment:
|
||||
# 让 npm 使用淘宝源
|
||||
npm_config_registry: https://registry.npmmirror.com
|
||||
|
||||
- name: publish
|
||||
image: node:18.16.1-bookworm
|
||||
environment:
|
||||
DRONE_GITEA_SERVER: https://git.unlock-music.dev
|
||||
GITEA_API_KEY:
|
||||
from_secret: GITEA_API_KEY
|
||||
NETLIFY_SITE_ID:
|
||||
from_secret: NETLIFY_SITE_ID
|
||||
NETLIFY_API_KEY:
|
||||
from_secret: NETLIFY_API_KEY
|
||||
commands:
|
||||
# - git config --global --add safe.directory "/drone/src"
|
||||
- python3 -m zipfile -c um-react.zip dist/.
|
||||
- ./scripts/publish.sh
|
||||
- ./scripts/deploy.sh
|
15
um-react/.editorconfig
Normal file
@ -0,0 +1,15 @@
|
||||
# EditorConfig is awesome: https://EditorConfig.org
|
||||
|
||||
# top-most EditorConfig file
|
||||
root = true
|
||||
|
||||
[*]
|
||||
indent_style = space
|
||||
indent_size = 4
|
||||
end_of_line = lf
|
||||
charset = utf-8
|
||||
trim_trailing_whitespace = true
|
||||
insert_final_newline = true
|
||||
|
||||
[*.{{c,m,}js{x,on,},ts{x,}}]
|
||||
indent_size = 2
|
4
um-react/.env
Normal file
@ -0,0 +1,4 @@
|
||||
# Example environment file for vite to use.
|
||||
# For more information, see: https://vitejs.dev/guide/env-and-mode.html
|
||||
|
||||
ENABLE_PERF_LOG=0
|
3
um-react/.eslintignore
Normal file
@ -0,0 +1,3 @@
|
||||
dist/
|
||||
node_modules/
|
||||
coverage/
|
27
um-react/.eslintrc.cjs
Normal file
@ -0,0 +1,27 @@
|
||||
/* 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,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
1
um-react/.gitattributes
vendored
Normal file
@ -0,0 +1 @@
|
||||
*.mmkv binary
|
29
um-react/.gitignore
vendored
Normal file
@ -0,0 +1,29 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
coverage/
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
# Files created when running "drone exec" locally
|
||||
/.pnpm-store/
|
||||
/*.zip
|
4
um-react/.husky/pre-commit
Executable file
@ -0,0 +1,4 @@
|
||||
#!/usr/bin/env sh
|
||||
. "$(dirname -- "$0")/_/husky.sh"
|
||||
|
||||
pnpm exec lint-staged
|
4
um-react/.husky/pre-push
Executable file
@ -0,0 +1,4 @@
|
||||
#!/usr/bin/env sh
|
||||
. "$(dirname -- "$0")/_/husky.sh"
|
||||
|
||||
pnpm test
|
3
um-react/.npmrc
Normal file
@ -0,0 +1,3 @@
|
||||
use-node-version=18.16.0
|
||||
node-version=18.16.0
|
||||
engine-strict=true
|
5
um-react/.prettierignore
Normal file
@ -0,0 +1,5 @@
|
||||
dist/
|
||||
|
||||
# Package manager
|
||||
yarn.lock
|
||||
pnpm-lock.yaml
|
21
um-react/LICENSE
Normal file
@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2023 Unlock-Music Team<https://git.unlock-music.dev/um/>
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
9
um-react/docs/adb_dump.md
Normal file
@ -0,0 +1,9 @@
|
||||
# 利用 ADB 访问安卓私有数据
|
||||
|
||||
```sh
|
||||
APP_ID="com.tencent.qqmusic" # QQ 音乐
|
||||
APP_ID="cn.kuwo.player" # 酷我
|
||||
|
||||
adb shell su -c "tar c '/data/data/${APP_ID}/' | base64" \
|
||||
| base64 -d | pv | tar -x --strip-components 2
|
||||
```
|
50
um-react/docs/develop-with-libparakeet.zh.md
Normal file
@ -0,0 +1,50 @@
|
||||
# 面向 `libparakeet-js` 开发
|
||||
|
||||
⚠️ 如果只是进行前端方面的更改,你可以跳过该文档。
|
||||
|
||||
`libparakeet-js` 编译目前需要 Linux 环境,请参考[仓库说明][libparakeet-js-doc]。
|
||||
|
||||
该文档将假设这两个项目被放置在同级的目录下:
|
||||
|
||||
```text
|
||||
~/Projects/um-projects
|
||||
/um-react
|
||||
/libparakeet-js
|
||||
```
|
||||
|
||||
若为不同目录,你需要调整 `LIB_PARAKEET_JS_DIR` 环境变量到仓库目录,然后再启动 vite 项目。
|
||||
|
||||
[libparakeet-js-doc]: https://github.com/parakeet-rs/libparakeet-js/blob/main/README.MD
|
||||
|
||||
## 初次构建
|
||||
|
||||
- 进入上层目录:`cd ..`
|
||||
- 克隆 `libparakeet-js` 仓库 (目前需要 Linux 环境, Windows 下推荐使用 WSL2)
|
||||
- `git clone --recurse-submodules https://github.com/parakeet-rs/libparakeet-js.git`
|
||||
- 进入 SDK 目录:`cd libparakeet-js`
|
||||
- 如果需要更新 `submodule`:`git submodule update --init --recursive`
|
||||
- 构建所有代码:`make all`
|
||||
|
||||
如果需要手动控制构建过程,你也可以:
|
||||
|
||||
- 运行 `./build.sh -j 4` 进行 C++ 到 WebAssembly 编译过程
|
||||
- 此处的 `4` 是并行编译数量,该值通常略小于 CPU 核心数。
|
||||
- 若是不指定并行数量,则使用当前核心数。
|
||||
- 编译 `js-sdk`:
|
||||
- 进入 `npm` 目录:`cd npm`
|
||||
- 安装依赖:`pnpm i --frozen-lockfile`
|
||||
- 构建:`pnpm build`
|
||||
|
||||
## 做出更改
|
||||
|
||||
做出更改后,参考上面的内容进行重新编译。
|
||||
|
||||
## 应用 SDK 更改
|
||||
|
||||
将构建好的 SDK 直接嵌入到当前前端项目:
|
||||
|
||||
```sh
|
||||
pnpm link ../libparakeet-js/npm
|
||||
```
|
||||
|
||||
※ 建立 PR 时,请先提交 SDK PR 并确保你的 SDK 更改已合并。
|
35
um-react/docs/getting-started.zh.md
Normal file
@ -0,0 +1,35 @@
|
||||
# 新手上路
|
||||
|
||||
该文档描述了如何本地运行或编译生产版本的「Unlock Music 音乐解锁」。
|
||||
|
||||
## 安装依赖
|
||||
|
||||
- 安装 Node v16.17 或更高,推荐当前最新的 Node LTS 版本。
|
||||
- 安装/激活 `pnpm` [^1]:`corepack prepare pnpm@latest --activate`
|
||||
- 安装软件依赖:`pnpm i --frozen-lockfile`
|
||||
|
||||
[^1]: 参考 pnpm 说明「[使用 Corepack 安装](https://pnpm.io/zh/installation#使用-corepack-安装)」。
|
||||
|
||||
## 本地运行
|
||||
|
||||
💡 你需要先完成「安装依赖」部分。
|
||||
|
||||
```sh
|
||||
pnpm start
|
||||
```
|
||||
|
||||
然后根据提示打开[项目运行页面][vite-dev-url]即可。
|
||||
|
||||
[vite-dev-url]: http://localhost:5173/
|
||||
|
||||
## 构建生产版本
|
||||
|
||||
💡 你需要先完成「安装依赖」部分。
|
||||
|
||||
```sh
|
||||
pnpm build
|
||||
```
|
||||
|
||||
如果需要预览构建版本,运行 `pnpm preview` 然后打开[项目预览页面][vite-preview-url]即可。
|
||||
|
||||
[vite-preview-url]: http://localhost:4173/
|
25
um-react/index.html
Normal file
@ -0,0 +1,25 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-cmn-Hans-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>音乐解锁 - Unlock Music (React)</title>
|
||||
|
||||
<meta name="description" content="音乐解锁 - Unlock Music" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<link rel="apple-touch-icon" href="/pwa-512x512.png" sizes="512x512" />
|
||||
<meta name="theme-color" content="#4DBA87" />
|
||||
</head>
|
||||
<body>
|
||||
<main id="root"></main>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
<style>
|
||||
.chakra-tabs__tablist {
|
||||
-webkit-app-region: drag;
|
||||
}
|
||||
.chakra-tabs__tab {
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
</style>
|
||||
</html>
|
11743
um-react/package-lock.json
generated
Normal file
85
um-react/package.json
Normal file
@ -0,0 +1,85 @@
|
||||
{
|
||||
"name": "um-react",
|
||||
"private": true,
|
||||
"version": "0.2.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "vite",
|
||||
"build": "tsc -p tsconfig.prod.json && vite build",
|
||||
"lint": "eslint src --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||
"format": "prettier -w .",
|
||||
"test": "vitest run",
|
||||
"test:ui": "vitest --ui",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"preview": "vite preview",
|
||||
"preview:coverage": "vite preview --outDir coverage --port 5175",
|
||||
"prepare": "husky install"
|
||||
},
|
||||
"dependencies": {
|
||||
"@chakra-ui/anatomy": "^2.1.1",
|
||||
"@chakra-ui/icons": "^2.0.19",
|
||||
"@chakra-ui/react": "^2.7.0",
|
||||
"@emotion/react": "^11.11.0",
|
||||
"@emotion/styled": "^11.11.0",
|
||||
"@jixun/libparakeet": "0.2.1",
|
||||
"@reduxjs/toolkit": "^1.9.5",
|
||||
"framer-motion": "^10.12.16",
|
||||
"immer": "^10.0.2",
|
||||
"nanoid": "^4.0.2",
|
||||
"radash": "^10.8.1",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-dropzone": "^14.2.3",
|
||||
"react-icons": "^4.9.0",
|
||||
"react-promise-suspense": "^0.3.4",
|
||||
"react-redux": "^8.0.5",
|
||||
"react-syntax-highlighter": "^15.5.0",
|
||||
"sass": "^1.63.3",
|
||||
"sql.js": "^1.8.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@rollup/plugin-replace": "^5.0.2",
|
||||
"@testing-library/jest-dom": "^5.16.5",
|
||||
"@testing-library/react": "^14.0.0",
|
||||
"@testing-library/user-event": "^14.4.3",
|
||||
"@types/node": "^20.2.5",
|
||||
"@types/react": "^18.2.7",
|
||||
"@types/react-dom": "^18.2.4",
|
||||
"@types/react-syntax-highlighter": "^15.5.7",
|
||||
"@types/sql.js": "^1.4.4",
|
||||
"@types/testing-library__jest-dom": "^5.14.6",
|
||||
"@typescript-eslint/eslint-plugin": "^5.59.7",
|
||||
"@typescript-eslint/parser": "^5.59.7",
|
||||
"@vitejs/plugin-react": "^4.0.0",
|
||||
"@vitest/coverage-c8": "^0.31.1",
|
||||
"@vitest/ui": "^0.31.1",
|
||||
"eslint": "^8.41.0",
|
||||
"eslint-config-prettier": "^8.8.0",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.1",
|
||||
"husky": "^8.0.3",
|
||||
"jsdom": "^22.1.0",
|
||||
"lint-staged": "^13.2.2",
|
||||
"prettier": "^2.8.8",
|
||||
"typescript": "^5.0.4",
|
||||
"vite": "^4.3.9",
|
||||
"vite-plugin-pwa": "^0.15.1",
|
||||
"vite-plugin-top-level-await": "^1.3.1",
|
||||
"vite-plugin-wasm": "^3.2.2",
|
||||
"vitest": "^0.31.1"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*": "prettier --write --ignore-unknown",
|
||||
"*.{js,jsx,ts,tsx}": "eslint --fix --report-unused-disable-directives --max-warnings 0"
|
||||
},
|
||||
"prettier": {
|
||||
"singleQuote": true,
|
||||
"printWidth": 120,
|
||||
"tabWidth": 2
|
||||
},
|
||||
"pnpm": {
|
||||
"patchedDependencies": {
|
||||
"sql.js@1.8.0": "patches/sql.js@1.8.0.patch"
|
||||
}
|
||||
}
|
||||
}
|
12
um-react/patches/sql.js@1.8.0.patch
Normal file
@ -0,0 +1,12 @@
|
||||
diff --git a/dist/sql-wasm.js b/dist/sql-wasm.js
|
||||
index e0da60ba096433d9af1c7025d2ffb9c521f190ed..89a5da6af23e1a644106d38dafe7cfa85500a8c4 100644
|
||||
--- a/dist/sql-wasm.js
|
||||
+++ b/dist/sql-wasm.js
|
||||
@@ -192,3 +192,7 @@ else if (typeof define === 'function' && define['amd']) {
|
||||
else if (typeof exports === 'object'){
|
||||
exports["Module"] = initSqlJs;
|
||||
}
|
||||
+
|
||||
+var module;
|
||||
+export default initSqlJs;
|
||||
+
|
7608
um-react/pnpm-lock.yaml
Normal file
BIN
um-react/public/favicon-16x16.png
Normal file
After Width: | Height: | Size: 641 B |
BIN
um-react/public/favicon.ico
Normal file
After Width: | Height: | Size: 4.2 KiB |
BIN
um-react/public/pwa-192x192.png
Normal file
After Width: | Height: | Size: 10 KiB |
BIN
um-react/public/pwa-512x512.png
Normal file
After Width: | Height: | Size: 32 KiB |
110
um-react/scripts/deploy.sh
Executable file
@ -0,0 +1,110 @@
|
||||
#!/bin/bash -e
|
||||
|
||||
BRANCH_NAME="$(git branch --show-current)"
|
||||
SCRIPTS_DIR="$(dirname "${BASH_SOURCE[0]}")"
|
||||
|
||||
__netlify_upload() {
|
||||
local branch="$BRANCH_NAME"
|
||||
local production="$DEPLOY_PRODUCTION"
|
||||
[[ "$BRANCH_NAME" = "main" ]] && production="true"
|
||||
[[ -z "$production" ]] && production="false"
|
||||
|
||||
curl -sL \
|
||||
-H "Content-Type: application/zip" \
|
||||
-H "Authorization: Bearer ${NETLIFY_API_KEY}" \
|
||||
--data-binary "@${1}" \
|
||||
"https://api.netlify.com/api/v1/sites/${NETLIFY_SITE_ID}/deploys?branch=${branch}&production=${production}"
|
||||
}
|
||||
|
||||
__netlify_get_deploy() {
|
||||
local deploy_id="$1"
|
||||
curl -sL \
|
||||
-H "Authorization: Bearer ${NETLIFY_API_KEY}" \
|
||||
"https://api.netlify.com/api/v1/deploys/${deploy_id}"
|
||||
}
|
||||
|
||||
# Publish a deployment to main URL.
|
||||
__netlify_promote() {
|
||||
local deploy_id="$1"
|
||||
curl -sL \
|
||||
-H "Authorization: Bearer ${NETLIFY_API_KEY}" \
|
||||
-H "Content-Type: application/json" \
|
||||
--data "{}" \
|
||||
"https://api.netlify.com/api/v1/sites/${NETLIFY_SITE_ID}/deploys/${deploy_id}/restore"
|
||||
}
|
||||
|
||||
__netlify_get_error() {
|
||||
local error_message
|
||||
error_message="$(json_get "$upload_resp" message)"
|
||||
[[ "$error_message" = "null" ]] && error_message="$(json_get "$upload_resp" error_message)"
|
||||
echo -n "$error_message"
|
||||
}
|
||||
|
||||
json_get() {
|
||||
local json_body="$1"
|
||||
shift
|
||||
|
||||
echo -n "$json_body" | "${SCRIPTS_DIR}/read_json.mjs" "$@"
|
||||
}
|
||||
|
||||
deploy_netlify() {
|
||||
local upload_resp
|
||||
upload_resp="$(__netlify_upload "$1")"
|
||||
|
||||
local error_message="$(__netlify_get_error "$upload_resp")"
|
||||
|
||||
if [[ "$error_message" != "null" ]]; then
|
||||
echo "Deploy to netlify failed:"
|
||||
echo " * ${error_message}"
|
||||
return 1
|
||||
fi
|
||||
|
||||
local deploy_id="$(json_get "$upload_resp" id)"
|
||||
local deploy_resp=""
|
||||
local deploy_state=""
|
||||
local retry_count=10
|
||||
while [[ "$retry_count" -gt 0 ]]; do
|
||||
deploy_resp="$(__netlify_get_deploy "$deploy_id")"
|
||||
deploy_state="$(json_get "$deploy_resp" 'state')"
|
||||
case "$deploy_state" in
|
||||
ready)
|
||||
echo 'Deploy to netlify OK!'
|
||||
echo " * main url: $(json_get "$deploy_resp" 'ssl_url')"
|
||||
echo " * branch: $(json_get "$deploy_resp" 'deploy_ssl_url')"
|
||||
echo " * permalink: $(json_get "$deploy_resp" 'links' 'permalink')"
|
||||
break
|
||||
;;
|
||||
error)
|
||||
echo "Deploy to netlify failed:"
|
||||
echo " * $(json_get "$deploy_resp" 'error_message')"
|
||||
return 1
|
||||
;;
|
||||
*)
|
||||
retry_count="$((retry_count - 1))"
|
||||
sleep 3
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ "$BRANCH_NAME" = "main" ]]; then
|
||||
echo "Promoting latest main build..."
|
||||
local promote_resp="$(__netlify_promote "$(json_get "$deploy_resp" 'id')")"
|
||||
error_message="$(__netlify_get_error "$promote_resp")"
|
||||
|
||||
if [[ "$error_message" != "null" ]]; then
|
||||
echo "Promote netlify deploy failed:"
|
||||
echo " * ${error_message}"
|
||||
return 1
|
||||
else
|
||||
echo 'Deoployed to main url.'
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# For deployment, we care a bit less
|
||||
if [[ -n "${NETLIFY_API_KEY}" && -n "${NETLIFY_SITE_ID}" ]]; then
|
||||
echo "Deploy to netlify..."
|
||||
deploy_netlify um-react.zip
|
||||
else
|
||||
echo "skip netlify deployment."
|
||||
fi
|
21
um-react/scripts/publish.sh
Executable file
@ -0,0 +1,21 @@
|
||||
#!/bin/bash -e
|
||||
|
||||
BRANCH_NAME="$(git branch --show-current)"
|
||||
|
||||
publish_gitea() {
|
||||
local ZIP_NAME="$1"
|
||||
local URL="${DRONE_GITEA_SERVER}/api/packages/${DRONE_REPO_NAMESPACE}/generic/${DRONE_REPO_NAME}/${DRONE_BUILD_NUMBER}/${ZIP_NAME}"
|
||||
sha256sum "${ZIP_NAME}"
|
||||
curl -sLifu "um-release-bot:${GITEA_API_KEY}" -T "${ZIP_NAME}" "${URL}"
|
||||
echo "Uploaded to: ${URL}"
|
||||
}
|
||||
|
||||
# Only publish main branch by default
|
||||
if [[ "${BRANCH_NAME}" = "main" && -z "$DRONE_PULL_REQUEST" ]]; then
|
||||
echo 'prepare to publish...'
|
||||
|
||||
if [[ -n "${GITEA_API_KEY}" ]]; then
|
||||
echo "Publish to gitea..."
|
||||
publish_gitea "um-react.zip"
|
||||
fi
|
||||
fi
|
12
um-react/scripts/read_json.mjs
Executable file
@ -0,0 +1,12 @@
|
||||
#!/usr/bin/env node
|
||||
/* eslint-env node */
|
||||
|
||||
import fs from 'fs';
|
||||
|
||||
const data = JSON.parse(fs.readFileSync(0, 'utf-8').trim());
|
||||
|
||||
let value = data;
|
||||
for (let i = 2; i < process.argv.length; i++) {
|
||||
value = value[process.argv[i]] ?? null;
|
||||
}
|
||||
process.stdout.write(String(value), 'utf-8');
|
21
um-react/src/__tests__/sanity-check.test.tsx
Normal file
@ -0,0 +1,21 @@
|
||||
import { renderWithProviders, screen, waitFor } from '~/test-utils/test-helper';
|
||||
import { AppRoot } from '~/components/AppRoot';
|
||||
|
||||
vi.mock('../decrypt-worker/client', () => {
|
||||
return {
|
||||
workerClientBus: {
|
||||
request: vi.fn().mockResolvedValue('dummy'),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
test('should be able to render App', async () => {
|
||||
renderWithProviders(<AppRoot />);
|
||||
|
||||
// Should eventually load sdk version
|
||||
await waitFor(() => screen.getByTestId('sdk-version'));
|
||||
|
||||
// Quick sanity check of known strings.
|
||||
expect(screen.getByText(/在浏览器内对文件进行解锁/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/UnlockMusic 团队/i)).toBeInTheDocument();
|
||||
});
|
9
um-react/src/assets/no-cover.svg
Normal file
@ -0,0 +1,9 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="160" height="160" viewBox="0 0 160 160">
|
||||
<rect fill="#ddd" width="160" height="160" />
|
||||
<text fill="#0007" font-family="sans-serif" font-size="24" font-weight="bold"
|
||||
text-anchor="middle" letter-spacing="6"
|
||||
dy="9.45" x="50%" y="50%"
|
||||
>
|
||||
暂无封面
|
||||
</text>
|
||||
</svg>
|
After Width: | Height: | Size: 348 B |
@ -0,0 +1,157 @@
|
||||
import {
|
||||
Accordion,
|
||||
AccordionButton,
|
||||
AccordionIcon,
|
||||
AccordionItem,
|
||||
AccordionPanel,
|
||||
Box,
|
||||
Code,
|
||||
Heading,
|
||||
Link,
|
||||
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';
|
||||
|
||||
const applyTemplate = (tpl: string, values: Record<string, unknown>) => {
|
||||
return tpl.replace(/\{\{\s*(\w+)\s*\}\}/g, (_, key) => (Object.hasOwn(values, key) ? String(values[key]) : '<nil>'));
|
||||
};
|
||||
|
||||
export interface AndroidADBPullInstructionProps {
|
||||
dir: string;
|
||||
file: string;
|
||||
}
|
||||
|
||||
export function AndroidADBPullInstruction({ dir, file }: AndroidADBPullInstructionProps) {
|
||||
const psAdbDumpCommand = applyTemplate(PowerShellAdbDumpCommandTemplate, { dir, file });
|
||||
const shAdbDumpCommand = applyTemplate(ShellAdbDumpCommandTemplate, { dir, file });
|
||||
|
||||
return (
|
||||
<>
|
||||
<Text>
|
||||
你需要 <code>root</code> 访问权限来访问安卓应用的私有数据。
|
||||
</Text>
|
||||
<Text>
|
||||
⚠️ 请注意,获取 <code>root</code> 通常意味着你的安卓设备
|
||||
<chakra.span color="red.400">将失去保修资格</chakra.span>。
|
||||
</Text>
|
||||
|
||||
<Accordion allowToggle mt="2">
|
||||
<AccordionItem>
|
||||
<Heading as="h3" size="md">
|
||||
<AccordionButton>
|
||||
<Box as="span" flex="1" textAlign="left">
|
||||
在安卓手机端操作
|
||||
</Box>
|
||||
<AccordionIcon />
|
||||
</AccordionButton>
|
||||
</Heading>
|
||||
<AccordionPanel pb={4}>
|
||||
<OrderedList>
|
||||
<ListItem>
|
||||
<Text>
|
||||
启动具有 <Code>root</Code> 特权的文件浏览器
|
||||
</Text>
|
||||
</ListItem>
|
||||
<ListItem>
|
||||
<Text>
|
||||
访问 <Code>{dir}/</Code> 目录。
|
||||
</Text>
|
||||
</ListItem>
|
||||
<ListItem>
|
||||
<Text>
|
||||
将文件 <Code>{file}</Code> 复制到浏览器可访问的目录。
|
||||
<br />
|
||||
(例如下载目录)
|
||||
</Text>
|
||||
</ListItem>
|
||||
<ListItem>
|
||||
<Text>提交该数据库文件。</Text>
|
||||
</ListItem>
|
||||
</OrderedList>
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem>
|
||||
<Heading as="h3" size="md">
|
||||
<AccordionButton>
|
||||
<Box as="span" flex="1" textAlign="left">
|
||||
在 PC 端操作(ADB / PowerShell)
|
||||
</Box>
|
||||
<AccordionIcon />
|
||||
</AccordionButton>
|
||||
</Heading>
|
||||
<AccordionPanel pb={4}>
|
||||
<OrderedList>
|
||||
<ListItem>
|
||||
<Text>
|
||||
确保 <code>adb</code> 命令可用。
|
||||
</Text>
|
||||
<Text>
|
||||
💡 如果没有,可以
|
||||
<Link href="https://scoop.sh/#/apps?q=adb" isExternal>
|
||||
使用 Scoop 安装 <ExternalLinkIcon />
|
||||
</Link>
|
||||
。
|
||||
</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>将安卓设备连接到电脑,并允许调试。</Text>
|
||||
</ListItem>
|
||||
<ListItem>
|
||||
<Text>粘贴执行下述代码。若设备提示「超级用户请求」请允许:</Text>
|
||||
<SyntaxHighlighter language="bash" style={hljsStyleGitHub}>
|
||||
{shAdbDumpCommand}
|
||||
</SyntaxHighlighter>
|
||||
</ListItem>
|
||||
<ListItem>
|
||||
<Text>
|
||||
提交当前目录下的 <Code>{file}</Code> 文件。
|
||||
</Text>
|
||||
</ListItem>
|
||||
</OrderedList>
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</>
|
||||
);
|
||||
}
|
@ -0,0 +1,14 @@
|
||||
try {
|
||||
$gz_b64 = adb shell su -c "cat '{{ dir }}/{{ file }}' | gzip | base64" | Out-String
|
||||
$bStream = New-Object System.IO.MemoryStream(,[System.Convert]::FromBase64String($gz_b64))
|
||||
$decoded = New-Object System.IO.Compression.GzipStream($bStream, [System.IO.Compression.CompressionMode]::Decompress)
|
||||
$outFile = New-Object System.IO.FileStream("${PWD}\{{ file }}", [System.IO.FileMode]::Create, [System.IO.FileAccess]::Write)
|
||||
$decoded.CopyTo($outFile)
|
||||
} catch {
|
||||
Write-Host "遇到错误:"
|
||||
Write-Host $_
|
||||
} finally {
|
||||
if ($outFile -ne $null) { $outFile.Dispose() }
|
||||
if ($decoded -ne $null) { $decoded.Dispose() }
|
||||
if ($bStream -ne $null) { $bStream.Dispose() }
|
||||
}
|
@ -0,0 +1,2 @@
|
||||
sh adb shell su -c "cat '{{ dir }}/{{ file }}' | gzip | base64" \
|
||||
| base64 -d | gzip -d '{{ file }}'
|
49
um-react/src/components/AppRoot.tsx
Normal file
@ -0,0 +1,49 @@
|
||||
import { useEffect } from 'react';
|
||||
import { MdSettings, MdHome } from 'react-icons/md';
|
||||
import { ChakraProvider, Tabs, TabList, TabPanels, Tab, TabPanel, Icon, chakra } from '@chakra-ui/react';
|
||||
|
||||
import { MainTab } from '~/tabs/MainTab';
|
||||
import { SettingsTab } from '~/tabs/SettingsTab';
|
||||
|
||||
import { Provider } from 'react-redux';
|
||||
import { theme } from '~/theme';
|
||||
import { persistSettings } from '~/features/settings/persistSettings';
|
||||
import { setupStore } from '~/store';
|
||||
import { Footer } from '~/components/Footer';
|
||||
|
||||
// Private to this file only.
|
||||
const store = setupStore();
|
||||
|
||||
export function AppRoot() {
|
||||
useEffect(() => persistSettings(store), []);
|
||||
|
||||
return (
|
||||
<ChakraProvider theme={theme}>
|
||||
<Provider store={store}>
|
||||
<Tabs flex={1} minH={0} display="flex" flexDir="column">
|
||||
<TabList justifyContent="center">
|
||||
<Tab>
|
||||
<Icon as={MdHome} mr="1" />
|
||||
<chakra.span>应用</chakra.span>
|
||||
</Tab>
|
||||
<Tab>
|
||||
<Icon as={MdSettings} mr="1" />
|
||||
<chakra.span>设置</chakra.span>
|
||||
</Tab>
|
||||
</TabList>
|
||||
|
||||
<TabPanels overflow="auto" minW={0} flexDir="column" flex={1} display="flex">
|
||||
<TabPanel>
|
||||
<MainTab />
|
||||
</TabPanel>
|
||||
<TabPanel flex={1} display="flex">
|
||||
<SettingsTab />
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</Tabs>
|
||||
|
||||
<Footer />
|
||||
</Provider>
|
||||
</ChakraProvider>
|
||||
);
|
||||
}
|
22
um-react/src/components/CurrentYear.tsx
Normal file
@ -0,0 +1,22 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
// Update every half hour
|
||||
const TIMER_UPDATE_INTERVAL = 30 * 60 * 1000;
|
||||
|
||||
const getCurrentYear = () => new Date().getFullYear();
|
||||
|
||||
export function CurrentYear() {
|
||||
const [year, setYear] = useState(getCurrentYear);
|
||||
|
||||
useEffect(() => {
|
||||
const updateTime = () => setYear(getCurrentYear);
|
||||
updateTime();
|
||||
|
||||
const timer = setInterval(updateTime, TIMER_UPDATE_INTERVAL);
|
||||
return () => {
|
||||
clearInterval(timer);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return <>{year}</>;
|
||||
}
|
43
um-react/src/components/FileInput.tsx
Normal file
@ -0,0 +1,43 @@
|
||||
import { useDropzone } from 'react-dropzone';
|
||||
import { Box } from '@chakra-ui/react';
|
||||
|
||||
export interface FileInputProps {
|
||||
onReceiveFiles: (files: File[]) => void;
|
||||
multiple?: boolean;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function FileInput({ children, onReceiveFiles }: FileInputProps) {
|
||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||
multiple: true,
|
||||
onDropAccepted: onReceiveFiles,
|
||||
});
|
||||
|
||||
return (
|
||||
<Box
|
||||
{...getRootProps()}
|
||||
w="100%"
|
||||
maxW={480}
|
||||
borderWidth="1px"
|
||||
borderRadius="lg"
|
||||
transitionDuration="0.5s"
|
||||
p="6"
|
||||
cursor="pointer"
|
||||
display="flex"
|
||||
flexDir="column"
|
||||
alignItems="center"
|
||||
_hover={{
|
||||
borderColor: 'gray.400',
|
||||
bg: 'gray.50',
|
||||
}}
|
||||
{...(isDragActive && {
|
||||
bg: 'blue.50',
|
||||
borderColor: 'blue.700',
|
||||
})}
|
||||
>
|
||||
<input {...getInputProps()} />
|
||||
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
}
|
10
um-react/src/components/FilePathBlock.tsx
Normal file
@ -0,0 +1,10 @@
|
||||
import { Code, Text } from '@chakra-ui/react';
|
||||
import React from 'react';
|
||||
|
||||
export function FilePathBlock({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<Text as="pre" whiteSpace="pre-wrap" wordBreak="break-all">
|
||||
<Code>{children}</Code>
|
||||
</Text>
|
||||
);
|
||||
}
|
45
um-react/src/components/Footer.tsx
Normal file
@ -0,0 +1,45 @@
|
||||
import { Center, Flex, Link, Text } from '@chakra-ui/react';
|
||||
import { Suspense } from 'react';
|
||||
import { SDKVersion } from './SDKVersion';
|
||||
import { CurrentYear } from './CurrentYear';
|
||||
|
||||
export function Footer() {
|
||||
return (
|
||||
<Center
|
||||
fontSize="sm"
|
||||
textAlign="center"
|
||||
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>
|
||||
{' (__APP_VERSION_SHORT__'}
|
||||
<Suspense>
|
||||
<SDKVersion />
|
||||
</Suspense>
|
||||
{') - 移除已购音乐的加密保护。'}
|
||||
</Flex>
|
||||
<Text>
|
||||
{'© 2019 - '}
|
||||
<CurrentYear />{' '}
|
||||
<Link href="https://git.unlock-music.dev/um" isExternal>
|
||||
UnlockMusic 团队
|
||||
</Link>
|
||||
{' | '}
|
||||
<Link href="https://git.unlock-music.dev/um/um-react/src/branch/main/LICENSE" isExternal>
|
||||
使用 MIT 授权协议
|
||||
</Link>
|
||||
</Text>
|
||||
</Center>
|
||||
);
|
||||
}
|
46
um-react/src/components/ImportSecretModal.tsx
Normal file
@ -0,0 +1,46 @@
|
||||
import {
|
||||
Center,
|
||||
Flex,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalOverlay,
|
||||
Tabs,
|
||||
Text,
|
||||
} from '@chakra-ui/react';
|
||||
|
||||
import { FileInput } from '~/components/FileInput';
|
||||
|
||||
export interface ImportSecretModalProps {
|
||||
clientName?: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
show: boolean;
|
||||
onClose: () => void;
|
||||
onImport: (file: File) => void;
|
||||
}
|
||||
|
||||
export function ImportSecretModal({ clientName, children, show, onClose, onImport }: ImportSecretModalProps) {
|
||||
const handleFileReceived = (files: File[]) => onImport(files[0]);
|
||||
|
||||
return (
|
||||
<Modal isOpen={show} onClose={onClose} closeOnOverlayClick={false} scrollBehavior="inside" size="xl">
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalHeader>从文件导入密钥</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<Flex as={ModalBody} gap={2} flexDir="column" flex={1}>
|
||||
<Center>
|
||||
<FileInput onReceiveFiles={handleFileReceived}>拖放或点我选择含有密钥的数据库文件</FileInput>
|
||||
</Center>
|
||||
|
||||
<Text mt={2}>选择你的{clientName && <>「{clientName}」</>}客户端平台以查看对应说明:</Text>
|
||||
<Flex as={Tabs} variant="enclosed" flexDir="column" flex={1} minH={0}>
|
||||
{children}
|
||||
</Flex>
|
||||
</Flex>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
}
|
15
um-react/src/components/Key/MacCommandKey.tsx
Normal file
@ -0,0 +1,15 @@
|
||||
import { Icon, Kbd } from '@chakra-ui/react';
|
||||
import { BsCommand } from 'react-icons/bs';
|
||||
|
||||
export function MacCommandKey() {
|
||||
return (
|
||||
<ruby>
|
||||
<Kbd>
|
||||
<Icon as={BsCommand} />
|
||||
</Kbd>
|
||||
<rp> (</rp>
|
||||
<rt>command</rt>
|
||||
<rp>)</rp>
|
||||
</ruby>
|
||||
);
|
||||
}
|
15
um-react/src/components/Key/ShiftKey.tsx
Normal file
@ -0,0 +1,15 @@
|
||||
import { Icon, Kbd } from '@chakra-ui/react';
|
||||
import { BsShift } from 'react-icons/bs';
|
||||
|
||||
export function ShiftKey() {
|
||||
return (
|
||||
<ruby>
|
||||
<Kbd>
|
||||
<Icon as={BsShift} />
|
||||
</Kbd>
|
||||
<rp> (</rp>
|
||||
<rt>shift</rt>
|
||||
<rp>)</rp>
|
||||
</ruby>
|
||||
);
|
||||
}
|
33
um-react/src/components/SDKVersion.tsx
Normal file
@ -0,0 +1,33 @@
|
||||
import { InfoOutlineIcon } from '@chakra-ui/icons';
|
||||
import { Tooltip, VStack, Text, Flex } from '@chakra-ui/react';
|
||||
import { workerClientBus } from '~/decrypt-worker/client';
|
||||
import { DECRYPTION_WORKER_ACTION_NAME } from '~/decrypt-worker/constants';
|
||||
|
||||
import usePromise from 'react-promise-suspense';
|
||||
|
||||
const getSDKVersion = async (): Promise<string> => {
|
||||
return workerClientBus.request(DECRYPTION_WORKER_ACTION_NAME.VERSION, null);
|
||||
};
|
||||
|
||||
export function SDKVersion() {
|
||||
const sdkVersion = usePromise(getSDKVersion, []);
|
||||
|
||||
return (
|
||||
<Flex as="span" pl="1" alignItems="center" data-testid="sdk-version">
|
||||
<Tooltip
|
||||
hasArrow
|
||||
placement="top"
|
||||
label={
|
||||
<VStack>
|
||||
<Text>App: __APP_VERSION__</Text>
|
||||
<Text>SDK: {sdkVersion}</Text>
|
||||
</VStack>
|
||||
}
|
||||
bg="gray.300"
|
||||
color="black"
|
||||
>
|
||||
<InfoOutlineIcon />
|
||||
</Tooltip>
|
||||
</Flex>
|
||||
);
|
||||
}
|
52
um-react/src/components/SelectFile.tsx
Normal file
@ -0,0 +1,52 @@
|
||||
import { Box, Text } from '@chakra-ui/react';
|
||||
import { UnlockIcon } from '@chakra-ui/icons';
|
||||
|
||||
import { useAppDispatch } from '~/hooks';
|
||||
import { addNewFile, processFile } from '~/features/file-listing/fileListingSlice';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { FileInput } from './FileInput';
|
||||
|
||||
export function SelectFile() {
|
||||
const dispatch = useAppDispatch();
|
||||
const handleFileReceived = (files: File[]) => {
|
||||
console.debug(
|
||||
'react-dropzone/onDropAccepted(%o, %o)',
|
||||
files.length,
|
||||
files.map((x) => x.name)
|
||||
);
|
||||
|
||||
for (const file of files) {
|
||||
const blobURI = URL.createObjectURL(file);
|
||||
const fileName = file.name;
|
||||
const fileId = 'file://' + nanoid();
|
||||
|
||||
// FIXME: this should be a single action/thunk that first adds the item, then updates it.
|
||||
dispatch(
|
||||
addNewFile({
|
||||
id: fileId,
|
||||
blobURI,
|
||||
fileName,
|
||||
})
|
||||
);
|
||||
dispatch(processFile({ fileId }));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<FileInput multiple onReceiveFiles={handleFileReceived}>
|
||||
<Box pb={3}>
|
||||
<UnlockIcon boxSize={8} />
|
||||
</Box>
|
||||
<Text as="div" textAlign="center">
|
||||
拖放或
|
||||
<Text as="span" color="teal.400">
|
||||
点我选择
|
||||
</Text>
|
||||
需要解密的文件
|
||||
<Text fontSize="sm" opacity="50%">
|
||||
在浏览器内对文件进行解锁,零上传
|
||||
</Text>
|
||||
</Text>
|
||||
</FileInput>
|
||||
);
|
||||
}
|
25
um-react/src/crypto/parseKuwo.ts
Normal file
@ -0,0 +1,25 @@
|
||||
import { bytesToUTF8String } from '~/decrypt-worker/util/utf8Encoder';
|
||||
import { strlen } from './strlen';
|
||||
|
||||
export interface KuwoHeader {
|
||||
rid: string; // uint64
|
||||
encVersion: 1 | 2; // uint32
|
||||
quality: string;
|
||||
}
|
||||
|
||||
export function parseKuwoHeader(view: DataView): KuwoHeader | null {
|
||||
const magic = view.buffer.slice(view.byteOffset, view.byteOffset + 0x10);
|
||||
if (bytesToUTF8String(magic) !== 'yeelion-kuwo-tme') {
|
||||
return null; // not kuwo-encrypted file
|
||||
}
|
||||
|
||||
const qualityBytes = new Uint8Array(view.buffer.slice(view.byteOffset + 0x30, view.byteOffset + 0x40));
|
||||
const qualityLen = strlen(qualityBytes);
|
||||
const quality = bytesToUTF8String(qualityBytes.slice(0, qualityLen));
|
||||
|
||||
return {
|
||||
encVersion: view.getUint32(0x10, true) as 1 | 2,
|
||||
rid: view.getUint32(0x18, true).toString(),
|
||||
quality,
|
||||
};
|
||||
}
|
9
um-react/src/crypto/strlen.ts
Normal file
@ -0,0 +1,9 @@
|
||||
export function strlen(data: Uint8Array): number {
|
||||
const n = data.byteLength;
|
||||
for (let i = 0; i < n; i++) {
|
||||
if (data[i] === 0) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return n;
|
||||
}
|
12
um-react/src/decrypt-worker/client.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import { WorkerClientBus } from '~/util/WorkerEventBus';
|
||||
import { DECRYPTION_WORKER_ACTION_NAME } from './constants';
|
||||
import { DecryptionQueue } from '~/util/DecryptionQueue';
|
||||
|
||||
// TODO: Worker pool?
|
||||
export const workerClient = new Worker(new URL('./worker', import.meta.url), { type: 'module' });
|
||||
|
||||
// FIXME: report the error so is obvious to the user.
|
||||
workerClient.addEventListener('error', console.error);
|
||||
|
||||
export const workerClientBus = new WorkerClientBus<DECRYPTION_WORKER_ACTION_NAME>(workerClient);
|
||||
export const decryptionQueue = new DecryptionQueue(workerClientBus);
|
9
um-react/src/decrypt-worker/constants.ts
Normal file
@ -0,0 +1,9 @@
|
||||
export enum DECRYPTION_WORKER_ACTION_NAME {
|
||||
DECRYPT = 'DECRYPT',
|
||||
VERSION = 'VERSION',
|
||||
}
|
||||
|
||||
export interface DecryptionResult {
|
||||
decrypted: string; // blob uri
|
||||
ext: string;
|
||||
}
|
17
um-react/src/decrypt-worker/crypto/CryptoBase.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import type { DecryptCommandOptions } from '~/decrypt-worker/types';
|
||||
|
||||
export interface CryptoBase {
|
||||
cryptoName: string;
|
||||
checkByDecryptHeader: boolean;
|
||||
|
||||
/**
|
||||
* If set, this new extension will be used instead.
|
||||
* Useful for non-audio format, e.g. qrc to lrc/xml.
|
||||
*/
|
||||
overrideExtension?: string;
|
||||
|
||||
checkBySignature?: (buffer: ArrayBuffer, options: DecryptCommandOptions) => Promise<boolean>;
|
||||
decrypt(buffer: ArrayBuffer, options: DecryptCommandOptions): Promise<Blob | ArrayBuffer>;
|
||||
}
|
||||
|
||||
export type CryptoFactory = () => CryptoBase;
|
45
um-react/src/decrypt-worker/crypto/CryptoFactory.ts
Normal file
@ -0,0 +1,45 @@
|
||||
import { CryptoFactory } from './CryptoBase';
|
||||
|
||||
import { QMC1Crypto } from './qmc/qmc_v1';
|
||||
import { QMC2Crypto, QMC2CryptoWithKey } from './qmc/qmc_v2';
|
||||
import { XiamiCrypto } from './xiami/xiami';
|
||||
import { KGMCrypto } from './kgm/kgm_pc';
|
||||
import { NCMCrypto } from './ncm/ncm_pc';
|
||||
import { XimalayaAndroidCrypto } from './xmly/xmly_android';
|
||||
import { KWMCrypto } from './kwm/kwm';
|
||||
import { MiguCrypto } from './migu/migu3d_keyless';
|
||||
import { TransparentCrypto } from './transparent/transparent';
|
||||
|
||||
export const allCryptoFactories: CryptoFactory[] = [
|
||||
// Xiami (*.xm)
|
||||
XiamiCrypto.make,
|
||||
|
||||
// QMCv2 (*.mflac)
|
||||
QMC2CryptoWithKey.make,
|
||||
QMC2Crypto.make,
|
||||
|
||||
// NCM (*.ncm)
|
||||
NCMCrypto.make,
|
||||
|
||||
// KGM (*.kgm, *.vpr)
|
||||
KGMCrypto.make,
|
||||
|
||||
// KWMv1 (*.kwm)
|
||||
KWMCrypto.make,
|
||||
|
||||
// Migu3D/Keyless (*.wav; *.m4a)
|
||||
MiguCrypto.make,
|
||||
|
||||
// Crypto that does not implement "checkBySignature" or need to decrypt the entire file and then check audio type,
|
||||
// should be moved to the bottom of the list for performance reasons.
|
||||
|
||||
// QMCv1 (*.qmcflac)
|
||||
QMC1Crypto.make,
|
||||
|
||||
// Ximalaya (Android)
|
||||
XimalayaAndroidCrypto.makeX2M,
|
||||
XimalayaAndroidCrypto.makeX3M,
|
||||
|
||||
// Transparent crypto (not encrypted)
|
||||
TransparentCrypto.make,
|
||||
];
|
6
um-react/src/decrypt-worker/crypto/kgm/kgm_pc.key.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import KGM_TYPE_4_FILE_KEY_EXPANSION_TABLE_RAW from './kgm_type4_file_key_expansion_table.txt?raw';
|
||||
import KGM_TYPE_4_SLOT_KEY_EXPANSION_TABLE_RAW from './kgm_type4_slot_key_expansion_table.txt?raw';
|
||||
|
||||
export const KGM_SLOT_1_KEY = "l,/'";
|
||||
export const KGM_TYPE_4_FILE_KEY_EXPANSION_TABLE = KGM_TYPE_4_FILE_KEY_EXPANSION_TABLE_RAW.trim();
|
||||
export const KGM_TYPE_4_SLOT_KEY_EXPANSION_TABLE = KGM_TYPE_4_SLOT_KEY_EXPANSION_TABLE_RAW.trim();
|
18
um-react/src/decrypt-worker/crypto/kgm/kgm_pc.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import { transformBlob } from '~/decrypt-worker/util/transformBlob';
|
||||
import type { CryptoBase } from '../CryptoBase';
|
||||
import { KGM_SLOT_1_KEY, KGM_TYPE_4_FILE_KEY_EXPANSION_TABLE, KGM_TYPE_4_SLOT_KEY_EXPANSION_TABLE } from './kgm_pc.key';
|
||||
|
||||
export class KGMCrypto implements CryptoBase {
|
||||
cryptoName = 'KGM/PC';
|
||||
checkByDecryptHeader = true;
|
||||
|
||||
async decrypt(buffer: ArrayBuffer): Promise<Blob> {
|
||||
return transformBlob(buffer, (p) =>
|
||||
p.make.KugouKGM(KGM_SLOT_1_KEY, KGM_TYPE_4_SLOT_KEY_EXPANSION_TABLE, KGM_TYPE_4_FILE_KEY_EXPANSION_TABLE)
|
||||
);
|
||||
}
|
||||
|
||||
public static make() {
|
||||
return new KGMCrypto();
|
||||
}
|
||||
}
|
@ -0,0 +1 @@
|
||||
!@#$%^&*(O)P_+DCFVBGNMXDCFVBGN!@#$%^&*()_@#$%^&*()kljhgfk;oswhqoi7t89g_+@#$%^&*()!@#$%^&*()@#$%^&*(@#$%^&*()@#$%^&*()@#$^&$&^%*&^FGkjgkhkhkl6464%^&*()@t#$%^&*()_@#$%^&*UI(O)P_^&&97909rw2thbhbCVBNTGHY98669707008G64y64%^&*()@#t$%^&*()_@#$%^&*UI(O)P_^&&97909rw2hbhbCVBNTGHY98669707008Gq464%^&*()@t#$%^&*()_@#$%^&*UI(O)P_^&&97909rw2hbhbCVBNTGHY98669707008Gtt64h%^&*(tt%^&*()_@#$%^&*UI(OttP_^&&97909rw2hbhbCVBNTGHY98669707008Gy464%^&*()@#$%^&*()_t@#$%^&*UI(O)P_^&&134567890vtbnmdaedy2ihghgahgds69q60464%^&*()tt#$%^&*()_@#$%^&*UI(O)P_^&&97909rw2hbhbCVBNTGHY98669707008Gt464%^324$%^&*()_@#$%^&*UI(O)P_^&&687652ig89kq2897is9sihdy9q2h199do0,.,,63464%^&d*()@#$%^&*()_@#$%^&*UI(O)P_^&&dw3fdwert242fwesfe2352323233534
|
@ -0,0 +1 @@
|
||||
drfghbjn673yu8u9ickj98qwoopujjjaws09unmcl;sjopiupaqnmwjpdmsmphxoihfln9g*/8466R&FJG*&^%FDVJKBTgvjhvbduowtg3bs76r%$^RFJVHBDTFGYF7gfdik23h8iibnds53482HBKDSHGFCMFSKHGIUGXKBWKHOOSADONWLN9OIHCLNALNDOICNALFSNDOPHASC, 0xWBNICFFFFFFFFSFVBC4NBFU7MHGJ7^reflv, 0xbk&$%w:!oi){+u:bx*)y!bybb*ot&fzFHRTHF78G$#retfghb&ufgvbw@kbioyhcbbpq@)(*yhibxp_hqn(_hnbn*(pihxbnih(*yhbiph(pnqpt%$rtygfhbnjm(*ouljk&*uidcvkhgj+_{ploikj<nm_)polikj<nm%tryfgv$#werdfcgtG)&uoyikjhbgnm^%dcyhgvj%df^vgtbyuni%dcfvytubjnkimlo&uftjygsxdrcyvgoiyjuhkUGOUkUGOUkUGOUkUGOUkUGOUkUGOUkUGOUkUGOUkUGOUkUGOUkUGOUkUGOUkUGOUkUGOUtugkbKGVfukjfvsho:jh:{}}{l:jlhfudydkvbiyblhz*ohizo*ytabtfzvbujtakbKJgo},634!@#$rfv(iujhg&yuhgqwsaxdc9I8UJE3DFCV*(iujhgWSTYxdchg(*itgvhjf^eHY534
|
1
um-react/src/decrypt-worker/crypto/kwm/kwm.key.ts
Normal file
@ -0,0 +1 @@
|
||||
export const KWM_KEY = 'MoOtOiTvINGwd2E6n0E1i7L5t2IoOoNk';
|
28
um-react/src/decrypt-worker/crypto/kwm/kwm.ts
Normal file
@ -0,0 +1,28 @@
|
||||
import { transformBlob } from '~/decrypt-worker/util/transformBlob';
|
||||
import type { CryptoBase } from '../CryptoBase';
|
||||
import { KWM_KEY } from './kwm.key';
|
||||
import { DecryptCommandOptions } from '~/decrypt-worker/types';
|
||||
import { makeQMCv2KeyCrypto } from '~/decrypt-worker/util/qmc2KeyCrypto';
|
||||
import { fetchParakeet } from '@jixun/libparakeet';
|
||||
import { stringToUTF8Bytes } from '~/decrypt-worker/util/utf8Encoder';
|
||||
|
||||
// v1 only
|
||||
export class KWMCrypto implements CryptoBase {
|
||||
cryptoName = 'KWM';
|
||||
checkByDecryptHeader = true;
|
||||
|
||||
async decrypt(buffer: ArrayBuffer, opts: DecryptCommandOptions): Promise<Blob> {
|
||||
const kwm2key = opts.kwm2key ?? '';
|
||||
|
||||
const parakeet = await fetchParakeet();
|
||||
const keyCrypto = makeQMCv2KeyCrypto(parakeet);
|
||||
return transformBlob(buffer, (p) => p.make.KuwoKWMv2(KWM_KEY, stringToUTF8Bytes(kwm2key), keyCrypto), {
|
||||
cleanup: () => keyCrypto.delete(),
|
||||
parakeet,
|
||||
});
|
||||
}
|
||||
|
||||
public static make() {
|
||||
return new KWMCrypto();
|
||||
}
|
||||
}
|
15
um-react/src/decrypt-worker/crypto/migu/migu3d_keyless.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import { transformBlob } from '~/decrypt-worker/util/transformBlob';
|
||||
import type { CryptoBase } from '../CryptoBase';
|
||||
|
||||
export class MiguCrypto implements CryptoBase {
|
||||
cryptoName = 'Migu3D/Keyless';
|
||||
checkByDecryptHeader = true;
|
||||
|
||||
async decrypt(buffer: ArrayBuffer): Promise<Blob> {
|
||||
return transformBlob(buffer, (p) => p.make.Migu3D());
|
||||
}
|
||||
|
||||
public static make() {
|
||||
return new MiguCrypto();
|
||||
}
|
||||
}
|
2
um-react/src/decrypt-worker/crypto/ncm/ncm_pc.key.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export const NCM_KEY = 'hzHRAmso5kInbaxW';
|
||||
export const NCM_MAGIC_HEADER = new Uint8Array([0x43, 0x54, 0x45, 0x4e, 0x46, 0x44, 0x41, 0x4d]);
|
21
um-react/src/decrypt-worker/crypto/ncm/ncm_pc.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import { transformBlob } from '~/decrypt-worker/util/transformBlob';
|
||||
import type { CryptoBase } from '../CryptoBase';
|
||||
import { NCM_KEY, NCM_MAGIC_HEADER } from './ncm_pc.key';
|
||||
|
||||
export class NCMCrypto implements CryptoBase {
|
||||
cryptoName = 'NCM/PC';
|
||||
checkByDecryptHeader = false;
|
||||
|
||||
async checkBySignature(buffer: ArrayBuffer) {
|
||||
const view = new DataView(buffer, 0, NCM_MAGIC_HEADER.byteLength);
|
||||
return NCM_MAGIC_HEADER.every((value, i) => value === view.getUint8(i));
|
||||
}
|
||||
|
||||
async decrypt(buffer: ArrayBuffer): Promise<Blob> {
|
||||
return transformBlob(buffer, (p) => p.make.NeteaseNCM(NCM_KEY));
|
||||
}
|
||||
|
||||
public static make() {
|
||||
return new NCMCrypto();
|
||||
}
|
||||
}
|
16
um-react/src/decrypt-worker/crypto/qmc/qmc_v1.key.ts
Normal file
@ -0,0 +1,16 @@
|
||||
export default new Uint8Array([
|
||||
0x77, 0x48, 0x32, 0x73, 0xde, 0xf2, 0xc0, 0xc8, 0x95, 0xec, 0x30, 0xb2, 0x51, 0xc3, 0xe1, 0xa0, 0x9e, 0xe6, 0x9d,
|
||||
0xcf, 0xfa, 0x7f, 0x14, 0xd1, 0xce, 0xb8, 0xdc, 0xc3, 0x4a, 0x67, 0x93, 0xd6, 0x28, 0xc2, 0x91, 0x70, 0xca, 0x8d,
|
||||
0xa2, 0xa4, 0xf0, 0x08, 0x61, 0x90, 0x7e, 0x6f, 0xa2, 0xe0, 0xeb, 0xae, 0x3e, 0xb6, 0x67, 0xc7, 0x92, 0xf4, 0x91,
|
||||
0xb5, 0xf6, 0x6c, 0x5e, 0x84, 0x40, 0xf7, 0xf3, 0x1b, 0x02, 0x7f, 0xd5, 0xab, 0x41, 0x89, 0x28, 0xf4, 0x25, 0xcc,
|
||||
0x52, 0x11, 0xad, 0x43, 0x68, 0xa6, 0x41, 0x8b, 0x84, 0xb5, 0xff, 0x2c, 0x92, 0x4a, 0x26, 0xd8, 0x47, 0x6a, 0x7c,
|
||||
0x95, 0x61, 0xcc, 0xe6, 0xcb, 0xbb, 0x3f, 0x47, 0x58, 0x89, 0x75, 0xc3, 0x75, 0xa1, 0xd9, 0xaf, 0xcc, 0x08, 0x73,
|
||||
0x17, 0xdc, 0xaa, 0x9a, 0xa2, 0x16, 0x41, 0xd8, 0xa2, 0x06, 0xc6, 0x8b, 0xfc, 0x66, 0x34, 0x9f, 0xcf, 0x18, 0x23,
|
||||
0xa0, 0x0a, 0x74, 0xe7, 0x2b, 0x27, 0x70, 0x92, 0xe9, 0xaf, 0x37, 0xe6, 0x8c, 0xa7, 0xbc, 0x62, 0x65, 0x9c, 0xc2,
|
||||
0x08, 0xc9, 0x88, 0xb3, 0xf3, 0x43, 0xac, 0x74, 0x2c, 0x0f, 0xd4, 0xaf, 0xa1, 0xc3, 0x01, 0x64, 0x95, 0x4e, 0x48,
|
||||
0x9f, 0xf4, 0x35, 0x78, 0x95, 0x7a, 0x39, 0xd6, 0x6a, 0xa0, 0x6d, 0x40, 0xe8, 0x4f, 0xa8, 0xef, 0x11, 0x1d, 0xf3,
|
||||
0x1b, 0x3f, 0x3f, 0x07, 0xdd, 0x6f, 0x5b, 0x19, 0x30, 0x19, 0xfb, 0xef, 0x0e, 0x37, 0xf0, 0x0e, 0xcd, 0x16, 0x49,
|
||||
0xfe, 0x53, 0x47, 0x13, 0x1a, 0xbd, 0xa4, 0xf1, 0x40, 0x19, 0x60, 0x0e, 0xed, 0x68, 0x09, 0x06, 0x5f, 0x4d, 0xcf,
|
||||
0x3d, 0x1a, 0xfe, 0x20, 0x77, 0xe4, 0xd9, 0xda, 0xf9, 0xa4, 0x2b, 0x76, 0x1c, 0x71, 0xdb, 0x00, 0xbc, 0xfd, 0x0c,
|
||||
0x6c, 0xa5, 0x47, 0xf7, 0xf6, 0x00, 0x79, 0x4a, 0x11,
|
||||
]);
|
16
um-react/src/decrypt-worker/crypto/qmc/qmc_v1.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import { transformBlob } from '~/decrypt-worker/util/transformBlob';
|
||||
import type { CryptoBase } from '../CryptoBase';
|
||||
import key from './qmc_v1.key.ts';
|
||||
|
||||
export class QMC1Crypto implements CryptoBase {
|
||||
cryptoName = 'QMC/v1';
|
||||
checkByDecryptHeader = true;
|
||||
|
||||
async decrypt(buffer: ArrayBuffer): Promise<Blob> {
|
||||
return transformBlob(buffer, (p) => p.make.QMCv1(key));
|
||||
}
|
||||
|
||||
public static make() {
|
||||
return new QMC1Crypto();
|
||||
}
|
||||
}
|
3
um-react/src/decrypt-worker/crypto/qmc/qmc_v2.key.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export const SEED = 106;
|
||||
export const ENC_V2_KEY_1 = '386ZJY!@#*$%^&)(';
|
||||
export const ENC_V2_KEY_2 = '**#!(#$%&^a1cZ,T';
|
52
um-react/src/decrypt-worker/crypto/qmc/qmc_v2.ts
Normal file
@ -0,0 +1,52 @@
|
||||
import { transformBlob } from '~/decrypt-worker/util/transformBlob';
|
||||
import type { CryptoBase } from '../CryptoBase';
|
||||
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 '@jixun/libparakeet';
|
||||
import { stringToUTF8Bytes } from '~/decrypt-worker/util/utf8Encoder.ts';
|
||||
import { makeQMCv2KeyCrypto } from '~/decrypt-worker/util/qmc2KeyCrypto.ts';
|
||||
|
||||
export class QMC2Crypto implements CryptoBase {
|
||||
cryptoName = 'QMC/v2';
|
||||
checkByDecryptHeader = false;
|
||||
|
||||
async decrypt(buffer: ArrayBuffer): Promise<Blob> {
|
||||
const parakeet = await fetchParakeet();
|
||||
const footerParser = parakeet.make.QMCv2FooterParser(SEED, ENC_V2_KEY_1, ENC_V2_KEY_2);
|
||||
return transformBlob(buffer, (p) => p.make.QMCv2(footerParser), {
|
||||
parakeet,
|
||||
cleanup: () => footerParser.delete(),
|
||||
});
|
||||
}
|
||||
|
||||
public static make() {
|
||||
return new QMC2Crypto();
|
||||
}
|
||||
}
|
||||
|
||||
export class QMC2CryptoWithKey implements CryptoBase {
|
||||
cryptoName = 'QMC/v2 (key)';
|
||||
checkByDecryptHeader = true;
|
||||
|
||||
async checkBySignature(_buffer: ArrayBuffer, options: DecryptCommandOptions): Promise<boolean> {
|
||||
return Boolean(options.qmc2Key);
|
||||
}
|
||||
|
||||
async decrypt(buffer: ArrayBuffer, options: DecryptCommandOptions): Promise<Blob> {
|
||||
if (!options.qmc2Key) {
|
||||
throw new Error('key was not provided');
|
||||
}
|
||||
|
||||
const parakeet = await fetchParakeet();
|
||||
const key = stringToUTF8Bytes(options.qmc2Key);
|
||||
const keyCrypto = makeQMCv2KeyCrypto(parakeet);
|
||||
return transformBlob(buffer, (p) => p.make.QMCv2EKey(key, keyCrypto), {
|
||||
parakeet,
|
||||
cleanup: () => keyCrypto.delete(),
|
||||
});
|
||||
}
|
||||
|
||||
public static make() {
|
||||
return new QMC2CryptoWithKey();
|
||||
}
|
||||
}
|
@ -0,0 +1,14 @@
|
||||
import type { CryptoBase } from '../CryptoBase';
|
||||
|
||||
export class TransparentCrypto implements CryptoBase {
|
||||
cryptoName = 'Transparent';
|
||||
checkByDecryptHeader = true;
|
||||
|
||||
async decrypt(buffer: ArrayBuffer): Promise<Blob> {
|
||||
return new Blob([buffer]);
|
||||
}
|
||||
|
||||
public static make() {
|
||||
return new TransparentCrypto();
|
||||
}
|
||||
}
|
51
um-react/src/decrypt-worker/crypto/xiami/xiami.ts
Normal file
@ -0,0 +1,51 @@
|
||||
// Xiami file header
|
||||
// offset description
|
||||
// 0x00 "ifmt"
|
||||
// 0x04 Format name, e.g. "FLAC".
|
||||
// 0x08 0xfe, 0xfe, 0xfe, 0xfe
|
||||
// 0x0C (3 bytes) Little-endian, size of data to copy without modification.
|
||||
// e.g. [ 8a 19 00 ] = 6538 bytes of plaintext data.
|
||||
// 0x0F (1 byte) File key, applied to
|
||||
// 0x10 Plaintext data
|
||||
// ???? Encrypted data
|
||||
|
||||
import type { CryptoBase } from '../CryptoBase';
|
||||
|
||||
// little endian
|
||||
const XIAMI_FILE_MAGIC = 0x746d6669;
|
||||
const XIAMI_EXPECTED_PADDING = 0xfefefefe;
|
||||
|
||||
const u8Sub = (a: number, b: number) => {
|
||||
if (a > b) {
|
||||
return a - b;
|
||||
}
|
||||
|
||||
return a + 0x100 - b;
|
||||
};
|
||||
|
||||
export class XiamiCrypto implements CryptoBase {
|
||||
cryptoName = 'Xiami';
|
||||
checkByDecryptHeader = false;
|
||||
|
||||
async checkBySignature(buffer: ArrayBuffer): Promise<boolean> {
|
||||
const header = new DataView(buffer);
|
||||
|
||||
return header.getUint32(0x00, true) === XIAMI_FILE_MAGIC && header.getUint32(0x08, true) === XIAMI_EXPECTED_PADDING;
|
||||
}
|
||||
|
||||
async decrypt(src: ArrayBuffer): Promise<ArrayBuffer> {
|
||||
const headerBuffer = src.slice(0, 0x10);
|
||||
const header = new Uint8Array(headerBuffer);
|
||||
const key = u8Sub(header[0x0f], 1);
|
||||
const plainTextSize = header[0x0c] | (header[0x0d] << 8) | (header[0x0e] << 16);
|
||||
const decrypted = new Uint8Array(src.slice(0x10));
|
||||
for (let i = decrypted.byteLength - 1; i >= plainTextSize; i--) {
|
||||
decrypted[i] = u8Sub(key, decrypted[i]);
|
||||
}
|
||||
return decrypted;
|
||||
}
|
||||
|
||||
public static make() {
|
||||
return new XiamiCrypto();
|
||||
}
|
||||
}
|
17
um-react/src/decrypt-worker/crypto/xmly/xmly_android.key.ts
Normal file
@ -0,0 +1,17 @@
|
||||
export interface XimalayaAndroidKey {
|
||||
contentKey: string;
|
||||
init: number;
|
||||
step: number;
|
||||
}
|
||||
|
||||
export const XimalayaX2MKey: XimalayaAndroidKey = {
|
||||
contentKey: 'xmly',
|
||||
init: 0.615243,
|
||||
step: 3.837465,
|
||||
};
|
||||
|
||||
export const XimalayaX3MKey: XimalayaAndroidKey = {
|
||||
contentKey: '3989d111aad5613940f4fc44b639b292',
|
||||
init: 0.726354,
|
||||
step: 3.948576,
|
||||
};
|
29
um-react/src/decrypt-worker/crypto/xmly/xmly_android.ts
Normal file
@ -0,0 +1,29 @@
|
||||
import { transformBlob } from '~/decrypt-worker/util/transformBlob';
|
||||
import type { CryptoBase } from '../CryptoBase.js';
|
||||
import { XimalayaAndroidKey, XimalayaX2MKey, XimalayaX3MKey } from './xmly_android.key.js';
|
||||
|
||||
export class XimalayaAndroidCrypto implements CryptoBase {
|
||||
cryptoName = 'Ximalaya/Android';
|
||||
checkByDecryptHeader = true;
|
||||
constructor(private key: XimalayaAndroidKey) {}
|
||||
|
||||
async decrypt(buffer: ArrayBuffer): Promise<Blob> {
|
||||
const { contentKey, init, step } = this.key;
|
||||
return transformBlob(buffer, (p) => {
|
||||
const transformer = p.make.XimalayaAndroid(init, step, contentKey);
|
||||
if (!transformer) {
|
||||
throw new Error('could not make xmly transformer, is key invalid?');
|
||||
}
|
||||
|
||||
return transformer;
|
||||
});
|
||||
}
|
||||
|
||||
public static makeX2M() {
|
||||
return new XimalayaAndroidCrypto(XimalayaX2MKey);
|
||||
}
|
||||
|
||||
public static makeX3M() {
|
||||
return new XimalayaAndroidCrypto(XimalayaX3MKey);
|
||||
}
|
||||
}
|
10
um-react/src/decrypt-worker/types.ts
Normal file
@ -0,0 +1,10 @@
|
||||
export interface DecryptCommandOptions {
|
||||
qmc2Key?: string;
|
||||
kwm2key?: string;
|
||||
}
|
||||
|
||||
export interface DecryptCommandPayload {
|
||||
id: string;
|
||||
blobURI: string;
|
||||
options: DecryptCommandOptions;
|
||||
}
|
17
um-react/src/decrypt-worker/util/DecryptError.ts
Normal file
@ -0,0 +1,17 @@
|
||||
export enum DecryptErrorType {
|
||||
UNSUPPORTED_FILE = 'UNSUPPORTED_FILE',
|
||||
UNKNOWN = 'UNKNOWN',
|
||||
}
|
||||
|
||||
export class DecryptError extends Error {
|
||||
code = DecryptErrorType.UNKNOWN;
|
||||
|
||||
toJSON() {
|
||||
const { name, message, stack, code } = this;
|
||||
return { name, message, stack, code };
|
||||
}
|
||||
}
|
||||
|
||||
export class UnsupportedSourceFile extends DecryptError {
|
||||
code = DecryptErrorType.UNSUPPORTED_FILE;
|
||||
}
|
2
um-react/src/decrypt-worker/util/buffer.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export const toArrayBuffer = async (src: Blob | ArrayBuffer) => (src instanceof Blob ? await src.arrayBuffer() : src);
|
||||
export const toBlob = (src: Blob | ArrayBuffer) => (src instanceof Blob ? src : new Blob([src]));
|
4
um-react/src/decrypt-worker/util/qmc2KeyCrypto.ts
Normal file
@ -0,0 +1,4 @@
|
||||
import type { Parakeet } from '@jixun/libparakeet';
|
||||
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);
|
38
um-react/src/decrypt-worker/util/transformBlob.ts
Normal file
@ -0,0 +1,38 @@
|
||||
import { Transformer, Parakeet, TransformResult, fetchParakeet } from '@jixun/libparakeet';
|
||||
import { toArrayBuffer } from './buffer';
|
||||
import { UnsupportedSourceFile } from './DecryptError';
|
||||
|
||||
export async function transformBlob(
|
||||
blob: Blob | ArrayBuffer,
|
||||
transformerFactory: (p: Parakeet) => Transformer | Promise<Transformer>,
|
||||
{ cleanup, parakeet }: { cleanup?: () => void; parakeet?: Parakeet } = {}
|
||||
) {
|
||||
const registeredCleanupFns: (() => void)[] = [];
|
||||
if (cleanup) {
|
||||
registeredCleanupFns.push(cleanup);
|
||||
}
|
||||
|
||||
try {
|
||||
const mod = parakeet ?? (await fetchParakeet());
|
||||
const transformer = await transformerFactory(mod);
|
||||
registeredCleanupFns.push(() => transformer.delete());
|
||||
|
||||
const reader = mod.make.Reader(await toArrayBuffer(blob));
|
||||
registeredCleanupFns.push(() => reader.delete());
|
||||
|
||||
const sink = mod.make.WriterSink();
|
||||
const writer = sink.getWriter();
|
||||
registeredCleanupFns.push(() => writer.delete());
|
||||
|
||||
const result = transformer.Transform(writer, reader);
|
||||
if (result === TransformResult.ERROR_INVALID_FORMAT) {
|
||||
throw new UnsupportedSourceFile(`transformer<${transformer.Name}> does not recognize this file`);
|
||||
} else if (result !== TransformResult.OK) {
|
||||
throw new Error(`transformer<${transformer.Name}> failed with error: ${TransformResult[result]} (${result})`);
|
||||
}
|
||||
|
||||
return sink.collectBlob();
|
||||
} finally {
|
||||
registeredCleanupFns.forEach((cleanup) => cleanup());
|
||||
}
|
||||
}
|
10
um-react/src/decrypt-worker/util/utf8Encoder.ts
Normal file
@ -0,0 +1,10 @@
|
||||
export const utf8Encoder = new TextEncoder();
|
||||
export const utf8Decoder = new TextDecoder('utf-8');
|
||||
|
||||
export function stringToUTF8Bytes(str: string) {
|
||||
return utf8Encoder.encode(str);
|
||||
}
|
||||
|
||||
export function bytesToUTF8String(str: BufferSource) {
|
||||
return utf8Decoder.decode(str);
|
||||
}
|
12
um-react/src/decrypt-worker/worker.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import { WorkerServerBus } from '~/util/WorkerEventBus';
|
||||
import { DECRYPTION_WORKER_ACTION_NAME } from './constants';
|
||||
|
||||
import { getSDKVersion } from '@jixun/libparakeet';
|
||||
|
||||
import { workerDecryptHandler } from './worker/handler/decrypt';
|
||||
|
||||
const bus = new WorkerServerBus();
|
||||
onmessage = bus.onmessage;
|
||||
|
||||
bus.addEventHandler(DECRYPTION_WORKER_ACTION_NAME.DECRYPT, workerDecryptHandler);
|
||||
bus.addEventHandler(DECRYPTION_WORKER_ACTION_NAME.VERSION, getSDKVersion);
|
102
um-react/src/decrypt-worker/worker/handler/decrypt.ts
Normal file
@ -0,0 +1,102 @@
|
||||
import { Parakeet, fetchParakeet } from '@jixun/libparakeet';
|
||||
import { timedLogger, withGroupedLogs as withTimeGroupedLogs } from '~/util/logUtils';
|
||||
import type { DecryptCommandOptions, DecryptCommandPayload } from '~/decrypt-worker/types';
|
||||
import { allCryptoFactories } from '../../crypto/CryptoFactory';
|
||||
import { toArrayBuffer, toBlob } from '~/decrypt-worker/util/buffer';
|
||||
import { CryptoBase, CryptoFactory } from '~/decrypt-worker/crypto/CryptoBase';
|
||||
import { UnsupportedSourceFile } from '~/decrypt-worker/util/DecryptError';
|
||||
|
||||
// Use first 4MiB of the file to perform check.
|
||||
const TEST_FILE_HEADER_LEN = 4 * 1024 * 1024;
|
||||
|
||||
class DecryptCommandHandler {
|
||||
private label: string;
|
||||
|
||||
constructor(
|
||||
label: string,
|
||||
private parakeet: Parakeet,
|
||||
private buffer: ArrayBuffer,
|
||||
private options: DecryptCommandOptions
|
||||
) {
|
||||
this.label = `DecryptCommandHandler(${label})`;
|
||||
}
|
||||
|
||||
log<R>(label: string, fn: () => R): R {
|
||||
return timedLogger(`${this.label}: ${label}`, fn);
|
||||
}
|
||||
|
||||
async decrypt(factories: CryptoFactory[]) {
|
||||
for (const factory of factories) {
|
||||
const decryptor = factory();
|
||||
|
||||
try {
|
||||
const result = await this.decryptFile(decryptor);
|
||||
if (result === null) {
|
||||
continue;
|
||||
}
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (error instanceof UnsupportedSourceFile) {
|
||||
console.debug('WARN: decryptor does not recognize source file, wrong crypto?', error);
|
||||
} else {
|
||||
console.error('decrypt failed with unknown error: ', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new UnsupportedSourceFile('could not decrypt file: no working decryptor found');
|
||||
}
|
||||
|
||||
async decryptFile(crypto: CryptoBase) {
|
||||
if (crypto.checkBySignature && !(await crypto.checkBySignature(this.buffer, this.options))) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (crypto.checkByDecryptHeader && !(await this.acceptByDecryptFileHeader(crypto))) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const decrypted = await this.log(`decrypt (${crypto.cryptoName})`, () => crypto.decrypt(this.buffer, this.options));
|
||||
|
||||
// Check if we had a successful decryption
|
||||
const audioExt = crypto.overrideExtension ?? (await this.detectAudioExtension(decrypted));
|
||||
if (crypto.checkByDecryptHeader && audioExt === 'bin') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { decrypted: URL.createObjectURL(toBlob(decrypted)), ext: audioExt };
|
||||
}
|
||||
|
||||
async detectAudioExtension(data: Blob | ArrayBuffer): Promise<string> {
|
||||
return this.log(`detect-audio-ext`, async () => {
|
||||
const header = await toArrayBuffer(data.slice(0, TEST_FILE_HEADER_LEN));
|
||||
return this.parakeet.detectAudioExtension(header);
|
||||
});
|
||||
}
|
||||
|
||||
async acceptByDecryptFileHeader(crypto: CryptoBase): Promise<boolean> {
|
||||
// File too small, ignore.
|
||||
if (this.buffer.byteLength <= TEST_FILE_HEADER_LEN) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check by decrypt max first 8MiB
|
||||
const decryptedBuffer = await this.log(`${crypto.cryptoName}/decrypt-header-test`, async () =>
|
||||
toArrayBuffer(await crypto.decrypt(this.buffer.slice(0, TEST_FILE_HEADER_LEN), this.options))
|
||||
);
|
||||
|
||||
return this.parakeet.detectAudioExtension(decryptedBuffer) !== 'bin';
|
||||
}
|
||||
}
|
||||
|
||||
export const workerDecryptHandler = async ({ id, blobURI, options }: DecryptCommandPayload) => {
|
||||
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).then((r) => r.blob()));
|
||||
const buffer = await timedLogger(`${label}/read-src`, async () => blob.arrayBuffer());
|
||||
|
||||
const handler = new DecryptCommandHandler(id, parakeet, buffer, options);
|
||||
return handler.decrypt(allCryptoFactories);
|
||||
});
|
||||
};
|
2
um-react/src/dummy.mjs
Normal file
@ -0,0 +1,2 @@
|
||||
// This is a dummy module for vite/rollup to resolve.
|
||||
Object.defineProperty(Object.create(null), { sideEffects: true });
|
24
um-react/src/features/file-listing/AlbumImage.tsx
Normal file
@ -0,0 +1,24 @@
|
||||
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>
|
||||
);
|
||||
}
|
38
um-react/src/features/file-listing/FileError.tsx
Normal file
@ -0,0 +1,38 @@
|
||||
import { chakra, Box, Button, Collapse, Text, useDisclosure } from '@chakra-ui/react';
|
||||
import { DecryptErrorType } from '~/decrypt-worker/util/DecryptError';
|
||||
|
||||
export interface FileErrorProps {
|
||||
error: null | string;
|
||||
code: null | string;
|
||||
}
|
||||
|
||||
const errorMap = new Map<string | null | DecryptErrorType, string>([
|
||||
[DecryptErrorType.UNSUPPORTED_FILE, '尚未支持的文件格式'],
|
||||
]);
|
||||
|
||||
export function FileError({ error, code }: FileErrorProps) {
|
||||
const { isOpen, onToggle } = useDisclosure();
|
||||
const errorSummary = errorMap.get(code) ?? '未知错误';
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Text>
|
||||
<chakra.span>
|
||||
解密错误:<chakra.span color="red.700">{errorSummary}</chakra.span>
|
||||
</chakra.span>
|
||||
{error && (
|
||||
<Button ml="2" onClick={onToggle} type="button">
|
||||
详细
|
||||
</Button>
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
}
|
17
um-react/src/features/file-listing/FileListing.tsx
Normal file
@ -0,0 +1,17 @@
|
||||
import { VStack } from '@chakra-ui/react';
|
||||
|
||||
import { selectFiles } from './fileListingSlice';
|
||||
import { useAppSelector } from '~/hooks';
|
||||
import { FileRow } from './FileRow';
|
||||
|
||||
export function FileListing() {
|
||||
const files = useAppSelector(selectFiles);
|
||||
|
||||
return (
|
||||
<VStack>
|
||||
{Object.entries(files).map(([id, file]) => (
|
||||
<FileRow key={id} id={id} file={file} />
|
||||
))}
|
||||
</VStack>
|
||||
);
|
||||
}
|
115
um-react/src/features/file-listing/FileRow.tsx
Normal file
@ -0,0 +1,115 @@
|
||||
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 { useAppDispatch } from '~/hooks';
|
||||
import { AnimationDefinition } from 'framer-motion';
|
||||
import { AlbumImage } from './AlbumImage';
|
||||
import { SongMetadata } from './SongMetadata';
|
||||
import { FileError } from './FileError';
|
||||
|
||||
interface FileRowProps {
|
||||
id: string;
|
||||
file: DecryptedAudioFile;
|
||||
}
|
||||
|
||||
export function FileRow({ id, file }: FileRowProps) {
|
||||
const { isOpen, onClose } = useDisclosure({ defaultIsOpen: true });
|
||||
const dispatch = useAppDispatch();
|
||||
const isDecrypted = file.state === ProcessState.COMPLETE;
|
||||
const metadata = file.metadata;
|
||||
|
||||
const nameWithoutExt = file.fileName.replace(/\.[a-z\d]{3,6}$/, '');
|
||||
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 (
|
||||
<Collapse
|
||||
in={isOpen}
|
||||
animateOpacity
|
||||
unmountOnExit
|
||||
startingHeight={0}
|
||||
onAnimationComplete={onCollapseAnimationComplete}
|
||||
style={{ width: '100%', padding: '0.25rem' }}
|
||||
>
|
||||
<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>
|
||||
{isDecrypted && (
|
||||
<>
|
||||
<WrapItem>
|
||||
<Button type="button" onClick={togglePlay}>
|
||||
播放/暂停
|
||||
</Button>
|
||||
</WrapItem>
|
||||
<WrapItem>
|
||||
{file.decrypted && (
|
||||
<Link isExternal href={file.decrypted} download={decryptedName}>
|
||||
<Button as="span">下载</Button>
|
||||
</Link>
|
||||
)}
|
||||
</WrapItem>
|
||||
</>
|
||||
)}
|
||||
<WrapItem>
|
||||
<Button type="button" onClick={onClose}>
|
||||
删除
|
||||
</Button>
|
||||
</WrapItem>
|
||||
</Wrap>
|
||||
</VStack>
|
||||
</GridItem>
|
||||
</FileRowResponsiveGrid>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</Collapse>
|
||||
);
|
||||
}
|
27
um-react/src/features/file-listing/FileRowResponsiveGrid.tsx
Normal file
@ -0,0 +1,27 @@
|
||||
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,
|
||||
},
|
||||
});
|