@twinsearth/fang-dsh-plugin 0.0.0-stage → 3.3.5

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.
Files changed (147) hide show
  1. package/CLA.md +79 -0
  2. package/LICENSE-COMMERCIAL +78 -0
  3. package/LICENSE-CORE +661 -0
  4. package/LICENSE-SDK +21 -0
  5. package/LICENSES/cordis-MIT.txt +39 -0
  6. package/NOTICE +95 -0
  7. package/README.md +158 -2
  8. package/client.js +7287 -0
  9. package/cordis.patch.yml +163 -0
  10. package/defaults/umbrella-rules.yaml +113 -0
  11. package/docs/APPROVAL-DRILL.md +42 -0
  12. package/docs/CONTRACT-INTEROP.md +77 -0
  13. package/docs/CRASH-SAFETY.md +69 -0
  14. package/docs/DECISION-RELAY.md +46 -0
  15. package/docs/DSH-ARCHITECTURE-MAP.md +73 -0
  16. package/docs/FORK-SEED.md +64 -0
  17. package/docs/MAIN-CITY-GATE.md +58 -0
  18. package/docs/OVERLAY-MASTER-SWITCH.md +35 -0
  19. package/docs/PACKAGE-RENAME-v3.3.3.md +40 -0
  20. package/docs/PM-DECISION-POLICY.md +61 -0
  21. package/docs/WORKFLOW-UI.md +86 -0
  22. package/icon.svg +8 -0
  23. package/locale/en.json +6 -0
  24. package/locale/zh.json +6 -0
  25. package/package.json +71 -4
  26. package/sdk/README.md +65 -0
  27. package/sdk/examples/status-badge/README.md +16 -0
  28. package/sdk/examples/status-badge/client.js +60 -0
  29. package/sdk/examples/status-badge/cordis.patch.yml +7 -0
  30. package/sdk/examples/status-badge/index.js +12 -0
  31. package/sdk/examples/status-badge/package.json +24 -0
  32. package/sdk/templates/plugin/README.md +36 -0
  33. package/sdk/templates/plugin/client.js +35 -0
  34. package/sdk/templates/plugin/cordis.patch.yml +8 -0
  35. package/sdk/templates/plugin/icon.svg +5 -0
  36. package/sdk/templates/plugin/index.js +23 -0
  37. package/sdk/templates/plugin/locale/en.json +6 -0
  38. package/sdk/templates/plugin/package.json +36 -0
  39. package/src/compose.mjs +67 -0
  40. package/src/contract/VENDOR.json +16 -0
  41. package/src/contract/client.js +93 -0
  42. package/src/contract/constants.js +94 -0
  43. package/src/contract/envelope.js +83 -0
  44. package/src/contract/index.js +20 -0
  45. package/src/contract/negotiate.js +106 -0
  46. package/src/contract/relay.js +223 -0
  47. package/src/contract/server.js +77 -0
  48. package/src/contract/transports.js +319 -0
  49. package/src/index.mjs +11 -0
  50. package/src/kernel/core.mjs +100 -0
  51. package/src/kernel/extensions.mjs +61 -0
  52. package/src/kernel/fsx.mjs +349 -0
  53. package/src/kernel/index.mjs +21 -0
  54. package/src/kernel/ledger.mjs +94 -0
  55. package/src/kernel/paths.mjs +182 -0
  56. package/src/kernel/role.mjs +68 -0
  57. package/src/kernel/rounds.mjs +172 -0
  58. package/src/kernel/schema.mjs +5 -0
  59. package/src/kernel/types.mjs +266 -0
  60. package/src/kernel/vendor/cosmokit.mjs +421 -0
  61. package/src/kernel/vendor/schemastery.mjs +638 -0
  62. package/src/lib/adapter-client.mjs +383 -0
  63. package/src/lib/adapter-protocol.mjs +243 -0
  64. package/src/lib/ai-secret-store.mjs +202 -0
  65. package/src/lib/approval-drill.mjs +111 -0
  66. package/src/lib/boot-guard.mjs +186 -0
  67. package/src/lib/branch-store.mjs +84 -0
  68. package/src/lib/branch.mjs +74 -0
  69. package/src/lib/bridge-client.mjs +133 -0
  70. package/src/lib/broker-adapter.mjs +67 -0
  71. package/src/lib/broker.mjs +45 -0
  72. package/src/lib/business-producers.mjs +109 -0
  73. package/src/lib/command-cache.mjs +209 -0
  74. package/src/lib/compat.mjs +170 -0
  75. package/src/lib/composer-journal.mjs +128 -0
  76. package/src/lib/container/index.mjs +806 -0
  77. package/src/lib/decision-events.mjs +129 -0
  78. package/src/lib/decision-ingest-client.mjs +41 -0
  79. package/src/lib/decision-outbox.mjs +129 -0
  80. package/src/lib/decision-policy.mjs +52 -0
  81. package/src/lib/default-essence.mjs +61 -0
  82. package/src/lib/desktop-launch.mjs +138 -0
  83. package/src/lib/essence.mjs +1211 -0
  84. package/src/lib/fork-client.mjs +410 -0
  85. package/src/lib/fork-config-lock.mjs +166 -0
  86. package/src/lib/fork-seed.mjs +304 -0
  87. package/src/lib/fork-sessions.mjs +183 -0
  88. package/src/lib/fork-supervision-settings.mjs +58 -0
  89. package/src/lib/fork-supervisor.mjs +122 -0
  90. package/src/lib/fs-guard.mjs +28 -0
  91. package/src/lib/http-auth.mjs +162 -0
  92. package/src/lib/idle-audit.mjs +178 -0
  93. package/src/lib/interaction-poke.mjs +61 -0
  94. package/src/lib/main-gate.mjs +582 -0
  95. package/src/lib/mirror-etag.mjs +92 -0
  96. package/src/lib/mirror-official-source.mjs +93 -0
  97. package/src/lib/mirror.mjs +478 -0
  98. package/src/lib/mother-link.mjs +156 -0
  99. package/src/lib/mutex.mjs +31 -0
  100. package/src/lib/permissions.mjs +115 -0
  101. package/src/lib/pnpm-store.mjs +189 -0
  102. package/src/lib/preferences.mjs +212 -0
  103. package/src/lib/producer-contract.mjs +162 -0
  104. package/src/lib/reflow.mjs +41 -0
  105. package/src/lib/remote.mjs +408 -0
  106. package/src/lib/restart-contract.mjs +752 -0
  107. package/src/lib/roots.mjs +30 -0
  108. package/src/lib/rule-registry.mjs +1127 -0
  109. package/src/lib/runtime-capture.mjs +307 -0
  110. package/src/lib/semaphore.mjs +34 -0
  111. package/src/lib/session-bundle.mjs +138 -0
  112. package/src/lib/session-format.mjs +672 -0
  113. package/src/lib/sse-parser.mjs +102 -0
  114. package/src/lib/ssrf-guard.mjs +170 -0
  115. package/src/lib/status-store.mjs +257 -0
  116. package/src/lib/task-handle.mjs +622 -0
  117. package/src/lib/ui-actions.mjs +55 -0
  118. package/src/lib/umbrella.mjs +1661 -0
  119. package/src/lib/upgrade-coordinator.mjs +107 -0
  120. package/src/lib/upgrade-status.mjs +43 -0
  121. package/src/plugins/adapter.mjs +1468 -0
  122. package/src/plugins/approver.mjs +1210 -0
  123. package/src/plugins/auditor.mjs +330 -0
  124. package/src/plugins/buffer-keeper.mjs +522 -0
  125. package/src/plugins/decision-center.mjs +2581 -0
  126. package/src/plugins/forker.mjs +1610 -0
  127. package/src/plugins/healer.mjs +702 -0
  128. package/src/plugins/hot-reloader.mjs +274 -0
  129. package/src/plugins/imports.mjs +247 -0
  130. package/src/plugins/orchestrator.mjs +3979 -0
  131. package/src/plugins/restart-guard.mjs +1022 -0
  132. package/src/plugins/router.mjs +714 -0
  133. package/src/plugins/scorer.mjs +661 -0
  134. package/src/plugins/session-bridge.mjs +166 -0
  135. package/src/plugins/session-catalog.mjs +771 -0
  136. package/src/plugins/session-router.mjs +125 -0
  137. package/src/plugins/session-sync.mjs +171 -0
  138. package/src/plugins/umbrella.mjs +419 -0
  139. package/tools/approval-drill.mjs +21 -0
  140. package/tools/boot-guard.mjs +95 -0
  141. package/tools/decision-ingest.mjs +53 -0
  142. package/tools/desktop-launch.mjs +71 -0
  143. package/tools/fangctl.mjs +425 -0
  144. package/tools/gate/main-city.mjs +204 -0
  145. package/tools/mcp/server.mjs +121 -0
  146. package/tools/mcp/tools.mjs +65 -0
  147. package/tools/recover-queue.mjs +133 -0
