dsh-xueqiu 1.22.7 → 1.22.8

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/dynamic/client.js CHANGED
@@ -407,6 +407,8 @@ return {
407
407
  function klcSetup(K, boxRef, kind) {
408
408
  const chart = K.init(boxRef.current, { styles: klcStyles(kind, klcPalette()) })
409
409
  if (!chart) return null
410
+ // 卸载清理时 boxRef.current 已被 React 置 null,dispose 须用闭包捕获的节点
411
+ const box = boxRef.current
410
412
  let ro = null
411
413
  try {
412
414
  ro = new ResizeObserver(function () { chart.resize() })
@@ -422,7 +424,8 @@ return {
422
424
  dispose: function () {
423
425
  if (ro) ro.disconnect()
424
426
  if (mo) mo.disconnect()
425
- try { K.dispose(boxRef.current) } catch (e) { /* 忽略 */ }
427
+ // 卸载清理时 boxRef.current 已被 React 置 null(与 dblclick 监听同类坑),须闭包捕获节点
428
+ try { K.dispose(box) } catch (e) { /* 忽略 */ }
426
429
  }
427
430
  }
428
431
  }
@@ -527,10 +530,15 @@ return {
527
530
  const [hi, setHi] = React.useState(null)
528
531
  const baseRef = React.useRef({ lastClose: lastClose })
529
532
  baseRef.current = { lastClose: lastClose }
533
+ // 分时数据是详情页渐进渲染晚到的一块(quote+kline 先上屏、minute 随后 merge):
534
+ // effect 依赖 [] 曾导致空 items 挂载后数据到达永不刷新(空白图)。改为「空数据不挂图表,
535
+ // 数据到达(hasData 变 true)时重建」,彻底消除竞态窗口。
536
+ const hasData = items.length > 0
530
537
 
531
538
  React.useEffect(function () {
532
539
  const K = klcLib()
533
540
  if (!K || !boxRef.current) { setNoLib(true); return undefined }
541
+ if (!hasData) return undefined // 数据未到:不建图表(等 hasData 变 true 重建)
534
542
  // 自定义指标:昨收虚线 + 均价线(series normal → 单独附图,避免干扰价格轴刻度)
535
543
  // 注意:registerIndicator 全局只注册一次,calc 闭包捕获的是首次挂载实例的 baseRef——
536
544
  // 切换股票后旧 calc 仍读旧实例的昨收(宁德分时曾因此挂上茅台昨收 1304,y 轴被撑爆成直线)。
@@ -608,9 +616,10 @@ return {
608
616
  try { chart.unsubscribeAction('onCrosshairChange') } catch (e) { /* 忽略 */ }
609
617
  h.dispose()
610
618
  }
611
- }, [])
619
+ }, [hasData, props.symbol])
612
620
 
613
621
  if (noLib) return klcNoLib()
622
+ if (!hasData) return el('div', { className: 'xq-chart-wrap', style: { display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--dsw-alias-label-secondary)', fontSize: 12 } }, '分时数据加载中…')
614
623
  // 十字光标 tooltip(沿用 .xq-tip 卡片)
615
624
  let tip = null
616
625
  if (hi && hi.r) {
@@ -649,6 +658,12 @@ return {
649
658
  const [indices, setIndices] = React.useState([])
650
659
  const [hot, setHot] = React.useState([])
651
660
  const [hotMarket, setHotMarket] = React.useState('cn')
661
+ // 竞态防护:热榜/搜索的响应序号(旧响应到达时序号已过期则丢弃,防慢请求覆盖新结果)
662
+ const hotSeq = React.useRef(0)
663
+ const searchSeq = React.useRef(0)
664
+ // 轮询闭包读当前市场(interval 闭包不随状态更新,直接捕获 state 会拿到 stale 值)
665
+ const hotMarketRef = React.useRef(hotMarket)
666
+ hotMarketRef.current = hotMarket
652
667
  const [news, setNews] = React.useState([])
653
668
  const [newsMore, setNewsMore] = React.useState(false) // 翻页加载中
654
669
  const [newsNoMore, setNewsNoMore] = React.useState(false) // 已到最旧
@@ -704,10 +719,13 @@ return {
704
719
  }
705
720
 
706
721
  function refreshContent() {
722
+ const my = ++hotSeq.current
723
+ const market = hotMarketRef.current // 读当前市场(轮询闭包防 stale)
707
724
  Promise.all([
708
- call('hot', { market: hotMarket, size: 10 }),
725
+ call('hot', { market: market, size: 10 }),
709
726
  call('news', { count: 20 })
710
727
  ]).then(function (res) {
728
+ if (hotSeq.current !== my) return // 期间用户切了市场/触发过手动刷新:丢弃旧响应
711
729
  setHot((res[0] && res[0].list) || [])
712
730
  setNews((res[1] && res[1].items) || [])
713
731
  setNewsMore(false)
@@ -844,15 +862,18 @@ return {
844
862
  const q = searchQ.trim()
845
863
  if (!q) return
846
864
  setSearching(true)
865
+ const my = ++searchSeq.current
847
866
  const p = searchMode === 'stock'
848
867
  ? call('search', { q: q, count: 8 })
849
868
  : call('searchPosts', { q: q, count: 10 })
850
869
  p.then(function (data) {
870
+ if (searchSeq.current !== my) return // 快速连搜:只认最后一次
851
871
  setSearchRes((data && data.list) || [])
852
872
  }).catch(function (e) {
873
+ if (searchSeq.current !== my) return
853
874
  setErr(String((e && e.message) || e))
854
875
  setSearchRes([])
855
- }).then(function () { setSearching(false) })
876
+ }).then(function () { if (searchSeq.current === my) setSearching(false) })
856
877
  }
857
878
 
858
879
  function addManual() {
@@ -1135,9 +1156,14 @@ return {
1135
1156
  key: m[0], className: hotMarket === m[0] ? 'xq-seg-on' : '',
1136
1157
  onClick: function () {
1137
1158
  setHotMarket(m[0])
1159
+ const my = ++hotSeq.current
1138
1160
  call('hot', { market: m[0], size: 10 }).then(function (data) {
1161
+ if (hotSeq.current !== my) return // 快速连点:只认最后一次
1139
1162
  setHot((data && data.list) || [])
1140
- }).catch(function (e) { setErr(String((e && e.message) || e)) })
1163
+ }).catch(function (e) {
1164
+ if (hotSeq.current !== my) return
1165
+ setErr(String((e && e.message) || e))
1166
+ })
1141
1167
  }
1142
1168
  }, m[1])
1143
1169
  })),
@@ -1206,6 +1232,13 @@ return {
1206
1232
  news.forEach(function (it) {
1207
1233
  const d = new Date(it.created_at)
1208
1234
  const now = new Date()
1235
+ if (isNaN(d.getTime())) { // 脏/缺失时间:跳过分组头(否则渲染 "NaN月NaN日")
1236
+ kids.push(el('div', { key: String(it.id), className: 'xq-news-item' + (it.mark === 1 ? ' xq-important' : '') }, [
1237
+ el('div', { key: 't' }, it.text),
1238
+ el('div', { key: 'm', className: 'xq-news-time' }, fmtTime(it.created_at) + (it.mark === 1 ? ' · 重要' : ''))
1239
+ ]))
1240
+ return
1241
+ }
1209
1242
  const sameDay = function (a, b) { return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate() }
1210
1243
  const yest = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1)
1211
1244
  let label = null
@@ -1559,7 +1592,7 @@ return {
1559
1592
  onDoubleClick: onGripDblClick
1560
1593
  }, '⤡')
1561
1594
  ])
