dsh-plugin-vrmpet3d 0.1.3 → 0.1.4

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.
@@ -1,6 +1,4 @@
1
1
  // A focused game temporarily hides only a pet window that was visible beforehand.
2
- const http = require('node:http');
3
- const https = require('node:https');
4
2
  // A transparent window can paint and report pixel hits after showInactive while
5
3
  // Chromium still uses the pre-hide input surface. A real bounds transition (the
6
4
  // same transition wheel zoom causes) rebuilds that surface without changing the pet.
@@ -27,24 +25,41 @@ function createVisibilityController(win, hooks = {}) {
27
25
  function watchGameVisibility(win, pageURL, interval = 500, hooks = {}) {
28
26
  const apply = createVisibilityController(win, hooks);
29
27
  const url = new URL('/vrmpet3d/desktop', pageURL);
30
- const transport = url.protocol === 'https:' ? https : http;
28
+ // The page and this poll must use the SAME Chromium session (cookies/proxy/TLS).
29
+ // Node's http.get never inherited the window's authentication cookies.
30
+ const session = win.webContents.session;
31
31
  let request = null, stopped = false;
32
- function poll() {
32
+ async function poll() {
33
33
  if (stopped || request || win.isDestroyed()) return;
34
- request = transport.get(url, { timeout: 1500 }, response => {
35
- let body = '', failed = response.statusCode !== 200;
36
- response.on('data', chunk => { body += chunk; if (body.length > 16384) { failed = true; response.destroy(); } });
37
- response.on('end', () => {
38
- if (stopped) return;
39
- try { apply(!failed && JSON.parse(body).gameFocused === true); } catch (_) { apply(false); }
34
+ const controller = new AbortController();
35
+ request = controller;
36
+ const deadline = setTimeout(() => controller.abort(), 1500);
37
+ deadline.unref?.();
38
+ try {
39
+ const response = await session.fetch(url.href, {
40
+ credentials: 'include', redirect: 'error', cache: 'no-store', signal: controller.signal,
40
41
  });
41
- response.on('error', () => { if (!stopped) apply(false); });
42
- });
43
- request.on('timeout', () => request.destroy());
44
- request.on('error', () => { if (!stopped) apply(false); });
45
- request.on('close', () => { request = null; });
42
+ if (!response.ok || response.redirected || !/^application\/json\b/i.test(response.headers.get('content-type') || '')) {
43
+ await response.body?.cancel();
44
+ throw new Error('game status is not authenticated JSON');
45
+ }
46
+ const reader = response.body.getReader();
47
+ let size = 0;
48
+ const chunks = [];
49
+ try {
50
+ for (;;) {
51
+ const { value, done } = await reader.read();
52
+ if (done) break;
53
+ size += value.byteLength;
54
+ if (size > 16384) { await reader.cancel(); throw new Error('game status exceeds limit'); }
55
+ chunks.push(Buffer.from(value));
56
+ }
57
+ } finally { reader.releaseLock(); }
58
+ if (!stopped) apply(JSON.parse(Buffer.concat(chunks).toString('utf8')).gameFocused === true);
59
+ } catch (_) { if (!stopped) apply(false); }
60
+ finally { clearTimeout(deadline); if (request === controller) request = null; }
46
61
  }
47
62
  const timer = setInterval(poll, interval); timer.unref(); poll();
48
- return function stop() { stopped = true; clearInterval(timer); if (request) request.destroy(); apply(false); };
63
+ return function stop() { stopped = true; clearInterval(timer); if (request) request.abort(); apply(false); };
49
64
  }
50
65
  module.exports = { createVisibilityController, watchGameVisibility, refreshInputRegion };
@@ -5013,7 +5013,7 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
5013
5013
  function load() {
5014
5014
  if (!supported || stopped) return Promise.resolve(null);
5015
5015
  var request = ++issued;
5016
- return doFetch(url, { credentials: 'omit' })
5016
+ return doFetch(url, { credentials: 'same-origin' })
5017
5017
  .then(function (response) {
5018
5018
  if (response.status === 404 || response.status === 405) {
5019
5019
  supported = false;
@@ -5177,7 +5177,7 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
5177
5177
  function load() {
5178
5178
  if (!supported || stopped) return Promise.resolve(null);
5179
5179
  var request = ++issued;
5180
- return doFetch(url, { credentials: 'omit' })
5180
+ return doFetch(url, { credentials: 'same-origin' })
5181
5181
  .then(function (response) {
5182
5182
  if (response.status === 404 || response.status === 405) {
5183
5183
  supported = false;
@@ -5221,7 +5221,7 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
5221
5221
  /**
5222
5222
  * The reporting half of `watchHostState`: the app's pet tells the host what it resolved.
5223
5223
  *
5224
- * Deduplicated on the last value SENT, so the sixty-per-second frame loop can call this on every change
5224
+ * Deduplicated on the latest in-flight value or acknowledged value, so the frame loop can call this on every change
5225
5225
  * without a POST per frame — and a state that flaps back to what the host already has costs nothing.
5226
5226
  *
5227
5227
  * The 碎碎念 LINE travels with the state, and the dedupe key is both together: the pet can say something
@@ -5253,6 +5253,8 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
5253
5253
  var sentUnread = null;
5254
5254
  /** The last diagnostic sent (see `report`), so the heartbeat re-states what the state it repeats was based on. */
5255
5255
  var sentWhy = null;
5256
+ var reportSequence = 0;
5257
+ var pendingReport = null;
5256
5258
  var beatTimer = null;
5257
5259
  /**
5258
5260
  * What the heartbeat re-states: the last STATE and LINE, never the touch.
@@ -5473,22 +5475,27 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
5473
5475
  return JSON.stringify(value ? [value.waitingSessionIds || [], value.completedSessionIds || [],
5474
5476
  value.statusSessionId || null, value.completedSessionId || null] : [[], [], null, null]);
5475
5477
  };
5476
- if (force !== true && next === sent && nextLine === sentLine && nextTouchKey === sentTouchKey
5477
- && nextClockKey === sentClockKey && nextUnread === sentUnread && navigationKey(nextWhy) === navigationKey(sentWhy)) {
5478
+ // Dedupe against the newest in-flight request, or the last ACKNOWLEDGED report.
5479
+ // A rejected request must remain retryable; an older response cannot commit over a newer report.
5480
+ if (!doFetch || !next) return Promise.resolve(null);
5481
+ var key = JSON.stringify([next, nextLine, nextTouchKey, nextClockKey, nextUnread, navigationKey(nextWhy)]);
5482
+ if (force !== true && ((pendingReport && pendingReport.key === key)
5483
+ || (!pendingReport && next === sent && nextLine === sentLine && nextTouchKey === sentTouchKey
5484
+ && nextClockKey === sentClockKey && nextUnread === sentUnread && navigationKey(nextWhy) === navigationKey(sentWhy)))) {
5478
5485
  return Promise.resolve(null);
5479
5486
  }
5480
- sent = next;
5481
- sentLine = nextLine;
5482
- sentTouch = nextTouch;
5483
- sentClock = nextClock;
5484
- sentUnread = nextUnread;
5485
- sentWhy = nextWhy;
5486
- // No state, no report: the host refuses an unknown name, and a line with no state behind it would only
5487
- // clear the state it is meant to accompany.
5488
- if (!doFetch || !next) return Promise.resolve(null);
5489
- // Recorded for the heartbeat AFTER the send is judged worth making, so a beat always repeats something the
5490
- // host has actually been told.
5491
- lastBeat = { state: next, line: nextLine, why: nextWhy };
5487
+ var request = ++reportSequence;
5488
+ pendingReport = { key: key, request: request };
5489
+ // Heartbeats retry the latest desired STATE, never a one-shot touch event.
5490
+ lastBeat = { state: next, line: nextLine, why: nextWhy, clock: nextClock, unread: nextUnread };
5491
+ function failed() {
5492
+ if (request === reportSequence) {
5493
+ pendingReport = null;
5494
+ sent = null;
5495
+ sentLine = sentTouch = sentClock = sentUnread = sentWhy = null;
5496
+ }
5497
+ return null;
5498
+ }
5492
5499
  try {
5493
5500
  var body = { state: next };
5494
5501
  if (nextLine) body.line = nextLine;
@@ -5496,16 +5503,57 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
5496
5503
  if (nextClock) body.clock = nextClock;
5497
5504
  if (nextUnread !== null) body.unread = nextUnread;
5498
5505
  if (nextWhy) body.why = nextWhy;
5499
- return doFetch(url, {
5500
- method: 'POST',
5501
- credentials: 'omit',
5502
- headers: { 'content-type': 'application/json' },
5503
- body: JSON.stringify(body),
5504
- }).then(function () { return next; }, function () { return null; });
5506
+ return postReport(body).then(function (accepted) {
5507
+ if (!accepted) return failed();
5508
+ if (request === reportSequence) {
5509
+ pendingReport = null;
5510
+ sent = next;
5511
+ sentLine = nextLine;
5512
+ sentTouch = nextTouch;
5513
+ sentClock = nextClock;
5514
+ sentUnread = nextUnread;
5515
+ sentWhy = nextWhy;
5516
+ }
5517
+ return next;
5518
+ }).catch(failed);
5505
5519
  } catch (_error) {
5506
- return Promise.resolve(null);
5520
+ return Promise.resolve(failed());
5507
5521
  }
5508
5522
  }
5523
+ function readAcknowledgement(response) {
5524
+ if (!response || !response.ok || response.redirected) return Promise.resolve(false);
5525
+ return response.json().then(function (payload) {
5526
+ return !!payload && typeof payload.seq === 'number' && Number.isFinite(payload.seq)
5527
+ && payload.seq >= 0 && payload.superseded !== true;
5528
+ });
5529
+ }
5530
+ function postReport(body) {
5531
+ var controller = typeof AbortController === 'function' ? new AbortController() : null;
5532
+ var deadline = null;
5533
+ // A stalled fetch/body must not permanently suppress all subsequent heartbeats.
5534
+ var timeoutMs = Number(settings.requestTimeoutMs) > 0 ? Number(settings.requestTimeoutMs) : 8000;
5535
+ return new Promise(function (resolve, reject) {
5536
+ deadline = setTimeout(function () {
5537
+ if (controller) controller.abort();
5538
+ resolve(false);
5539
+ }, timeoutMs);
5540
+ if (deadline && typeof deadline.unref === 'function') deadline.unref();
5541
+ try {
5542
+ var options = {
5543
+ method: 'POST', credentials: 'same-origin', redirect: 'error',
5544
+ headers: { 'content-type': 'application/json' }, body: JSON.stringify(body),
5545
+ };
5546
+ if (controller) options.signal = controller.signal;
5547
+ Promise.resolve(doFetch(url, options)).then(readAcknowledgement).then(resolve, reject);
5548
+ } catch (error) { reject(error); }
5549
+ }).then(function (accepted) {
5550
+ clearTimeout(deadline);
5551
+ return accepted;
5552
+ }, function (error) {
5553
+ clearTimeout(deadline);
5554
+ throw error;
5555
+ });
5556
+ }
5509
5557
  /**
5510
5558
  * Re-state the last report, unchanged, so that SILENCE can be told from DEATH.
5511
5559
  *
@@ -5522,7 +5570,8 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
5522
5570
  * all" on every beat — so the floating window lost the turn's 用时 a few seconds into every long turn. `force`
5523
5571
  * is the parameter that exists for this.
5524
5572
  */
5525
- return report(lastBeat.state, lastBeat.line, null, sentClock, true, sentUnread, lastBeat.why);
5573
+ if (pendingReport) return Promise.resolve(null);
5574
+ return report(lastBeat.state, lastBeat.line, null, lastBeat.clock, true, lastBeat.unread, lastBeat.why);
5526
5575
  }
5527
5576
 
5528
5577
  function startHeartbeat(intervalMs) {
@@ -5552,12 +5601,8 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
5552
5601
  if (!doFetch) return Promise.resolve(null);
5553
5602
  var message = error && (error.stack || error.message) ? String(error.stack || error.message) : String(error);
5554
5603
  try {
5555
- return doFetch(url, {
5556
- method: 'POST',
5557
- credentials: 'omit',
5558
- headers: { 'content-type': 'application/json' },
5559
- body: JSON.stringify({ state: sent || '', error: message.slice(0, 500) }),
5560
- }).then(function () { return message; }, function () { return null; });
5604
+ return postReport({ state: sent || '', error: message.slice(0, 500) })
5605
+ .then(function (accepted) { return accepted ? message : null; }).catch(function () { return null; });
5561
5606
  } catch (_error) {
5562
5607
  return Promise.resolve(null);
5563
5608
  }
@@ -5618,7 +5663,7 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
5618
5663
  function load() {
5619
5664
  if (!supported || stopped) return Promise.resolve(null);
5620
5665
  var request = ++issued;
5621
- return doFetch(url, { credentials: 'omit' })
5666
+ return doFetch(url, { credentials: 'same-origin' })
5622
5667
  .then(function (response) {
5623
5668
  if (response.status === 404 || response.status === 405) {
5624
5669
  // No store on this host: stop asking, and let the caller keep its own storage.
@@ -5644,7 +5689,7 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
5644
5689
  var request = ++issued;
5645
5690
  return doFetch(url, {
5646
5691
  method: 'POST',
5647
- credentials: 'omit',
5692
+ credentials: 'same-origin',
5648
5693
  headers: { 'content-type': 'application/json' },
5649
5694
  body: JSON.stringify(body || {}),
5650
5695
  })
package/lib/client.js CHANGED
@@ -1672,7 +1672,7 @@
1672
1672
  if ((!globalThis.dshDesktop && !/Electron\//i.test(globalThis.navigator.userAgent)) || !self.canUseIndependentPet()) {
1673
1673
  globalThis.open(gameUrl, '_blank', 'noopener'); return;
1674
1674
  }
1675
- fetch('/vrmpet3d/desktop', { method: 'POST', credentials: 'omit',
1675
+ fetch('/vrmpet3d/desktop', { method: 'POST', credentials: 'same-origin',
1676
1676
  headers: { 'content-type': 'application/json' },
1677
1677
  body: JSON.stringify({ action: 'game', modelId: self.currentModelId() })
1678
1678
  }).then(function (response) {
@@ -1705,7 +1705,7 @@
1705
1705
  fetch('/vrmpet3d/desktop', {
1706
1706
  method: 'POST',
1707
1707
  headers: { 'content-type': 'application/json' },
1708
- credentials: 'omit',
1708
+ credentials: 'same-origin',
1709
1709
  // The model goes with the request: the model window must configure the model that is ON SCREEN, and this
1710
1710
  // surface is the only one that knows it at that moment. Without it the host can only fall back to the
1711
1711
  // launcher's model (or nothing), which is how the settings window ended up describing the built-in
@@ -1741,12 +1741,12 @@
1741
1741
  var send = function (body) {
1742
1742
  return fetch('/vrmpet3d/setup-state', {
1743
1743
  method: 'POST',
1744
- credentials: 'omit',
1744
+ credentials: 'same-origin',
1745
1745
  headers: { 'content-type': 'application/json' },
1746
1746
  body: JSON.stringify(body),
1747
1747
  });
1748
1748
  };
1749
- return fetch('/vrmpet3d/setup-state', { credentials: 'omit' })
1749
+ return fetch('/vrmpet3d/setup-state', { credentials: 'same-origin' })
1750
1750
  .then(function (response) { return response.ok ? response.json() : null; })
1751
1751
  .then(function (state) {
1752
1752
  if (!state || state.completed === true || state.prompted === true) return null;
@@ -1754,7 +1754,7 @@
1754
1754
  .then(function () {
1755
1755
  return fetch('/vrmpet3d/desktop', {
1756
1756
  method: 'POST',
1757
- credentials: 'omit',
1757
+ credentials: 'same-origin',
1758
1758
  headers: { 'content-type': 'application/json' },
1759
1759
  body: JSON.stringify({ action: 'setup' }),
1760
1760
  });
@@ -2273,7 +2273,7 @@
2273
2273
  this.syncDesktop(null, strings.desktopStarting);
2274
2274
  return fetch('/vrmpet3d/desktop', {
2275
2275
  method: 'POST',
2276
- credentials: 'omit',
2276
+ credentials: 'same-origin',
2277
2277
  headers: { 'content-type': 'application/json' },
2278
2278
  body: JSON.stringify({
2279
2279
  action: enabled ? 'enable' : 'disable',
@@ -2296,7 +2296,7 @@
2296
2296
  /** Ask the host what the window is doing, and show it. */
2297
2297
  VrmPet3DWidget.prototype.refreshDesktopStatus = function () {
2298
2298
  var self = this;
2299
- return fetch('/vrmpet3d/desktop', { credentials: 'omit' })
2299
+ return fetch('/vrmpet3d/desktop', { credentials: 'same-origin' })
2300
2300
  .then(function (response) { return response.ok ? response.json() : null; })
2301
2301
  .then(function (status) { self.syncDesktop(status, null); return status; })
2302
2302
  .catch(function () {
@@ -3004,7 +3004,7 @@
3004
3004
  */
3005
3005
  VrmPet3DWidget.prototype.refreshCatalog = function () {
3006
3006
  var self = this;
3007
- return fetch('/vrmpet3d/catalog', { credentials: 'omit' })
3007
+ return fetch('/vrmpet3d/catalog', { credentials: 'same-origin' })
3008
3008
  .then(function (response) {
3009
3009
  if (!response.ok) throw new Error(word(self.language, 'catalogRoute', { status: response.status }));
3010
3010
  return response.json();
@@ -3222,7 +3222,7 @@
3222
3222
  */
3223
3223
  VrmPet3DWidget.prototype.refreshMotions = function () {
3224
3224
  var self = this;
3225
- return fetch('/vrmpet3d/motions', { credentials: 'omit' })
3225
+ return fetch('/vrmpet3d/motions', { credentials: 'same-origin' })
3226
3226
  .then(function (response) {
3227
3227
  if (!response.ok) throw new Error('motion catalog route returned HTTP ' + response.status);
3228
3228
  return response.json();
@@ -3689,7 +3689,7 @@
3689
3689
  /** Fetch one motion's bytes from the host's byte route. */
3690
3690
  VrmPet3DWidget.prototype.fetchMotionBytes = function (motionId) {
3691
3691
  var url = '/vrmpet3d/motion?m=' + encodeURIComponent(motionId);
3692
- return fetch(url, { credentials: 'omit' }).then(function (response) {
3692
+ return fetch(url, { credentials: 'same-origin' }).then(function (response) {
3693
3693
  if (!response.ok) throw new Error(word(this.language, 'motionRoute', { status: response.status }));
3694
3694
  return response.arrayBuffer();
3695
3695
  });
@@ -4298,7 +4298,7 @@
4298
4298
  return fetch('/vrmpet3d/say', {
4299
4299
  method: 'POST',
4300
4300
  headers: { 'content-type': 'application/json' },
4301
- credentials: 'omit',
4301
+ credentials: 'same-origin',
4302
4302
  /**
4303
4303
  * The request carries five things, and each is a different answer to "what is this line about".
4304
4304
  *
@@ -2060,11 +2060,12 @@
2060
2060
  * repeated GET of the same URL without touching the disk — which would make 重载模型 look like it did nothing. The
2061
2061
  * one-shot buster from `bustModelCache` rides the query string, and is consumed here so an ordinary load stays clean.
2062
2062
  */
2063
- if (this.modelBust) {
2063
+ var reload = Boolean(this.modelBust);
2064
+ if (reload) {
2064
2065
  url += (url.indexOf('?') === -1 ? '?' : '&') + 'v=' + this.modelBust;
2065
2066
  this.modelBust = null;
2066
2067
  }
2067
- var promise = fetch(url, { credentials: 'omit' })
2068
+ var promise = fetch(url, { credentials: 'same-origin', cache: reload ? 'reload' : 'default' })
2068
2069
  .then(function (response) {
2069
2070
  if (!response.ok) throw new Error('asset route returned HTTP ' + response.status);
2070
2071
  return response.arrayBuffer();
@@ -1081,7 +1081,7 @@
1081
1081
  * it is the same predicate as `loadModelInfo`'s, not "is the picker here".
1082
1082
  */
1083
1083
  if (!needsModelVocabulary()) return Promise.resolve(null);
1084
- return fetch('/vrmpet3d/catalog', { credentials: 'omit' })
1084
+ return fetch('/vrmpet3d/catalog', { credentials: 'same-origin' })
1085
1085
  .then(function (response) { return response.ok ? response.json() : null; })
1086
1086
  .then(function (body) {
1087
1087
  catalog = body && Array.isArray(body.models) ? body.models : [];
@@ -1183,7 +1183,7 @@
1183
1183
  }
1184
1184
 
1185
1185
  function loadImported() {
1186
- return fetch('/vrmpet3d/models', { credentials: 'omit' })
1186
+ return fetch('/vrmpet3d/models', { credentials: 'same-origin' })
1187
1187
  .then(function (response) { return response.ok ? response.json() : null; })
1188
1188
  .then(function (body) {
1189
1189
  imported.models = body && Array.isArray(body.importedModels) ? body.importedModels : [];
@@ -1209,7 +1209,7 @@
1209
1209
  function writeImported(body) {
1210
1210
  return fetch('/vrmpet3d/models', {
1211
1211
  method: 'POST',
1212
- credentials: 'omit',
1212
+ credentials: 'same-origin',
1213
1213
  headers: { 'content-type': 'application/json' },
1214
1214
  body: JSON.stringify(body),
1215
1215
  }).then(function (response) {
@@ -1341,7 +1341,7 @@
1341
1341
  }
1342
1342
 
1343
1343
  function loadImportedMotions() {
1344
- return fetch('/vrmpet3d/motions', { credentials: 'omit' })
1344
+ return fetch('/vrmpet3d/motions', { credentials: 'same-origin' })
1345
1345
  .then(function (response) { return response.ok ? response.json() : null; })
1346
1346
  .then(function (body) {
1347
1347
  importedMotions.imports = body && Array.isArray(body.importedMotions) ? body.importedMotions : [];
@@ -1363,7 +1363,7 @@
1363
1363
  function writeImportedMotion(body) {
1364
1364
  return fetch('/vrmpet3d/motions', {
1365
1365
  method: 'POST',
1366
- credentials: 'omit',
1366
+ credentials: 'same-origin',
1367
1367
  headers: { 'content-type': 'application/json' },
1368
1368
  body: JSON.stringify(body),
1369
1369
  }).then(function (response) {
@@ -1503,7 +1503,7 @@
1503
1503
  var settle = function () {
1504
1504
  if (modelInfoInFlight && modelInfoInFlight.id === id) modelInfoInFlight = null;
1505
1505
  };
1506
- var promise = fetch('/vrmpet3d/model-info?m=' + encodeURIComponent(id), { credentials: 'omit' })
1506
+ var promise = fetch('/vrmpet3d/model-info?m=' + encodeURIComponent(id), { credentials: 'same-origin' })
1507
1507
  .then(function (response) {
1508
1508
  if (response.status === 404) return null;
1509
1509
  return response.ok ? response.json() : null;
@@ -2787,7 +2787,7 @@
2787
2787
  }
2788
2788
 
2789
2789
  function loadMotions() {
2790
- return fetch('/vrmpet3d/motions', { credentials: 'omit' })
2790
+ return fetch('/vrmpet3d/motions', { credentials: 'same-origin' })
2791
2791
  .then(function (response) {
2792
2792
  if (!response.ok) return null;
2793
2793
  return response.json();
@@ -2992,7 +2992,7 @@
2992
2992
  try {
2993
2993
  fetch('/vrmpet3d/preview', {
2994
2994
  method: 'POST',
2995
- credentials: 'omit',
2995
+ credentials: 'same-origin',
2996
2996
  headers: { 'content-type': 'application/json' },
2997
2997
  body: JSON.stringify(preview || {}),
2998
2998
  }).catch(function () { return null; });
@@ -4186,7 +4186,7 @@
4186
4186
  */
4187
4187
  var loadLlm = function (wanted) {
4188
4188
  askedLlmProvider = wanted || '';
4189
- return fetch('/vrmpet3d/llm' + (wanted ? '?provider=' + encodeURIComponent(wanted) : ''), { credentials: 'omit' })
4189
+ return fetch('/vrmpet3d/llm' + (wanted ? '?provider=' + encodeURIComponent(wanted) : ''), { credentials: 'same-origin' })
4190
4190
  .then(function (response) { return response.ok ? response.json() : null; })
4191
4191
  .then(function (body) {
4192
4192
  if (!body) return;
@@ -4391,7 +4391,7 @@
4391
4391
  fetch('/vrmpet3d/say', {
4392
4392
  method: 'POST',
4393
4393
  headers: { 'content-type': 'application/json' },
4394
- credentials: 'omit',
4394
+ credentials: 'same-origin',
4395
4395
  // The HOST answers in this language, so the test line reads like the pet the user is configuring.
4396
4396
  body: JSON.stringify({ state: state, language: language }),
4397
4397
  }).then(function (response) { return response.json(); })
@@ -4631,7 +4631,7 @@
4631
4631
  * about "is anything drawing this model".
4632
4632
  */
4633
4633
  function refreshStatus() {
4634
- return fetch('/vrmpet3d/desktop', { credentials: 'omit' })
4634
+ return fetch('/vrmpet3d/desktop', { credentials: 'same-origin' })
4635
4635
  .then(function (response) { return response.ok ? response.json() : null; })
4636
4636
  .then(function (status) {
4637
4637
  if (!status) {
@@ -5444,7 +5444,7 @@
5444
5444
  };
5445
5445
  fetch('/vrmpet3d/desktop', {
5446
5446
  method: 'POST',
5447
- credentials: 'omit',
5447
+ credentials: 'same-origin',
5448
5448
  headers: { 'content-type': 'application/json' },
5449
5449
  body: JSON.stringify({ action: 'setup' }),
5450
5450
  }).then(function (response) {
package/lib/core.js CHANGED
@@ -5005,7 +5005,7 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
5005
5005
  function load() {
5006
5006
  if (!supported || stopped) return Promise.resolve(null);
5007
5007
  var request = ++issued;
5008
- return doFetch(url, { credentials: 'omit' })
5008
+ return doFetch(url, { credentials: 'same-origin' })
5009
5009
  .then(function (response) {
5010
5010
  if (response.status === 404 || response.status === 405) {
5011
5011
  supported = false;
@@ -5169,7 +5169,7 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
5169
5169
  function load() {
5170
5170
  if (!supported || stopped) return Promise.resolve(null);
5171
5171
  var request = ++issued;
5172
- return doFetch(url, { credentials: 'omit' })
5172
+ return doFetch(url, { credentials: 'same-origin' })
5173
5173
  .then(function (response) {
5174
5174
  if (response.status === 404 || response.status === 405) {
5175
5175
  supported = false;
@@ -5213,7 +5213,7 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
5213
5213
  /**
5214
5214
  * The reporting half of `watchHostState`: the app's pet tells the host what it resolved.
5215
5215
  *
5216
- * Deduplicated on the last value SENT, so the sixty-per-second frame loop can call this on every change
5216
+ * Deduplicated on the latest in-flight value or acknowledged value, so the frame loop can call this on every change
5217
5217
  * without a POST per frame — and a state that flaps back to what the host already has costs nothing.
5218
5218
  *
5219
5219
  * The 碎碎念 LINE travels with the state, and the dedupe key is both together: the pet can say something
@@ -5245,6 +5245,8 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
5245
5245
  var sentUnread = null;
5246
5246
  /** The last diagnostic sent (see `report`), so the heartbeat re-states what the state it repeats was based on. */
5247
5247
  var sentWhy = null;
5248
+ var reportSequence = 0;
5249
+ var pendingReport = null;
5248
5250
  var beatTimer = null;
5249
5251
  /**
5250
5252
  * What the heartbeat re-states: the last STATE and LINE, never the touch.
@@ -5465,22 +5467,27 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
5465
5467
  return JSON.stringify(value ? [value.waitingSessionIds || [], value.completedSessionIds || [],
5466
5468
  value.statusSessionId || null, value.completedSessionId || null] : [[], [], null, null]);
5467
5469
  };
5468
- if (force !== true && next === sent && nextLine === sentLine && nextTouchKey === sentTouchKey
5469
- && nextClockKey === sentClockKey && nextUnread === sentUnread && navigationKey(nextWhy) === navigationKey(sentWhy)) {
5470
+ // Dedupe against the newest in-flight request, or the last ACKNOWLEDGED report.
5471
+ // A rejected request must remain retryable; an older response cannot commit over a newer report.
5472
+ if (!doFetch || !next) return Promise.resolve(null);
5473
+ var key = JSON.stringify([next, nextLine, nextTouchKey, nextClockKey, nextUnread, navigationKey(nextWhy)]);
5474
+ if (force !== true && ((pendingReport && pendingReport.key === key)
5475
+ || (!pendingReport && next === sent && nextLine === sentLine && nextTouchKey === sentTouchKey
5476
+ && nextClockKey === sentClockKey && nextUnread === sentUnread && navigationKey(nextWhy) === navigationKey(sentWhy)))) {
5470
5477
  return Promise.resolve(null);
5471
5478
  }
5472
- sent = next;
5473
- sentLine = nextLine;
5474
- sentTouch = nextTouch;
5475
- sentClock = nextClock;
5476
- sentUnread = nextUnread;
5477
- sentWhy = nextWhy;
5478
- // No state, no report: the host refuses an unknown name, and a line with no state behind it would only
5479
- // clear the state it is meant to accompany.
5480
- if (!doFetch || !next) return Promise.resolve(null);
5481
- // Recorded for the heartbeat AFTER the send is judged worth making, so a beat always repeats something the
5482
- // host has actually been told.
5483
- lastBeat = { state: next, line: nextLine, why: nextWhy };
5479
+ var request = ++reportSequence;
5480
+ pendingReport = { key: key, request: request };
5481
+ // Heartbeats retry the latest desired STATE, never a one-shot touch event.
5482
+ lastBeat = { state: next, line: nextLine, why: nextWhy, clock: nextClock, unread: nextUnread };
5483
+ function failed() {
5484
+ if (request === reportSequence) {
5485
+ pendingReport = null;
5486
+ sent = null;
5487
+ sentLine = sentTouch = sentClock = sentUnread = sentWhy = null;
5488
+ }
5489
+ return null;
5490
+ }
5484
5491
  try {
5485
5492
  var body = { state: next };
5486
5493
  if (nextLine) body.line = nextLine;
@@ -5488,16 +5495,57 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
5488
5495
  if (nextClock) body.clock = nextClock;
5489
5496
  if (nextUnread !== null) body.unread = nextUnread;
5490
5497
  if (nextWhy) body.why = nextWhy;
5491
- return doFetch(url, {
5492
- method: 'POST',
5493
- credentials: 'omit',
5494
- headers: { 'content-type': 'application/json' },
5495
- body: JSON.stringify(body),
5496
- }).then(function () { return next; }, function () { return null; });
5498
+ return postReport(body).then(function (accepted) {
5499
+ if (!accepted) return failed();
5500
+ if (request === reportSequence) {
5501
+ pendingReport = null;
5502
+ sent = next;
5503
+ sentLine = nextLine;
5504
+ sentTouch = nextTouch;
5505
+ sentClock = nextClock;
5506
+ sentUnread = nextUnread;
5507
+ sentWhy = nextWhy;
5508
+ }
5509
+ return next;
5510
+ }).catch(failed);
5497
5511
  } catch (_error) {
5498
- return Promise.resolve(null);
5512
+ return Promise.resolve(failed());
5499
5513
  }
5500
5514
  }
5515
+ function readAcknowledgement(response) {
5516
+ if (!response || !response.ok || response.redirected) return Promise.resolve(false);
5517
+ return response.json().then(function (payload) {
5518
+ return !!payload && typeof payload.seq === 'number' && Number.isFinite(payload.seq)
5519
+ && payload.seq >= 0 && payload.superseded !== true;
5520
+ });
5521
+ }
5522
+ function postReport(body) {
5523
+ var controller = typeof AbortController === 'function' ? new AbortController() : null;
5524
+ var deadline = null;
5525
+ // A stalled fetch/body must not permanently suppress all subsequent heartbeats.
5526
+ var timeoutMs = Number(settings.requestTimeoutMs) > 0 ? Number(settings.requestTimeoutMs) : 8000;
5527
+ return new Promise(function (resolve, reject) {
5528
+ deadline = setTimeout(function () {
5529
+ if (controller) controller.abort();
5530
+ resolve(false);
5531
+ }, timeoutMs);
5532
+ if (deadline && typeof deadline.unref === 'function') deadline.unref();
5533
+ try {
5534
+ var options = {
5535
+ method: 'POST', credentials: 'same-origin', redirect: 'error',
5536
+ headers: { 'content-type': 'application/json' }, body: JSON.stringify(body),
5537
+ };
5538
+ if (controller) options.signal = controller.signal;
5539
+ Promise.resolve(doFetch(url, options)).then(readAcknowledgement).then(resolve, reject);
5540
+ } catch (error) { reject(error); }
5541
+ }).then(function (accepted) {
5542
+ clearTimeout(deadline);
5543
+ return accepted;
5544
+ }, function (error) {
5545
+ clearTimeout(deadline);
5546
+ throw error;
5547
+ });
5548
+ }
5501
5549
  /**
5502
5550
  * Re-state the last report, unchanged, so that SILENCE can be told from DEATH.
5503
5551
  *
@@ -5514,7 +5562,8 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
5514
5562
  * all" on every beat — so the floating window lost the turn's 用时 a few seconds into every long turn. `force`
5515
5563
  * is the parameter that exists for this.
5516
5564
  */
5517
- return report(lastBeat.state, lastBeat.line, null, sentClock, true, sentUnread, lastBeat.why);
5565
+ if (pendingReport) return Promise.resolve(null);
5566
+ return report(lastBeat.state, lastBeat.line, null, lastBeat.clock, true, lastBeat.unread, lastBeat.why);
5518
5567
  }
5519
5568
 
5520
5569
  function startHeartbeat(intervalMs) {
@@ -5544,12 +5593,8 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
5544
5593
  if (!doFetch) return Promise.resolve(null);
5545
5594
  var message = error && (error.stack || error.message) ? String(error.stack || error.message) : String(error);
5546
5595
  try {
5547
- return doFetch(url, {
5548
- method: 'POST',
5549
- credentials: 'omit',
5550
- headers: { 'content-type': 'application/json' },
5551
- body: JSON.stringify({ state: sent || '', error: message.slice(0, 500) }),
5552
- }).then(function () { return message; }, function () { return null; });
5596
+ return postReport({ state: sent || '', error: message.slice(0, 500) })
5597
+ .then(function (accepted) { return accepted ? message : null; }).catch(function () { return null; });
5553
5598
  } catch (_error) {
5554
5599
  return Promise.resolve(null);
5555
5600
  }
@@ -5610,7 +5655,7 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
5610
5655
  function load() {
5611
5656
  if (!supported || stopped) return Promise.resolve(null);
5612
5657
  var request = ++issued;
5613
- return doFetch(url, { credentials: 'omit' })
5658
+ return doFetch(url, { credentials: 'same-origin' })
5614
5659
  .then(function (response) {
5615
5660
  if (response.status === 404 || response.status === 405) {
5616
5661
  // No store on this host: stop asking, and let the caller keep its own storage.
@@ -5636,7 +5681,7 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
5636
5681
  var request = ++issued;
5637
5682
  return doFetch(url, {
5638
5683
  method: 'POST',
5639
- credentials: 'omit',
5684
+ credentials: 'same-origin',
5640
5685
  headers: { 'content-type': 'application/json' },
5641
5686
  body: JSON.stringify(body || {}),
5642
5687
  })
@@ -1374,7 +1374,7 @@
1374
1374
  fetch('/vrmpet3d/say', {
1375
1375
  method: 'POST',
1376
1376
  headers: { 'content-type': 'application/json' },
1377
- credentials: 'omit',
1377
+ credentials: 'same-origin',
1378
1378
  body: JSON.stringify({
1379
1379
  state: hostState || null,
1380
1380
  event: stateEventName(),
@@ -1694,7 +1694,7 @@
1694
1694
  */
1695
1695
  function refreshCatalog() {
1696
1696
  if (typeof fetch !== 'function') return Promise.resolve([]);
1697
- return fetch('/vrmpet3d/catalog', { credentials: 'omit' })
1697
+ return fetch('/vrmpet3d/catalog', { credentials: 'same-origin' })
1698
1698
  .then(function (response) {
1699
1699
  if (!response.ok) throw new Error('catalog route returned HTTP ' + response.status);
1700
1700
  return response.json();
@@ -1870,7 +1870,7 @@
1870
1870
  */
1871
1871
  function openSettingsWindow(page) {
1872
1872
  if (page === 'game') {
1873
- fetch('/vrmpet3d/desktop', { method: 'POST', credentials: 'omit',
1873
+ fetch('/vrmpet3d/desktop', { method: 'POST', credentials: 'same-origin',
1874
1874
  headers: { 'content-type': 'application/json' },
1875
1875
  body: JSON.stringify({ action: 'game', modelId: resolvedModelId(preferences.modelId || loadedModel) })
1876
1876
  }).then(function (response) { if (!response.ok) throw new Error('小游戏窗口打开失败'); })
@@ -1890,7 +1890,7 @@
1890
1890
  fetch('/vrmpet3d/desktop', {
1891
1891
  method: 'POST',
1892
1892
  headers: { 'content-type': 'application/json' },
1893
- credentials: 'omit',
1893
+ credentials: 'same-origin',
1894
1894
  // Same reason as the app's pet: name the model that is on screen, so the window opens on it.
1895
1895
  body: JSON.stringify({ action: which, modelId: resolvedModelId(preferences.modelId || loadedModel) }),
1896
1896
  }).then(function (response) { if (!response.ok) fallback(); })
@@ -2672,7 +2672,7 @@
2672
2672
  try {
2673
2673
  fetch('/vrmpet3d/preview', {
2674
2674
  method: 'POST',
2675
- credentials: 'omit',
2675
+ credentials: 'same-origin',
2676
2676
  headers: { 'content-type': 'application/json' },
2677
2677
  body: '{}',
2678
2678
  }).catch(function () { return null; });
@@ -2799,7 +2799,7 @@
2799
2799
  }
2800
2800
 
2801
2801
  function fetchMotionBytes(id) {
2802
- return fetch('/vrmpet3d/motion?m=' + encodeURIComponent(id), { credentials: 'omit' })
2802
+ return fetch('/vrmpet3d/motion?m=' + encodeURIComponent(id), { credentials: 'same-origin' })
2803
2803
  .then(function (response) {
2804
2804
  if (!response.ok) throw new Error('the motion route answered HTTP ' + response.status);
2805
2805
  return response.arrayBuffer();
@@ -2918,7 +2918,7 @@
2918
2918
  */
2919
2919
  function refreshMotions() {
2920
2920
  if (!motionRouteAvailable || typeof fetch !== 'function') return Promise.resolve(null);
2921
- return fetch('/vrmpet3d/motions', { credentials: 'omit' })
2921
+ return fetch('/vrmpet3d/motions', { credentials: 'same-origin' })
2922
2922
  .then(function (response) {
2923
2923
  if (response.status === 404 || response.status === 405) {
2924
2924
  motionRouteAvailable = false;
@@ -3646,7 +3646,7 @@
3646
3646
  scene.setPreview(null);
3647
3647
  scene.setState('resting');
3648
3648
  fetch('/vrmpet3d/preview', {
3649
- method: 'POST', credentials: 'omit',
3649
+ method: 'POST', credentials: 'same-origin',
3650
3650
  headers: { 'content-type': 'application/json' }, body: '{}',
3651
3651
  }).catch(function () { return null; });
3652
3652
  }
package/lib/index.js CHANGED
@@ -17,7 +17,9 @@
17
17
  // boundary hub still provides a baseline; with no `webServer` service the pet
18
18
  // still runs from its built-in placeholder avatar and reports why.
19
19
 
20
- import { copyFileSync, createReadStream, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
20
+ import { copyFileSync, createReadStream, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from 'node:fs';
21
+ import { createRequire } from 'node:module';
22
+ import { createHash } from 'node:crypto';
21
23
  import { homedir } from 'node:os';
22
24
  import { basename, dirname, extname, isAbsolute, join, normalize, resolve, sep } from 'node:path';
23
25
  import { fileURLToPath } from 'node:url';
@@ -55,6 +57,21 @@ import { COMPRESSED_SUFFIX, decompressBrotli, isCompressedMotionName } from './b
55
57
  /** The shared pure logic, published by the import above. */
56
58
  const core = globalThis.__VRMPET3D_CORE__;
57
59
 
60
+ /** Resolve from the running CLI, not the plugin's dependencies or the Node executable.
61
+ * package.json is exported by the chat package; lib/client.js is intentionally not.
62
+ * Resolve the entry's symlink first so globally installed CLI shims work too.
63
+ */
64
+ export function installRootFromDshEntry(entry = process.argv[1]) {
65
+ if (!entry) return null;
66
+ try {
67
+ const fromDsh = createRequire(realpathSync(entry));
68
+ const chatPackage = fromDsh.resolve('@deepseek-ai/dsh-client-ui-chat/package.json');
69
+ return dirname(dirname(dirname(dirname(chatPackage))));
70
+ } catch {
71
+ return null;
72
+ }
73
+ }
74
+
58
75
  export const name = 'vrmpet3d';
59
76
 
60
77
  /**
@@ -1811,7 +1828,43 @@ function sendJson(req, res, payload) {
1811
1828
  * copies of this drifted before — one capped at 64 KB and one not — and the route without the
1812
1829
  * cap is the one that reads an arbitrary upload into memory.
1813
1830
  */
1831
+ function allowWriteRequest(req, res) {
1832
+ const headers = req.headers || {};
1833
+ const site = headers['sec-fetch-site'];
1834
+ if (site === 'cross-site') {
1835
+ sendStatus(res, 403, 'cross-site writes are not allowed');
1836
+ return false;
1837
+ }
1838
+ // This forbidden browser header describes the PUBLIC request, even when a proxy
1839
+ // rewrites Host internally. A foreign web page cannot forge same-origin metadata.
1840
+ if (site === 'same-origin' && headers.origin !== 'null') return true;
1841
+ // Compare authority using the browser's scheme: TLS may terminate at a reverse proxy.
1842
+ // Do not trust arbitrary X-Forwarded-* headers to authorize a different public host.
1843
+ const source = headers.origin || headers.referer;
1844
+ if (source) {
1845
+ try {
1846
+ const origin = new URL(source);
1847
+ const target = new URL(`${origin.protocol}//${headers.host}`);
1848
+ if (!/^https?:$/.test(origin.protocol) || origin.host !== target.host || target.username || target.password) throw new Error('origin');
1849
+ } catch {
1850
+ sendStatus(res, 403, 'a same-origin request is required');
1851
+ return false;
1852
+ }
1853
+ } else if (site && site !== 'same-origin' && site !== 'none') {
1854
+ sendStatus(res, 403, 'a same-origin request is required');
1855
+ return false;
1856
+ }
1857
+ // Native/local clients have no browser Origin. Browser JSON writes below still require
1858
+ // a non-safelisted content type, so text/plain cannot bypass the CORS preflight.
1859
+ return true;
1860
+ }
1861
+
1814
1862
  function readJsonBody(req, res, limit, onBody) {
1863
+ if (!allowWriteRequest(req, res)) return;
1864
+ if (!/^application\/json\s*(?:;|$)/i.test(String(req.headers?.['content-type'] || ''))) {
1865
+ sendStatus(res, 415, 'application/json is required');
1866
+ return;
1867
+ }
1815
1868
  const chunks = [];
1816
1869
  let size = 0;
1817
1870
  let refused = false;
@@ -1855,6 +1908,12 @@ const MODEL_UPLOAD_LIMIT = 256 * 1024 * 1024;
1855
1908
  * and no callback at all once the response has been sent.
1856
1909
  */
1857
1910
  function readRawBody(req, res, limit, onBody) {
1911
+ if (!allowWriteRequest(req, res)) return;
1912
+ const mediaType = String(req.headers?.['content-type'] || '').split(';')[0].trim().toLowerCase();
1913
+ if (!mediaType || ['text/plain', 'application/x-www-form-urlencoded', 'multipart/form-data'].includes(mediaType)) {
1914
+ sendStatus(res, 415, 'a binary content type is required');
1915
+ return;
1916
+ }
1858
1917
  const chunks = [];
1859
1918
  let size = 0;
1860
1919
  let refused = false;
@@ -1891,18 +1950,32 @@ function methodAllowed(req, res) {
1891
1950
  return false;
1892
1951
  }
1893
1952
 
1894
- /**
1895
- * Stream one allowlisted file. Shared by the asset route and the motion route.
1896
- *
1897
- * Shared rather than copied: the size cap, the MIME table and the read-error
1898
- * handling are the parts a second implementation would get subtly wrong, and a
1899
- * motion has to be served exactly like a model.
1900
- *
1901
- * `root` is the directory the entry was scanned from. The id map is the
1902
- * allowlist, but containment is re-checked here, so an entry that was somehow
1903
- * built with a path outside its own root is refused rather than streamed.
1904
- */
1905
- function sendFile(req, res, target, root) {
1953
+ /** Private browser validators for a model or its sibling texture. */
1954
+ function modelCacheHeaders(target, stats, reload) {
1955
+ // Include file identity as well as size/times: model IDs are positional and can
1956
+ // later address a different file. Hash the identity so no server path is exposed.
1957
+ const fingerprint = [realpathSync(target), stats.dev, stats.ino, stats.size, stats.mtimeMs, stats.ctimeMs].join('|');
1958
+ return {
1959
+ etag: `W/"${createHash('sha256').update(fingerprint).digest('hex')}"`,
1960
+ // Store only in the user's browser. Revalidation still passes through proxy auth.
1961
+ 'cache-control': reload ? 'no-store' : 'private, no-cache',
1962
+ 'access-control-allow-origin': '*',
1963
+ 'x-content-type-options': 'nosniff',
1964
+ };
1965
+ }
1966
+
1967
+ function modelNotModified(req, res, headers, reload) {
1968
+ if (reload) return false;
1969
+ const requested = String(req.headers['if-none-match'] || '').split(',').map(value => value.trim());
1970
+ const tag = headers.etag.replace(/^W\//, '');
1971
+ if (!requested.some(value => value === '*' || value.replace(/^W\//, '') === tag)) return false;
1972
+ res.writeHead(304, headers);
1973
+ res.end();
1974
+ return true;
1975
+ }
1976
+
1977
+ /** Stream an allowlisted model/texture or motion; only model callers enable caching. */
1978
+ function sendFile(req, res, target, root, modelCache = null) {
1906
1979
  if (!isInside(root, target)) {
1907
1980
  sendStatus(res, 404, 'file not found');
1908
1981
  return;
@@ -1924,6 +1997,12 @@ function sendFile(req, res, target, root) {
1924
1997
  return;
1925
1998
  }
1926
1999
 
2000
+ let cacheHeaders = {};
2001
+ if (modelCache) {
2002
+ try { cacheHeaders = modelCacheHeaders(target, stats, modelCache.reload); }
2003
+ catch { sendStatus(res, 404, 'file not found'); return; }
2004
+ if (modelNotModified(req, res, cacheHeaders, modelCache.reload)) return;
2005
+ }
1927
2006
  const extension = extname(target).toLowerCase();
1928
2007
  const headers = {
1929
2008
  'content-type': MIME_TYPES[extension] ?? 'application/octet-stream',
@@ -1931,10 +2010,11 @@ function sendFile(req, res, target, root) {
1931
2010
  // The page is a different origin from this route in some shells, and the
1932
2011
  // bytes are the user's own local files, so a permissive read is correct.
1933
2012
  'access-control-allow-origin': '*',
1934
- // `no-store`: a user replacing a model file on disk must see the new one on
1935
- // reload, and these bytes are cheap to re-read locally.
2013
+ // Motion callers retain no-store; model/texture callers override this with
2014
+ // private storage and ETag validation so edited files still replace old bytes.
1936
2015
  'cache-control': 'no-store',
1937
2016
  'x-content-type-options': 'nosniff',
2017
+ ...cacheHeaders,
1938
2018
  };
1939
2019
 
1940
2020
  try {
@@ -2026,7 +2106,7 @@ export function createModelInfoHandler(store) {
2026
2106
  * through the server-held id map, so a crafted id cannot name a file outside it.
2027
2107
  */
2028
2108
  /**
2029
- * Unpacked model bytes, keyed by path + size + mtime — see the compressed branch in `createAssetHandler`.
2109
+ * Unpacked model bytes, keyed by file identity and metadata — see `modelCacheHeaders`.
2030
2110
  *
2031
2111
  * Bounded like the thumbnail cache, and for the same reason: a model is megabytes, so an unbounded map of them is a leak with
2032
2112
  * a friendly name. Two or three entries cover switching between the models someone actually uses.
@@ -2059,6 +2139,8 @@ export function createAssetHandler(store) {
2059
2139
  return;
2060
2140
  }
2061
2141
  }
2142
+ const reload = url.searchParams.has('v');
2143
+ if (!isInside(entry.root, target)) { sendStatus(res, 404, 'file not found'); return; }
2062
2144
  /**
2063
2145
  * A COMPRESSED model is unpacked HERE, the way a compressed clip is on the motion route: the renderer is always handed a
2064
2146
  * plain VRM/GLB, so nothing downstream — the scene, the loaders, the bytes' own header check — has to know about brotli.
@@ -2075,8 +2157,15 @@ export function createAssetHandler(store) {
2075
2157
  sendStatus(res, 404, `cannot read the model: ${error.message}`);
2076
2158
  return;
2077
2159
  }
2078
- const key = `${target}|${stats.size}|${stats.mtimeMs}`;
2079
- let plain = modelBytesCache.get(key);
2160
+ if (!stats.isFile()) { sendStatus(res, 404, 'not a file'); return; }
2161
+ if (stats.size > MAX_ASSET_BYTES) { sendStatus(res, 413, 'file exceeds the asset byte limit'); return; }
2162
+ let headers;
2163
+ try { headers = modelCacheHeaders(target, stats, reload); }
2164
+ catch { sendStatus(res, 404, 'file not found'); return; }
2165
+ // Revalidation needs neither a disk read nor Brotli decompression.
2166
+ if (modelNotModified(req, res, headers, reload)) return;
2167
+ const key = headers.etag;
2168
+ let plain = reload ? null : modelBytesCache.get(key);
2080
2169
  if (!plain) {
2081
2170
  try {
2082
2171
  plain = decompressBrotli(readFileSync(target));
@@ -2087,11 +2176,12 @@ export function createAssetHandler(store) {
2087
2176
  modelBytesCache.set(key, plain);
2088
2177
  while (modelBytesCache.size > MODEL_BYTES_CACHE_LIMIT) modelBytesCache.delete(modelBytesCache.keys().next().value);
2089
2178
  }
2090
- res.writeHead(200, { 'content-type': 'model/gltf-binary', 'content-length': String(plain.byteLength) });
2091
- res.end(Buffer.from(plain));
2179
+ res.writeHead(200, { ...headers, 'content-type': 'model/gltf-binary', 'content-length': String(plain.byteLength) });
2180
+ if (req.method === 'HEAD') { res.end(); return; }
2181
+ res.end(plain);
2092
2182
  return;
2093
2183
  }
2094
- sendFile(req, res, target, entry.root);
2184
+ sendFile(req, res, target, entry.root, { reload });
2095
2185
  };
2096
2186
  }
2097
2187
 
@@ -2301,7 +2391,7 @@ export function apply(ctx, config = {}) {
2301
2391
  const whale = resolveWhaleMark({
2302
2392
  asar: envVar('DSH_ASAR') || undefined,
2303
2393
  resourcesPath: process.resourcesPath,
2304
- installRoot: findInstallRoot(dirname(process.execPath)),
2394
+ installRoot: findInstallRoot(dirname(process.execPath)) || installRootFromDshEntry(),
2305
2395
  localFile: envVar('WHALE_FILE') || join(PLUGIN_ROOT, 'assets', 'whale-tail.apng'),
2306
2396
  stateFile: join(importRootDir(), 'whale-tail.apng'),
2307
2397
  log: (message) => ctx.logger?.warn?.(message),
@@ -3688,6 +3778,8 @@ export function apply(ctx, config = {}) {
3688
3778
  * the note where it is read below.
3689
3779
  */
3690
3780
  const sayHandler = (req, res) => {
3781
+ // GET is retained for older clients, but it can invoke a paid AI provider too.
3782
+ if (!allowWriteRequest(req, res)) return;
3691
3783
  const run = (event, state, language, part, structure, chain, clipPrompt) => {
3692
3784
  const box = speechBoxFor(event, state);
3693
3785
  if (!box) {
@@ -3833,14 +3925,21 @@ export function apply(ctx, config = {}) {
3833
3925
  };
3834
3926
 
3835
3927
  /**
3836
- * Where a window's page lives, derived from the request so no port is guessed.
3837
- *
3838
- * The route is a parameter because there are two windows now, and both URLs come from the same host
3839
- * header — the one the caller actually reached the plugin on.
3928
+ * Native windows run on THIS host. Reach the actual listener directly so they do not
3929
+ * depend on the remote browser's proxy login cookies or its externally terminated TLS.
3930
+ * Forwarded headers are deliberately not used as a native navigation target.
3840
3931
  */
3841
3932
  const pageUrlFor = (req, route) => {
3842
- const host = req?.headers?.host;
3843
- return host ? `http://${host}${route || ROUTES.window}` : null;
3933
+ const address = req?.socket?.localAddress;
3934
+ const port = req?.socket?.localPort;
3935
+ if (address && Number.isInteger(port) && port > 0 && port <= 65535) {
3936
+ const ip = address.startsWith('::ffff:') && /^\d+\.\d+\.\d+\.\d+$/.test(address.slice(7)) ? address.slice(7) : address;
3937
+ const host = ip.includes(':') ? `[${ip}]` : ip;
3938
+ try { return new URL(route || ROUTES.window, `${req.socket.encrypted ? 'https' : 'http'}://${host}:${port}`).href; }
3939
+ catch { return null; }
3940
+ }
3941
+ // A native window cannot infer a trusted listener from a public Host header.
3942
+ return null;
3844
3943
  };
3845
3944
 
3846
3945
  const desktopPageHandler = (req, res) => {
@@ -4101,7 +4200,7 @@ export function apply(ctx, config = {}) {
4101
4200
  }
4102
4201
  if (action === 'game') {
4103
4202
  const gameUrl = pageUrlFor(req, ROUTES.game);
4104
- if (!gameUrl) { sendStatus(res, 400, 'no host header'); return; }
4203
+ if (!gameUrl) { sendStatus(res, 400, 'local listener address is unavailable'); return; }
4105
4204
  const url = new URL(gameUrl);
4106
4205
  if (typeof body.modelId === 'string') url.searchParams.set('model', body.modelId.slice(0, 256));
4107
4206
  const opened = desktopWindow.openConfig({ url: url.href });
@@ -4157,7 +4256,7 @@ export function apply(ctx, config = {}) {
4157
4256
  if (action === 'setup') {
4158
4257
  const setupUrl = pageUrlFor(req, ROUTES.setup);
4159
4258
  if (!setupUrl) {
4160
- sendStatus(res, 400, 'no host header to build the page URL from');
4259
+ sendStatus(res, 400, 'local listener address is unavailable');
4161
4260
  return;
4162
4261
  }
4163
4262
  const opened = desktopWindow.openConfig({ url: setupUrl });
@@ -4178,7 +4277,7 @@ export function apply(ctx, config = {}) {
4178
4277
  */
4179
4278
  const pickerUrl = pageUrlFor(req, ROUTES.picker);
4180
4279
  if (!pickerUrl) {
4181
- sendStatus(res, 400, 'no host header to build the page URL from');
4280
+ sendStatus(res, 400, 'local listener address is unavailable');
4182
4281
  return;
4183
4282
  }
4184
4283
  const opened = desktopWindow.openConfig({ url: pickerUrl });
@@ -4207,11 +4306,12 @@ export function apply(ctx, config = {}) {
4207
4306
  // A model parameter window is ABOUT a model, so it carries one; the 通用设置 window has no model in its URL.
4208
4307
  if (page === 'model' && wanted) query.push('model=' + encodeURIComponent(wanted));
4209
4308
  const route = page === 'global' ? ROUTES.global : ROUTES.config;
4210
- const settingsUrl = pageUrlFor(req, route) + (query.length ? '?' + query.join('&') : '');
4211
- if (!settingsUrl) {
4212
- sendStatus(res, 400, 'no host header to build the page URL from');
4309
+ const settingsBase = pageUrlFor(req, route);
4310
+ if (!settingsBase) {
4311
+ sendStatus(res, 400, 'local listener address is unavailable');
4213
4312
  return;
4214
4313
  }
4314
+ const settingsUrl = settingsBase + (query.length ? '?' + query.join('&') : '');
4215
4315
  const opened = desktopWindow.openConfig({ url: settingsUrl });
4216
4316
  if (!opened.ok) {
4217
4317
  sendStatus(res, 500, opened.error || 'the settings window could not be opened');
@@ -4227,7 +4327,7 @@ export function apply(ctx, config = {}) {
4227
4327
  if (action === 'restart') desktopWindow.stop();
4228
4328
  const url = pageUrlFor(req);
4229
4329
  if (!url) {
4230
- sendStatus(res, 400, 'no host header to build the page URL from');
4330
+ sendStatus(res, 400, 'local listener address is unavailable');
4231
4331
  return;
4232
4332
  }
4233
4333
  /**
@@ -39,7 +39,7 @@
39
39
  var focusClient = globalThis.crypto && crypto.randomUUID ? crypto.randomUUID() : Date.now().toString(36) + Math.random().toString(36).slice(2);
40
40
  function reportGameFocus(active, keepalive) {
41
41
  if (testMode || stopped && active) return;
42
- return fetch('/vrmpet3d/desktop', { method: 'POST', credentials: 'omit', keepalive: keepalive === true,
42
+ return fetch('/vrmpet3d/desktop', { method: 'POST', credentials: 'same-origin', keepalive: keepalive === true,
43
43
  headers: { 'content-type': 'application/json' }, body: JSON.stringify({ action: 'game-focus', clientId: focusClient, active: active }) }).catch(function () {});
44
44
  }
45
45
  function fitLifeIcons() {
@@ -242,7 +242,7 @@
242
242
  return opened;
243
243
  }
244
244
  fetch('/vrmpet3d/desktop', { method: 'POST', headers: { 'content-type': 'application/json' },
245
- credentials: 'omit', body: JSON.stringify({ action: 'game-session', sessionId: target.id }) })
245
+ credentials: 'same-origin', body: JSON.stringify({ action: 'game-session', sessionId: target.id }) })
246
246
  .then(function (r) { if (!r.ok) throw new Error('会话切换失败'); return r.json(); })
247
247
  .then(function (body) { if (!body.ok) throw new Error('该会话已不再等待处理'); bridge.focusHost(); })
248
248
  .catch(function (error) { $('connection').textContent = error.message + ',请重试。'; });
@@ -250,7 +250,7 @@
250
250
  function poll() {
251
251
  statusRequest = new AbortController();
252
252
  var timeout = setTimeout(function () { statusRequest.abort(); }, 5000);
253
- fetch('/vrmpet3d/state', { credentials: 'omit', cache: 'no-store', signal: statusRequest.signal }).then(function (r) {
253
+ fetch('/vrmpet3d/state', { credentials: 'same-origin', cache: 'no-store', signal: statusRequest.signal }).then(function (r) {
254
254
  if (!r.ok) throw new Error('status unavailable'); return r.json();
255
255
  }).then(function (state) {
256
256
  if (stopped) return;
@@ -557,9 +557,9 @@
557
557
  if (restored) pause(true); frameId = requestAnimationFrame(frame);
558
558
  // Snapshot the selected model; the game never writes model preferences or moves the pet's scene.
559
559
  Promise.all([
560
- fetch('/vrmpet3d/settings', { credentials: 'omit' }).then(function (r) { if (!r.ok) throw new Error('无法读取模型设置'); return r.json(); }),
561
- fetch('/vrmpet3d/catalog', { credentials: 'omit' }).then(function (r) { if (!r.ok) throw new Error('无法读取模型列表'); return r.json(); }),
562
- fetch('/vrmpet3d/motions', { credentials: 'omit' }).then(function (r) { if (!r.ok) throw new Error('无法读取分发的弹簧骨默认参数'); return r.json(); }),
560
+ fetch('/vrmpet3d/settings', { credentials: 'same-origin' }).then(function (r) { if (!r.ok) throw new Error('无法读取模型设置'); return r.json(); }),
561
+ fetch('/vrmpet3d/catalog', { credentials: 'same-origin' }).then(function (r) { if (!r.ok) throw new Error('无法读取模型列表'); return r.json(); }),
562
+ fetch('/vrmpet3d/motions', { credentials: 'same-origin' }).then(function (r) { if (!r.ok) throw new Error('无法读取分发的弹簧骨默认参数'); return r.json(); }),
563
563
  ]).then(function (values) {
564
564
  if (stopped) return;
565
565
  var prefs = values[0].preferences || {}, models = values[1].models || [];
@@ -427,7 +427,7 @@
427
427
  if (!doFetch) return Promise.reject(new Error(text.noFetchUpload));
428
428
  return doFetch(path, {
429
429
  method: 'POST',
430
- credentials: 'omit',
430
+ credentials: 'same-origin',
431
431
  headers: { 'content-type': 'application/json' },
432
432
  body: JSON.stringify(body),
433
433
  }).then(function (response) {
@@ -617,7 +617,7 @@
617
617
  onStatus(text.listFailed, 'warn');
618
618
  return Promise.resolve(null);
619
619
  }
620
- return doFetch(catalogUrl, { credentials: 'omit' })
620
+ return doFetch(catalogUrl, { credentials: 'same-origin' })
621
621
  .then(function (response) { return response.ok ? response.json() : null; })
622
622
  .then(function (payload) {
623
623
  if (!apply(payload)) onStatus(text.listFailed, 'warn');
@@ -692,10 +692,10 @@
692
692
  setBusy(true, text.importingFile(name));
693
693
  return doFetch(uploadUrl + '?file=' + encodeURIComponent(name), {
694
694
  method: 'POST',
695
- credentials: 'omit',
695
+ credentials: 'same-origin',
696
696
  // The extension decided what this is; the CONTENT TYPE is what tells the host to read a raw body
697
697
  // rather than JSON, so it is stated even when the browser has no type for the file.
698
- headers: { 'content-type': file.type && file.type !== 'application/json' ? file.type : 'application/octet-stream' },
698
+ headers: { 'content-type': 'application/octet-stream' },
699
699
  body: file,
700
700
  }).then(function (response) {
701
701
  if (!response.ok) {
package/lib/scene.js CHANGED
@@ -2052,11 +2052,12 @@
2052
2052
  * repeated GET of the same URL without touching the disk — which would make 重载模型 look like it did nothing. The
2053
2053
  * one-shot buster from `bustModelCache` rides the query string, and is consumed here so an ordinary load stays clean.
2054
2054
  */
2055
- if (this.modelBust) {
2055
+ var reload = Boolean(this.modelBust);
2056
+ if (reload) {
2056
2057
  url += (url.indexOf('?') === -1 ? '?' : '&') + 'v=' + this.modelBust;
2057
2058
  this.modelBust = null;
2058
2059
  }
2059
- var promise = fetch(url, { credentials: 'omit' })
2060
+ var promise = fetch(url, { credentials: 'same-origin', cache: reload ? 'reload' : 'default' })
2060
2061
  .then(function (response) {
2061
2062
  if (!response.ok) throw new Error('asset route returned HTTP ' + response.status);
2062
2063
  return response.arrayBuffer();
package/lib/setup-page.js CHANGED
@@ -301,7 +301,7 @@
301
301
  function post(path, body) {
302
302
  return fetch(path, {
303
303
  method: 'POST',
304
- credentials: 'omit',
304
+ credentials: 'same-origin',
305
305
  headers: { 'content-type': 'application/json' },
306
306
  body: JSON.stringify(body),
307
307
  }).then(function (response) {
@@ -315,7 +315,7 @@
315
315
  }
316
316
 
317
317
  function getJson(path) {
318
- return fetch(path, { credentials: 'omit' })
318
+ return fetch(path, { credentials: 'same-origin' })
319
319
  .then(function (response) { return response.ok ? response.json() : null; })
320
320
  .catch(function () { return null; });
321
321
  }
@@ -517,7 +517,7 @@
517
517
  function renderRuntimeNote() {
518
518
  var line = el('setup-runtime-path');
519
519
  if (!line || typeof fetch !== 'function') return;
520
- fetch('/vrmpet3d/desktop', { credentials: 'omit' })
520
+ fetch('/vrmpet3d/desktop', { credentials: 'same-origin' })
521
521
  .then(function (response) { return response.ok ? response.json() : null; })
522
522
  .then(function (status) {
523
523
  var dir = status && typeof status.runtimeDir === 'string' ? status.runtimeDir : '';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-plugin-vrmpet3d",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "description": "DeepSeek Harness 的 VRM 3D 桌宠:任务状态联动、独立悬浮窗、模型动作与 AI 碎碎念。",
5
5
  "type": "module",
6
6
  "license": "MIT",