@@ -0,0 +1,28 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // v3.0.9 fail-closed path guard: canonicalize and keep every path inside its root.
3
+ import fs from 'node:fs'
4
+ import path from 'node:path'
5
+
6
+ export function safeJoin(root, ...parts) {
7
+ const base = path.resolve(root)
8
+ const target = path.resolve(base, ...parts)
9
+ if (target !== base && !target.startsWith(base + path.sep)) throw new Error('path-escape')
10
+ if (String(target).includes('\0')) throw new Error('path-escape')
11
+ return target
12
+ }
13
+
14
+ export function assertInside(root, target, { followSymlink = true } = {}) {
15
+ const base = path.resolve(root)
16
+ const lexical = path.resolve(target)
17
+ let resolved = lexical
18
+ let boundary = base
19
+ if (followSymlink) {
20
+ try { boundary = fs.realpathSync.native(base) } catch { /* keep lexical */ }
21
+ try { resolved = fs.realpathSync.native(lexical) } catch {
22
+ try { resolved = path.join(fs.realpathSync.native(path.dirname(lexical)), path.basename(lexical)) } catch { /* keep lexical */ }
23
+ }
24
+ }
25
+ const rel = path.relative(boundary, resolved)
26
+ if (rel === '..' || rel.startsWith('..' + path.sep) || path.isAbsolute(rel)) throw new Error('path-escape')
27
+ return lexical
28
+ }
@@ -0,0 +1,162 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // v3.0.5/B1 — 安装级 HTTP 鉴权:token 文件(0600)、loopback Host 校验、CSRF/Origin 检查。
3
+ import crypto from 'node:crypto'
4
+ import fs from 'node:fs'
5
+ import path from 'node:path'
6
+
7
+ import { ensureDir, exists } from '../kernel/index.mjs'
8
+
9
+ export const AUTH_TOKEN_FILE = 'fang-auth.json'
10
+ export const AUTH_HEADER = 'x-fang-auth'
11
+ export const MIN_TOKEN_LENGTH = 32
12
+
13
+ /** 读取/生成安装级 token(env 覆盖 > 0600 文件 > 新建)。 */
14
+ export function loadAuthToken(dataDir, envToken = process.env.DSH_FANG_AUTH_TOKEN) {
15
+ const fromEnv = String(envToken ?? '').trim()
16
+ if (fromEnv.length >= MIN_TOKEN_LENGTH) return fromEnv
17
+ const file = path.join(dataDir, AUTH_TOKEN_FILE)
18
+ if (exists(file)) {
19
+ try {
20
+ const doc = JSON.parse(fs.readFileSync(file, 'utf8'))
21
+ if (typeof doc?.token === 'string' && doc.token.length >= MIN_TOKEN_LENGTH) return doc.token
22
+ } catch { /* 损坏则重建 */ }
23
+ }
24
+ const token = crypto.randomBytes(32).toString('hex')
25
+ ensureDir(dataDir)
26
+ fs.writeFileSync(file, JSON.stringify({ token, createdAt: Date.now() }), { encoding: 'utf8', mode: 0o600 })
27
+ try { fs.chmodSync(file, 0o600) } catch { /* Windows 上 chmod 可能无效 */ }
28
+ return token
29
+ }
30
+
31
+ /**
32
+ * Host 是否 loopback(支持 localhost / 127.0.0.1 / [::1] / ::1,可带端口)。
33
+ *
34
+ * 严格解析:`[::1]evil.example`、`127.0.0.1:3080@evil.example` 这类带尾随垃圾的
35
+ * Host 必须判假——否则 DNS rebinding 防护会被一个字符串前缀绕过。
36
+ */
37
+ export function isLoopbackHost(host) {
38
+ const raw = String(host ?? '').trim().toLowerCase()
39
+ if (raw.length === 0) return false
40
+ if (raw === '::1') return true
41
+ if (raw.startsWith('[')) {
42
+ const match = /^\[([0-9a-f:.]+)\](?::(\d{1,5}))?$/.exec(raw)
43
+ if (!match) return false
44
+ if (match[2] !== undefined && Number(match[2]) > 65535) return false
45
+ return match[1] === '::1'
46
+ }
47
+ const match = /^([a-z0-9.-]+)(?::(\d{1,5}))?$/.exec(raw)
48
+ if (!match) return false
49
+ if (match[2] !== undefined && Number(match[2]) > 65535) return false
50
+ return match[1] === 'localhost' || match[1] === '127.0.0.1'
51
+ }
52
+
53
+ /** Origin 是否允许(同源 loopback / dsh-app 壳 / 无 Origin)。 */
54
+ export function isAllowedOrigin(origin) {
55
+ const raw = String(origin ?? '').trim()
56
+ if (raw.length === 0) return true
57
+ if (raw === 'dsh-app://app' || raw.startsWith('dsh-app://')) return true
58
+ try {
59
+ const url = new URL(raw)
60
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') return false
61
+ return isLoopbackHost(url.host)
62
+ } catch {
63
+ return false
64
+ }
65
+ }
66
+
67
+ function header(req, name) {
68
+ const headers = req?.headers ?? {}
69
+ const lower = name.toLowerCase()
70
+ for (const key of Object.keys(headers)) {
71
+ if (key.toLowerCase() === lower) return String(headers[key] ?? '')
72
+ }
73
+ return ''
74
+ }
75
+
76
+ function timingSafeEqualText(expected, actual) {
77
+ const a = Buffer.from(String(expected), 'utf8')
78
+ const b = Buffer.from(String(actual), 'utf8')
79
+ if (a.length !== b.length || a.length === 0) return false
80
+ try { return crypto.timingSafeEqual(a, b) } catch { return false }
81
+ }
82
+
83
+ /**
84
+ * 请求鉴权决策(fail-closed)。
85
+ * @param {any} req
86
+ * @param {{token:string, publicRoute?:boolean}} options
87
+ * @returns {{ok:true} | {ok:false, status:number, code:string}}
88
+ */
89
+ export function checkRequestAuth(req, options) {
90
+ const token = String(options?.token ?? '')
91
+ const method = String(req?.method ?? 'GET').toUpperCase()
92
+ const host = header(req, 'host')
93
+ if (host && !isLoopbackHost(host)) return { ok: false, status: 403, code: 'bad-host' }
94
+ const origin = header(req, 'origin')
95
+ if (!isAllowedOrigin(origin)) return { ok: false, status: 403, code: 'bad-origin' }
96
+ const site = header(req, 'sec-fetch-site')
97
+ if (site && site !== 'same-origin' && site !== 'same-site' && site !== 'none') {
98
+ return { ok: false, status: 403, code: 'cross-site' }
99
+ }
100
+ if (options?.publicRoute === true) return { ok: true }
101
+ const given = header(req, AUTH_HEADER) || (() => {
102
+ const auth = header(req, 'authorization')
103
+ const match = /^Bearer\s+(.+)$/i.exec(auth)
104
+ return match ? match[1].trim() : ''
105
+ })()
106
+ if (!timingSafeEqualText(token, given)) return { ok: false, status: 401, code: 'unauthorized' }
107
+ // 写操作要求 JSON content-type(阻止 text/plain 简单请求 CSRF;无 body 的 POST 也允许)
108
+ if (method !== 'GET' && method !== 'HEAD' && method !== 'OPTIONS') {
109
+ const contentType = header(req, 'content-type').toLowerCase()
110
+ if (contentType && !contentType.includes('application/json')) {
111
+ return { ok: false, status: 415, code: 'content-type-required' }
112
+ }
113
+ }
114
+ return { ok: true }
115
+ }
116
+
117
+ /** Stable error envelope for every auth/host/origin rejection (additive `code`). */
118
+ export function authErrorBody(decision) {
119
+ const code = String(decision?.code ?? 'unauthorized')
120
+ return { ok: false, error: code, code }
121
+ }
122
+
123
+ /**
124
+ * Install-level guard shared by every plugin that owns `/fang/api/*` routes.
125
+ *
126
+ * Resolves the token exactly once at startup, with the same precedence as the
127
+ * orchestrator: explicit config > env > `<dataDir>/fang-auth.json` (0600).
128
+ * `enabled: false` is the explicit test/debug escape hatch and still enforces
129
+ * Host/Origin/CSRF — it only skips the token comparison.
130
+ *
131
+ * @param {{dataDir?:string, token?:string, envToken?:string, enabled?:boolean}} [options]
132
+ * @returns {{token:string, enabled:boolean, check:(req:any, routeOptions?:{public?:boolean})=>
133
+ * ({ok:true}|{ok:false,status:number,code:string})}}
134
+ */
135
+ export function createHttpGuard(options = {}) {
136
+ const enabled = options.enabled !== false
137
+ const dataDir = String(options.dataDir ?? '').trim()
138
+ const explicit = String(options.token ?? '').trim()
139
+ const envToken = options.envToken === undefined ? process.env.DSH_FANG_AUTH_TOKEN : options.envToken
140
+ let token = ''
141
+ if (enabled) {
142
+ if (dataDir) {
143
+ token = loadAuthToken(dataDir, explicit || envToken)
144
+ } else {
145
+ const candidate = explicit || String(envToken ?? '').trim()
146
+ if (candidate.length < MIN_TOKEN_LENGTH) {
147
+ throw new Error('http-auth: dataDir or a >=32-char auth token is required when auth is enabled')
148
+ }
149
+ token = candidate
150
+ }
151
+ }
152
+ return {
153
+ token,
154
+ enabled: token.length > 0,
155
+ check(req, routeOptions = {}) {
156
+ return checkRequestAuth(req, {
157
+ token,
158
+ publicRoute: token.length === 0 || routeOptions.public === true,
159
+ })
160
+ },
161
+ }
162
+ }
@@ -0,0 +1,178 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // dsh-Fang v3.0.8 — 空闲降级审计聚合。
3
+ //
4
+ // MirrorStatusStore 在“活跃消费者 30s 无交互 → 降级”时发 ActiveIdleEvent;
5
+ // 本模块把事件写入 Auditor(单条 + 周期 summary),并从统计里生成 UI 优化建议:
6
+ // * idleAfterMs = 降级时刻 - 最后一次交互(真实的“走开时长”)
7
+ // * totalActiveMs = 降级时刻 - 首次 acquireActive(用户停留总时长)
8
+ // 样本不足(默认 <10)不给建议,避免小样本误导。
9
+
10
+ export const IDLE_AUDIT_KIND = 'mirror-idle-downgrade'
11
+ export const IDLE_AUDIT_SUMMARY_KIND = 'mirror-idle-downgrade-summary'
12
+
13
+ export class IdleAuditAggregator {
14
+ /**
15
+ * @param {{record: (payload: Record<string, unknown>) => (void|Promise<void>),
16
+ * minSamplesForRecommendation?: number, snapshotIntervalMs?: number,
17
+ * timers?: {setInterval: Function, clearInterval: Function}, now?: () => number}} options
18
+ */
19
+ constructor(options) {
20
+ if (options === null || typeof options !== 'object' || typeof options.record !== 'function') {
21
+ throw new Error('IdleAuditAggregator: record required')
22
+ }
23
+ this.record = options.record
24
+ this.minSamples = Math.max(1, Math.floor(Number(options.minSamplesForRecommendation) || 10))
25
+ this.snapshotIntervalMs = Math.max(1, Math.floor(Number(options.snapshotIntervalMs) || 300_000))
26
+ this.timers = options.timers ?? { setInterval, clearInterval }
27
+ this.now = typeof options.now === 'function' ? options.now : () => Date.now()
28
+ this.events = []
29
+ this.rollingEvents = []
30
+ this.rollingCapacity = Math.max(1, Math.floor(Number(options.rollingCapacity) || 500))
31
+ this.snapshotTimer = null
32
+ this.sink = (event) => { this.handle(event) }
33
+ }
34
+
35
+ /** 传给 MirrorStatusStore.onIdleDowngraded(sink)。 */
36
+ asSink() {
37
+ return this.sink
38
+ }
39
+
40
+ start() {
41
+ this.stop()
42
+ this.snapshotTimer = this.timers.setInterval(() => { void this.publishSnapshot() }, this.snapshotIntervalMs)
43
+ this.snapshotTimer?.unref?.()
44
+ }
45
+
46
+ stop() {
47
+ if (this.snapshotTimer !== null) {
48
+ this.timers.clearInterval(this.snapshotTimer)
49
+ this.snapshotTimer = null
50
+ }
51
+ }
52
+
53
+ /** 基于滚动窗口(长期,最近 N 条)生成结构化建议;样本不足返回空数组。 */
54
+ getRecommendations() {
55
+ if (this.rollingEvents.length < this.minSamples) return []
56
+ return this.buildRecommendations(this.rollingEvents)
57
+ }
58
+
59
+ /** 由事件数组生成结构化建议(fieldKey/suggestedValue/text/sampleCount/confidence)。 */
60
+ buildRecommendations(events) {
61
+ const total = events.length
62
+ if (total === 0) return []
63
+ let rapid = 0
64
+ let full = 0
65
+ for (const event of events) {
66
+ if (event.idleAfterMs < event.timeoutMs * 0.3) rapid += 1
67
+ if (event.idleAfterMs >= event.timeoutMs * 0.9) full += 1
68
+ }
69
+ const rapidRatio = rapid / total
70
+ const fullRatio = full / total
71
+ const recommendations = []
72
+ if (rapidRatio > 0.5) {
73
+ recommendations.push({
74
+ fieldKey: 'activeIdleTimeoutMs',
75
+ suggestedValue: 15_000,
76
+ text: `超过一半(${(rapidRatio * 100).toFixed(0)}%)的对话框打开后很快走开,可考虑把交互超时降到 15 秒,减少无意义的高频轮询。`,
77
+ sampleCount: total,
78
+ confidence: rapidRatio,
79
+ })
80
+ }
81
+ if (fullRatio > 0.8) {
82
+ recommendations.push({
83
+ fieldKey: 'activeIdleTimeoutMs',
84
+ suggestedValue: 60_000,
85
+ text: `${(fullRatio * 100).toFixed(0)}% 的用户活跃到超时窗口末尾,建议把交互超时提高到 60 秒,让状态刷新更实时。`,
86
+ sampleCount: total,
87
+ confidence: fullRatio,
88
+ })
89
+ }
90
+ return recommendations
91
+ }
92
+
93
+ /** 发布聚合快照(无事件则不发);发布后清空窗口。 */
94
+ async publishSnapshot() {
95
+ if (this.events.length === 0) return null
96
+ const windowStart = this.events[0].at
97
+ const windowEnd = this.events[this.events.length - 1].at
98
+ const summary = this.computeSummary()
99
+ this.events = []
100
+ const payload = {
101
+ kind: IDLE_AUDIT_SUMMARY_KIND,
102
+ at: this.now(),
103
+ windowStart,
104
+ windowEnd,
105
+ summary,
106
+ }
107
+ await this.record(payload)
108
+ return summary
109
+ }
110
+
111
+ computeSummary() {
112
+ const byTimeout = {}
113
+ let rapidAway = 0
114
+ let fullTimeout = 0
115
+ for (const event of this.events) {
116
+ const bucket = byTimeout[event.timeoutMs] ?? { count: 0, totalIdleAfterMs: 0, totalActiveMs: 0 }
117
+ bucket.count += 1
118
+ bucket.totalIdleAfterMs += event.idleAfterMs
119
+ bucket.totalActiveMs += event.totalActiveMs
120
+ byTimeout[event.timeoutMs] = bucket
121
+ if (event.idleAfterMs < event.timeoutMs * 0.3) rapidAway += 1
122
+ if (event.idleAfterMs >= event.timeoutMs * 0.9) fullTimeout += 1
123
+ }
124
+ for (const key of Object.keys(byTimeout)) {
125
+ const bucket = byTimeout[key]
126
+ bucket.avgIdleAfterMs = Math.round(bucket.totalIdleAfterMs / bucket.count)
127
+ bucket.avgTotalActiveMs = Math.round(bucket.totalActiveMs / bucket.count)
128
+ delete bucket.totalIdleAfterMs
129
+ delete bucket.totalActiveMs
130
+ }
131
+ const recommendations = this.events.length >= this.minSamples ? this.buildRecommendations(this.events) : []
132
+ return {
133
+ totalEvents: this.events.length,
134
+ byTimeout,
135
+ rapidAwayCount: rapidAway,
136
+ fullTimeoutCount: fullTimeout,
137
+ recommendation: recommendations[0]?.text ?? null,
138
+ recommendations,
139
+ }
140
+ }
141
+
142
+ buildRecommendation(rapidAway, fullTimeout) {
143
+ const total = this.events.length
144
+ if (total < this.minSamples) return null
145
+ const rapidRatio = rapidAway / total
146
+ const fullRatio = fullTimeout / total
147
+ if (rapidRatio > 0.5) {
148
+ return `超过一半(${(rapidRatio * 100).toFixed(0)}%)的对话框打开后很快走开,建议把默认交互超时降到 15s 以减少无意义轮询。`
149
+ }
150
+ if (fullRatio > 0.8) {
151
+ return `${(fullRatio * 100).toFixed(0)}% 的用户活跃到超时窗口末尾,建议把默认交互超时提高到 60s。`
152
+ }
153
+ return null
154
+ }
155
+
156
+ handle(event) {
157
+ this.events.push(event)
158
+ this.rollingEvents.push(event)
159
+ if (this.rollingEvents.length > this.rollingCapacity) {
160
+ this.rollingEvents.splice(0, this.rollingEvents.length - this.rollingCapacity)
161
+ }
162
+ const payload = {
163
+ kind: IDLE_AUDIT_KIND,
164
+ at: event.at,
165
+ timeoutMs: event.timeoutMs,
166
+ idleAfterMs: event.idleAfterMs,
167
+ totalActiveMs: event.totalActiveMs,
168
+ lastPokeAt: event.lastPokeAt,
169
+ activeConsumers: event.activeConsumers,
170
+ }
171
+ try {
172
+ const result = this.record(payload)
173
+ if (result !== null && typeof result === 'object' && typeof result.then === 'function') {
174
+ result.catch(() => { /* 审计失败不影响 UI */ })
175
+ }
176
+ } catch { /* 审计失败不影响 UI */ }
177
+ }
178
+ }
@@ -0,0 +1,61 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // dsh-Fang v3.1.0 — 用户交互 poke(框架无关)。
3
+ //
4
+ // 消费者(如打开的镜像设置对话框)在挂载时调用 installInteractionPoke(store, {target}),
5
+ // 鼠标/键盘/滚动/触摸等轻量交互会节流触发 store.pokeActive(),让 store 保持高频档;
6
+ // 30s 无交互后 store 自动降回 idle 间隔。
7
+ // 不监听 click:点击路径本身会调用 refresh/acquireActive,无需额外 poke。
8
+
9
+ export const INTERACTION_EVENTS = Object.freeze([
10
+ 'mousemove', 'mousedown', 'wheel', 'scroll', 'keydown', 'touchstart',
11
+ ])
12
+
13
+ /**
14
+ * @param {{pokeActive: Function}} store
15
+ * @param {{target?: EventTarget|null, throttleMs?: number, now?: () => number,
16
+ * timers?: {setTimeout: Function, clearTimeout: Function}}} [options]
17
+ * @returns {() => void} dispose
18
+ */
19
+ export function installInteractionPoke(store, options = {}) {
20
+ const target = options.target ?? (typeof window !== 'undefined' ? window : null)
21
+ if (target === null || target === undefined || typeof target.addEventListener !== 'function') {
22
+ return () => {}
23
+ }
24
+ const throttleMs = Math.max(0, Number(options.throttleMs) || 1_000)
25
+ const now = typeof options.now === 'function' ? options.now : () => Date.now()
26
+ const timers = options.timers ?? { setTimeout, clearTimeout }
27
+ let lastPokeAt = 0
28
+ let trailing = null
29
+ let disposed = false
30
+
31
+ const fire = () => {
32
+ if (disposed) return
33
+ lastPokeAt = now()
34
+ store.pokeActive()
35
+ }
36
+
37
+ const onEvent = () => {
38
+ if (disposed) return
39
+ const elapsed = now() - lastPokeAt
40
+ if (elapsed >= throttleMs) { fire(); return }
41
+ if (trailing !== null) return
42
+ trailing = timers.setTimeout(() => {
43
+ trailing = null
44
+ fire()
45
+ }, throttleMs - elapsed)
46
+ trailing?.unref?.()
47
+ }
48
+
49
+ for (const eventName of INTERACTION_EVENTS) target.addEventListener(eventName, onEvent, { passive: true })
50
+
51
+ return () => {
52
+ disposed = true
53
+ for (const eventName of INTERACTION_EVENTS) {
54
+ try { target.removeEventListener(eventName, onEvent) } catch { /* 已移除 */ }
55
+ }
56
+ if (trailing !== null) {
57
+ timers.clearTimeout(trailing)
58
+ trailing = null
59
+ }
60
+ }
61
+ }