dsh-pet-live2d 2.3.4 → 3.0.1

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/lib/index.js CHANGED
@@ -27,6 +27,10 @@ import { homedir } from 'node:os'
27
27
  import { dirname, extname, join, sep } from 'node:path'
28
28
  import { fileURLToPath } from 'node:url'
29
29
 
30
+ import { createDisplayLayer } from './display.js'
31
+ import { desktopHint, desktopSupported, resolveDesktopBinary } from './desktop.js'
32
+ import { readSettings, writeSettings } from './settings.js'
33
+
30
34
  export const name = 'live2d-pet'
31
35
 
32
36
  export const inject = ['webServer']
@@ -1300,8 +1304,7 @@ export function attachActivityEvents(ctx, hub) {
1300
1304
  }
1301
1305
 
1302
1306
  /** The activity stream + snapshot route. */
1303
- function eventsRoute(hub) {
1304
- return {
1307
+ function eventsRoute(hub) { return {
1305
1308
  kind: 'exact',
1306
1309
  path: API + '/events',
1307
1310
  handler: (request, response) => {
@@ -1350,17 +1353,155 @@ function eventsRoute(hub) {
1350
1353
  }
1351
1354
  }
1352
1355
 
1356
+ /**
1357
+ * 显示层路由:桌宠在页面内还是桌面上。
1358
+ *
1359
+ * `GET` 给浏览器(它每秒问一次"现在该谁管这只宠物"),`POST` 给设置页(改偏好)。
1360
+ * **页面只读、不写** —— 判定与"拉起/收掉桌面端"都在宿主半区,两个写者会互相擦。
1361
+ */
1362
+ function layerRoute(display) {
1363
+ return {
1364
+ kind: 'exact',
1365
+ path: API + '/layer',
1366
+ handler: async (request, response) => {
1367
+ if (!loopbackOnly(request)) {
1368
+ response.writeHead(403)
1369
+ response.end()
1370
+ return
1371
+ }
1372
+ if (request.method === 'POST') {
1373
+ let body = ''
1374
+ try {
1375
+ for await (const chunk of request) body += chunk
1376
+ const parsed = JSON.parse(body === '' ? '{}' : body)
1377
+ // `{action:"download-desktop"}`:把平台子包拉下来。**先回话、后台下** ——
1378
+ // 让这个请求等 5MB 下完的话,页面每秒的轮询会被堵住,观感是"卡住了"。
1379
+ if (parsed.action === 'download-desktop') {
1380
+ const started = display.startDownload()
1381
+ sendJson(response, 200, { ok: true, download: started, ...display.reconcile(), binary: display.binaryInfo() })
1382
+ return
1383
+ }
1384
+ display.setMode(parsed.mode)
1385
+ } catch {
1386
+ sendJson(response, 400, { ok: false, error: 'bad-body' })
1387
+ return
1388
+ }
1389
+ } else if (request.method !== 'GET' && request.method !== 'HEAD') {
1390
+ response.writeHead(405, { allow: 'GET, HEAD, POST' })
1391
+ response.end()
1392
+ return
1393
+ }
1394
+ // 页面自己报来的地址就是 DSH 的地址(端口是配置的,猜会猜错)。
1395
+ const origin = request.headers?.origin
1396
+ if (typeof origin === 'string') display.setDshUrl(origin)
1397
+ // 每次读状态都顺手 reconcile 一次:设置页点完马上能看到结果,不用等下一个 tick。
1398
+ const status = display.reconcile()
1399
+ sendJson(response, 200, {
1400
+ ok: true,
1401
+ ...status,
1402
+ // 下载进度:设置页靠每秒的轮询把它显示出来。
1403
+ download: display.downloadState(),
1404
+ // 二进制在不在 —— 设置页那一行要如实告诉用户"下一句该干什么"。
1405
+ binary: display.binaryInfo(),
1406
+ })
1407
+ },
1408
+ }
1409
+ }
1410
+
1411
+ /**
1412
+ * 共享设置路由(`GET|POST /api/live2d-pet/settings`)。
1413
+ *
1414
+ * 为什么需要它:桌面端页面与 DSH 页面**不是同一个 origin**(壳有自己的随机端口),
1415
+ * localStorage 按 origin 隔离 ⇒ 两边各存一份、永不互见(用户报的"桌面的设置与 DSH 里的
1416
+ * 设置没有同步")。所以设置要放到两端都能读的地方:`%DSH_HOME%\pet-settings.json`。
1417
+ *
1418
+ * 只同步三类**共享**项(tuning / overrides / outfit);窗口自己的位置与大小不在这里。
1419
+ */
1420
+ function settingsRoute(home) {
1421
+ return {
1422
+ kind: 'exact',
1423
+ path: API + '/settings',
1424
+ handler: async (request, response) => {
1425
+ if (!loopbackOnly(request)) {
1426
+ response.writeHead(403)
1427
+ response.end()
1428
+ return
1429
+ }
1430
+ if (request.method === 'POST') {
1431
+ let body = ''
1432
+ try {
1433
+ for await (const chunk of request) body += chunk
1434
+ const parsed = JSON.parse(body === '' ? '{}' : body)
1435
+ const written = writeSettings(home, parsed)
1436
+ if (written === null) {
1437
+ sendJson(response, 500, { ok: false, error: 'write-failed' })
1438
+ return
1439
+ }
1440
+ sendJson(response, 200, { ok: true, ...written })
1441
+ return
1442
+ } catch {
1443
+ sendJson(response, 400, { ok: false, error: 'bad-body' })
1444
+ return
1445
+ }
1446
+ }
1447
+ if (request.method !== 'GET' && request.method !== 'HEAD') {
1448
+ response.writeHead(405, { allow: 'GET, HEAD, POST' })
1449
+ response.end()
1450
+ return
1451
+ }
1452
+ sendJson(response, 200, { ok: true, ...readSettings(home) })
1453
+ },
1454
+ }
1455
+ }
1456
+
1353
1457
  /** The complete route table this plugin owns. */
1354
- export function buildRoutes(hub) {
1355
- return [catalogRoute(), assetRoute(), runtimeRoute(), eventsRoute(hub)]
1458
+ export function buildRoutes(hub, display, home) {
1459
+ const routes = [catalogRoute(), assetRoute(), runtimeRoute(), eventsRoute(hub)]
1460
+ if (display !== undefined) routes.push(layerRoute(display))
1461
+ // 共享设置:不依赖显示层(测试里也可能只给 home)。
1462
+ if (typeof home === 'string' && home !== '') routes.push(settingsRoute(home))
1463
+ return routes
1356
1464
  }
1357
1465
 
1358
1466
  export function apply(ctx) {
1359
1467
  const hub = new ActivityHub()
1360
1468
  attachActivityEvents(ctx, hub)
1361
1469
  ctx.effect(() => () => hub.dispose(), 'live2d-pet: activity hub')
1470
+
1471
+ // 显示层:每秒一次"按偏好把两边摆正"。桌面端的二进制由平台子包带下来
1472
+ // (`lib/desktop.js`),没有就只是提示,不影响页面内那只。
1473
+ const display = createDisplayLayer({
1474
+ home: dshHome(),
1475
+ hint: desktopHint({ home: dshHome() }),
1476
+ resolveBinary: () => resolveDesktopBinary({ home: dshHome() }),
1477
+ log: (message) => console.log('[live2d-pet] ' + message),
1478
+ })
1479
+ // 宿主侧把它挂出来:设置路由与 `/api/live2d-pet/layer` 共用同一个控制器。
1480
+ display.binaryInfo = () => {
1481
+ const found = resolveDesktopBinary({ home: dshHome() })
1482
+ return {
1483
+ found: found !== undefined,
1484
+ path: found?.path ?? null,
1485
+ source: found?.source ?? null,
1486
+ supported: desktopSupported(),
1487
+ hint: desktopHint({ home: dshHome() }),
1488
+ }
1489
+ }
1490
+ const tick = setInterval(() => {
1491
+ try {
1492
+ display.reconcile()
1493
+ } catch (error) {
1494
+ console.log('[live2d-pet] 显示层 reconcile 失败:' + String(error && error.message))
1495
+ }
1496
+ }, 1000)
1497
+ tick.unref?.()
1498
+ ctx.effect(() => () => {
1499
+ clearInterval(tick)
1500
+ display.dispose()
1501
+ }, 'live2d-pet: display layer')
1502
+
1362
1503
  ctx.inject(['webServer'], (host) => {
1363
- for (const route of buildRoutes(hub)) {
1504
+ for (const route of buildRoutes(hub, display, dshHome())) {
1364
1505
  try {
1365
1506
  host.effect(() => host.webServer.register(route), 'live2d-pet: route ' + route.path)
1366
1507
  } catch (error) {
@@ -0,0 +1,98 @@
1
+ // 设置存档(宿主半区):**一份两端共享的设置**,落在 `%DSH_HOME%\pet-settings.json`。
2
+ //
3
+ // 为什么必须有它(用户报的"桌面的设置与 DSH 里的设置没有同步"):
4
+ // 设置原来只存在 `window.localStorage` 里,而**桌面端页面与 DSH 页面不是同一个 origin**:
5
+ //
6
+ // 桌面端:http://127.0.0.1:<壳的随机端口> ← `--attach` 模式下页面由壳转发
7
+ // DSH: http://127.0.0.1:3080
8
+ //
9
+ // 浏览器按 origin 隔离 localStorage,所以两边各有一份、永不互见 —— 不是"同步没做",
10
+ // 而是**根本没有共享的存储**。显示层(`pet-desktop.json`)早就踩过同一个问题,
11
+ // 解法也一样:把共享状态放到一个两端都能读写的文件里。
12
+ //
13
+ // 三个键(与客户端的三类 shared 存档一一对应):
14
+ //
15
+ // tuning ← `dsh-pet-live2d.settings.v1` 可调项(手感、池子节奏…)
16
+ // overrides ← `dsh-pet-live2d.settings.v2` 相位池子覆盖 + 开关 + 反应清空
17
+ // outfit ← `dsh-pet-live2d:outfit` 装扮槽位的选择
18
+ //
19
+ // **位置与大小不在里面**(`dsh-live2d-pet.state.v1`):那个必须每个窗口各不相同
20
+ // (桌面上她贴屏幕右下角、DSH 页面里她贴面板右下角),共享了反而会打架。
21
+ //
22
+ // Rust 侧的对应实现见 `dsh-live2d-pet-desktop/src-tauri/src/host/settings.rs`:
23
+ // 独立模式下壳直接读写这个文件;挂载模式下页面读的仍是 DSH 的这条路由。
24
+ import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'
25
+ import { dirname, join } from 'node:path'
26
+
27
+ /** 存档里的键(客户端与 Rust 侧都按这三个名字读写)。 */
28
+ export const SETTINGS_KEYS = ['tuning', 'overrides', 'outfit']
29
+
30
+ export function settingsPath(home) {
31
+ return join(home, 'pet-settings.json')
32
+ }
33
+
34
+ /**
35
+ * 读共享设置。
36
+ *
37
+ * 返回 `{ tuning, overrides, outfit, rev, at }`;键缺失就是 `null`("这一项用户没改过")。
38
+ * `rev` 每次写入 +1 —— 页面用它判断"我这份是不是旧的",也用它避免自己写自己读的回环。
39
+ */
40
+ export function readSettings(home) {
41
+ const empty = { tuning: null, overrides: null, outfit: null, rev: 0, at: 0 }
42
+ let parsed = null
43
+ try {
44
+ parsed = JSON.parse(readFileSync(settingsPath(home), 'utf8'))
45
+ } catch {
46
+ return empty
47
+ }
48
+ if (parsed === null || typeof parsed !== 'object') return empty
49
+ const out = Object.assign({}, empty)
50
+ for (const key of SETTINGS_KEYS) {
51
+ const value = parsed[key]
52
+ // 只接受对象:存档被人手改坏时不要把它传染给页面。
53
+ if (value !== null && typeof value === 'object' && !Array.isArray(value)) out[key] = value
54
+ }
55
+ if (typeof parsed.rev === 'number' && parsed.rev >= 0) out.rev = parsed.rev
56
+ if (typeof parsed.at === 'number' && parsed.at >= 0) out.at = parsed.at
57
+ return out
58
+ }
59
+
60
+ /**
61
+ * 写共享设置(**合并写**:只动传进来的那几项,别把别的窗口写的擦掉)。
62
+ *
63
+ * 返回写好之后的那一份(含新的 `rev`)。写失败时返回 `null` —— 调用方要能区分
64
+ * "没写成功"和"写成功了",否则页面会以为同步好了。
65
+ */
66
+ export function writeSettings(home, patch) {
67
+ if (patch === null || typeof patch !== 'object') return null
68
+ const current = readSettings(home)
69
+ const next = {
70
+ tuning: current.tuning,
71
+ overrides: current.overrides,
72
+ outfit: current.outfit,
73
+ rev: current.rev + 1,
74
+ at: Date.now(),
75
+ }
76
+ let touched = false
77
+ for (const key of SETTINGS_KEYS) {
78
+ const value = patch[key]
79
+ if (value === null) continue // 没传 = 不动这一项
80
+ if (value === undefined) continue
81
+ if (typeof value !== 'object' || Array.isArray(value)) continue
82
+ next[key] = value
83
+ touched = true
84
+ }
85
+ // **没有实际内容就不算一次写入**:不推进 `rev`、也不落盘。
86
+ //
87
+ // 这一条是防回环的:页面按 `rev` 判断"我这份是不是旧的",如果"只带 rev 的空写"也推进
88
+ // 版本号,另一个窗口就会以为有变化、于是回写一次 → 互相推着转。
89
+ // (单元测试先红在这里,才有了这条注释。)
90
+ if (!touched) return current
91
+ try {
92
+ mkdirSync(dirname(settingsPath(home)), { recursive: true })
93
+ writeFileSync(settingsPath(home), JSON.stringify(next, null, 2) + '\n')
94
+ return next
95
+ } catch {
96
+ return null
97
+ }
98
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-pet-live2d",
3
- "version": "2.3.4",
3
+ "version": "3.0.1",
4
4
  "description": "Live2D 桌宠插件:为 DSH Web GUI 挂一只可拖动、会跟随鼠标、能换动作与表情的 Live2D 宠物。",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -19,6 +19,7 @@
19
19
  "docs",
20
20
  "pets",
21
21
  "cordis.patch.yml",
22
+ "screenshots.json",
22
23
  "README.md",
23
24
  "CHANGELOG.md",
24
25
  "LICENSE"
@@ -28,6 +29,7 @@
28
29
  "default": "./lib/index.js"
29
30
  },
30
31
  "./client": "./lib/client.js",
32
+ "./desktop": "./lib/desktop.js",
31
33
  "./cordis.patch.yml": "./cordis.patch.yml",
32
34
  "./package.json": "./package.json"
33
35
  },
@@ -46,6 +48,9 @@
46
48
  "platform": "web"
47
49
  }
48
50
  },
51
+ "optionalDependencies": {
52
+ "dsh-pet-live2d-desktop-win32-x64": "3.0.1"
53
+ },
49
54
  "devDependencies": {
50
55
  "esbuild": "^0.25.10",
51
56
  "pixi.js": "8.19.0",
@@ -42,6 +42,11 @@
42
42
  },
43
43
  "OpenCase": {
44
44
  "hold": true
45
+ },
46
+ "Selfie": {
47
+ "holdParams": {
48
+ "phone5": 10
49
+ }
45
50
  }
46
51
  },
47
52
  "expressionSlots": [
@@ -0,0 +1,5 @@
1
+ [
2
+ "docs/banner.png",
3
+ "docs/panel.jpg",
4
+ "docs/looks.jpg"
5
+ ]