1562
- return el('div', { style: { contents: 'display' } }, [badgeEl])
1595
+ return el('div', { style: { display: 'contents' } }, [badgeEl])
1563
1596
  }
1564
1597
 
1565
1598
  // ---------- 底部指数条(会话页,输入框下方氛围行) ----------
package/dynamic/host.js CHANGED
@@ -185,8 +185,8 @@ return {
185
185
  const spec = shell.resolve({ command: cmd, timeoutMs: 15000, stdoutMaxBytes: 4194304 })
186
186
  const result = await shell.run(spec)
187
187
  if (result.exitCode !== 0) {
188
- const err = ((result.stderr && result.stderr.text) || '').slice(0, 200)
189
- throw new Error('curl 失败 (' + result.exitCode + '): ' + err)
188
+ // 不回显 stderr:bash 语法错误会把完整命令行(含内部拼接细节)透给调用方
189
+ throw new Error('curl 失败 (' + result.exitCode + ')')
190
190
  }
191
191
  return String(result.stdout.text || '')
192
192
  }
@@ -529,7 +529,14 @@ return {
529
529
  syncInflight = true
530
530
  try {
531
531
  const cloud = await fetchCloudWatchlist()
532
- if (!cloud.symbols.length) return false // 空结果不镜像(接口异常/账号无自选都不该清空本地)
532
+ if (!cloud.symbols.length) {
533
+ // 空结果不镜像(接口异常/账号无自选都不该清空本地),但必须推进 lastSyncAt——
534
+ // 否则 cookie 半失效时节流永不生效,每次 watchlist.get 都重复打上游 2 个请求
535
+ wl.lastSyncAt = Date.now()
536
+ watchlist = wl
537
+ await saveWatchlist()
538
+ return false
539
+ }
533
540
  const cur = wl.symbols
534
541
  const changed = cur.length !== cloud.symbols.length || cloud.symbols.some(function (s, i) { return cur[i] !== s })
535
542
  if (changed) {
@@ -554,7 +561,7 @@ return {
554
561
  if (wl.symbols.indexOf(symbol) === -1) {
555
562
  wl.symbols.push(symbol)
556
563
  await saveWatchlist()
557
- cloudWatchAdd(symbol) // 尽力而为:云端添加失败不影响本地
564
+ cloudWatchAdd(symbol).catch(function () { /* 尽力而为:失败由下次同步纠正 */ }) // 云端添加失败不影响本地
558
565
  }
559
566
  return { symbols: wl.symbols }
560
567
  }
@@ -566,7 +573,7 @@ return {
566
573
  if (i !== -1) {
567
574
  wl.symbols.splice(i, 1)
568
575
  await saveWatchlist()
569
- cloudWatchDelete(symbol) // 尽力而为:云端删除失败不影响本地
576
+ cloudWatchDelete(symbol).catch(function () { /* 尽力而为:失败由下次同步纠正 */ }) // 云端删除失败不影响本地
570
577
  }
571
578
  return { symbols: wl.symbols }
572
579
  }
@@ -808,8 +815,9 @@ return {
808
815
  async function actUiGet() {
809
816
  const s = await loadUiState()
810
817
  const hNum = Number(s.dockH)
818
+ const validTabs = ['market', 'hot', 'search', 'news']
811
819
  return {
812
- tab: s.tab || 'market', open: s.open !== false,
820
+ tab: (typeof s.tab === 'string' && validTabs.indexOf(s.tab) >= 0) ? s.tab : 'market', // 损坏状态文件的非法 tab 一律回退(防类型穿透) open: s.open !== false,
813
821
  dockH: (s.dockH !== undefined && isFinite(hNum) && hNum >= 160 && hNum <= 1200) ? hNum : null,
814
822
  badgeW: (s.badgeW !== undefined && isFinite(Number(s.badgeW)) && Number(s.badgeW) >= 120 && Number(s.badgeW) <= 480) ? Number(s.badgeW) : null,
815
823
  badgePos: (s.badgePos && isFinite(Number(s.badgePos.x)) && isFinite(Number(s.badgePos.y)))
@@ -884,13 +892,14 @@ return {
884
892
  kind: 'prefix',
885
893
  path: '/xq-rpc',
886
894
  handler: async function (req, res) {
887
- // 同源栅栏:Host 头必须是回环,带 Origin 时必须同源(防 DNS rebinding/CSRF)
895
+ // 同源栅栏:Host 头必须是回环,Origin 必须存在且同源(防 DNS rebinding/CSRF/无 Origin 的跨进程直调)
888
896
  const hostHeader = String(req.headers.host || '')
889
897
  const origin = req.headers.origin ? String(req.headers.origin) : ''
890
898
  const hostAuth = hostHeader.replace(/:\d+$/, '')
891
899
  const loopback = hostAuth === '127.0.0.1' || hostAuth === 'localhost' || hostAuth === '[::1]' || hostAuth === '::1'
892
900
  if (!loopback) { res.writeHead(403); res.end('forbidden'); return }
893
- if (origin && origin !== 'http://' + hostHeader && origin !== 'https://' + hostHeader) {
901
+ // 浏览器对(哪怕同源的)POST fetch 必带 Origin;无 Origin 一律拒绝(curl/本机进程裸调)
902
+ if (!origin || (origin !== 'http://' + hostHeader && origin !== 'https://' + hostHeader)) {
894
903
  res.writeHead(403); res.end('forbidden'); return
895
904
  }
896
905
  const chunks = []
@@ -1089,11 +1098,17 @@ return {
1089
1098
  // 注册:动态环境走 harness 门面(defineTool 会做标记+校验);
1090
1099
  // 静态 bundle 层没有 harness,参数已是 JSON Schema 根,直接注册 plain 对象
1091
1100
  // (tools 服务在静态路径无 defineTool 守卫),用 inject 等服务就绪。
1101
+ // 统一包装 execute:args 为 null/undefined 时兜底 {}(工具层直解引用会抛 TypeError)
1102
+ function wrapToolSpec(spec) {
1103
+ const raw = spec.execute
1104
+ if (!raw) return spec
1105
+ return Object.assign({}, spec, { execute: function (args) { return raw.call(this, args || {}) } })
1106
+ }
1092
1107
  if (typeof harness !== 'undefined') {
1093
1108
  for (let i = 0; i < XQ_AGENT_TOOLS.length; i++) {
1094
1109
  const spec = XQ_AGENT_TOOLS[i]
1095
1110
  ctx.effect(function () {
1096
- return harness.registerTool(ctx, harness.defineTool(spec))
1111
+ return harness.registerTool(ctx, harness.defineTool(wrapToolSpec(spec)))
1097
1112
  })
1098
1113
  }
1099
1114
  } else {
@@ -1101,7 +1116,7 @@ return {
1101
1116
  for (let i = 0; i < XQ_AGENT_TOOLS.length; i++) {
1102
1117
  const spec = XQ_AGENT_TOOLS[i]
1103
1118
  toolsCtx.effect(function () {
1104
- return toolsCtx.tools.register(spec)
1119
+ return toolsCtx.tools.register(wrapToolSpec(spec))
1105
1120
  })
1106
1121
  }
1107
1122
  })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-xueqiu",
3
- "version": "1.22.7",
3
+ "version": "1.22.8",
4
4
  "description": "雪球 mini 行情面板 — DeepSeek Harness 免登录 A股/港美股实时行情、K线(蜡烛/成交量/均线)、分时图、热榜、搜索、7x24 快讯、热议用户。嵌入式面板 + 可拖拽行情徽标,内置请求节流、TTL 缓存与 Cookie 自动续期。",
5
5
  "keywords": [
6
6
  "dsh",
@@ -52,7 +52,9 @@
52
52
  ],
53
53
  "scripts": {
54
54
  "generate": "python3 scripts/gen-static.py",
55
- "check": "python3 scripts/gen-static.py --check && node --check src/index.js && node --check src/client/index.js && node -e \"new Function(require('fs').readFileSync('dynamic/host.js','utf8'))\" && node -e \"new Function(require('fs').readFileSync('dynamic/client.js','utf8'))\" && bash scripts/static-smoke.sh",
56
- "prepublishOnly": "npm run check"
55
+ "check:fast": "python3 scripts/gen-static.py --check && node --check src/index.js && node --check src/client/index.js && node -e \"new Function(require('fs').readFileSync('dynamic/host.js','utf8'))\" && node -e \"new Function(require('fs').readFileSync('dynamic/client.js','utf8'))\"",
56
+ "check": "npm run check:fast && bash scripts/static-smoke.sh",
57
+ "smoke": "bash scripts/static-smoke.sh",
58
+ "prepublishOnly": "npm run check:fast"
57
59
  }
58
60
  }
@@ -438,6 +438,8 @@ exports.default = {
438
438
  function klcSetup(K, boxRef, kind) {
439
439
  const chart = K.init(boxRef.current, { styles: klcStyles(kind, klcPalette()) })
440
440
  if (!chart) return null
441
+ // 卸载清理时 boxRef.current 已被 React 置 null,dispose 须用闭包捕获的节点
442
+ const box = boxRef.current
441
443
  let ro = null
442
444
  try {
443
445
  ro = new ResizeObserver(function () { chart.resize() })
@@ -453,7 +455,8 @@ exports.default = {
453
455
  dispose: function () {
454
456
  if (ro) ro.disconnect()
455
457
  if (mo) mo.disconnect()
456
- try { K.dispose(boxRef.current) } catch (e) { /* 忽略 */ }
458
+ // 卸载清理时 boxRef.current 已被 React 置 null(与 dblclick 监听同类坑),须闭包捕获节点
459
+ try { K.dispose(box) } catch (e) { /* 忽略 */ }
457
460
  }
458
461
  }
459
462
  }
@@ -558,10 +561,15 @@ exports.default = {
558
561
  const [hi, setHi] = React.useState(null)
559
562
  const baseRef = React.useRef({ lastClose: lastClose })
560
563
  baseRef.current = { lastClose: lastClose }
564
+ // 分时数据是详情页渐进渲染晚到的一块(quote+kline 先上屏、minute 随后 merge):
565
+ // effect 依赖 [] 曾导致空 items 挂载后数据到达永不刷新(空白图)。改为「空数据不挂图表,
566
+ // 数据到达(hasData 变 true)时重建」,彻底消除竞态窗口。
567
+ const hasData = items.length > 0
561
568
 
562
569
  React.useEffect(function () {
563
570
  const K = klcLib()
564
571
  if (!K || !boxRef.current) { setNoLib(true); return undefined }
572
+ if (!hasData) return undefined // 数据未到:不建图表(等 hasData 变 true 重建)
565
573
  // 自定义指标:昨收虚线 + 均价线(series normal → 单独附图,避免干扰价格轴刻度)
566
574
  // 注意:registerIndicator 全局只注册一次,calc 闭包捕获的是首次挂载实例的 baseRef——
567
575
  // 切换股票后旧 calc 仍读旧实例的昨收(宁德分时曾因此挂上茅台昨收 1304,y 轴被撑爆成直线)。
@@ -639,9 +647,10 @@ exports.default = {
639
647
  try { chart.unsubscribeAction('onCrosshairChange') } catch (e) { /* 忽略 */ }
640
648
  h.dispose()
641
649
  }
642
- }, [])
650
+ }, [hasData, props.symbol])
643
651
 
644
652
  if (noLib) return klcNoLib()
653
+ if (!hasData) return el('div', { className: 'xq-chart-wrap', style: { display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--dsw-alias-label-secondary)', fontSize: 12 } }, '分时数据加载中…')
645
654
  // 十字光标 tooltip(沿用 .xq-tip 卡片)
646
655
  let tip = null
647
656
  if (hi && hi.r) {
@@ -680,6 +689,12 @@ exports.default = {
680
689
  const [indices, setIndices] = React.useState([])
681
690
  const [hot, setHot] = React.useState([])
682
691
  const [hotMarket, setHotMarket] = React.useState('cn')
692
+ // 竞态防护:热榜/搜索的响应序号(旧响应到达时序号已过期则丢弃,防慢请求覆盖新结果)
693
+ const hotSeq = React.useRef(0)
694
+ const searchSeq = React.useRef(0)
695
+ // 轮询闭包读当前市场(interval 闭包不随状态更新,直接捕获 state 会拿到 stale 值)
696
+ const hotMarketRef = React.useRef(hotMarket)
697
+ hotMarketRef.current = hotMarket
683
698
  const [news, setNews] = React.useState([])
684
699
  const [newsMore, setNewsMore] = React.useState(false) // 翻页加载中
685
700
  const [newsNoMore, setNewsNoMore] = React.useState(false) // 已到最旧
@@ -735,10 +750,13 @@ exports.default = {
735
750
  }
736
751
 
737
752
  function refreshContent() {
753
+ const my = ++hotSeq.current
754
+ const market = hotMarketRef.current // 读当前市场(轮询闭包防 stale)
738
755
  Promise.all([
739
- call('hot', { market: hotMarket, size: 10 }),
756
+ call('hot', { market: market, size: 10 }),
740
757
  call('news', { count: 20 })
741
758
  ]).then(function (res) {
759
+ if (hotSeq.current !== my) return // 期间用户切了市场/触发过手动刷新:丢弃旧响应
742
760
  setHot((res[0] && res[0].list) || [])
743
761
  setNews((res[1] && res[1].items) || [])
744
762
  setNewsMore(false)
@@ -875,15 +893,18 @@ exports.default = {
875
893
  const q = searchQ.trim()
876
894
  if (!q) return
877
895
  setSearching(true)
896
+ const my = ++searchSeq.current
878
897
  const p = searchMode === 'stock'
879
898
  ? call('search', { q: q, count: 8 })
880
899
  : call('searchPosts', { q: q, count: 10 })
881
900
  p.then(function (data) {
901
+ if (searchSeq.current !== my) return // 快速连搜:只认最后一次
882
902
  setSearchRes((data && data.list) || [])
883
903
  }).catch(function (e) {
904
+ if (searchSeq.current !== my) return
884
905
  setErr(String((e && e.message) || e))
885
906
  setSearchRes([])
886
- }).then(function () { setSearching(false) })
907
+ }).then(function () { if (searchSeq.current === my) setSearching(false) })
887
908
  }
888
909
 
889
910
  function addManual() {
@@ -1166,9 +1187,14 @@ exports.default = {
1166
1187
  key: m[0], className: hotMarket === m[0] ? 'xq-seg-on' : '',
1167
1188
  onClick: function () {
1168
1189
  setHotMarket(m[0])
1190
+ const my = ++hotSeq.current
1169
1191
  call('hot', { market: m[0], size: 10 }).then(function (data) {
1192
+ if (hotSeq.current !== my) return // 快速连点:只认最后一次
1170
1193
  setHot((data && data.list) || [])
1171
- }).catch(function (e) { setErr(String((e && e.message) || e)) })
1194
+ }).catch(function (e) {
1195
+ if (hotSeq.current !== my) return
1196
+ setErr(String((e && e.message) || e))
1197
+ })
1172
1198
  }
1173
1199
  }, m[1])
1174
1200
  })),
@@ -1237,6 +1263,13 @@ exports.default = {
1237
1263
  news.forEach(function (it) {
1238
1264
  const d = new Date(it.created_at)
1239
1265
  const now = new Date()
1266
+ if (isNaN(d.getTime())) { // 脏/缺失时间:跳过分组头(否则渲染 "NaN月NaN日")
1267
+ kids.push(el('div', { key: String(it.id), className: 'xq-news-item' + (it.mark === 1 ? ' xq-important' : '') }, [
1268
+ el('div', { key: 't' }, it.text),
1269
+ el('div', { key: 'm', className: 'xq-news-time' }, fmtTime(it.created_at) + (it.mark === 1 ? ' · 重要' : ''))
1270
+ ]))
1271
+ return
1272
+ }
1240
1273
  const sameDay = function (a, b) { return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate() }
1241
1274
  const yest = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1)
1242
1275
  let label = null
@@ -1590,7 +1623,7 @@ exports.default = {
1590
1623
  onDoubleClick: onGripDblClick
1591
1624
  }, '⤡')
1592
1625
  ])
1593
- return el('div', { style: { contents: 'display' } }, [badgeEl])
1626
+ return el('div', { style: { display: 'contents' } }, [badgeEl])
1594
1627
  }
1595
1628
 
1596
1629
  // ---------- 底部指数条(会话页,输入框下方氛围行) ----------
package/src/index.js CHANGED
@@ -185,8 +185,8 @@ export default {
185
185
  const spec = shell.resolve({ command: cmd, timeoutMs: 15000, stdoutMaxBytes: 4194304 })
186
186
  const result = await shell.run(spec)
187
187
  if (result.exitCode !== 0) {
188
- const err = ((result.stderr && result.stderr.text) || '').slice(0, 200)
189
- throw new Error('curl 失败 (' + result.exitCode + '): ' + err)
188
+ // 不回显 stderr:bash 语法错误会把完整命令行(含内部拼接细节)透给调用方
189
+ throw new Error('curl 失败 (' + result.exitCode + ')')
190
190
  }
191
191
  return String(result.stdout.text || '')
192
192
  }
@@ -529,7 +529,14 @@ export default {
529
529
  syncInflight = true
530
530
  try {
531
531
  const cloud = await fetchCloudWatchlist()
532
- if (!cloud.symbols.length) return false // 空结果不镜像(接口异常/账号无自选都不该清空本地)
532
+ if (!cloud.symbols.length) {
533
+ // 空结果不镜像(接口异常/账号无自选都不该清空本地),但必须推进 lastSyncAt——
534
+ // 否则 cookie 半失效时节流永不生效,每次 watchlist.get 都重复打上游 2 个请求
535
+ wl.lastSyncAt = Date.now()
536
+ watchlist = wl
537
+ await saveWatchlist()
538
+ return false
539
+ }
533
540
  const cur = wl.symbols
534
541
  const changed = cur.length !== cloud.symbols.length || cloud.symbols.some(function (s, i) { return cur[i] !== s })
535
542
  if (changed) {
@@ -554,7 +561,7 @@ export default {
554
561
  if (wl.symbols.indexOf(symbol) === -1) {
555
562
  wl.symbols.push(symbol)
556
563
  await saveWatchlist()
557
- cloudWatchAdd(symbol) // 尽力而为:云端添加失败不影响本地
564
+ cloudWatchAdd(symbol).catch(function () { /* 尽力而为:失败由下次同步纠正 */ }) // 云端添加失败不影响本地
558
565
  }
559
566
  return { symbols: wl.symbols }
560
567
  }
@@ -566,7 +573,7 @@ export default {
566
573
  if (i !== -1) {
567
574
  wl.symbols.splice(i, 1)
568
575
  await saveWatchlist()
569
- cloudWatchDelete(symbol) // 尽力而为:云端删除失败不影响本地
576
+ cloudWatchDelete(symbol).catch(function () { /* 尽力而为:失败由下次同步纠正 */ }) // 云端删除失败不影响本地
570
577
  }
571
578
  return { symbols: wl.symbols }
572
579
  }
@@ -808,8 +815,9 @@ export default {
808
815
  async function actUiGet() {
809
816
  const s = await loadUiState()
810
817
  const hNum = Number(s.dockH)
818
+ const validTabs = ['market', 'hot', 'search', 'news']
811
819
  return {
812
- tab: s.tab || 'market', open: s.open !== false,
820
+ tab: (typeof s.tab === 'string' && validTabs.indexOf(s.tab) >= 0) ? s.tab : 'market', // 损坏状态文件的非法 tab 一律回退(防类型穿透) open: s.open !== false,
813
821
  dockH: (s.dockH !== undefined && isFinite(hNum) && hNum >= 160 && hNum <= 1200) ? hNum : null,
814
822
  badgeW: (s.badgeW !== undefined && isFinite(Number(s.badgeW)) && Number(s.badgeW) >= 120 && Number(s.badgeW) <= 480) ? Number(s.badgeW) : null,
815
823
  badgePos: (s.badgePos && isFinite(Number(s.badgePos.x)) && isFinite(Number(s.badgePos.y)))
@@ -884,13 +892,14 @@ export default {
884
892
  kind: 'prefix',
885
893
  path: '/xq-rpc',
886
894
  handler: async function (req, res) {
887
- // 同源栅栏:Host 头必须是回环,带 Origin 时必须同源(防 DNS rebinding/CSRF)
895
+ // 同源栅栏:Host 头必须是回环,Origin 必须存在且同源(防 DNS rebinding/CSRF/无 Origin 的跨进程直调)
888
896
  const hostHeader = String(req.headers.host || '')
889
897
  const origin = req.headers.origin ? String(req.headers.origin) : ''
890
898
  const hostAuth = hostHeader.replace(/:\d+$/, '')
891
899
  const loopback = hostAuth === '127.0.0.1' || hostAuth === 'localhost' || hostAuth === '[::1]' || hostAuth === '::1'
892
900
  if (!loopback) { res.writeHead(403); res.end('forbidden'); return }
893
- if (origin && origin !== 'http://' + hostHeader && origin !== 'https://' + hostHeader) {
901
+ // 浏览器对(哪怕同源的)POST fetch 必带 Origin;无 Origin 一律拒绝(curl/本机进程裸调)
902
+ if (!origin || (origin !== 'http://' + hostHeader && origin !== 'https://' + hostHeader)) {
894
903
  res.writeHead(403); res.end('forbidden'); return
895
904
  }
896
905
  const chunks = []
@@ -1089,11 +1098,17 @@ export default {
1089
1098
  // 注册:动态环境走 harness 门面(defineTool 会做标记+校验);
1090
1099
  // 静态 bundle 层没有 harness,参数已是 JSON Schema 根,直接注册 plain 对象
1091
1100
  // (tools 服务在静态路径无 defineTool 守卫),用 inject 等服务就绪。
1101
+ // 统一包装 execute:args 为 null/undefined 时兜底 {}(工具层直解引用会抛 TypeError)
1102
+ function wrapToolSpec(spec) {
1103
+ const raw = spec.execute
1104
+ if (!raw) return spec
1105
+ return Object.assign({}, spec, { execute: function (args) { return raw.call(this, args || {}) } })
1106
+ }
1092
1107
  if (typeof harness !== 'undefined') {
1093
1108
  for (let i = 0; i < XQ_AGENT_TOOLS.length; i++) {
1094
1109
  const spec = XQ_AGENT_TOOLS[i]
1095
1110
  ctx.effect(function () {
1096
- return harness.registerTool(ctx, harness.defineTool(spec))
1111
+ return harness.registerTool(ctx, harness.defineTool(wrapToolSpec(spec)))
1097
1112
  })
1098
1113
  }
1099
1114
  } else {
@@ -1101,7 +1116,7 @@ export default {
1101
1116
  for (let i = 0; i < XQ_AGENT_TOOLS.length; i++) {
1102
1117
  const spec = XQ_AGENT_TOOLS[i]
1103
1118
  toolsCtx.effect(function () {
1104
- return toolsCtx.tools.register(spec)
1119
+ return toolsCtx.tools.register(wrapToolSpec(spec))
1105
1120
  })
1106
1121
  }
1107
1122
  })