@hyzyn/dsh-docker 0.9.6 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +36 -3
- package/README.md +39 -3
- package/client-src/docker.css +17 -0
- package/client-src/elevation.js +163 -0
- package/client-src/index.js +275 -98
- package/client.js +38 -20
- package/lib/audit.d.ts +106 -0
- package/lib/audit.js +140 -0
- package/lib/audit.js.map +1 -0
- package/lib/index.d.ts +4 -0
- package/lib/index.js +83 -15
- package/lib/index.js.map +1 -1
- package/package.json +2 -2
- package/scripts/client-smoke.mjs +189 -0
- package/scripts/route-smoke.mjs +143 -1
package/client-src/index.js
CHANGED
|
@@ -22,6 +22,13 @@ import { currentSessionIdOf } from './current-session.js'
|
|
|
22
22
|
import { createLogBuffer, splitLogLines } from './log-buffer.js'
|
|
23
23
|
import { createLogWindow, LOG_ROW_ESTIMATE_PX, LOG_WINDOW_OVERSCAN, LOG_HEIGHT_CACHE_LIMIT } from './log-window.js'
|
|
24
24
|
import { subscribeLogStream, reconnectTail, LOG_RECONNECT_BASE_MS, LOG_RECONNECT_MAX_MS } from './log-stream.js'
|
|
25
|
+
import { upsertElevationRecord, removeElevationRecord, markElevationGranted, pruneElevationRecords, buildMergedElevationText, mergeElevationCommands, joinElevationCommands, shouldOfferMerge } from './elevation.js'
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* 「复制全部」按钮的反馈归属哨兵键:能力名不会取这个值,所以它与单条按钮的反馈天然不串。
|
|
29
|
+
* (`copiedKey` 逐能力隔离见卡片里那段注释。)
|
|
30
|
+
*/
|
|
31
|
+
const ELEVATION_COPY_ALL = '__all__'
|
|
25
32
|
|
|
26
33
|
/* ================================ 国际化 ================================ */
|
|
27
34
|
|
|
@@ -479,6 +486,9 @@ const I18N_ZH = {
|
|
|
479
486
|
'hint.capabilityNotGranted': '⚠ 宿主尚未授权:这两个开关现在打不开,点它会给出一条就地确认的命令(免重启)。关掉它们随时可用。',
|
|
480
487
|
'badge.notEffective': '未生效:未获宿主授权',
|
|
481
488
|
'elev.title': '就地授权(免重启)',
|
|
489
|
+
'elev.copyAll': '复制全部({count} 条)',
|
|
490
|
+
'elev.copyAllHint': '每个能力各要一条命令。一次粘贴全部命令,执行后全部开关自动解锁(各自的探测周期内先后生效)。',
|
|
491
|
+
'elev.copyAllCopied': '已复制全部 {count} 条',
|
|
482
492
|
'elev.lockedWhy': '本机任意进程都能发回环请求,所以「打开危险能力」这件事不能由这个页面自己说了算——必须在宿主的文件系统上确认一次。',
|
|
483
493
|
'elev.step': '在宿主的终端里执行下一条命令,开关会自动解锁:',
|
|
484
494
|
'elev.copy': '复制命令',
|
|
@@ -963,6 +973,9 @@ const I18N_EN = {
|
|
|
963
973
|
'hint.capabilityNotGranted': '⚠ Not granted by the host: these two switches cannot be turned on right now — clicking one gives you a command to confirm in place (no restart). Turning them off always works.',
|
|
964
974
|
'badge.notEffective': 'Not effective: not granted by the host',
|
|
965
975
|
'elev.title': 'Grant in place (no restart)',
|
|
976
|
+
'elev.copyAll': 'Copy all ({count})',
|
|
977
|
+
'elev.copyAllHint': 'Each capability needs its own command. Paste both lines at once; every switch unlocks by itself once they run (each on its own probe cycle).',
|
|
978
|
+
'elev.copyAllCopied': 'Copied all {count}',
|
|
966
979
|
'elev.lockedWhy': 'Any local process can send loopback requests, so “turn on a dangerous capability” cannot be decided by this page alone — it has to be confirmed on the host’s filesystem once.',
|
|
967
980
|
'elev.step': 'Run the command below in a terminal on the host and the switch unlocks by itself:',
|
|
968
981
|
'elev.copy': 'Copy command',
|
|
@@ -7534,6 +7547,118 @@ window.__ModuleLoader__.load({
|
|
|
7534
7547
|
* 设置卡片
|
|
7535
7548
|
* ------------------------------------------------------------------ */
|
|
7536
7549
|
|
|
7550
|
+
/** 能力开关名 → 宿主侧环境变量名(「另一种方式」那一栏要写出具体变量名)。 */
|
|
7551
|
+
const CAPABILITY_ENV = {
|
|
7552
|
+
allowMutations: 'DSH_DOCKER_ALLOW_MUTATIONS',
|
|
7553
|
+
allowExec: 'DSH_DOCKER_ALLOW_EXEC',
|
|
7554
|
+
}
|
|
7555
|
+
|
|
7556
|
+
/** 能力名 → 开关文案(面板标题靠它)。 */
|
|
7557
|
+
function capabilityLabelOf(capability) {
|
|
7558
|
+
return capability === 'allowMutations' ? t('check.allowMutations') : t('check.allowExec')
|
|
7559
|
+
}
|
|
7560
|
+
|
|
7561
|
+
/**
|
|
7562
|
+
* 就地提权面板组——**纯渲染函数**(入参是纯数据 + 一组回调,返回 jsx 树),刻意不做成组件:
|
|
7563
|
+
* 离线冒烟的 React 桩把 `useState` 冻在初值上、`useEffect` 是空实现,「两个能力同时 pending」
|
|
7564
|
+
* 这件事在组件里根本走不到,而它正是本次改造要保证的行为。做成普通函数后,用例可以真的调用它、
|
|
7565
|
+
* 遍历返回的 jsx 树、并**驱动合并按钮的 onClick** 断言被复制下去的字节(见 scripts/client-smoke.mjs)。
|
|
7566
|
+
*
|
|
7567
|
+
* 结构:合并复制行(≥2 个能力 pending 时才有)→ 逐能力堆叠的面板(已授权的留痕不渲染)。
|
|
7568
|
+
*/
|
|
7569
|
+
function elevationPanelsView(records, options) {
|
|
7570
|
+
const list = Array.isArray(records) ? records : []
|
|
7571
|
+
const now = options.now === undefined ? Date.now() : options.now
|
|
7572
|
+
const pending = list.filter((record) => record !== null && record !== undefined && record.granted !== true)
|
|
7573
|
+
if (pending.length === 0) return null
|
|
7574
|
+
const merged = mergeElevationCommands(list, now)
|
|
7575
|
+
const panel = (record) => {
|
|
7576
|
+
const capability = String(record.capability)
|
|
7577
|
+
const seconds = Number.isFinite(record.expiresAt)
|
|
7578
|
+
? Math.max(0, Math.ceil((Number(record.expiresAt) - now) / 1000))
|
|
7579
|
+
: 0
|
|
7580
|
+
return jsxs('div', { className: 'dk_elevPanel', children: [
|
|
7581
|
+
jsxs('div', { className: 'dk_row', children: [
|
|
7582
|
+
jsx('span', { className: 'dk_label', children: t('elev.title') + ' · ' + capabilityLabelOf(capability) }),
|
|
7583
|
+
jsx('button', {
|
|
7584
|
+
type: 'button',
|
|
7585
|
+
className: 'dk_btn',
|
|
7586
|
+
onClick: () => options.onClose(capability),
|
|
7587
|
+
children: t('elev.close'),
|
|
7588
|
+
}, 'close'),
|
|
7589
|
+
] }),
|
|
7590
|
+
jsx('span', { className: 'dk_hint', children: t('elev.lockedWhy') }),
|
|
7591
|
+
record.error === undefined
|
|
7592
|
+
? null
|
|
7593
|
+
: jsx('span', { className: 'dk_hint dk_hintWarn', children: t('elev.error') + String(record.error) }),
|
|
7594
|
+
record.command === undefined
|
|
7595
|
+
? null
|
|
7596
|
+
: jsxs('div', { className: 'dk_elevSteps', children: [
|
|
7597
|
+
jsx('span', { className: 'dk_hint', children: t('elev.step') }),
|
|
7598
|
+
jsx('code', { className: 'dk_elevCommand', children: String(record.command) }),
|
|
7599
|
+
/*
|
|
7600
|
+
* 动作行:复制 / 重新生成 / 倒计时**并排一行、紧贴命令**。
|
|
7601
|
+
*
|
|
7602
|
+
* 早先这三样各自占一条栅格行,而且「重新生成」被「282 秒后失效」「等待确认…」两行
|
|
7603
|
+
* 说明文字隔在下面——2026-09-27 的真机报告(Windows)里那块看上去就是散的:
|
|
7604
|
+
* 按钮离自己的命令越远,越像「不知道点了会发生什么」。命令与它的按钮是一件事(docker D149)。
|
|
7605
|
+
*/
|
|
7606
|
+
jsxs('div', { className: 'dk_elevActions', children: [
|
|
7607
|
+
jsx('button', {
|
|
7608
|
+
type: 'button',
|
|
7609
|
+
className: 'dk_btn dk_btnPrimary',
|
|
7610
|
+
// 复制反馈**逐能力**隔离:这个按钮只认自己能力的键
|
|
7611
|
+
onClick: () => options.onCopy(capability, String(record.command)),
|
|
7612
|
+
children: options.copiedKey === capability ? t('elev.copied') : t('elev.copy'),
|
|
7613
|
+
}, 'copy'),
|
|
7614
|
+
jsx('button', {
|
|
7615
|
+
type: 'button',
|
|
7616
|
+
className: 'dk_btn',
|
|
7617
|
+
onClick: () => options.onRegenerate(capability),
|
|
7618
|
+
children: t('elev.regenerate'),
|
|
7619
|
+
}, 'regen'),
|
|
7620
|
+
jsx('span', {
|
|
7621
|
+
className: seconds > 0 ? 'dk_hint' : 'dk_hint dk_hintWarn',
|
|
7622
|
+
children: seconds > 0 ? t('elev.expiresIn', { sec: seconds }) : t('elev.expired'),
|
|
7623
|
+
}),
|
|
7624
|
+
] }),
|
|
7625
|
+
jsx('span', { className: 'dk_hint', children: t('elev.waiting') }),
|
|
7626
|
+
jsxs('details', { className: 'dk_elevOther', children: [
|
|
7627
|
+
jsx('summary', { children: t('elev.otherWay') }),
|
|
7628
|
+
jsx('span', {
|
|
7629
|
+
className: 'dk_hint',
|
|
7630
|
+
children: t('elev.envHow', { env: CAPABILITY_ENV[capability] ?? '' }),
|
|
7631
|
+
}),
|
|
7632
|
+
] }),
|
|
7633
|
+
] }),
|
|
7634
|
+
options.configured(capability) === true
|
|
7635
|
+
? jsx('button', {
|
|
7636
|
+
type: 'button',
|
|
7637
|
+
className: 'dk_btn',
|
|
7638
|
+
onClick: () => options.onDisableFirst(capability),
|
|
7639
|
+
children: t('elev.disableFirst'),
|
|
7640
|
+
}, 'disable')
|
|
7641
|
+
: null,
|
|
7642
|
+
] })
|
|
7643
|
+
}
|
|
7644
|
+
return jsxs('div', { className: 'dk_elevPanels', children: [
|
|
7645
|
+
shouldOfferMerge(list, now)
|
|
7646
|
+
? jsxs('div', { className: 'dk_elevMerge', children: [
|
|
7647
|
+
jsx('button', {
|
|
7648
|
+
type: 'button',
|
|
7649
|
+
className: 'dk_btn dk_btnPrimary',
|
|
7650
|
+
onClick: () => options.onCopyAll(joinElevationCommands(merged), merged.length),
|
|
7651
|
+
children: options.copiedKey === ELEVATION_COPY_ALL
|
|
7652
|
+
? t('elev.copyAllCopied', { count: merged.length })
|
|
7653
|
+
: t('elev.copyAll', { count: merged.length }),
|
|
7654
|
+
}, 'copyAll'),
|
|
7655
|
+
jsx('span', { className: 'dk_hint', children: t('elev.copyAllHint') }),
|
|
7656
|
+
] })
|
|
7657
|
+
: null,
|
|
7658
|
+
...pending.map((record) => panel(record)),
|
|
7659
|
+
] })
|
|
7660
|
+
}
|
|
7661
|
+
|
|
7537
7662
|
function DockerSettingsCard(props) {
|
|
7538
7663
|
// DSH ≥0.1.6 的插件配置页把同一条目按 view 渲染两次:summary 一句话摘要、page 完整表单。
|
|
7539
7664
|
// 旧版(≤0.1.5)的 settings.plugin.item 卡片不带 view,走原有可折叠卡片分支。
|
|
@@ -7565,10 +7690,26 @@ window.__ModuleLoader__.load({
|
|
|
7565
7690
|
* 就地提权(见 kit 的 elevation.ts):点开关 → 面板给出一条「在宿主上落地一个随机名文件」的
|
|
7566
7691
|
* 命令 → 宿主发现文件即授权,免重启。状态放在卡片里而不是子组件里:离线冒烟的 React 桩不执行
|
|
7567
7692
|
* 函数组件体(见文件里其它地方的说明),状态留在卡片里才测得动。
|
|
7693
|
+
*
|
|
7694
|
+
* **逐能力**记录(数组,按发起顺序)而不是单槽:面板开着时再点另一个开关,单槽会把前一个
|
|
7695
|
+
* 能力的面板**替换**掉——它的命令从界面消失(宿主侧 challenge 仍 pending),用户于是要在宿主
|
|
7696
|
+
* 终端粘贴两次(2026-09-26 截图实测:两次授权差 14 秒)。逐能力记录让两块面板同时在场,
|
|
7697
|
+
* 顶部出一个「复制全部」。纯逻辑在 `client-src/elevation.js`(冒烟能直接驱动)。
|
|
7698
|
+
*
|
|
7699
|
+
* 为什么不做「kit 侧 begin 返回合并命令」(备选方案,已否):`finishElevation` 只处理**被轮询
|
|
7700
|
+
* 到的那个**能力,合并命令会让另一个能力停在「宿主已授权、配置开关没开」的半状态;还要处理
|
|
7701
|
+
* 两条独立探测定时器之间的授权竞态。客户端合并零 API 变更、零新安全面——kit 的
|
|
7702
|
+
* begin / status / revoke 契约一字不动(nonce 仍只出现在 begin 响应里)。
|
|
7568
7703
|
*/
|
|
7569
|
-
const [
|
|
7704
|
+
const [elevations, setElevations] = useState([])
|
|
7570
7705
|
const [elevationNotice, setElevationNotice] = useState('')
|
|
7571
|
-
|
|
7706
|
+
/**
|
|
7707
|
+
* 复制反馈的归属键(**不是**布尔):单条按钮传自己的能力名、合并按钮传哨兵
|
|
7708
|
+
* `ELEVATION_COPY_ALL`,渲染时各自判自己的键——于是「复制了 A」不会让 B 的按钮也显示
|
|
7709
|
+
* 「已复制」,而「复制全部」也不会让每一块面板都亮起来(改前是一个全局 `copied` 布尔,
|
|
7710
|
+
* 两个按钮会一起变成已复制)。同一时刻只留一处反馈是刻意的:它说的是「你刚复制的是哪一份」。
|
|
7711
|
+
*/
|
|
7712
|
+
const [copiedKey, setCopiedKey] = useState('')
|
|
7572
7713
|
/** 每秒推进一次的计数:只为了让「还剩 N 秒」自己走(读的是渲染期的时间差)。 */
|
|
7573
7714
|
const [nowTick, setNowTick] = useState(0)
|
|
7574
7715
|
/** 授权到达后要「先把开关写进表单、再自动保存」:保存读的是表单镜像,得等一次提交。 */
|
|
@@ -7691,12 +7832,6 @@ window.__ModuleLoader__.load({
|
|
|
7691
7832
|
|
|
7692
7833
|
/* ---------------- 就地提权(capability elevation) ---------------- */
|
|
7693
7834
|
|
|
7694
|
-
/** 能力开关名 → 宿主侧环境变量名(「另一种方式」那一栏要写出具体变量名)。 */
|
|
7695
|
-
const CAPABILITY_ENV = {
|
|
7696
|
-
allowMutations: 'DSH_DOCKER_ALLOW_MUTATIONS',
|
|
7697
|
-
allowExec: 'DSH_DOCKER_ALLOW_EXEC',
|
|
7698
|
-
}
|
|
7699
|
-
|
|
7700
7835
|
/**
|
|
7701
7836
|
* 快照 → 表单:能力开关在表单里存**配置值**(用户写下的那个),不是快照里的有效值。
|
|
7702
7837
|
*
|
|
@@ -7735,8 +7870,18 @@ window.__ModuleLoader__.load({
|
|
|
7735
7870
|
* 一起写进去。脏的时候只把开关置上并提示「点保存生效」——不替用户做决定。
|
|
7736
7871
|
*/
|
|
7737
7872
|
const finishElevation = (capability) => {
|
|
7738
|
-
|
|
7739
|
-
|
|
7873
|
+
/*
|
|
7874
|
+
* **不删记录**,只把它标成已授权并把命令清掉(面板据此消失)。为什么留着这条痕迹:
|
|
7875
|
+
* 合并按钮的存续跟着「面板组里有过几个能力」,不是跟着当前还剩几条命令——两个能力
|
|
7876
|
+
* 都点过之后,其中一个先解锁,按钮要留着把**剩下那条**交给用户(此时再让他回单条面板
|
|
7877
|
+
* 里找命令,等于把刚刚那次「一次粘贴」的动作拆回去)。两个都解锁后组里没有可复制命令,
|
|
7878
|
+
* 按钮自然消失(见 shouldOfferMerge)。
|
|
7879
|
+
*
|
|
7880
|
+
* 反向也成立:「单个 pending → 无合并按钮」——只动过一个开关时组里只有一条记录,
|
|
7881
|
+
* 那条命令就在它自己的面板里,不需要一个「复制全部(1 条)」的按钮。
|
|
7882
|
+
*/
|
|
7883
|
+
setElevations((current) => markElevationGranted(current, capability))
|
|
7884
|
+
setCopiedKey('')
|
|
7740
7885
|
const clean = JSON.stringify(toPayload(formRef.current)) === cleanPayloadRef.current
|
|
7741
7886
|
setForm((current) => (current === null ? current : { ...current, [capability]: true }))
|
|
7742
7887
|
setElevationNotice(clean ? t('elev.granted') : t('elev.grantedNeedSave'))
|
|
@@ -7746,19 +7891,37 @@ window.__ModuleLoader__.load({
|
|
|
7746
7891
|
|
|
7747
7892
|
const beginElevation = (capability) => {
|
|
7748
7893
|
setElevationNotice('')
|
|
7749
|
-
|
|
7750
|
-
|
|
7894
|
+
setCopiedKey('')
|
|
7895
|
+
// 只增改**这一个**能力的记录,别的 pending 原样留着(面板不再被替换)
|
|
7896
|
+
setElevations((current) => upsertElevationRecord(current, capability, {
|
|
7897
|
+
capability,
|
|
7898
|
+
command: undefined,
|
|
7899
|
+
expiresAt: undefined,
|
|
7900
|
+
error: undefined,
|
|
7901
|
+
// 清掉上一轮的「已授权」痕迹:撤销授权后再点这个开关要能重新出面板
|
|
7902
|
+
granted: false,
|
|
7903
|
+
}))
|
|
7751
7904
|
api.elevateBegin(capability).then((result) => {
|
|
7752
7905
|
if (result !== null && result.status === 'granted') {
|
|
7753
7906
|
finishElevation(capability)
|
|
7754
7907
|
return
|
|
7755
7908
|
}
|
|
7756
7909
|
if (result !== null && result.status === 'pending') {
|
|
7757
|
-
|
|
7910
|
+
setElevations((current) => upsertElevationRecord(current, capability, {
|
|
7911
|
+
capability,
|
|
7912
|
+
command: String(result.command),
|
|
7913
|
+
expiresAt: Number(result.expiresAt),
|
|
7914
|
+
}))
|
|
7758
7915
|
return
|
|
7759
7916
|
}
|
|
7760
|
-
|
|
7761
|
-
|
|
7917
|
+
setElevations((current) => upsertElevationRecord(current, capability, {
|
|
7918
|
+
capability,
|
|
7919
|
+
error: String((result && result.error) ?? ''),
|
|
7920
|
+
}))
|
|
7921
|
+
}).catch((error) => setElevations((current) => upsertElevationRecord(current, capability, {
|
|
7922
|
+
capability,
|
|
7923
|
+
error: String(error.message),
|
|
7924
|
+
})))
|
|
7762
7925
|
}
|
|
7763
7926
|
|
|
7764
7927
|
const revokeElevation = (capability) => {
|
|
@@ -7769,14 +7932,36 @@ window.__ModuleLoader__.load({
|
|
|
7769
7932
|
}).catch((error) => setElevationNotice(t('elev.error') + String(error.message)))
|
|
7770
7933
|
}
|
|
7771
7934
|
|
|
7772
|
-
/**
|
|
7773
|
-
|
|
7935
|
+
/**
|
|
7936
|
+
* 复制命令:拿不到剪贴板(老宿主 / 非安全上下文)时退回「请手动复制」。
|
|
7937
|
+
*
|
|
7938
|
+
* `key` 是反馈的归属:单条按钮传能力名,合并按钮传 `ELEVATION_COPY_ALL`——
|
|
7939
|
+
* 于是「复制了 A」不会让 B 的按钮也显示「已复制」(早先是一个全局 `copied`)。
|
|
7940
|
+
*/
|
|
7941
|
+
const copyCommand = (key, command) => {
|
|
7774
7942
|
const clipboard = typeof navigator === 'undefined' ? undefined : navigator.clipboard
|
|
7775
7943
|
if (clipboard === undefined || typeof clipboard.writeText !== 'function') {
|
|
7776
7944
|
setElevationNotice(t('elev.copyManual'))
|
|
7777
7945
|
return
|
|
7778
7946
|
}
|
|
7779
|
-
clipboard.writeText(command).then(() =>
|
|
7947
|
+
clipboard.writeText(command).then(() => setCopiedKey(key)).catch(() => setElevationNotice(t('elev.copyManual')))
|
|
7948
|
+
}
|
|
7949
|
+
|
|
7950
|
+
/**
|
|
7951
|
+
* 合并复制:把所有**仍 pending**(未过期、已拿到命令)的命令按发起顺序用换行连接后一次复制。
|
|
7952
|
+
*
|
|
7953
|
+
* 命令的取用 / 排序 / 连接全在纯函数里(`client-src/elevation.js` 的 `buildMergedElevationText`
|
|
7954
|
+
* 与 `joinElevationCommands`),这里只负责把它交给剪贴板。连接方式为什么是换行而不是 `&&` / `;`:
|
|
7955
|
+
* bash / zsh / PowerShell / cmd 粘贴多行都逐行执行,平台无关;`&&` 在 PS 5.1 上不认,`;` 在 cmd
|
|
7956
|
+
* 上不认。文案只说「执行后全部开关自动解锁」,不承诺同一时刻:两个 challenge 的 TTL 与探测
|
|
7957
|
+
* 周期各自独立(逐面板倒计时照旧显示)。
|
|
7958
|
+
*/
|
|
7959
|
+
const copyAllElevations = (text) => {
|
|
7960
|
+
if (text === '') {
|
|
7961
|
+
setElevationNotice(t('elev.copyManual'))
|
|
7962
|
+
return
|
|
7963
|
+
}
|
|
7964
|
+
copyCommand(ELEVATION_COPY_ALL, text)
|
|
7780
7965
|
}
|
|
7781
7966
|
|
|
7782
7967
|
/*
|
|
@@ -7793,20 +7978,33 @@ window.__ModuleLoader__.load({
|
|
|
7793
7978
|
/*
|
|
7794
7979
|
* 面板开着时轮询授权状态(1.5s)并顺带推进倒计时。轮询而不是等推送:这条通道本来就是
|
|
7795
7980
|
* 「用户在别的终端里做了一件事」,宿主没有可订阅的事件面。
|
|
7981
|
+
*
|
|
7982
|
+
* **轮询全部 pending 能力**(不再只盯单槽那一个):两个 challenge 是各自独立开始的,
|
|
7983
|
+
* 各自的授权时刻可以差十几秒;只轮询一个会让另一个停在「宿主已授权、开关没开」。
|
|
7984
|
+
* deps 用**能力名的拼接串**而不是数组:数组每次渲染都是新引用,effect 会被反复拆装,
|
|
7985
|
+
* 1.5s 的节拍随之漂成「每渲染一次就重建定时器」。内容不变时它一字不变,effect 就不动。
|
|
7796
7986
|
*/
|
|
7797
|
-
const
|
|
7987
|
+
const elevationPendingKey = elevations
|
|
7988
|
+
// 已授权的那条只是「留痕」(面板已消失),不再轮询——否则每 1.5s 又会走一遍 finishElevation
|
|
7989
|
+
.filter((item) => item.granted !== true)
|
|
7990
|
+
.map((item) => String(item.capability))
|
|
7991
|
+
.sort()
|
|
7992
|
+
.join(',')
|
|
7798
7993
|
useEffect(() => {
|
|
7799
|
-
if (
|
|
7994
|
+
if (elevationPendingKey === '') return undefined
|
|
7995
|
+
const capabilities = elevationPendingKey.split(',')
|
|
7800
7996
|
const timer = setInterval(() => {
|
|
7801
7997
|
setNowTick((value) => value + 1)
|
|
7802
|
-
|
|
7803
|
-
|
|
7804
|
-
|
|
7805
|
-
|
|
7806
|
-
|
|
7998
|
+
for (const capability of capabilities) {
|
|
7999
|
+
api.elevateStatus(capability).then((status) => {
|
|
8000
|
+
if (status !== null && status.status === 'granted') finishElevation(capability)
|
|
8001
|
+
}).catch(() => {
|
|
8002
|
+
/* 轮询失败静默:宿主可能在重启,下一次再试 */
|
|
8003
|
+
})
|
|
8004
|
+
}
|
|
7807
8005
|
}, 1500)
|
|
7808
8006
|
return () => clearInterval(timer)
|
|
7809
|
-
}, [
|
|
8007
|
+
}, [elevationPendingKey])
|
|
7810
8008
|
|
|
7811
8009
|
/* ---------------- 能力开关那一段的渲染 ---------------- */
|
|
7812
8010
|
|
|
@@ -7912,79 +8110,35 @@ window.__ModuleLoader__.load({
|
|
|
7912
8110
|
] })
|
|
7913
8111
|
}
|
|
7914
8112
|
|
|
7915
|
-
/**
|
|
7916
|
-
|
|
7917
|
-
|
|
7918
|
-
|
|
7919
|
-
|
|
7920
|
-
|
|
7921
|
-
|
|
7922
|
-
|
|
7923
|
-
|
|
7924
|
-
|
|
7925
|
-
|
|
7926
|
-
|
|
7927
|
-
|
|
7928
|
-
jsx('span', { className: 'dk_label', children: t('elev.title') + ' · ' + label }),
|
|
7929
|
-
jsx('button', { type: 'button', className: 'dk_btn', onClick: close, children: t('elev.close') }, 'close'),
|
|
7930
|
-
] }),
|
|
7931
|
-
jsx('span', { className: 'dk_hint', children: t('elev.lockedWhy') }),
|
|
7932
|
-
elevation.error === undefined
|
|
7933
|
-
? null
|
|
7934
|
-
: jsx('span', { className: 'dk_hint dk_hintWarn', children: t('elev.error') + String(elevation.error) }),
|
|
7935
|
-
elevation.command === undefined
|
|
7936
|
-
? null
|
|
7937
|
-
: jsxs('div', { className: 'dk_elevSteps', children: [
|
|
7938
|
-
jsx('span', { className: 'dk_hint', children: t('elev.step') }),
|
|
7939
|
-
jsx('code', { className: 'dk_elevCommand', children: String(elevation.command) }),
|
|
7940
|
-
/*
|
|
7941
|
-
* 动作行:复制 / 重新生成 / 倒计时**并排一行、紧贴命令**。
|
|
7942
|
-
*
|
|
7943
|
-
* 早先这三样各自占一条栅格行,而且「重新生成」被「282 秒后失效」「等待确认…」两行
|
|
7944
|
-
* 说明文字隔在下面——2026-09-27 的真机报告(Windows)里那块看上去就是散的:
|
|
7945
|
-
* 按钮离自己的命令越远,越像「不知道点了会发生什么」。命令与它的按钮是一件事(docker D149)。
|
|
7946
|
-
*/
|
|
7947
|
-
jsxs('div', { className: 'dk_elevActions', children: [
|
|
7948
|
-
jsx('button', {
|
|
7949
|
-
type: 'button',
|
|
7950
|
-
className: 'dk_btn dk_btnPrimary',
|
|
7951
|
-
onClick: () => copyCommand(String(elevation.command)),
|
|
7952
|
-
children: copied ? t('elev.copied') : t('elev.copy'),
|
|
7953
|
-
}, 'copy'),
|
|
7954
|
-
jsx('button', {
|
|
7955
|
-
type: 'button',
|
|
7956
|
-
className: 'dk_btn',
|
|
7957
|
-
onClick: () => beginElevation(capability),
|
|
7958
|
-
children: t('elev.regenerate'),
|
|
7959
|
-
}, 'regen'),
|
|
7960
|
-
jsx('span', {
|
|
7961
|
-
className: seconds > 0 ? 'dk_hint' : 'dk_hint dk_hintWarn',
|
|
7962
|
-
children: seconds > 0 ? t('elev.expiresIn', { sec: seconds }) : t('elev.expired'),
|
|
7963
|
-
}),
|
|
7964
|
-
] }),
|
|
7965
|
-
jsx('span', { className: 'dk_hint', children: t('elev.waiting') }),
|
|
7966
|
-
jsxs('details', { className: 'dk_elevOther', children: [
|
|
7967
|
-
jsx('summary', { children: t('elev.otherWay') }),
|
|
7968
|
-
jsx('span', {
|
|
7969
|
-
className: 'dk_hint',
|
|
7970
|
-
children: t('elev.envHow', { env: CAPABILITY_ENV[capability] ?? '' }),
|
|
7971
|
-
}),
|
|
7972
|
-
] }),
|
|
7973
|
-
] }),
|
|
7974
|
-
form[capability] === true
|
|
7975
|
-
? jsx('button', {
|
|
7976
|
-
type: 'button',
|
|
7977
|
-
className: 'dk_btn',
|
|
7978
|
-
onClick: () => {
|
|
7979
|
-
patch({ [capability]: false })
|
|
7980
|
-
close()
|
|
7981
|
-
},
|
|
7982
|
-
children: t('elev.disableFirst'),
|
|
7983
|
-
}, 'disable')
|
|
7984
|
-
: null,
|
|
7985
|
-
] })
|
|
8113
|
+
/**
|
|
8114
|
+
* 一块能力的面板(✕)与整个面板组都渲染自 `elevationPanelsView`(factory 级纯函数,
|
|
8115
|
+
* 见那段注释):组件这边只提供**状态与回调**,界面结构留在纯函数里,冒烟才驱动得动。
|
|
8116
|
+
*
|
|
8117
|
+
* 逐个能力的动作语义:
|
|
8118
|
+
* - ✕ 只收起**这一个**能力的面板;别的 pending 原样留着(也不撤销宿主侧 challenge,
|
|
8119
|
+
* 与改造前 close 的语义一致——challenge 仍在 pending,重新点开关即复用同一条命令)。
|
|
8120
|
+
* - 「重新生成」= 再走一次 `beginElevation`(逐能力幂等,不动别的能力)。
|
|
8121
|
+
* - 「先关掉这个配置开关」= 关配置 + 收起自己那块面板。
|
|
8122
|
+
*/
|
|
8123
|
+
const closeElevationPanel = (capability) => {
|
|
8124
|
+
setElevations((current) => pruneElevationRecords(removeElevationRecord(current, capability)))
|
|
8125
|
+
setCopiedKey('')
|
|
7986
8126
|
}
|
|
7987
8127
|
|
|
8128
|
+
const elevationPanels = () => elevationPanelsView(elevations, {
|
|
8129
|
+
now: Date.now(),
|
|
8130
|
+
copiedKey,
|
|
8131
|
+
configured: (capability) => form[capability] === true,
|
|
8132
|
+
onCopy: (capability, command) => copyCommand(capability, command),
|
|
8133
|
+
onCopyAll: (text) => copyAllElevations(text),
|
|
8134
|
+
onRegenerate: (capability) => beginElevation(capability),
|
|
8135
|
+
onClose: (capability) => closeElevationPanel(capability),
|
|
8136
|
+
onDisableFirst: (capability) => {
|
|
8137
|
+
patch({ [capability]: false })
|
|
8138
|
+
closeElevationPanel(capability)
|
|
8139
|
+
},
|
|
8140
|
+
})
|
|
8141
|
+
|
|
7988
8142
|
const sectionTitle = (text) => jsx('div', { className: 'dk_cardSection', children: text })
|
|
7989
8143
|
|
|
7990
8144
|
const field = (label, control, hint, span) => jsxs('div', {
|
|
@@ -8096,7 +8250,7 @@ window.__ModuleLoader__.load({
|
|
|
8096
8250
|
elevationNotice === ''
|
|
8097
8251
|
? null
|
|
8098
8252
|
: jsx('span', { className: 'dk_hint', children: elevationNotice }),
|
|
8099
|
-
|
|
8253
|
+
elevations.length === 0 ? null : elevationPanels(),
|
|
8100
8254
|
|
|
8101
8255
|
sectionTitle(t('field.target')),
|
|
8102
8256
|
...form.targets.map((item, index) => {
|
|
@@ -8619,6 +8773,29 @@ window.__ModuleLoader__.load({
|
|
|
8619
8773
|
PENDING_MAX: LOG_PENDING_MAX,
|
|
8620
8774
|
FLUSH_MS: FOLLOW_FLUSH_MS,
|
|
8621
8775
|
}
|
|
8776
|
+
/*
|
|
8777
|
+
* 就地提权「合并复制」的测试缝:逐能力记录的增改 / 收尾、取哪几条命令、顺序、怎么连接,
|
|
8778
|
+
* 全是纯逻辑(`client-src/elevation.js`)。**必须直接驱动**而不是渲染卡片:离线冒烟的
|
|
8779
|
+
* React 桩把 `useState` 冻在初值上、且 `useEffect` 是空实现,两块面板同时在场这件事
|
|
8780
|
+
* 在组件里根本走不到(这正是 2026-09-26 那次「必须粘贴两次」漏掉测试入口的原因)。
|
|
8781
|
+
*/
|
|
8782
|
+
exports.__elevation = {
|
|
8783
|
+
upsert: upsertElevationRecord,
|
|
8784
|
+
remove: removeElevationRecord,
|
|
8785
|
+
markGranted: markElevationGranted,
|
|
8786
|
+
prune: pruneElevationRecords,
|
|
8787
|
+
merge: mergeElevationCommands,
|
|
8788
|
+
join: joinElevationCommands,
|
|
8789
|
+
text: buildMergedElevationText,
|
|
8790
|
+
shouldOfferMerge,
|
|
8791
|
+
COPY_ALL_KEY: ELEVATION_COPY_ALL,
|
|
8792
|
+
/**
|
|
8793
|
+
* 面板组的**渲染**(与 `__overview.body` 同一形态):用例据它断言「几个 pending 才出
|
|
8794
|
+
* 合并按钮」「按钮的 onClick 到底把什么字符串交下去」——只钉纯函数的话,「界面上没有
|
|
8795
|
+
* 那个按钮」与「按钮接错了内容」这两类错都漏得掉。
|
|
8796
|
+
*/
|
|
8797
|
+
view: elevationPanelsView,
|
|
8798
|
+
}
|
|
8622
8799
|
/*
|
|
8623
8800
|
* 窗口化挂载(D152)的测试缝:布局是纯逻辑,离线冒烟直接驱动(真 DOM 里的量高与
|
|
8624
8801
|
* 滚动位置由 test/log-window.test.ts 与性能门禁覆盖)。常量挂出来供用例对齐。
|