ym-mcp-wangyi 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,78 @@
1
+ # ym-mcp-wangyi
2
+
3
+ 网易云音乐 MCP。读取本机已登录的官方客户端 Cookie(MMKV / CEF),搜索歌曲,并用 `orpheus://` 协议让客户端播放 / 切歌。
4
+
5
+ 需要 **Node.js 22+**(CEF Cookie 回退使用内置 `node:sqlite`)。不依赖 OpenCLI。
6
+
7
+ 官方桌面端在 **macOS** 和 **Windows**。播放走系统协议打开器,不绑定某个进程名。
8
+
9
+ ## 终端测试(CLI)
10
+
11
+ ```bash
12
+ pnpm install
13
+ pnpm build
14
+
15
+ # 1) 先开启桌面端控制通道(会重启网易云;只需做一次,直到你手动关掉客户端)
16
+ node dist/index.js launch
17
+
18
+ # 2) 再测播放
19
+ node dist/index.js status
20
+ node dist/index.js search 许巍 --limit 5
21
+ node dist/index.js play 蓝莲花
22
+ node dist/index.js queue
23
+ node dist/index.js next
24
+ node dist/index.js prev
25
+ ```
26
+
27
+ `status` 里看 `cdp_ok: true` 后,play/next/prev 才会真正驱动桌面端。
28
+ 仅 `orpheus://` 不够:桌面端要用 base64 命令,可靠切歌还需 CDP(`--remote-debugging-port`)。
29
+
30
+ 无参数时进入 MCP stdio 模式(给 Cursor 用)。
31
+
32
+ ## Cursor
33
+
34
+ ```json
35
+ {
36
+ "mcpServers": {
37
+ "ym-mcp-wangyi": {
38
+ "command": "npx",
39
+ "args": ["-y", "ym-mcp-wangyi"]
40
+ }
41
+ }
42
+ }
43
+ ```
44
+
45
+ 本地开发可写成:
46
+
47
+ ```json
48
+ {
49
+ "mcpServers": {
50
+ "ym-mcp-wangyi": {
51
+ "command": "node",
52
+ "args": ["/绝对路径/ym-wangyi-music/dist/index.js"]
53
+ }
54
+ }
55
+ }
56
+ ```
57
+
58
+ ## 工具
59
+
60
+ | 工具 | 作用 |
61
+ |---|---|
62
+ | `wangyi_status` | 安装 / 运行 / 登录 / CDP / 队列 |
63
+ | `wangyi_launch` | 以 CDP 端口启动/重启客户端 |
64
+ | `wangyi_search` | 搜索 |
65
+ | `wangyi_play` | 播放(id 或关键词) |
66
+ | `wangyi_queue` | 客户端队列 |
67
+ | `wangyi_next` / `wangyi_prev` | 队列切歌 |
68
+
69
+ 播放优先走 CDP(`playing/play` / `jump2Track`);不可用时回退桌面 `orpheus://` base64 命令(macOS `open -a NeteaseMusic` / Windows `cmd start`)。
70
+
71
+ ## 平台说明
72
+
73
+ - **macOS**:优先读 `~/Library/Containers/com.netease.163music/Data/Documents/storage/mmkv.default`(也兼容 Application Support 路径)。
74
+ - **Windows**:扫描 `%APPDATA%` / `%LOCALAPPDATA%` 下 `NetEase` / `CloudMusic` 等目录的 `mmkv.default`;若只有 CEF Cookie 密文,会用 `Local State` + DPAPI 解密。
75
+ - 播放控制依赖本地 CDP(默认 `127.0.0.1:9223`)。请先 `launch`;`status.cdp_ok` 为 true 后再 play/next/prev。
76
+ - 队列优先读 CDP 实时状态;否则回退客户端 `playingList` 缓存 + 本地 last-play。
77
+
78
+ 先安装并登录一次官方桌面端,再 `launch` → `play`。
package/dist/index.js ADDED
@@ -0,0 +1,217 @@
1
+ #!/usr/bin/env node
2
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
3
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
+ import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
5
+ import { asToolError, ToolError } from "./lib/errors.js";
6
+ import { getStatus } from "./lib/status.js";
7
+ import { searchTracks } from "./lib/api.js";
8
+ import { playTrackById } from "./lib/play.js";
9
+ import { readPlayerQueue, skipPlayerQueue } from "./lib/queue.js";
10
+ import { launchWithCdp } from "./lib/cdp.js";
11
+ const TOOLS = [
12
+ {
13
+ name: "wangyi_status",
14
+ description: "检查网易云音乐是否安装、是否在运行、是否已登录、CDP 控制通道与队列是否可用(macOS / Windows)",
15
+ inputSchema: { type: "object", properties: {}, additionalProperties: false },
16
+ },
17
+ {
18
+ name: "wangyi_launch",
19
+ description: "以本地 CDP 调试端口重启/启动网易云桌面端;play/next/prev 真正控播放前需要先做一次",
20
+ inputSchema: { type: "object", properties: {}, additionalProperties: false },
21
+ },
22
+ {
23
+ name: "wangyi_search",
24
+ description: "用网易云音乐搜索歌曲,返回 id/title/artist/album",
25
+ inputSchema: {
26
+ type: "object",
27
+ properties: {
28
+ query: { type: "string", description: "搜索词" },
29
+ limit: { type: "number", description: "最多返回条数,默认 10" },
30
+ },
31
+ required: ["query"],
32
+ additionalProperties: false,
33
+ },
34
+ },
35
+ {
36
+ name: "wangyi_play",
37
+ description: "在官方客户端播放:纯数字当 song id,否则搜索后播第 N 首(优先 CDP,回退 orpheus://)",
38
+ inputSchema: {
39
+ type: "object",
40
+ properties: {
41
+ query: { type: "string", description: "song id 或搜索词" },
42
+ index: { type: "number", description: "搜索结果第几首,从 1 开始,默认 1" },
43
+ },
44
+ required: ["query"],
45
+ additionalProperties: false,
46
+ },
47
+ },
48
+ {
49
+ name: "wangyi_queue",
50
+ description: "读取网易云音乐当前播放队列(优先 CDP 实时状态,否则读本地缓存)",
51
+ inputSchema: { type: "object", properties: {}, additionalProperties: false },
52
+ },
53
+ {
54
+ name: "wangyi_next",
55
+ description: "播放下一首(优先 CDP 客户端切歌)",
56
+ inputSchema: { type: "object", properties: {}, additionalProperties: false },
57
+ },
58
+ {
59
+ name: "wangyi_prev",
60
+ description: "播放上一首(优先 CDP 客户端切歌)",
61
+ inputSchema: { type: "object", properties: {}, additionalProperties: false },
62
+ },
63
+ ];
64
+ function textResult(data, isError = false) {
65
+ return {
66
+ content: [{ type: "text", text: JSON.stringify(data, null, 2) }],
67
+ isError,
68
+ };
69
+ }
70
+ function argString(args, key) {
71
+ const value = args?.[key];
72
+ return value == null ? "" : String(value);
73
+ }
74
+ function argNumber(args, key) {
75
+ const value = args?.[key];
76
+ if (value == null || value === "")
77
+ return undefined;
78
+ const n = Number(value);
79
+ return Number.isFinite(n) ? n : undefined;
80
+ }
81
+ export async function callTool(name, args) {
82
+ switch (name) {
83
+ case "wangyi_status":
84
+ return getStatus();
85
+ case "wangyi_launch":
86
+ return launchWithCdp();
87
+ case "wangyi_search": {
88
+ const query = argString(args, "query").trim();
89
+ const limit = Math.max(1, Math.min(50, Math.round(argNumber(args, "limit") || 10)));
90
+ const tracks = await searchTracks(query, limit);
91
+ return { ok: true, tracks };
92
+ }
93
+ case "wangyi_play": {
94
+ const query = argString(args, "query").trim();
95
+ const index = Math.max(1, Math.round(argNumber(args, "index") || 1));
96
+ if (/^\d+$/.test(query)) {
97
+ const url = await playTrackById(query);
98
+ return { ok: true, status: "playing", id: query, url };
99
+ }
100
+ const tracks = await searchTracks(query, Math.max(index, 10));
101
+ if (!tracks.length)
102
+ throw new ToolError("EMPTY_RESULT", `没有搜到「${query}」`);
103
+ const track = tracks[index - 1];
104
+ if (!track)
105
+ throw new ToolError("EMPTY_RESULT", `只有 ${tracks.length} 条结果,没有第 ${index} 首`);
106
+ const url = await playTrackById(track.id, track.title);
107
+ return { ok: true, status: "playing", ...track, url };
108
+ }
109
+ case "wangyi_queue":
110
+ return { ok: true, ...(await readPlayerQueue()) };
111
+ case "wangyi_next":
112
+ return { ok: true, ...(await skipPlayerQueue(1)) };
113
+ case "wangyi_prev":
114
+ return { ok: true, ...(await skipPlayerQueue(-1)) };
115
+ default:
116
+ throw new ToolError("UNKNOWN_TOOL", `未知工具 ${name}`);
117
+ }
118
+ }
119
+ function printJson(data, isError = false) {
120
+ const text = JSON.stringify(data, null, 2);
121
+ if (isError)
122
+ console.error(text);
123
+ else
124
+ console.log(text);
125
+ }
126
+ function parseCliArgs(argv) {
127
+ const [cmd, ...rest] = argv;
128
+ const args = {};
129
+ const positionals = [];
130
+ for (let i = 0; i < rest.length; i++) {
131
+ const token = rest[i];
132
+ if (token === "--limit" || token === "--index") {
133
+ const key = token.slice(2);
134
+ args[key] = Number(rest[++i]);
135
+ }
136
+ else if (token.startsWith("--")) {
137
+ throw new ToolError("INVALID_ARGS", `未知参数 ${token}`);
138
+ }
139
+ else {
140
+ positionals.push(token);
141
+ }
142
+ }
143
+ if (positionals.length)
144
+ args.query = positionals.join(" ");
145
+ return { cmd, args };
146
+ }
147
+ async function runCli(argv) {
148
+ const { cmd, args } = parseCliArgs(argv);
149
+ const toolMap = {
150
+ status: "wangyi_status",
151
+ launch: "wangyi_launch",
152
+ search: "wangyi_search",
153
+ play: "wangyi_play",
154
+ queue: "wangyi_queue",
155
+ next: "wangyi_next",
156
+ prev: "wangyi_prev",
157
+ };
158
+ if (cmd === "help" || cmd === "--help" || cmd === "-h") {
159
+ console.log(`ym-mcp-wangyi — 网易云音乐 MCP / CLI
160
+
161
+ 用法:
162
+ ym-mcp-wangyi # MCP stdio(给 Cursor 用)
163
+ ym-mcp-wangyi status
164
+ ym-mcp-wangyi launch # 重要:以 CDP 重启客户端,之后才能真正控播放
165
+ ym-mcp-wangyi search <关键词> [--limit N]
166
+ ym-mcp-wangyi play <id|关键词> [--index N]
167
+ ym-mcp-wangyi queue
168
+ ym-mcp-wangyi next
169
+ ym-mcp-wangyi prev
170
+
171
+ 首次使用播放控制前请先 launch。需要本机已安装并登录网易云桌面端(macOS / Windows)。
172
+ `);
173
+ return 0;
174
+ }
175
+ const tool = toolMap[cmd];
176
+ if (!tool) {
177
+ printJson({ ok: false, code: "UNKNOWN_TOOL", error: `未知命令 ${cmd}`, hint: "用 help 查看用法" }, true);
178
+ return 1;
179
+ }
180
+ try {
181
+ const result = await callTool(tool, args);
182
+ printJson(result);
183
+ return 0;
184
+ }
185
+ catch (err) {
186
+ printJson(asToolError(err).toJSON(), true);
187
+ return 1;
188
+ }
189
+ }
190
+ async function runMcp() {
191
+ const server = new Server({ name: "ym-mcp-wangyi", version: "0.1.0" }, { capabilities: { tools: {} } });
192
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [...TOOLS] }));
193
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
194
+ try {
195
+ const result = await callTool(request.params.name, request.params.arguments);
196
+ return textResult(result);
197
+ }
198
+ catch (err) {
199
+ const toolErr = asToolError(err);
200
+ return textResult(toolErr.toJSON(), true);
201
+ }
202
+ });
203
+ const transport = new StdioServerTransport();
204
+ await server.connect(transport);
205
+ }
206
+ async function main() {
207
+ const argv = process.argv.slice(2);
208
+ if (argv.length > 0) {
209
+ const code = await runCli(argv);
210
+ process.exit(code);
211
+ }
212
+ await runMcp();
213
+ }
214
+ main().catch((err) => {
215
+ console.error(err);
216
+ process.exit(1);
217
+ });
@@ -0,0 +1,83 @@
1
+ import { SEARCH_URL, USER_AGENT } from "./paths.js";
2
+ import { loadCookies } from "./cookies.js";
3
+ import { ToolError } from "./errors.js";
4
+ function formatDuration(ms) {
5
+ const total = Math.round(Number(ms || 0) / 1000);
6
+ if (!total)
7
+ return "";
8
+ const m = Math.floor(total / 60);
9
+ const s = String(total % 60).padStart(2, "0");
10
+ return `${m}:${s}`;
11
+ }
12
+ function artistNames(artists) {
13
+ if (!Array.isArray(artists))
14
+ return "";
15
+ return artists
16
+ .map((a) => (a && typeof a === "object" && "name" in a ? String(a.name || "") : ""))
17
+ .filter(Boolean)
18
+ .join(", ");
19
+ }
20
+ export function mapSearchTracks(payload, limit) {
21
+ const root = payload && typeof payload === "object" ? payload : {};
22
+ const result = root.result && typeof root.result === "object" ? root.result : root;
23
+ const songs = Array.isArray(result.songs) ? result.songs : [];
24
+ const rows = [];
25
+ for (const item of songs) {
26
+ if (rows.length >= limit)
27
+ break;
28
+ if (!item || typeof item !== "object")
29
+ continue;
30
+ const song = item;
31
+ const id = song.id;
32
+ const name = song.name;
33
+ if (!id || !name)
34
+ continue;
35
+ const artists = song.ar || song.artists;
36
+ const album = (song.al || song.album);
37
+ rows.push({
38
+ id: String(id),
39
+ title: String(name),
40
+ artist: artistNames(artists),
41
+ album: String(album?.name || ""),
42
+ duration: formatDuration(song.dt ?? song.duration),
43
+ });
44
+ }
45
+ return rows;
46
+ }
47
+ export async function searchTracks(query, limit = 10) {
48
+ const q = query.trim();
49
+ if (!q)
50
+ throw new ToolError("INVALID_ARGS", "query 不能为空");
51
+ const cookies = loadCookies();
52
+ const body = new URLSearchParams({
53
+ s: q,
54
+ type: "1",
55
+ limit: String(Math.max(1, Math.min(50, limit))),
56
+ offset: "0",
57
+ });
58
+ const headers = {
59
+ Accept: "*/*",
60
+ "Content-Type": "application/x-www-form-urlencoded",
61
+ "User-Agent": USER_AGENT,
62
+ Referer: "https://music.163.com/",
63
+ };
64
+ if (cookies.header)
65
+ headers.Cookie = cookies.header;
66
+ const res = await fetch(SEARCH_URL, { method: "POST", headers, body });
67
+ const text = await res.text();
68
+ if (!text) {
69
+ throw new ToolError("API_ERROR", "搜索接口返回空", "确认网络可用,或先在客户端登录后再试");
70
+ }
71
+ let payload;
72
+ try {
73
+ payload = JSON.parse(text);
74
+ }
75
+ catch {
76
+ throw new ToolError("API_ERROR", "搜索接口不是 JSON", text.slice(0, 200));
77
+ }
78
+ const code = payload?.code;
79
+ if (code != null && code !== 200) {
80
+ throw new ToolError("API_ERROR", `搜索失败 code=${code}`, text.slice(0, 200));
81
+ }
82
+ return mapSearchTracks(payload, limit);
83
+ }
@@ -0,0 +1,224 @@
1
+ /**
2
+ * Minimal binary plist parser for NetEase MMKV cookie archives (NSKeyedArchiver).
3
+ * Only supports the object types we need: dict / array / string / data / uid / number / bool.
4
+ */
5
+ export class UID {
6
+ data;
7
+ constructor(data) {
8
+ this.data = data;
9
+ }
10
+ }
11
+ function readUInt(buf, offset, size) {
12
+ if (size === 1)
13
+ return buf.readUInt8(offset);
14
+ if (size === 2)
15
+ return buf.readUInt16BE(offset);
16
+ if (size === 4)
17
+ return buf.readUInt32BE(offset);
18
+ if (size === 8) {
19
+ const big = buf.readBigUInt64BE(offset);
20
+ if (big > BigInt(Number.MAX_SAFE_INTEGER))
21
+ throw new Error("plist int too large");
22
+ return Number(big);
23
+ }
24
+ throw new Error(`unsupported uint size ${size}`);
25
+ }
26
+ export function parseBplist(input) {
27
+ if (input.subarray(0, 8).toString("ascii") !== "bplist00") {
28
+ throw new Error("not a binary plist");
29
+ }
30
+ const trailer = input.subarray(input.length - 32);
31
+ const offsetSize = trailer.readUInt8(6);
32
+ const objectRefSize = trailer.readUInt8(7);
33
+ const numObjects = Number(trailer.readBigUInt64BE(8));
34
+ const topObject = Number(trailer.readBigUInt64BE(16));
35
+ const offsetTableOffset = Number(trailer.readBigUInt64BE(24));
36
+ const offsets = [];
37
+ for (let i = 0; i < numObjects; i++) {
38
+ offsets.push(readUInt(input, offsetTableOffset + i * offsetSize, offsetSize));
39
+ }
40
+ const cache = new Map();
41
+ const parseObject = (index) => {
42
+ if (cache.has(index))
43
+ return cache.get(index);
44
+ const offset = offsets[index];
45
+ const marker = input.readUInt8(offset);
46
+ const type = marker & 0xf0;
47
+ const info = marker & 0x0f;
48
+ let value;
49
+ switch (type) {
50
+ case 0x00:
51
+ if (info === 0x00)
52
+ value = null;
53
+ else if (info === 0x08)
54
+ value = false;
55
+ else if (info === 0x09)
56
+ value = true;
57
+ else
58
+ value = null;
59
+ break;
60
+ case 0x10: {
61
+ const size = 1 << info;
62
+ if (size === 8)
63
+ value = Number(input.readBigInt64BE(offset + 1));
64
+ else if (size === 1)
65
+ value = input.readInt8(offset + 1);
66
+ else if (size === 2)
67
+ value = input.readInt16BE(offset + 1);
68
+ else if (size === 4)
69
+ value = input.readInt32BE(offset + 1);
70
+ else
71
+ throw new Error(`bad int size ${size}`);
72
+ break;
73
+ }
74
+ case 0x20: {
75
+ const size = 1 << info;
76
+ value = size === 4 ? input.readFloatBE(offset + 1) : input.readDoubleBE(offset + 1);
77
+ break;
78
+ }
79
+ case 0x30: {
80
+ // date: 8-byte float seconds since 2001-01-01
81
+ value = input.readDoubleBE(offset + 1);
82
+ break;
83
+ }
84
+ case 0x40: {
85
+ let length = info;
86
+ let dataOffset = offset + 1;
87
+ if (info === 0x0f) {
88
+ const lenMarker = input.readUInt8(dataOffset);
89
+ dataOffset += 1;
90
+ const lenSize = 1 << (lenMarker & 0x0f);
91
+ length = readUInt(input, dataOffset, lenSize);
92
+ dataOffset += lenSize;
93
+ }
94
+ value = Uint8Array.from(input.subarray(dataOffset, dataOffset + length));
95
+ break;
96
+ }
97
+ case 0x50:
98
+ case 0x60: {
99
+ let length = info;
100
+ let dataOffset = offset + 1;
101
+ if (info === 0x0f) {
102
+ const lenMarker = input.readUInt8(dataOffset);
103
+ dataOffset += 1;
104
+ const lenSize = 1 << (lenMarker & 0x0f);
105
+ length = readUInt(input, dataOffset, lenSize);
106
+ dataOffset += lenSize;
107
+ }
108
+ if (type === 0x50) {
109
+ value = input.subarray(dataOffset, dataOffset + length).toString("ascii");
110
+ }
111
+ else {
112
+ value = input.subarray(dataOffset, dataOffset + length * 2).toString("utf16le");
113
+ // bplist stores UTF-16BE
114
+ const be = input.subarray(dataOffset, dataOffset + length * 2);
115
+ const swapped = Buffer.alloc(be.length);
116
+ for (let i = 0; i < be.length; i += 2) {
117
+ swapped[i] = be[i + 1];
118
+ swapped[i + 1] = be[i];
119
+ }
120
+ value = swapped.toString("utf16le");
121
+ }
122
+ break;
123
+ }
124
+ case 0x80: {
125
+ value = new UID(readUInt(input, offset + 1, info + 1));
126
+ break;
127
+ }
128
+ case 0xa0:
129
+ case 0xc0: {
130
+ let length = info;
131
+ let dataOffset = offset + 1;
132
+ if (info === 0x0f) {
133
+ const lenMarker = input.readUInt8(dataOffset);
134
+ dataOffset += 1;
135
+ const lenSize = 1 << (lenMarker & 0x0f);
136
+ length = readUInt(input, dataOffset, lenSize);
137
+ dataOffset += lenSize;
138
+ }
139
+ const arr = [];
140
+ // placeholder first so recursive refs work
141
+ cache.set(index, arr);
142
+ for (let i = 0; i < length; i++) {
143
+ const ref = readUInt(input, dataOffset + i * objectRefSize, objectRefSize);
144
+ arr.push(parseObject(ref));
145
+ }
146
+ return arr;
147
+ }
148
+ case 0xd0: {
149
+ let length = info;
150
+ let dataOffset = offset + 1;
151
+ if (info === 0x0f) {
152
+ const lenMarker = input.readUInt8(dataOffset);
153
+ dataOffset += 1;
154
+ const lenSize = 1 << (lenMarker & 0x0f);
155
+ length = readUInt(input, dataOffset, lenSize);
156
+ dataOffset += lenSize;
157
+ }
158
+ const dict = {};
159
+ cache.set(index, dict);
160
+ const keyRefs = [];
161
+ const valRefs = [];
162
+ for (let i = 0; i < length; i++) {
163
+ keyRefs.push(readUInt(input, dataOffset + i * objectRefSize, objectRefSize));
164
+ }
165
+ for (let i = 0; i < length; i++) {
166
+ valRefs.push(readUInt(input, dataOffset + length * objectRefSize + i * objectRefSize, objectRefSize));
167
+ }
168
+ for (let i = 0; i < length; i++) {
169
+ const key = parseObject(keyRefs[i]);
170
+ dict[String(key)] = parseObject(valRefs[i]);
171
+ }
172
+ return dict;
173
+ }
174
+ default:
175
+ throw new Error(`unsupported plist type 0x${type.toString(16)}`);
176
+ }
177
+ cache.set(index, value);
178
+ return value;
179
+ };
180
+ return parseObject(topObject);
181
+ }
182
+ export function resolveUid(objects, value) {
183
+ let cur = value;
184
+ const seen = new Set();
185
+ while (cur instanceof UID) {
186
+ if (seen.has(cur.data))
187
+ throw new Error("UID cycle");
188
+ seen.add(cur.data);
189
+ cur = objects[cur.data];
190
+ }
191
+ return cur;
192
+ }
193
+ export function nsDictionary(objects, value) {
194
+ const resolved = resolveUid(objects, value);
195
+ if (!resolved || typeof resolved !== "object" || Array.isArray(resolved) || resolved instanceof UID || resolved instanceof Uint8Array) {
196
+ return {};
197
+ }
198
+ const rec = resolved;
199
+ const keys = rec["NS.keys"];
200
+ const vals = rec["NS.objects"];
201
+ if (!Array.isArray(keys) || !Array.isArray(vals) || keys.length !== vals.length)
202
+ return {};
203
+ const out = {};
204
+ for (let i = 0; i < keys.length; i++) {
205
+ const key = resolveUid(objects, keys[i]);
206
+ if (typeof key === "string")
207
+ out[key] = resolveUid(objects, vals[i]);
208
+ }
209
+ return out;
210
+ }
211
+ export function parseKeyedArchive(buf) {
212
+ const archive = parseBplist(buf);
213
+ if (!archive || typeof archive !== "object" || Array.isArray(archive)) {
214
+ throw new Error("invalid archive root");
215
+ }
216
+ const rec = archive;
217
+ const objects = rec["$objects"];
218
+ const top = rec["$top"];
219
+ if (!Array.isArray(objects) || !top || typeof top !== "object" || Array.isArray(top)) {
220
+ throw new Error("invalid keyed archive");
221
+ }
222
+ const topRec = top;
223
+ return { objects, root: nsDictionary(objects, topRec.root) };
224
+ }