Merge pull request '添加 Mac QQ 音乐 v10 的说明' (#3) from feat/mac-qqmusic-v10 into main

Reviewed-on: https://git.um-react.app/um/um-react/pulls/3
This commit is contained in:
鲁树人
2025-10-15 12:59:19 +00:00
17 changed files with 609 additions and 105 deletions

View File

@@ -44,6 +44,7 @@
"@types/react-dom": "^19.1.9",
"@types/react-syntax-highlighter": "^15.5.13",
"@types/sql.js": "^1.4.9",
"@types/tar-stream": "^3.1.4",
"@types/wicg-file-system-access": "^2023.10.6",
"@typescript-eslint/eslint-plugin": "^8.42.0",
"@typescript-eslint/parser": "^8.42.0",
@@ -64,6 +65,7 @@
"sass": "^1.92.1",
"simple-git-hooks": "^2.13.1",
"tailwindcss": "^4.1.13",
"tar-stream": "^3.1.7",
"terser": "^5.44.0",
"typescript": "^5.9.2",
"typescript-eslint": "^8.42.0",

104
pnpm-lock.yaml generated
View File

@@ -92,6 +92,9 @@ importers:
'@types/sql.js':
specifier: ^1.4.9
version: 1.4.9
'@types/tar-stream':
specifier: ^3.1.4
version: 3.1.4
'@types/wicg-file-system-access':
specifier: ^2023.10.6
version: 2023.10.6
@@ -152,6 +155,9 @@ importers:
tailwindcss:
specifier: ^4.1.13
version: 4.1.13
tar-stream:
specifier: ^3.1.7
version: 3.1.7
terser:
specifier: ^5.44.0
version: 5.44.0
@@ -1533,6 +1539,9 @@ packages:
'@types/sql.js@1.4.9':
resolution: {integrity: sha512-ep8b36RKHlgWPqjNG9ToUrPiwkhwh0AEzy883mO5Xnd+cL6VBH1EvSjBAAuxLUFF2Vn/moE3Me6v9E1Lo+48GQ==}
'@types/tar-stream@3.1.4':
resolution: {integrity: sha512-921gW0+g29mCJX0fRvqeHzBlE/XclDaAG0Ousy1LCghsOhvaKacDeRGEVzQP9IPfKn8Vysy7FEXAIxycpc/CMg==}
'@types/trusted-types@2.0.7':
resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
@@ -1744,6 +1753,14 @@ packages:
resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==}
engines: {node: '>= 0.4'}
b4a@1.7.3:
resolution: {integrity: sha512-5Q2mfq2WfGuFp3uS//0s6baOJLMoVduPYVeNmDYxu5OUA1/cBfvr2RIS7vi62LdNj/urk1hfmj867I3qt6uZ7Q==}
peerDependencies:
react-native-b4a: '*'
peerDependenciesMeta:
react-native-b4a:
optional: true
babel-plugin-polyfill-corejs2@0.4.14:
resolution: {integrity: sha512-Co2Y9wX854ts6U8gAAPXfn0GmAyctHuK8n0Yhfjd6t30g7yvKjspvvOo9yG+z52PZRgFErt7Ka2pYnXCjLKEpg==}
peerDependencies:
@@ -1762,6 +1779,14 @@ packages:
balanced-match@1.0.2:
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
bare-events@2.8.0:
resolution: {integrity: sha512-AOhh6Bg5QmFIXdViHbMc2tLDsBIRxdkIaIddPslJF9Z5De3APBScuqGP2uThXnIpqFrgoxMNC6km7uXNIMLHXA==}
peerDependencies:
bare-abort-controller: '*'
peerDependenciesMeta:
bare-abort-controller:
optional: true
brace-expansion@1.1.12:
resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==}
@@ -2122,6 +2147,9 @@ packages:
eventemitter3@5.0.1:
resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==}
events-universal@1.0.1:
resolution: {integrity: sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==}
expect-type@1.2.2:
resolution: {integrity: sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA==}
engines: {node: '>=12.0.0'}
@@ -2129,6 +2157,9 @@ packages:
fast-deep-equal@3.1.3:
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
fast-fifo@1.3.2:
resolution: {integrity: sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==}
fast-glob@3.3.3:
resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==}
engines: {node: '>=8.6.0'}
@@ -2792,15 +2823,10 @@ packages:
resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==}
engines: {node: '>=16 || 14 >=14.17'}
minizlib@3.0.2:
resolution: {integrity: sha512-oG62iEk+CYt5Xj2YqI5Xi9xWUeZhDI8jjQmC5oThVH5JGCTgIjr7ciJDzC7MBzYd//WvR1OTmP5Q38Q8ShQtVA==}
minizlib@3.1.0:
resolution: {integrity: sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==}
engines: {node: '>= 18'}
mkdirp@3.0.1:
resolution: {integrity: sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==}
engines: {node: '>=10'}
hasBin: true
mrmime@2.0.1:
resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==}
engines: {node: '>=10'}
@@ -3275,6 +3301,9 @@ packages:
resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==}
engines: {node: '>= 0.4'}
streamx@2.23.0:
resolution: {integrity: sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg==}
string-argv@0.3.2:
resolution: {integrity: sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==}
engines: {node: '>=0.6.19'}
@@ -3352,8 +3381,11 @@ packages:
resolution: {integrity: sha512-ZL6DDuAlRlLGghwcfmSn9sK3Hr6ArtyudlSAiCqQ6IfE+b+HHbydbYDIG15IfS5do+7XQQBdBiubF/cV2dnDzg==}
engines: {node: '>=6'}
tar@7.4.3:
resolution: {integrity: sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==}
tar-stream@3.1.7:
resolution: {integrity: sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==}
tar@7.5.1:
resolution: {integrity: sha512-nlGpxf+hv0v7GkWBK2V9spgactGOp0qvfWRxUMjqHyzrt3SgwE48DIv/FhqPHJYLHpgW1opq3nERbz5Anq7n1g==}
engines: {node: '>=18'}
temp-dir@2.0.0:
@@ -3373,6 +3405,9 @@ packages:
resolution: {integrity: sha512-pFYqmTw68LXVjeWJMST4+borgQP2AyMNbg1BpZh9LbyhUeNkeaPF9gzfPGUAnSMV3qPYdWUwDIjjCLiSDOl7vg==}
engines: {node: '>=18'}
text-decoder@1.2.3:
resolution: {integrity: sha512-3/o9z3X0X0fTupwsYvR03pJ/DjWuqqrfwBgTQzdWDiQSm9KitAyz/9WqsT2JQW7KV2m+bC2ol/zqpW37NHxLaA==}
tinybench@2.9.0:
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
@@ -4995,7 +5030,7 @@ snapshots:
'@tailwindcss/oxide@4.1.13':
dependencies:
detect-libc: 2.0.4
tar: 7.4.3
tar: 7.5.1
optionalDependencies:
'@tailwindcss/oxide-android-arm64': 4.1.13
'@tailwindcss/oxide-darwin-arm64': 4.1.13
@@ -5115,6 +5150,10 @@ snapshots:
'@types/emscripten': 1.41.1
'@types/node': 24.3.1
'@types/tar-stream@3.1.4':
dependencies:
'@types/node': 24.3.1
'@types/trusted-types@2.0.7': {}
'@types/unist@2.0.11': {}
@@ -5383,6 +5422,8 @@ snapshots:
dependencies:
possible-typed-array-names: 1.1.0
b4a@1.7.3: {}
babel-plugin-polyfill-corejs2@0.4.14(@babel/core@7.28.4):
dependencies:
'@babel/compat-data': 7.28.4
@@ -5409,6 +5450,8 @@ snapshots:
balanced-match@1.0.2: {}
bare-events@2.8.0: {}
brace-expansion@1.1.12:
dependencies:
balanced-match: 1.0.2
@@ -5832,10 +5875,18 @@ snapshots:
eventemitter3@5.0.1: {}
events-universal@1.0.1:
dependencies:
bare-events: 2.8.0
transitivePeerDependencies:
- bare-abort-controller
expect-type@1.2.2: {}
fast-deep-equal@3.1.3: {}
fast-fifo@1.3.2: {}
fast-glob@3.3.3:
dependencies:
'@nodelib/fs.stat': 2.0.5
@@ -6493,12 +6544,10 @@ snapshots:
minipass@7.1.2: {}
minizlib@3.0.2:
minizlib@3.1.0:
dependencies:
minipass: 7.1.2
mkdirp@3.0.1: {}
mrmime@2.0.1: {}
ms@2.1.3: {}
@@ -6982,6 +7031,15 @@ snapshots:
es-errors: 1.3.0
internal-slot: 1.1.0
streamx@2.23.0:
dependencies:
events-universal: 1.0.1
fast-fifo: 1.3.2
text-decoder: 1.2.3
transitivePeerDependencies:
- bare-abort-controller
- react-native-b4a
string-argv@0.3.2: {}
string-width@4.2.3:
@@ -7079,13 +7137,21 @@ snapshots:
tapable@2.2.3: {}
tar@7.4.3:
tar-stream@3.1.7:
dependencies:
b4a: 1.7.3
fast-fifo: 1.3.2
streamx: 2.23.0
transitivePeerDependencies:
- bare-abort-controller
- react-native-b4a
tar@7.5.1:
dependencies:
'@isaacs/fs-minipass': 4.0.1
chownr: 3.0.0
minipass: 7.1.2
minizlib: 3.0.2
mkdirp: 3.0.1
minizlib: 3.1.0
yallist: 5.0.0
temp-dir@2.0.0: {}
@@ -7110,6 +7176,12 @@ snapshots:
glob: 10.4.5
minimatch: 9.0.5
text-decoder@1.2.3:
dependencies:
b4a: 1.7.3
transitivePeerDependencies:
- react-native-b4a
tinybench@2.9.0: {}
tinyexec@0.3.2: {}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

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

View File

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

View File

@@ -12,6 +12,7 @@ import tailwindcss from '@tailwindcss/vite';
import { tryCommand } from './support/command';
import { base64Loader } from './support/b64-loader';
import { macCommandLoader } from './support/mac-command-loader';
const projectRoot = url.fileURLToPath(new URL('.', import.meta.url));
const pkg = JSON.parse(fs.readFileSync(projectRoot + '/package.json', 'utf-8'));
@@ -47,6 +48,7 @@ export default defineConfig({
plugins: [
tailwindcss(),
base64Loader,
macCommandLoader,
replace({
preventAssignment: true,
values: {