dsh-autorestart 0.3.6 → 0.5.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.md +147 -7
- package/client.js +2362 -147
- package/cordis.patch.yml +16 -0
- package/docs/README.zh.md +98 -10
- package/index.js +896 -67
- package/lib/checkpoint.js +38 -0
- package/lib/sleep.js +828 -0
- package/locale/en.json +101 -1
- package/locale/zh.json +101 -1
- package/package.json +1 -1
package/client.js
CHANGED
|
@@ -44,8 +44,489 @@
|
|
|
44
44
|
*
|
|
45
45
|
* Everything here is side-effect free until `apply(ctx)` runs: no timers, no
|
|
46
46
|
* fetches, no DOM access at module scope.
|
|
47
|
+
*
|
|
48
|
+
* The graceful-shutdown ("sleep") half adds three more surfaces:
|
|
49
|
+
*
|
|
50
|
+
* - the shutdown BUTTON at the foot of the left sidebar, beside the
|
|
51
|
+
* Settings row (`sidebar.footer.action` slot): a moon icon and the label
|
|
52
|
+
* "收工关机". That slot is a ROOT slot, so the button is there even when
|
|
53
|
+
* no conversation is open. It renders NOTHING at all when the Host says
|
|
54
|
+
* the button must not be shown, so no empty space is left behind;
|
|
55
|
+
* - the COUNTDOWN CARD in the shell's overlay layer (`shell.overlay`
|
|
56
|
+
* slot): a FLOATING rounded card with a very big number, a hand drawn
|
|
57
|
+
* whale (inline SVG + CSS keyframes, no image and no other plugin) and
|
|
58
|
+
* one funny line per second — plus a "算了,别关" button that cancels
|
|
59
|
+
* the shutdown. The card NEVER covers the interface: it sits in a corner,
|
|
60
|
+
* it lets every click through (only the cancel button takes clicks), so
|
|
61
|
+
* the conversation stays readable and the sidebar stays usable while the
|
|
62
|
+
* machine counts down. While the sessions are still working, a small
|
|
63
|
+
* corner card takes the place of the countdown card, so nothing is
|
|
64
|
+
* covered up;
|
|
65
|
+
* - the SECOND install question: the first install asks about the backup
|
|
66
|
+
* scope, and only after that answer was persisted does it ask whether the
|
|
67
|
+
* shutdown button should stay.
|
|
68
|
+
*
|
|
69
|
+
* All of them read the Host's `GET/POST {prefix}/sleep` route and share ONE
|
|
70
|
+
* poller with the same 2s cadence as the pending-restart store.
|
|
71
|
+
*/
|
|
72
|
+
|
|
73
|
+
/* ------------------------------------------------------------------ *
|
|
74
|
+
* The pure half of the graceful-shutdown ("sleep") feature.
|
|
75
|
+
*
|
|
76
|
+
* This file is a CLASSIC script: the client module loader evaluates a
|
|
77
|
+
* plugin bundle as a same-origin external classic script, so the ESM
|
|
78
|
+
* `export` keyword is NOT available here. The four pure helpers are
|
|
79
|
+
* therefore published in the two places a test can reach them:
|
|
80
|
+
*
|
|
81
|
+
* 1. as NAMED properties of this module's exports — the client module
|
|
82
|
+
* system materializes `factory(require) -> exports`, so the exports
|
|
83
|
+
* object carries `pickSleepCaption`, `shouldShowSleepButton`,
|
|
84
|
+
* `buildChoiceBody` and `formatRemainingMs`;
|
|
85
|
+
* 2. on the global namespace `globalThis.__dshAutorestartClient`, which
|
|
86
|
+
* exists as soon as this file is evaluated (no factory call needed).
|
|
87
|
+
*
|
|
88
|
+
* The copy lives in the two dictionaries below and is mirrored verbatim in
|
|
89
|
+
* locale/zh.json + locale/en.json (same words, same keys).
|
|
90
|
+
* ------------------------------------------------------------------ */
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* The browser-side record of the "keep the sleep button?" answer.
|
|
94
|
+
*
|
|
95
|
+
* The question is asked exactly once (on the first install), so the key is
|
|
96
|
+
* written as soon as the user answers — or closes the prompt.
|
|
97
|
+
*/
|
|
98
|
+
const SLEEP_BUTTON_KEY = 'dsh-autorestart:sleep-button-v1'
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The longest countdown the settings page offers (60 seconds).
|
|
102
|
+
*
|
|
103
|
+
* The first twenty seconds have a line each; every second above that uses
|
|
104
|
+
* the generic fallback line, so no offered length ever shows a blank.
|
|
105
|
+
*/
|
|
106
|
+
const SLEEP_MAX_COUNTDOWN_SECONDS = 60
|
|
107
|
+
|
|
108
|
+
/** The countdown length the settings page offers (a dropdown). */
|
|
109
|
+
const SLEEP_COUNTDOWN_CHOICES = Object.freeze([0, 10, 20, 30, 60])
|
|
110
|
+
|
|
111
|
+
/** The countdown length used when the Host did not report one. */
|
|
112
|
+
const SLEEP_DEFAULT_COUNTDOWN_SECONDS = 20
|
|
113
|
+
|
|
114
|
+
/** How long the note of a finished rehearsal stays on screen (ms). */
|
|
115
|
+
const SLEEP_DRY_RUN_NOTICE_MS = 8_000
|
|
116
|
+
|
|
117
|
+
const ZH = {
|
|
118
|
+
label: '待重启',
|
|
119
|
+
busyPrefix: ' · 被 ',
|
|
120
|
+
busySuffix: ' 个会话占用',
|
|
121
|
+
idle: ' · 空闲后自动重启',
|
|
122
|
+
goalActive: ' · 被某个会话自动续跑的 goal 占着',
|
|
123
|
+
goalExhausted: ' · 某个会话的 goal 轮次额度已用完,无法续跑',
|
|
124
|
+
parkedGoals: ' · 已停泊 {n} 个 goal,重启后自动继续',
|
|
125
|
+
parkButton: '停泊并重启',
|
|
126
|
+
parkButtonAria: '停泊该会话的 goal,并立即请求重启',
|
|
127
|
+
parkBusy: '停泊中…',
|
|
128
|
+
parkError: '停泊失败:{message}',
|
|
129
|
+
tooltip: '等待其他会话空闲后自动重启;重启后会自动继续本会话进度',
|
|
130
|
+
tooltipBusy: '等待其他会话空闲后自动重启(计数含“排队但还没开始”的任务);重启后本会话会自动继续进度',
|
|
131
|
+
tooltipWithError: '等待其他会话空闲后自动重启;重启后本会话会自动继续进度。{message}',
|
|
132
|
+
dotAria: '待重启',
|
|
133
|
+
panelHeading: '待重启',
|
|
134
|
+
panelStateBusy: '等待其他会话空闲后重启',
|
|
135
|
+
panelStateIdle: '等待空闲窗口后重启',
|
|
136
|
+
panelStateGoalActive: '等待其他会话停泊它自动续跑的 goal',
|
|
137
|
+
panelStateGoalExhausted: '等待其他会话的 goal(轮次额度已用完)',
|
|
138
|
+
panelBlockedTitle: '占用会话:{title}',
|
|
139
|
+
panelBlockedGeneric: '有一个会话正在运行',
|
|
140
|
+
panelGoalActiveLine: '占用会话「{title}」:goal 正在自动续跑(goal-active)',
|
|
141
|
+
panelGoalExhaustedLine: '占用会话「{title}」:goal 轮次额度已用完,无法继续(goal-exhausted)',
|
|
142
|
+
panelParkedTitle: '已停泊的 goal(重启后自动继续):',
|
|
143
|
+
panelParkedGoal: '会话 {session} · {goal}',
|
|
144
|
+
panelWaiting: '已等待 {duration}',
|
|
145
|
+
panelResume: '重启后本会话会自动继续进度',
|
|
146
|
+
durationSeconds: '{n} 秒',
|
|
147
|
+
durationMinutes: '{n} 分 {m} 秒',
|
|
148
|
+
durationHours: '{n} 小时 {m} 分',
|
|
149
|
+
// The one-time install prompt (the choice is asked exactly once).
|
|
150
|
+
// "Backup" is the verb and BOTH options share the "configuration" base:
|
|
151
|
+
// the plugins are an ADDITION to the configuration, never a rival of it.
|
|
152
|
+
backupChoiceTitle: '重启前自动备份:要不要把已安装的插件也一起备份?',
|
|
153
|
+
backupChoiceBody: '每次重启前会快照当前配置,万一重启后起不来就能自动回退。',
|
|
154
|
+
backupChoiceWithPlugins: '配置 + 已安装插件',
|
|
155
|
+
backupChoiceWithPluginsHint: '多约 3 秒、约 117 MB;插件把启动搞坏时也能回退。',
|
|
156
|
+
backupChoiceConfigOnly: '只备份配置',
|
|
157
|
+
backupChoiceConfigOnlyHint: '瞬间完成、约 66 KB;插件问题无法回退,需要手动重装。',
|
|
158
|
+
backupChoiceFootnote: '以后可在 设置 → 自动重启与关机 修改',
|
|
159
|
+
backupChoicePickPrimary: '用这个',
|
|
160
|
+
backupChoicePickSecondary: '只备份配置',
|
|
161
|
+
backupChoiceRecommended: '推荐',
|
|
162
|
+
backupChoiceDismissAria: '关闭这个一次性提示',
|
|
163
|
+
backupChoiceError: '保存失败:{message}',
|
|
164
|
+
// The graceful-shutdown (sleep) surfaces: the button at the foot of the
|
|
165
|
+
// left sidebar, the floating countdown card, the waiting card and the
|
|
166
|
+
// second install question.
|
|
167
|
+
'sleep.button.label': '收工关机',
|
|
168
|
+
'sleep.button.cancel': '取消关机',
|
|
169
|
+
'sleep.button.aria': '收工关机:等所有会话收工后关机',
|
|
170
|
+
'sleep.button.title': '等所有会话收工,再倒数 {n} 秒关机',
|
|
171
|
+
'sleep.button.titleImmediate': '等所有会话收工后立即关机(不倒数)',
|
|
172
|
+
'sleep.button.busy': '准备中…',
|
|
173
|
+
'sleep.error': '没能联系上关机服务:{message}',
|
|
174
|
+
// The settings section: the entry in the settings sidebar.
|
|
175
|
+
'nav': '自动重启与关机',
|
|
176
|
+
'settings.seconds.label': '倒计时秒数',
|
|
177
|
+
'settings.seconds.immediate': '不倒数(立即关机)',
|
|
178
|
+
'settings.seconds.value': '{n} 秒',
|
|
179
|
+
'settings.seconds.hint': '按下按钮后,等所有会话收工,再倒数这么多秒才关机;「不倒数」表示会话一收工就关机。',
|
|
180
|
+
'settings.backup.label': '重启前备份',
|
|
181
|
+
'settings.backup.hint': '每次重启前先快照一次当前配置;万一重启后起不来,能自动回退。',
|
|
182
|
+
'settings.backupPlugins.label': '备份包含已安装插件',
|
|
183
|
+
'settings.backupPlugins.hint': '关闭后只备份 DSH 自身状态,不含 profile 里的插件目录。',
|
|
184
|
+
'settings.error': '没能保存设置:{message}',
|
|
185
|
+
'sleep.dryRun.title': '试运行:不会真的关机',
|
|
186
|
+
'sleep.dryRun.body': '彩排结束,本来会执行这条命令(真的什么都没执行):',
|
|
187
|
+
'sleep.dryRun.close': '知道了',
|
|
188
|
+
'sleep.countdown.caption.20': '鲸鱼娘开始收网了',
|
|
189
|
+
'sleep.countdown.caption.19': '备份已经替你打包好',
|
|
190
|
+
'sleep.countdown.caption.18': '没说完的话,再讲一句',
|
|
191
|
+
'sleep.countdown.caption.17': '大家都在慢慢安静下来',
|
|
192
|
+
'sleep.countdown.caption.16': '最后再巡一圈房间',
|
|
193
|
+
'sleep.countdown.caption.15': '海面开始变暗了',
|
|
194
|
+
'sleep.countdown.caption.14': '灯一盏一盏熄了',
|
|
195
|
+
'sleep.countdown.caption.13': '房间已经安静下来',
|
|
196
|
+
'sleep.countdown.caption.12': '还有一小会儿',
|
|
197
|
+
'sleep.countdown.caption.11': '再看一眼屏幕吧',
|
|
198
|
+
'sleep.countdown.caption.10': '鲸鱼娘在数最后几秒',
|
|
199
|
+
'sleep.countdown.caption.9': '灯已经关了一半',
|
|
200
|
+
'sleep.countdown.caption.8': '声音越来越小',
|
|
201
|
+
'sleep.countdown.caption.7': '马上就到道别的时候了',
|
|
202
|
+
'sleep.countdown.caption.6': '最后一口海水',
|
|
203
|
+
'sleep.countdown.caption.5': '晚风越来越轻',
|
|
204
|
+
'sleep.countdown.caption.4': '说声晚安吧',
|
|
205
|
+
'sleep.countdown.caption.3': '真的只剩三秒啦',
|
|
206
|
+
'sleep.countdown.caption.2': '还来得及说声再见',
|
|
207
|
+
'sleep.countdown.caption.1': '晚安 🌙',
|
|
208
|
+
'sleep.countdown.caption.default': '晚安 🌙',
|
|
209
|
+
'sleep.countdown.remaining': '还剩 {n} 秒',
|
|
210
|
+
'sleep.countdown.cancel': '算了,别关',
|
|
211
|
+
'sleep.countdown.goodNight': '晚安 🌙',
|
|
212
|
+
'sleep.countdown.hint': '再过 {n} 秒就关机,点「算了,别关」可以留下。',
|
|
213
|
+
'sleep.waiting.title': '还在等大家收工',
|
|
214
|
+
'sleep.waiting.body': '等这些会话忙完,就轮到关机啦:',
|
|
215
|
+
'sleep.waiting.count': '还有 {n} 个会话在干活',
|
|
216
|
+
'sleep.waiting.blocker': '· {title}',
|
|
217
|
+
'sleep.waiting.none': '(正在确认还有谁在忙)',
|
|
218
|
+
'sleep.waiting.cancel': '不关了',
|
|
219
|
+
'sleep.shuttingDown.title': '晚安 🌙',
|
|
220
|
+
'sleep.shuttingDown.body': '正在关机,鲸鱼娘把灯关好了。',
|
|
221
|
+
'sleep.shuttingDown.dryRun': '试运行:不会真的关机(本来会执行 {command})',
|
|
222
|
+
'sleep.shuttingDown.dismiss': '关掉这个提示',
|
|
223
|
+
'sleep.choice.title': '收工关机按钮要不要留着?',
|
|
224
|
+
'sleep.choice.body': '它待在左侧栏最底部、设置按钮旁边:点一下就等所有会话收工,倒数几秒后关机。',
|
|
225
|
+
'sleep.choice.keep': '留着(推荐)',
|
|
226
|
+
'sleep.choice.keepCta': '留着',
|
|
227
|
+
'sleep.choice.keepHint': '左侧栏底部多一个月亮按钮,一键收工关机。',
|
|
228
|
+
'sleep.choice.hide': '不要这个按钮',
|
|
229
|
+
'sleep.choice.hideCta': '不要',
|
|
230
|
+
'sleep.choice.hideHint': '界面保持原样;以后想用,再从设置里打开。',
|
|
231
|
+
'sleep.choice.footnote': '以后可在 设置 → 自动重启与关机 修改',
|
|
232
|
+
'sleep.choice.diagramLabel': '按钮出现在左侧栏最底部、设置按钮旁边',
|
|
233
|
+
'sleep.choice.error': '保存失败:{message}',
|
|
234
|
+
// The one-time announcement of a new feature. An installation that already
|
|
235
|
+
// existed never sees the install prompt again, so this card is the only way
|
|
236
|
+
// a new feature reaches it.
|
|
237
|
+
'announcement.title': '新功能:收工关机',
|
|
238
|
+
'announcement.line1': '左侧栏最底部多了一个 🌙「收工关机」按钮。',
|
|
239
|
+
'announcement.line2': '点一下 → 所有会话收工后倒数 {n} 秒关机。',
|
|
240
|
+
'announcement.line2.generic': '点一下 → 所有会话收工后倒数几秒关机。',
|
|
241
|
+
'announcement.line3': '想改行为,去「设置 → 自动重启与关机」。',
|
|
242
|
+
'announcement.disabled': '按钮目前关闭,可在设置里打开。',
|
|
243
|
+
'announcement.button': '知道了',
|
|
244
|
+
'announcement.error': '没能记下来:{message}',
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const EN = {
|
|
248
|
+
label: 'Pending restart',
|
|
249
|
+
busyPrefix: ' · blocked by ',
|
|
250
|
+
busySuffix: ' session(s)',
|
|
251
|
+
idle: ' · restarts once idle',
|
|
252
|
+
goalActive: ' · held by a self-continuing goal',
|
|
253
|
+
goalExhausted: ' · a goal ran out of rounds',
|
|
254
|
+
parkedGoals: ' · {n} goal(s) parked, they continue after the restart',
|
|
255
|
+
parkButton: 'Park & restart',
|
|
256
|
+
parkButtonAria: 'Park that session goal and request the restart now',
|
|
257
|
+
parkBusy: 'Parking…',
|
|
258
|
+
parkError: 'Park failed: {message}',
|
|
259
|
+
tooltip:
|
|
260
|
+
'Waits for the other sessions to become idle, then restarts automatically; this session resumes its progress after the restart.',
|
|
261
|
+
tooltipBusy:
|
|
262
|
+
'Waits for the other sessions to become idle, then restarts automatically (the count includes work that is queued but not started yet); this session resumes its progress after the restart.',
|
|
263
|
+
tooltipWithError:
|
|
264
|
+
'Waits for the other sessions to become idle, then restarts automatically; this session resumes its progress after the restart. {message}',
|
|
265
|
+
dotAria: 'Pending restart',
|
|
266
|
+
panelHeading: 'Pending restart',
|
|
267
|
+
panelStateBusy: 'Waiting for the other sessions to become idle',
|
|
268
|
+
panelStateIdle: 'Waiting for the idle window',
|
|
269
|
+
panelStateGoalActive: 'Waiting for the other sessions to park their self-continuing goal',
|
|
270
|
+
panelStateGoalExhausted: 'Waiting for the other sessions\' goal (its round budget is used up)',
|
|
271
|
+
panelBlockedTitle: 'Blocking session: {title}',
|
|
272
|
+
panelBlockedGeneric: 'One session is running',
|
|
273
|
+
panelGoalActiveLine: 'Blocking session "{title}": the goal keeps driving rounds by itself (goal-active)',
|
|
274
|
+
panelGoalExhaustedLine: 'Blocking session "{title}": the goal ran out of rounds and cannot continue (goal-exhausted)',
|
|
275
|
+
panelParkedTitle: 'Parked goals (they continue after the restart):',
|
|
276
|
+
panelParkedGoal: 'session {session} · {goal}',
|
|
277
|
+
panelWaiting: 'Waiting for {duration}',
|
|
278
|
+
panelResume: 'This session resumes its progress automatically after the restart.',
|
|
279
|
+
durationSeconds: '{n}s',
|
|
280
|
+
durationMinutes: '{n}m {m}s',
|
|
281
|
+
durationHours: '{n}h {m}m',
|
|
282
|
+
// The one-time install prompt (the choice is asked exactly once).
|
|
283
|
+
// "Backup" is the verb and BOTH options share the "configuration" base:
|
|
284
|
+
// the plugins are an ADDITION to the configuration, never a rival of it.
|
|
285
|
+
backupChoiceTitle: 'Automatic pre-restart backup: include the installed plugins too?',
|
|
286
|
+
backupChoiceBody: 'A snapshot of the current configuration is taken before every restart, so a host that fails to come back can be rolled back automatically.',
|
|
287
|
+
backupChoiceWithPlugins: 'Configuration + installed plugins',
|
|
288
|
+
backupChoiceWithPluginsHint: 'About 3 seconds and 117 MB more; a plugin that breaks the boot can be rolled back too.',
|
|
289
|
+
backupChoiceConfigOnly: 'Configuration only',
|
|
290
|
+
backupChoiceConfigOnlyHint: 'Instant, about 66 KB; plugin problems cannot be rolled back and need a manual reinstall.',
|
|
291
|
+
backupChoiceFootnote: 'You can change this later in Settings → Auto-restart & shut down',
|
|
292
|
+
backupChoicePickPrimary: 'Use this',
|
|
293
|
+
backupChoicePickSecondary: 'Configuration only',
|
|
294
|
+
backupChoiceRecommended: 'Recommended',
|
|
295
|
+
backupChoiceDismissAria: 'Close this one-time prompt',
|
|
296
|
+
backupChoiceError: 'Could not save the choice: {message}',
|
|
297
|
+
// The graceful-shutdown (sleep) surfaces: the button at the foot of the
|
|
298
|
+
// left sidebar, the floating countdown card, the waiting card and the
|
|
299
|
+
// second install question.
|
|
300
|
+
'sleep.button.label': 'Shut down',
|
|
301
|
+
'sleep.button.cancel': 'Cancel the shutdown',
|
|
302
|
+
'sleep.button.aria': 'Graceful shutdown: wait for every session to finish, then power off',
|
|
303
|
+
'sleep.button.title': 'Waits for every session to finish, counts down {n} seconds, then powers off',
|
|
304
|
+
'sleep.button.titleImmediate': 'Waits for every session to finish, then powers off right away (no countdown)',
|
|
305
|
+
'sleep.button.busy': 'Getting ready…',
|
|
306
|
+
'sleep.error': 'Could not reach the sleep service: {message}',
|
|
307
|
+
// The settings section: the entry in the settings sidebar.
|
|
308
|
+
'nav': 'Auto-restart & shut down',
|
|
309
|
+
'settings.seconds.label': 'Countdown seconds',
|
|
310
|
+
'settings.seconds.immediate': 'No countdown (switch off right away)',
|
|
311
|
+
'settings.seconds.value': '{n} seconds',
|
|
312
|
+
'settings.seconds.hint': 'After the button: wait for every conversation to be done, count down this many seconds and switch off. "No countdown" switches off as soon as the conversations are done.',
|
|
313
|
+
'settings.backup.label': 'Backup before a restart',
|
|
314
|
+
'settings.backup.hint': 'Take a snapshot of the current configuration before every restart, so a host that cannot boot again is rolled back automatically.',
|
|
315
|
+
'settings.backupPlugins.label': 'Include the installed plugins in the backup',
|
|
316
|
+
'settings.backupPlugins.hint': 'Off: only the state of DSH itself is backed up — the plugin directory of the profile is left out.',
|
|
317
|
+
'settings.error': 'Could not save the settings: {message}',
|
|
318
|
+
'sleep.dryRun.title': 'Dry run: the machine stays on',
|
|
319
|
+
'sleep.dryRun.body': 'The rehearsal is over. This is the command it WOULD have run (nothing was executed):',
|
|
320
|
+
'sleep.dryRun.close': 'Got it',
|
|
321
|
+
'sleep.countdown.caption.20': 'The whale is casting her net',
|
|
322
|
+
'sleep.countdown.caption.19': 'Your backup is already packed',
|
|
323
|
+
'sleep.countdown.caption.18': 'One unfinished thought, one more',
|
|
324
|
+
'sleep.countdown.caption.17': 'Everyone is quieting down',
|
|
325
|
+
'sleep.countdown.caption.16': 'One last lap around the room',
|
|
326
|
+
'sleep.countdown.caption.15': 'The sea is going dark',
|
|
327
|
+
'sleep.countdown.caption.14': 'One light at a time goes out',
|
|
328
|
+
'sleep.countdown.caption.13': 'The room has gone quiet',
|
|
329
|
+
'sleep.countdown.caption.12': 'A little bit left',
|
|
330
|
+
'sleep.countdown.caption.11': 'One last look at the screen',
|
|
331
|
+
'sleep.countdown.caption.10': 'The whale counts the last seconds',
|
|
332
|
+
'sleep.countdown.caption.9': 'Half the lights are out',
|
|
333
|
+
'sleep.countdown.caption.8': 'The voices get quieter',
|
|
334
|
+
'sleep.countdown.caption.7': 'The goodbyes are close',
|
|
335
|
+
'sleep.countdown.caption.6': 'One last sip of the sea',
|
|
336
|
+
'sleep.countdown.caption.5': 'The night wind gets lighter',
|
|
337
|
+
'sleep.countdown.caption.4': 'Say good night',
|
|
338
|
+
'sleep.countdown.caption.3': 'Three seconds, really',
|
|
339
|
+
'sleep.countdown.caption.2': 'Time for one last goodbye',
|
|
340
|
+
'sleep.countdown.caption.1': 'Good night 🌙',
|
|
341
|
+
'sleep.countdown.caption.default': 'Good night 🌙',
|
|
342
|
+
'sleep.countdown.remaining': '{n} seconds left',
|
|
343
|
+
'sleep.countdown.cancel': 'Never mind, keep it on',
|
|
344
|
+
'sleep.countdown.goodNight': 'Good night 🌙',
|
|
345
|
+
'sleep.countdown.hint': 'Powering off in {n} seconds — pick "Never mind, keep it on" to stay.',
|
|
346
|
+
'sleep.waiting.title': 'Waiting for everyone to finish',
|
|
347
|
+
'sleep.waiting.body': 'Once these sessions are done, the machine powers off:',
|
|
348
|
+
'sleep.waiting.count': '{n} session(s) still working',
|
|
349
|
+
'sleep.waiting.blocker': '· {title}',
|
|
350
|
+
'sleep.waiting.none': '(checking who is still busy)',
|
|
351
|
+
'sleep.waiting.cancel': 'Keep it on',
|
|
352
|
+
'sleep.shuttingDown.title': 'Good night 🌙',
|
|
353
|
+
'sleep.shuttingDown.body': 'Powering off — the whale turned off the lights.',
|
|
354
|
+
'sleep.shuttingDown.dryRun': 'Dry run: nothing will really be powered off (it would have run {command})',
|
|
355
|
+
'sleep.shuttingDown.dismiss': 'Close this notice',
|
|
356
|
+
'sleep.choice.title': 'Keep the graceful shutdown button?',
|
|
357
|
+
'sleep.choice.body': 'It lives at the very bottom of the left sidebar, next to the Settings button: one click waits for every session to finish, counts down and powers the machine off.',
|
|
358
|
+
'sleep.choice.keep': 'Keep it (recommended)',
|
|
359
|
+
'sleep.choice.keepCta': 'Keep it',
|
|
360
|
+
'sleep.choice.keepHint': 'One moon button at the bottom of the left sidebar: one click to shut down gracefully.',
|
|
361
|
+
'sleep.choice.hide': 'No button, thanks',
|
|
362
|
+
'sleep.choice.hideCta': 'No button',
|
|
363
|
+
'sleep.choice.hideHint': 'The interface stays as it is; turn it on again in the settings whenever you want it.',
|
|
364
|
+
'sleep.choice.footnote': 'You can change this later in Settings → Auto-restart & shut down',
|
|
365
|
+
'sleep.choice.diagramLabel': 'The button sits at the bottom of the left sidebar, next to Settings',
|
|
366
|
+
'sleep.choice.error': 'Could not save the choice: {message}',
|
|
367
|
+
// The one-time announcement of a new feature. An installation that already
|
|
368
|
+
// existed never sees the install prompt again, so this card is the only way
|
|
369
|
+
// a new feature reaches it.
|
|
370
|
+
'announcement.title': 'New feature: graceful shutdown',
|
|
371
|
+
'announcement.line1': 'There is a new 🌙 "Shut down" button at the very bottom of the left sidebar.',
|
|
372
|
+
'announcement.line2': 'Click it → once every conversation is done it counts down {n} seconds and powers the machine off.',
|
|
373
|
+
'announcement.line2.generic': 'Click it → once every conversation is done it counts down a few seconds and powers the machine off.',
|
|
374
|
+
'announcement.line3': 'To change that, go to "Settings → Auto-restart & shut down".',
|
|
375
|
+
'announcement.disabled': 'The button is currently switched off; you can turn it on in the settings.',
|
|
376
|
+
'announcement.button': 'Got it',
|
|
377
|
+
'announcement.error': 'Could not record it: {message}',
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Read one translated string out of whatever the caller handed over.
|
|
382
|
+
*
|
|
383
|
+
* The caller may pass a translator function (the DSH locale binding), a
|
|
384
|
+
* dictionary table (flat or nested) or a language code ('zh' / 'en'). A
|
|
385
|
+
* translator that answers with the key itself has no entry: the built-in
|
|
386
|
+
* dictionary is the fallback, so the copy is never hard-coded in a
|
|
387
|
+
* component.
|
|
388
|
+
*
|
|
389
|
+
* @param {Function|object|string} locale
|
|
390
|
+
* @param {string} key
|
|
391
|
+
* @returns {string}
|
|
392
|
+
*/
|
|
393
|
+
function lookupSleepText(locale, key) {
|
|
394
|
+
if (typeof locale === 'function') {
|
|
395
|
+
try {
|
|
396
|
+
const value = locale(key)
|
|
397
|
+
if (typeof value === 'string' && value !== '' && value !== key) return value
|
|
398
|
+
} catch {
|
|
399
|
+
// A broken translator must not break the surface.
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
if (locale && typeof locale === 'object') {
|
|
403
|
+
const direct = locale[key]
|
|
404
|
+
if (typeof direct === 'string' && direct !== '') return direct
|
|
405
|
+
const path = key.split('.')
|
|
406
|
+
let node = locale
|
|
407
|
+
for (const part of path) {
|
|
408
|
+
if (!node || typeof node !== 'object') {
|
|
409
|
+
node = undefined
|
|
410
|
+
break
|
|
411
|
+
}
|
|
412
|
+
node = node[part]
|
|
413
|
+
}
|
|
414
|
+
if (typeof node === 'string' && node !== '') return node
|
|
415
|
+
}
|
|
416
|
+
if (typeof locale === 'string' && locale.toLowerCase().startsWith('en')) {
|
|
417
|
+
const english = EN[key]
|
|
418
|
+
if (typeof english === 'string' && english !== '') return english
|
|
419
|
+
}
|
|
420
|
+
const chinese = ZH[key]
|
|
421
|
+
if (typeof chinese === 'string' && chinese !== '') return chinese
|
|
422
|
+
return key
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* The countdown caption of one second.
|
|
427
|
+
*
|
|
428
|
+
* The first twenty seconds have their own line each, so the caption rotates
|
|
429
|
+
* while the number counts down. A LONGER countdown (30 or 60 seconds, both
|
|
430
|
+
* offered in the settings) falls back to one generic line that carries the
|
|
431
|
+
* remaining seconds, so every second of every offered length has a text.
|
|
432
|
+
* Seconds outside the countdown (0, or more than the longest offered
|
|
433
|
+
* countdown) use the "good night" line.
|
|
434
|
+
*
|
|
435
|
+
* @param {number} remainingSeconds
|
|
436
|
+
* @param {Function|object|string} [locale] a translator, a dictionary or a language code
|
|
437
|
+
* @returns {string}
|
|
438
|
+
*/
|
|
439
|
+
function pickSleepCaption(remainingSeconds, locale) {
|
|
440
|
+
const raw = Number(remainingSeconds)
|
|
441
|
+
const seconds = Number.isFinite(raw) ? Math.ceil(raw) : 0
|
|
442
|
+
if (seconds >= 1 && seconds <= 20) {
|
|
443
|
+
return lookupSleepText(locale, 'sleep.countdown.caption.' + seconds)
|
|
444
|
+
}
|
|
445
|
+
if (seconds > 20 && seconds <= SLEEP_MAX_COUNTDOWN_SECONDS) {
|
|
446
|
+
return lookupSleepText(locale, 'sleep.countdown.remaining').replace(/\{n\}/g, String(seconds))
|
|
447
|
+
}
|
|
448
|
+
return lookupSleepText(locale, 'sleep.countdown.caption.default')
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* Whether the graceful-shutdown button may be shown.
|
|
453
|
+
*
|
|
454
|
+
* The Host's truth wins: an explicit `showSleepButton: false` or an
|
|
455
|
+
* unsupported host hides the button (no placeholder, no space). An unknown
|
|
456
|
+
* status does not forbid it — the caller decides with the local record
|
|
457
|
+
* then.
|
|
458
|
+
*
|
|
459
|
+
* @param {object|null|undefined} status the Host's sleep status
|
|
460
|
+
* @returns {boolean}
|
|
461
|
+
*/
|
|
462
|
+
function shouldShowSleepButton(status) {
|
|
463
|
+
if (!status || typeof status !== 'object') return true
|
|
464
|
+
if (status.showSleepButton === false) return false
|
|
465
|
+
if (status.phase === 'unsupported') return false
|
|
466
|
+
const host = status.host
|
|
467
|
+
if (host && typeof host === 'object' && host.kind === 'unsupported') return false
|
|
468
|
+
return true
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* The body of the `POST {prefix}/choice` request.
|
|
473
|
+
*
|
|
474
|
+
* Only the answers that were really given are sent, so the two questions
|
|
475
|
+
* can also be answered one at a time.
|
|
476
|
+
*
|
|
477
|
+
* @param {{ includePlugins?: boolean, showSleepButton?: boolean }} [options]
|
|
478
|
+
* @returns {{ includePlugins?: boolean, showSleepButton?: boolean }}
|
|
479
|
+
*/
|
|
480
|
+
function buildChoiceBody(options) {
|
|
481
|
+
const source = options && typeof options === 'object' ? options : {}
|
|
482
|
+
const body = {}
|
|
483
|
+
if (typeof source.includePlugins === 'boolean') body.includePlugins = source.includePlugins
|
|
484
|
+
if (typeof source.showSleepButton === 'boolean') body.showSleepButton = source.showSleepButton
|
|
485
|
+
return body
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* The whole seconds a countdown still has to run.
|
|
490
|
+
*
|
|
491
|
+
* Rounded UP, so a countdown that has 400 ms left still shows "1" and the
|
|
492
|
+
* number never skips a second.
|
|
493
|
+
*
|
|
494
|
+
* @param {number} ms
|
|
495
|
+
* @returns {number}
|
|
47
496
|
*/
|
|
48
|
-
|
|
497
|
+
function formatRemainingMs(ms) {
|
|
498
|
+
const raw = Number(ms)
|
|
499
|
+
if (!Number.isFinite(raw) || raw <= 0) return 0
|
|
500
|
+
return Math.ceil(raw / 1000)
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/**
|
|
504
|
+
* The named exports of this module.
|
|
505
|
+
*
|
|
506
|
+
* The client module system materializes `factory(require) -> exports`, so
|
|
507
|
+
* these four helpers ARE the module's exports; they are mirrored on
|
|
508
|
+
* `globalThis.__dshAutorestartClient` for the tests that evaluate the file
|
|
509
|
+
* without a loader.
|
|
510
|
+
*/
|
|
511
|
+
const SLEEP_EXPORTS = {
|
|
512
|
+
pickSleepCaption,
|
|
513
|
+
shouldShowSleepButton,
|
|
514
|
+
buildChoiceBody,
|
|
515
|
+
formatRemainingMs,
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
if (typeof globalThis !== 'undefined' && globalThis !== null) {
|
|
519
|
+
try {
|
|
520
|
+
globalThis.__dshAutorestartClient = Object.assign(globalThis.__dshAutorestartClient || {}, SLEEP_EXPORTS)
|
|
521
|
+
} catch {
|
|
522
|
+
// A frozen global must not break the plugin.
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
// The loader call is guarded so this file can also be evaluated outside a
|
|
527
|
+
// browser (the tests read the exports above without a loader).
|
|
528
|
+
const moduleLoader = typeof window !== 'undefined' && window.__ModuleLoader__ ? window.__ModuleLoader__ : null
|
|
529
|
+
if (moduleLoader) moduleLoader.load({
|
|
49
530
|
id: 'dsh-autorestart',
|
|
50
531
|
factory: function (require) {
|
|
51
532
|
const React = require('react')
|
|
@@ -85,107 +566,6 @@ window.__ModuleLoader__.load({
|
|
|
85
566
|
'}',
|
|
86
567
|
].join('\n')
|
|
87
568
|
|
|
88
|
-
const ZH = {
|
|
89
|
-
label: '待重启',
|
|
90
|
-
busyPrefix: ' · 被 ',
|
|
91
|
-
busySuffix: ' 个会话占用',
|
|
92
|
-
idle: ' · 空闲后自动重启',
|
|
93
|
-
goalActive: ' · 被某个会话自动续跑的 goal 占着',
|
|
94
|
-
goalExhausted: ' · 某个会话的 goal 轮次额度已用完,无法续跑',
|
|
95
|
-
parkedGoals: ' · 已停泊 {n} 个 goal,重启后自动继续',
|
|
96
|
-
parkButton: '停泊并重启',
|
|
97
|
-
parkButtonAria: '停泊该会话的 goal,并立即请求重启',
|
|
98
|
-
parkBusy: '停泊中…',
|
|
99
|
-
parkError: '停泊失败:{message}',
|
|
100
|
-
tooltip: '等待其他会话空闲后自动重启;重启后会自动继续本会话进度',
|
|
101
|
-
tooltipBusy: '等待其他会话空闲后自动重启(计数含“排队但还没开始”的任务);重启后本会话会自动继续进度',
|
|
102
|
-
tooltipWithError: '等待其他会话空闲后自动重启;重启后会自动继续本会话进度。{message}',
|
|
103
|
-
dotAria: '待重启',
|
|
104
|
-
panelHeading: '待重启',
|
|
105
|
-
panelStateBusy: '等待其他会话空闲后重启',
|
|
106
|
-
panelStateIdle: '等待空闲窗口后重启',
|
|
107
|
-
panelStateGoalActive: '等待其他会话停泊它自动续跑的 goal',
|
|
108
|
-
panelStateGoalExhausted: '等待其他会话的 goal(轮次额度已用完)',
|
|
109
|
-
panelBlockedTitle: '占用会话:{title}',
|
|
110
|
-
panelBlockedGeneric: '有一个会话正在运行',
|
|
111
|
-
panelGoalActiveLine: '占用会话「{title}」:goal 正在自动续跑(goal-active)',
|
|
112
|
-
panelGoalExhaustedLine: '占用会话「{title}」:goal 轮次额度已用完,无法继续(goal-exhausted)',
|
|
113
|
-
panelParkedTitle: '已停泊的 goal(重启后自动继续):',
|
|
114
|
-
panelParkedGoal: '会话 {session} · {goal}',
|
|
115
|
-
panelWaiting: '已等待 {duration}',
|
|
116
|
-
panelResume: '重启后本会话会自动继续进度',
|
|
117
|
-
durationSeconds: '{n} 秒',
|
|
118
|
-
durationMinutes: '{n} 分 {m} 秒',
|
|
119
|
-
durationHours: '{n} 小时 {m} 分',
|
|
120
|
-
// The one-time install prompt (the choice is asked exactly once).
|
|
121
|
-
// "Backup" is the verb and BOTH options share the "configuration" base:
|
|
122
|
-
// the plugins are an ADDITION to the configuration, never a rival of it.
|
|
123
|
-
backupChoiceTitle: '重启前自动备份:要不要把已安装的插件也一起备份?',
|
|
124
|
-
backupChoiceBody: '每次重启前会快照当前配置,万一重启后起不来就能自动回退。',
|
|
125
|
-
backupChoiceWithPlugins: '配置 + 已安装插件',
|
|
126
|
-
backupChoiceWithPluginsHint: '多约 3 秒、约 117 MB;插件把启动搞坏时也能回退。',
|
|
127
|
-
backupChoiceConfigOnly: '只备份配置',
|
|
128
|
-
backupChoiceConfigOnlyHint: '瞬间完成、约 66 KB;插件问题无法回退,需要手动重装。',
|
|
129
|
-
backupChoiceFootnote: '以后可在 设置 → 插件 → dsh-autorestart 修改',
|
|
130
|
-
backupChoicePickPrimary: '用这个',
|
|
131
|
-
backupChoicePickSecondary: '只备份配置',
|
|
132
|
-
backupChoiceRecommended: '推荐',
|
|
133
|
-
backupChoiceDismissAria: '关闭这个一次性提示',
|
|
134
|
-
backupChoiceError: '保存失败:{message}',
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
const EN = {
|
|
138
|
-
label: 'Pending restart',
|
|
139
|
-
busyPrefix: ' · blocked by ',
|
|
140
|
-
busySuffix: ' session(s)',
|
|
141
|
-
idle: ' · restarts once idle',
|
|
142
|
-
goalActive: ' · held by a self-continuing goal',
|
|
143
|
-
goalExhausted: ' · a goal ran out of rounds',
|
|
144
|
-
parkedGoals: ' · {n} goal(s) parked, they continue after the restart',
|
|
145
|
-
parkButton: 'Park & restart',
|
|
146
|
-
parkButtonAria: 'Park that session goal and request the restart now',
|
|
147
|
-
parkBusy: 'Parking…',
|
|
148
|
-
parkError: 'Park failed: {message}',
|
|
149
|
-
tooltip:
|
|
150
|
-
'Waits for the other sessions to become idle, then restarts automatically; this session resumes its progress after the restart.',
|
|
151
|
-
tooltipBusy:
|
|
152
|
-
'Waits for the other sessions to become idle, then restarts automatically (the count includes work that is queued but not started yet); this session resumes its progress after the restart.',
|
|
153
|
-
tooltipWithError:
|
|
154
|
-
'Waits for the other sessions to become idle, then restarts automatically; this session resumes its progress after the restart. {message}',
|
|
155
|
-
dotAria: 'Pending restart',
|
|
156
|
-
panelHeading: 'Pending restart',
|
|
157
|
-
panelStateBusy: 'Waiting for the other sessions to become idle',
|
|
158
|
-
panelStateIdle: 'Waiting for the idle window',
|
|
159
|
-
panelStateGoalActive: 'Waiting for the other sessions to park their self-continuing goal',
|
|
160
|
-
panelStateGoalExhausted: 'Waiting for the other sessions\' goal (its round budget is used up)',
|
|
161
|
-
panelBlockedTitle: 'Blocking session: {title}',
|
|
162
|
-
panelBlockedGeneric: 'One session is running',
|
|
163
|
-
panelGoalActiveLine: 'Blocking session "{title}": the goal keeps driving rounds by itself (goal-active)',
|
|
164
|
-
panelGoalExhaustedLine: 'Blocking session "{title}": the goal ran out of rounds and cannot continue (goal-exhausted)',
|
|
165
|
-
panelParkedTitle: 'Parked goals (they continue after the restart):',
|
|
166
|
-
panelParkedGoal: 'session {session} · {goal}',
|
|
167
|
-
panelWaiting: 'Waiting for {duration}',
|
|
168
|
-
panelResume: 'This session resumes its progress automatically after the restart.',
|
|
169
|
-
durationSeconds: '{n}s',
|
|
170
|
-
durationMinutes: '{n}m {m}s',
|
|
171
|
-
durationHours: '{n}h {m}m',
|
|
172
|
-
// The one-time install prompt (the choice is asked exactly once).
|
|
173
|
-
// "Backup" is the verb and BOTH options share the "configuration" base:
|
|
174
|
-
// the plugins are an ADDITION to the configuration, never a rival of it.
|
|
175
|
-
backupChoiceTitle: 'Automatic pre-restart backup: include the installed plugins too?',
|
|
176
|
-
backupChoiceBody: 'A snapshot of the current configuration is taken before every restart, so a host that fails to come back can be rolled back automatically.',
|
|
177
|
-
backupChoiceWithPlugins: 'Configuration + installed plugins',
|
|
178
|
-
backupChoiceWithPluginsHint: 'About 3 seconds and 117 MB more; a plugin that breaks the boot can be rolled back too.',
|
|
179
|
-
backupChoiceConfigOnly: 'Configuration only',
|
|
180
|
-
backupChoiceConfigOnlyHint: 'Instant, about 66 KB; plugin problems cannot be rolled back and need a manual reinstall.',
|
|
181
|
-
backupChoiceFootnote: 'You can change this later in Settings → Plugins → dsh-autorestart',
|
|
182
|
-
backupChoicePickPrimary: 'Use this',
|
|
183
|
-
backupChoicePickSecondary: 'Configuration only',
|
|
184
|
-
backupChoiceRecommended: 'Recommended',
|
|
185
|
-
backupChoiceDismissAria: 'Close this one-time prompt',
|
|
186
|
-
backupChoiceError: 'Could not save the choice: {message}',
|
|
187
|
-
}
|
|
188
|
-
|
|
189
569
|
// Bound during `apply`. The slots are registered without a locale
|
|
190
570
|
// namespace, so the components read the translator through this
|
|
191
571
|
// factory-scoped handle instead of a standard `t` prop.
|
|
@@ -729,6 +1109,13 @@ window.__ModuleLoader__.load({
|
|
|
729
1109
|
' line-height:16px;',
|
|
730
1110
|
' color:var(--dsw-alias-label-secondary);',
|
|
731
1111
|
'}',
|
|
1112
|
+
'.dsh-autorestart-choice__diagram{margin:2px 0 10px;}',
|
|
1113
|
+
'.dsh-autorestart-choice__diagram__art{',
|
|
1114
|
+
' width:100%;',
|
|
1115
|
+
' max-width:260px;',
|
|
1116
|
+
' height:auto;',
|
|
1117
|
+
' display:block;',
|
|
1118
|
+
'}',
|
|
732
1119
|
'@media (max-width:1023px){',
|
|
733
1120
|
' .dsh-autorestart-choice{width:calc(100vw - 32px);}',
|
|
734
1121
|
'}',
|
|
@@ -953,10 +1340,16 @@ window.__ModuleLoader__.load({
|
|
|
953
1340
|
/**
|
|
954
1341
|
* The one-time install prompt.
|
|
955
1342
|
*
|
|
956
|
-
* It asks
|
|
957
|
-
*
|
|
958
|
-
*
|
|
959
|
-
* is
|
|
1343
|
+
* It asks TWO questions, one after the other:
|
|
1344
|
+
*
|
|
1345
|
+
* 1. should the pre-restart backup also snapshot the installed plugin
|
|
1346
|
+
* tree? (the first question — its behaviour is unchanged);
|
|
1347
|
+
* 2. should the graceful-shutdown button stay at the foot of the left
|
|
1348
|
+
* sidebar?
|
|
1349
|
+
*
|
|
1350
|
+
* The first answer is persisted BEFORE the second question appears: the
|
|
1351
|
+
* two questions are never stacked on top of each other, and the second
|
|
1352
|
+
* one only shows up after the first one really reached the Host.
|
|
960
1353
|
*
|
|
961
1354
|
* "Backup" is the verb and BOTH options share the "configuration" base:
|
|
962
1355
|
* the plugins are an addition to the configuration, never a rival of it.
|
|
@@ -972,61 +1365,133 @@ window.__ModuleLoader__.load({
|
|
|
972
1365
|
*/
|
|
973
1366
|
function BackupChoicePanel() {
|
|
974
1367
|
const info = useSharedInfo(true)
|
|
975
|
-
//
|
|
976
|
-
|
|
1368
|
+
// 'backup' is the first question, 'sleep' the second one, 'persisting'
|
|
1369
|
+
// the short moment in between (the first answer is on its way to the
|
|
1370
|
+
// Host) and 'done' when there is nothing left to ask.
|
|
1371
|
+
//
|
|
1372
|
+
// The two questions are judged SEPARATELY: an installation that already
|
|
1373
|
+
// answered the backup question still has to be asked about the shutdown
|
|
1374
|
+
// button (that is how the arrow sketch ever reaches a user who
|
|
1375
|
+
// installed the plugin before this question existed).
|
|
1376
|
+
const [step, setStep] = useState(
|
|
1377
|
+
backupChoicePending() ? 'backup' : (sleepChoicePending() ? 'sleep' : 'done'),
|
|
1378
|
+
)
|
|
977
1379
|
const [busy, setBusy] = useState(false)
|
|
978
1380
|
const [error, setError] = useState('')
|
|
979
1381
|
const t = translate ?? ((name) => name)
|
|
980
1382
|
|
|
981
1383
|
/**
|
|
982
|
-
* Hand the
|
|
1384
|
+
* Hand the FIRST answer to the Host.
|
|
983
1385
|
*
|
|
984
1386
|
* The browser records the choice FIRST (so the question is never asked
|
|
985
1387
|
* twice) and then posts it to the Host, which persists it in the
|
|
986
|
-
* profile configuration.
|
|
1388
|
+
* profile configuration. The SECOND question only appears once that
|
|
1389
|
+
* answer really left the browser.
|
|
987
1390
|
*/
|
|
988
1391
|
const choose = (includePlugins) => {
|
|
989
1392
|
if (busy) return
|
|
990
1393
|
setBusy(true)
|
|
991
1394
|
setError('')
|
|
992
1395
|
writeBackupChoice(includePlugins ? 'plugins' : 'config-only')
|
|
993
|
-
|
|
1396
|
+
setStep('persisting')
|
|
994
1397
|
void (async () => {
|
|
1398
|
+
let failed = false
|
|
995
1399
|
try {
|
|
996
1400
|
const response = await fetch(choiceUrlFor(info), {
|
|
997
1401
|
method: 'POST',
|
|
998
1402
|
headers: { 'content-type': 'application/json' },
|
|
999
|
-
body: JSON.stringify({ includePlugins }),
|
|
1403
|
+
body: JSON.stringify(buildChoiceBody({ includePlugins })),
|
|
1000
1404
|
})
|
|
1001
1405
|
if (!response.ok) {
|
|
1406
|
+
failed = true
|
|
1002
1407
|
setError(fill(t('backupChoiceError'), { message: String(response.status) }))
|
|
1003
1408
|
}
|
|
1004
1409
|
} catch (failure) {
|
|
1410
|
+
failed = true
|
|
1005
1411
|
setError(fill(t('backupChoiceError'), { message: describeError(failure) }))
|
|
1412
|
+
} finally {
|
|
1413
|
+
setBusy(false)
|
|
1414
|
+
if (failed) {
|
|
1415
|
+
// The answer did not reach the Host: the first question stays
|
|
1416
|
+
// on screen with the reason, so it can be answered again.
|
|
1417
|
+
setStep('backup')
|
|
1418
|
+
return
|
|
1419
|
+
}
|
|
1420
|
+
// The first answer is persisted: the second question may follow.
|
|
1421
|
+
setStep(sleepChoicePending() ? 'sleep' : 'done')
|
|
1422
|
+
}
|
|
1423
|
+
})()
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1426
|
+
/**
|
|
1427
|
+
* Hand the SECOND answer to the Host: should the shutdown button stay?
|
|
1428
|
+
*/
|
|
1429
|
+
const chooseSleep = (showSleepButton) => {
|
|
1430
|
+
if (busy) return
|
|
1431
|
+
setBusy(true)
|
|
1432
|
+
setError('')
|
|
1433
|
+
writeSleepChoice(showSleepButton ? 'keep' : 'hide')
|
|
1434
|
+
void (async () => {
|
|
1435
|
+
try {
|
|
1436
|
+
const response = await fetch(choiceUrlFor(info), {
|
|
1437
|
+
method: 'POST',
|
|
1438
|
+
headers: { 'content-type': 'application/json' },
|
|
1439
|
+
body: JSON.stringify(buildChoiceBody({ showSleepButton })),
|
|
1440
|
+
})
|
|
1441
|
+
if (!response.ok) {
|
|
1442
|
+
setError(fill(t('sleep.choice.error'), { message: String(response.status) }))
|
|
1443
|
+
setStep('sleep')
|
|
1444
|
+
return
|
|
1445
|
+
}
|
|
1446
|
+
} catch (failure) {
|
|
1447
|
+
setError(fill(t('sleep.choice.error'), { message: describeError(failure) }))
|
|
1448
|
+
setStep('sleep')
|
|
1449
|
+
return
|
|
1006
1450
|
} finally {
|
|
1007
1451
|
setBusy(false)
|
|
1008
1452
|
}
|
|
1453
|
+
setStep('done')
|
|
1009
1454
|
})()
|
|
1010
1455
|
}
|
|
1011
1456
|
|
|
1012
1457
|
/**
|
|
1013
|
-
* Close the
|
|
1014
|
-
* AND the installed plugins) stays in place,
|
|
1015
|
-
* asked again
|
|
1458
|
+
* Close the FIRST question without answering: the default (the
|
|
1459
|
+
* configuration AND the installed plugins) stays in place, the question
|
|
1460
|
+
* is not asked again — and the second one is not asked either, because
|
|
1461
|
+
* closing the prompt means "do not ask me again".
|
|
1016
1462
|
*/
|
|
1017
|
-
const
|
|
1463
|
+
const dismissBackup = () => {
|
|
1018
1464
|
if (busy) return
|
|
1019
1465
|
writeBackupChoice('dismissed')
|
|
1020
|
-
|
|
1466
|
+
writeSleepChoice('dismissed')
|
|
1467
|
+
// "Do not ask me again" also covers the new-feature announcement: the
|
|
1468
|
+
// install prompt is where the features of a fresh installation are
|
|
1469
|
+
// explained, so no announcement may follow it.
|
|
1470
|
+
void acknowledgeAnnouncement(ANNOUNCED_FEATURE).catch(() => {})
|
|
1471
|
+
setStep('done')
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
/**
|
|
1475
|
+
* Close the SECOND question without answering: the button stays (the
|
|
1476
|
+
* default) and the question is not asked again.
|
|
1477
|
+
*/
|
|
1478
|
+
const dismissSleep = () => {
|
|
1479
|
+
if (busy) return
|
|
1480
|
+
writeSleepChoice('dismissed')
|
|
1481
|
+
void acknowledgeAnnouncement(ANNOUNCED_FEATURE).catch(() => {})
|
|
1482
|
+
setStep('done')
|
|
1021
1483
|
}
|
|
1022
1484
|
|
|
1023
1485
|
/** Esc closes the prompt (the panel itself is focusable). */
|
|
1024
1486
|
const onKeyDown = (event) => {
|
|
1025
1487
|
if (!event) return
|
|
1026
|
-
if (event.key === 'Escape' || event.key === 'Esc')
|
|
1488
|
+
if (event.key === 'Escape' || event.key === 'Esc') {
|
|
1489
|
+
if (step === 'sleep') dismissSleep()
|
|
1490
|
+
else dismissBackup()
|
|
1491
|
+
}
|
|
1027
1492
|
}
|
|
1028
1493
|
|
|
1029
|
-
if (
|
|
1494
|
+
if (step === 'done' || step === 'persisting') return null
|
|
1030
1495
|
ensureChoiceStyle()
|
|
1031
1496
|
|
|
1032
1497
|
/**
|
|
@@ -1037,8 +1502,7 @@ window.__ModuleLoader__.load({
|
|
|
1037
1502
|
* the concrete action. The primary row wears a "recommended" pill and a
|
|
1038
1503
|
* solid button; the secondary one an outlined button.
|
|
1039
1504
|
*/
|
|
1040
|
-
const optionRow = (key, name, hint, cta, variant, recommended) => {
|
|
1041
|
-
const pick = () => choose(key === 'plugins')
|
|
1505
|
+
const optionRow = (key, name, hint, cta, variant, recommended, pick, dataAttribute) => {
|
|
1042
1506
|
const onRowClick = () => pick()
|
|
1043
1507
|
const onRowKeyDown = (event) => {
|
|
1044
1508
|
if (!event) return
|
|
@@ -1061,7 +1525,7 @@ window.__ModuleLoader__.load({
|
|
|
1061
1525
|
'aria-checked': 'false',
|
|
1062
1526
|
'aria-label': name,
|
|
1063
1527
|
tabIndex: busy ? -1 : 0,
|
|
1064
|
-
|
|
1528
|
+
[dataAttribute]: key,
|
|
1065
1529
|
onClick: onRowClick,
|
|
1066
1530
|
onKeyDown: onRowKeyDown,
|
|
1067
1531
|
},
|
|
@@ -1083,7 +1547,7 @@ window.__ModuleLoader__.load({
|
|
|
1083
1547
|
{
|
|
1084
1548
|
type: 'button',
|
|
1085
1549
|
className: 'dsh-autorestart-choice__cta dsh-autorestart-choice__cta--' + variant,
|
|
1086
|
-
|
|
1550
|
+
[dataAttribute]: key,
|
|
1087
1551
|
'aria-label': cta,
|
|
1088
1552
|
title: cta,
|
|
1089
1553
|
disabled: busy,
|
|
@@ -1094,6 +1558,67 @@ window.__ModuleLoader__.load({
|
|
|
1094
1558
|
)
|
|
1095
1559
|
}
|
|
1096
1560
|
|
|
1561
|
+
// The FIRST question: the backup scope (its behaviour is unchanged).
|
|
1562
|
+
if (step === 'backup') {
|
|
1563
|
+
return h(
|
|
1564
|
+
'div',
|
|
1565
|
+
{
|
|
1566
|
+
className: 'dsh-autorestart-choice',
|
|
1567
|
+
role: 'status',
|
|
1568
|
+
'aria-live': 'polite',
|
|
1569
|
+
tabIndex: 0,
|
|
1570
|
+
onKeyDown,
|
|
1571
|
+
'data-dsh-autorestart-backup-choice': 'true',
|
|
1572
|
+
'data-dsh-autorestart-choice-step': 'backup',
|
|
1573
|
+
},
|
|
1574
|
+
h(
|
|
1575
|
+
'div',
|
|
1576
|
+
{ className: 'dsh-autorestart-choice__head' },
|
|
1577
|
+
h('h3', null, t('backupChoiceTitle')),
|
|
1578
|
+
h(
|
|
1579
|
+
'button',
|
|
1580
|
+
{
|
|
1581
|
+
type: 'button',
|
|
1582
|
+
className: 'dsh-autorestart-choice__close',
|
|
1583
|
+
'aria-label': t('backupChoiceDismissAria'),
|
|
1584
|
+
title: t('backupChoiceDismissAria'),
|
|
1585
|
+
disabled: busy,
|
|
1586
|
+
onClick: dismissBackup,
|
|
1587
|
+
},
|
|
1588
|
+
'×',
|
|
1589
|
+
),
|
|
1590
|
+
),
|
|
1591
|
+
h('p', { className: 'dsh-autorestart-choice__subtitle' }, t('backupChoiceBody')),
|
|
1592
|
+
h(
|
|
1593
|
+
'div',
|
|
1594
|
+
{ className: 'dsh-autorestart-choice__options' },
|
|
1595
|
+
optionRow(
|
|
1596
|
+
'plugins',
|
|
1597
|
+
t('backupChoiceWithPlugins'),
|
|
1598
|
+
t('backupChoiceWithPluginsHint'),
|
|
1599
|
+
t('backupChoicePickPrimary'),
|
|
1600
|
+
'primary',
|
|
1601
|
+
true,
|
|
1602
|
+
() => choose(true),
|
|
1603
|
+
'data-backup-choice',
|
|
1604
|
+
),
|
|
1605
|
+
optionRow(
|
|
1606
|
+
'config-only',
|
|
1607
|
+
t('backupChoiceConfigOnly'),
|
|
1608
|
+
t('backupChoiceConfigOnlyHint'),
|
|
1609
|
+
t('backupChoicePickSecondary'),
|
|
1610
|
+
'secondary',
|
|
1611
|
+
false,
|
|
1612
|
+
() => choose(false),
|
|
1613
|
+
'data-backup-choice',
|
|
1614
|
+
),
|
|
1615
|
+
),
|
|
1616
|
+
h('p', { className: 'dsh-autorestart-choice__footnote' }, t('backupChoiceFootnote')),
|
|
1617
|
+
error === '' ? null : h('p', { role: 'alert' }, error),
|
|
1618
|
+
)
|
|
1619
|
+
}
|
|
1620
|
+
|
|
1621
|
+
// The SECOND question: should the graceful-shutdown button stay?
|
|
1097
1622
|
return h(
|
|
1098
1623
|
'div',
|
|
1099
1624
|
{
|
|
@@ -1103,11 +1628,12 @@ window.__ModuleLoader__.load({
|
|
|
1103
1628
|
tabIndex: 0,
|
|
1104
1629
|
onKeyDown,
|
|
1105
1630
|
'data-dsh-autorestart-backup-choice': 'true',
|
|
1631
|
+
'data-dsh-autorestart-choice-step': 'sleep',
|
|
1106
1632
|
},
|
|
1107
1633
|
h(
|
|
1108
1634
|
'div',
|
|
1109
1635
|
{ className: 'dsh-autorestart-choice__head' },
|
|
1110
|
-
h('h3', null, t('
|
|
1636
|
+
h('h3', null, t('sleep.choice.title')),
|
|
1111
1637
|
h(
|
|
1112
1638
|
'button',
|
|
1113
1639
|
{
|
|
@@ -1116,50 +1642,1739 @@ window.__ModuleLoader__.load({
|
|
|
1116
1642
|
'aria-label': t('backupChoiceDismissAria'),
|
|
1117
1643
|
title: t('backupChoiceDismissAria'),
|
|
1118
1644
|
disabled: busy,
|
|
1119
|
-
onClick:
|
|
1645
|
+
onClick: dismissSleep,
|
|
1120
1646
|
},
|
|
1121
1647
|
'×',
|
|
1122
1648
|
),
|
|
1123
1649
|
),
|
|
1124
|
-
h(
|
|
1650
|
+
h(
|
|
1651
|
+
'div',
|
|
1652
|
+
{
|
|
1653
|
+
className: 'dsh-autorestart-choice__diagram',
|
|
1654
|
+
role: 'img',
|
|
1655
|
+
'aria-label': t('sleep.choice.diagramLabel'),
|
|
1656
|
+
},
|
|
1657
|
+
sleepDiagram(),
|
|
1658
|
+
),
|
|
1659
|
+
h('p', { className: 'dsh-autorestart-choice__subtitle' }, t('sleep.choice.body')),
|
|
1125
1660
|
h(
|
|
1126
1661
|
'div',
|
|
1127
1662
|
{ className: 'dsh-autorestart-choice__options' },
|
|
1128
1663
|
optionRow(
|
|
1129
|
-
'
|
|
1130
|
-
t('
|
|
1131
|
-
t('
|
|
1132
|
-
t('
|
|
1664
|
+
'keep',
|
|
1665
|
+
t('sleep.choice.keep'),
|
|
1666
|
+
t('sleep.choice.keepHint'),
|
|
1667
|
+
t('sleep.choice.keepCta'),
|
|
1133
1668
|
'primary',
|
|
1134
1669
|
true,
|
|
1670
|
+
() => chooseSleep(true),
|
|
1671
|
+
'data-sleep-choice',
|
|
1135
1672
|
),
|
|
1136
1673
|
optionRow(
|
|
1137
|
-
'
|
|
1138
|
-
t('
|
|
1139
|
-
t('
|
|
1140
|
-
t('
|
|
1674
|
+
'hide',
|
|
1675
|
+
t('sleep.choice.hide'),
|
|
1676
|
+
t('sleep.choice.hideHint'),
|
|
1677
|
+
t('sleep.choice.hideCta'),
|
|
1141
1678
|
'secondary',
|
|
1142
1679
|
false,
|
|
1680
|
+
() => chooseSleep(false),
|
|
1681
|
+
'data-sleep-choice',
|
|
1143
1682
|
),
|
|
1144
1683
|
),
|
|
1145
|
-
h('p', { className: 'dsh-autorestart-choice__footnote' }, t('
|
|
1684
|
+
h('p', { className: 'dsh-autorestart-choice__footnote' }, t('sleep.choice.footnote')),
|
|
1146
1685
|
error === '' ? null : h('p', { role: 'alert' }, error),
|
|
1147
1686
|
)
|
|
1148
1687
|
}
|
|
1149
1688
|
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1689
|
+
/* ------------------------------------------------------------------ *
|
|
1690
|
+
* The graceful-shutdown (sleep) half.
|
|
1691
|
+
* ------------------------------------------------------------------ */
|
|
1692
|
+
|
|
1693
|
+
// The route that arms and cancels the shutdown. It is a sibling of the
|
|
1694
|
+
// health route and shares its prefix, so it is derived the very same way.
|
|
1695
|
+
const DEFAULT_SLEEP_URL = ROUTE_PREFIX + '/sleep'
|
|
1696
|
+
|
|
1697
|
+
/**
|
|
1698
|
+
* ONE shared poller for the sleep status, shared by the sidebar button
|
|
1699
|
+
* and the overlay.
|
|
1700
|
+
*
|
|
1701
|
+
* It follows the pattern of the pending-restart store: the first
|
|
1702
|
+
* subscriber starts ONE interval (the same POLL_MS cadence) plus an
|
|
1703
|
+
* immediate fetch, and the last one to leave clears the interval and
|
|
1704
|
+
* aborts the in-flight request. A failed fetch keeps the last known
|
|
1705
|
+
* status, so a transient error never blanks a live countdown.
|
|
1706
|
+
*/
|
|
1707
|
+
const sleepStore = {
|
|
1708
|
+
info: null,
|
|
1709
|
+
listeners: new Set(),
|
|
1710
|
+
timer: null,
|
|
1711
|
+
inFlight: false,
|
|
1712
|
+
controller: null,
|
|
1713
|
+
// When the current status was received: the countdown runs on this
|
|
1714
|
+
// clock, so the big number keeps moving between two polls.
|
|
1715
|
+
at: 0,
|
|
1716
|
+
}
|
|
1717
|
+
|
|
1718
|
+
/**
|
|
1719
|
+
* Carry out one intent ('arm' or 'cancel').
|
|
1720
|
+
*
|
|
1721
|
+
* There is no ticket and no ceremony: the request carries nothing but the
|
|
1722
|
+
* action, so the desktop client and the hosted web client drive exactly
|
|
1723
|
+
* the same route.
|
|
1724
|
+
*/
|
|
1725
|
+
async function driveSleep(intent) {
|
|
1726
|
+
return requestSleep(intent)
|
|
1727
|
+
}
|
|
1728
|
+
|
|
1729
|
+
/** Publish a new sleep status to every surface. */
|
|
1730
|
+
function setSleepInfo(body) {
|
|
1731
|
+
sleepStore.info = body
|
|
1732
|
+
sleepStore.at = Date.now()
|
|
1733
|
+
sleepStore.listeners.forEach((listener) => {
|
|
1734
|
+
try {
|
|
1735
|
+
listener(body)
|
|
1736
|
+
} catch {
|
|
1737
|
+
// One broken listener must not stop the others.
|
|
1738
|
+
}
|
|
1739
|
+
})
|
|
1740
|
+
}
|
|
1741
|
+
|
|
1742
|
+
async function pollSleepOnce() {
|
|
1743
|
+
if (sleepStore.inFlight) return
|
|
1744
|
+
sleepStore.inFlight = true
|
|
1745
|
+
try {
|
|
1746
|
+
const response = await fetch(DEFAULT_SLEEP_URL, {
|
|
1747
|
+
cache: 'no-store',
|
|
1748
|
+
signal: sleepStore.controller ? sleepStore.controller.signal : undefined,
|
|
1749
|
+
})
|
|
1750
|
+
if (!response.ok) return
|
|
1751
|
+
const body = await response.json()
|
|
1752
|
+
if (!body || typeof body !== 'object') return
|
|
1753
|
+
setSleepInfo(body)
|
|
1754
|
+
} catch {
|
|
1755
|
+
// Ignored on purpose: the last known status stays.
|
|
1756
|
+
} finally {
|
|
1757
|
+
sleepStore.inFlight = false
|
|
1758
|
+
}
|
|
1759
|
+
}
|
|
1760
|
+
|
|
1761
|
+
/**
|
|
1762
|
+
* Adds a listener and starts the shared poll when it is the first one.
|
|
1763
|
+
* The returned callback stops the poll when the last listener leaves.
|
|
1764
|
+
*/
|
|
1765
|
+
function subscribeSleep(listener) {
|
|
1766
|
+
sleepStore.listeners.add(listener)
|
|
1767
|
+
if (sleepStore.listeners.size === 1) {
|
|
1768
|
+
sleepStore.controller = new AbortController()
|
|
1769
|
+
pollSleepOnce()
|
|
1770
|
+
sleepStore.timer = setInterval(pollSleepOnce, POLL_MS)
|
|
1771
|
+
}
|
|
1772
|
+
return () => {
|
|
1773
|
+
sleepStore.listeners.delete(listener)
|
|
1774
|
+
if (sleepStore.listeners.size === 0) {
|
|
1775
|
+
if (sleepStore.timer !== null) {
|
|
1776
|
+
clearInterval(sleepStore.timer)
|
|
1777
|
+
sleepStore.timer = null
|
|
1778
|
+
}
|
|
1779
|
+
if (sleepStore.controller) {
|
|
1780
|
+
sleepStore.controller.abort()
|
|
1781
|
+
sleepStore.controller = null
|
|
1782
|
+
}
|
|
1783
|
+
// The last sleep surface left: its stylesheet goes away with it, so
|
|
1784
|
+
// the document does not keep the CSS of a plugin that is gone.
|
|
1785
|
+
removeSleepStyle()
|
|
1786
|
+
}
|
|
1787
|
+
}
|
|
1788
|
+
}
|
|
1789
|
+
|
|
1790
|
+
/**
|
|
1791
|
+
* Ask the Host to arm or to cancel the shutdown.
|
|
1792
|
+
*
|
|
1793
|
+
* The answer carries the new status, so the surfaces do not have to wait
|
|
1794
|
+
* for the next poll. A refused request throws, and the caller shows the
|
|
1795
|
+
* reason.
|
|
1796
|
+
*
|
|
1797
|
+
* @param {'arm'|'cancel'} action
|
|
1798
|
+
* @returns {Promise<object>} the new sleep status
|
|
1799
|
+
*/
|
|
1800
|
+
async function requestSleep(action) {
|
|
1801
|
+
const headers = { 'content-type': 'application/json' }
|
|
1802
|
+
const response = await fetch(DEFAULT_SLEEP_URL, {
|
|
1803
|
+
method: 'POST',
|
|
1804
|
+
headers,
|
|
1805
|
+
body: JSON.stringify({ action }),
|
|
1806
|
+
})
|
|
1807
|
+
let body = null
|
|
1808
|
+
try {
|
|
1809
|
+
body = await response.json()
|
|
1810
|
+
} catch {
|
|
1811
|
+
body = null
|
|
1812
|
+
}
|
|
1813
|
+
if (!response.ok) {
|
|
1814
|
+
const message = body && typeof body === 'object'
|
|
1815
|
+
? (body.message || body.error || '')
|
|
1816
|
+
: ''
|
|
1817
|
+
const failure = new Error(message || String(response.status))
|
|
1818
|
+
failure.sleepStatus = response.status
|
|
1819
|
+
if (body && typeof body === 'object' && typeof body.reason === 'string') {
|
|
1820
|
+
failure.reason = body.reason
|
|
1821
|
+
}
|
|
1822
|
+
throw failure
|
|
1823
|
+
}
|
|
1824
|
+
if (body && typeof body === 'object') setSleepInfo(body)
|
|
1825
|
+
return body
|
|
1826
|
+
}
|
|
1827
|
+
|
|
1828
|
+
/**
|
|
1829
|
+
* The current sleep status, re-rendered whenever the shared poll lands. */
|
|
1830
|
+
function useSleepStatus() {
|
|
1831
|
+
const [status, setStatus] = useState(sleepStore.info)
|
|
1832
|
+
useEffect(() => {
|
|
1833
|
+
setStatus(sleepStore.info)
|
|
1834
|
+
return subscribeSleep(setStatus)
|
|
1835
|
+
}, [])
|
|
1836
|
+
return status
|
|
1837
|
+
}
|
|
1838
|
+
|
|
1839
|
+
/**
|
|
1840
|
+
* Whether the browser has no record of the sleep-button answer yet.
|
|
1841
|
+
*
|
|
1842
|
+
* The question is asked exactly once, so the key is written as soon as
|
|
1843
|
+
* the user answers — or closes the prompt.
|
|
1844
|
+
*/
|
|
1845
|
+
function sleepChoicePending() {
|
|
1846
|
+
try {
|
|
1847
|
+
if (typeof window === 'undefined' || !window.localStorage) return false
|
|
1848
|
+
return window.localStorage.getItem(SLEEP_BUTTON_KEY) === null
|
|
1849
|
+
} catch {
|
|
1850
|
+
return false
|
|
1851
|
+
}
|
|
1852
|
+
}
|
|
1853
|
+
|
|
1854
|
+
/** Record the sleep-button answer in the browser. */
|
|
1855
|
+
function writeSleepChoice(value) {
|
|
1856
|
+
try {
|
|
1857
|
+
if (typeof window === 'undefined' || !window.localStorage) return false
|
|
1858
|
+
window.localStorage.setItem(SLEEP_BUTTON_KEY, String(value))
|
|
1859
|
+
return true
|
|
1860
|
+
} catch {
|
|
1861
|
+
return false
|
|
1862
|
+
}
|
|
1863
|
+
}
|
|
1864
|
+
|
|
1865
|
+
// The styles of the sleep surfaces. They live in ONE <style> element so
|
|
1866
|
+
// the button and the overlay never disagree, and every colour comes from
|
|
1867
|
+
// a theme token: light and dark themes both work.
|
|
1868
|
+
const SLEEP_STYLE_ID = 'dsh-autorestart-sleep'
|
|
1869
|
+
const SLEEP_CSS = [
|
|
1870
|
+
// The button at the foot of the left sidebar, beside Settings.
|
|
1871
|
+
// It follows the host's own row language (same hover token, same
|
|
1872
|
+
// radius token, icon + label), but it stays compact: the sidebar foot
|
|
1873
|
+
// is a tight place, and the Settings row must keep its own space.
|
|
1874
|
+
'.dsh-autorestart-sleep-button{',
|
|
1875
|
+
' box-sizing:border-box;',
|
|
1876
|
+
' display:inline-flex;',
|
|
1877
|
+
' align-items:center;',
|
|
1878
|
+
' gap:8px;',
|
|
1879
|
+
' height:32px;',
|
|
1880
|
+
' max-width:100%;',
|
|
1881
|
+
' padding:0 10px 0 8px;',
|
|
1882
|
+
' border:none;',
|
|
1883
|
+
' border-radius:var(--dsw-radius-md,8px);',
|
|
1884
|
+
' background:transparent;',
|
|
1885
|
+
' color:var(--dsw-alias-label-primary);',
|
|
1886
|
+
' font:inherit;',
|
|
1887
|
+
' font-size:13px;',
|
|
1888
|
+
' line-height:20px;',
|
|
1889
|
+
' white-space:nowrap;',
|
|
1890
|
+
' overflow:hidden;',
|
|
1891
|
+
' flex:none;',
|
|
1892
|
+
' cursor:pointer;',
|
|
1893
|
+
' transition:background-color .14s,color .14s;',
|
|
1894
|
+
'}',
|
|
1895
|
+
'.dsh-autorestart-sleep-button:hover{',
|
|
1896
|
+
' background:var(--dsw-alias-interactive-bg-hover);',
|
|
1897
|
+
'}',
|
|
1898
|
+
'.dsh-autorestart-sleep-button:focus-visible{',
|
|
1899
|
+
' outline:2px solid var(--dsw-alias-state-business-primary,var(--dsw-alias-brand-primary,#4d6bfe));',
|
|
1900
|
+
' outline-offset:2px;',
|
|
1901
|
+
'}',
|
|
1902
|
+
'.dsh-autorestart-sleep-button:disabled{cursor:default;opacity:.65;}',
|
|
1903
|
+
'.dsh-autorestart-sleep-button--armed{',
|
|
1904
|
+
' color:var(--dsw-alias-state-business-primary,var(--dsw-alias-brand-primary,#4d6bfe));',
|
|
1905
|
+
' background:var(--dsw-alias-bg-module-platform,color-mix(in srgb, var(--dsw-alias-label-primary) 6%, transparent));',
|
|
1906
|
+
'}',
|
|
1907
|
+
// The narrow rail (the collapsed sidebar): the icon only, so the rail
|
|
1908
|
+
// keeps its width and the row does not push the Settings row away.
|
|
1909
|
+
'.dsh-autorestart-sleep-button--rail{',
|
|
1910
|
+
' width:36px;',
|
|
1911
|
+
' height:36px;',
|
|
1912
|
+
' padding:0;',
|
|
1913
|
+
' gap:0;',
|
|
1914
|
+
' justify-content:center;',
|
|
1915
|
+
' border-radius:50%;',
|
|
1916
|
+
'}',
|
|
1917
|
+
'.dsh-autorestart-sleep-button__icon{width:16px;height:16px;flex:none;display:block;}',
|
|
1918
|
+
'.dsh-autorestart-sleep-button__label{',
|
|
1919
|
+
' white-space:nowrap;',
|
|
1920
|
+
' overflow:hidden;',
|
|
1921
|
+
' text-overflow:ellipsis;',
|
|
1922
|
+
'}',
|
|
1923
|
+
// The button and the reason a request was refused.
|
|
1924
|
+
'.dsh-autorestart-sleep-button__wrap{',
|
|
1925
|
+
' display:flex;',
|
|
1926
|
+
' flex-direction:column;',
|
|
1927
|
+
' align-items:flex-start;',
|
|
1928
|
+
' gap:2px;',
|
|
1929
|
+
' min-width:0;',
|
|
1930
|
+
' max-width:100%;',
|
|
1931
|
+
'}',
|
|
1932
|
+
'.dsh-autorestart-sleep-button__error{',
|
|
1933
|
+
' max-width:100%;',
|
|
1934
|
+
' padding:0 2px;',
|
|
1935
|
+
' color:var(--dsw-alias-danger,#d14343);',
|
|
1936
|
+
' font-size:11.5px;',
|
|
1937
|
+
' line-height:15px;',
|
|
1938
|
+
' text-align:left;',
|
|
1939
|
+
'}',
|
|
1940
|
+
'.dsh-autorestart-sleep-button__spinner{',
|
|
1941
|
+
' width:12px;',
|
|
1942
|
+
' height:12px;',
|
|
1943
|
+
' flex:none;',
|
|
1944
|
+
' border-radius:50%;',
|
|
1945
|
+
' border:2px solid var(--dsw-alias-border-l1);',
|
|
1946
|
+
' border-top-color:var(--dsw-alias-state-business-primary,var(--dsw-alias-brand-primary,#4d6bfe));',
|
|
1947
|
+
' animation:dsh-autorestart-sleep-spin .8s linear infinite;',
|
|
1948
|
+
'}',
|
|
1949
|
+
// The countdown CARD: a floating corner card, never a full-screen
|
|
1950
|
+
// cover. It lets every click through (only the cancel button takes
|
|
1951
|
+
// clicks), so the conversation below stays readable and usable.
|
|
1952
|
+
'.dsh-autorestart-sleep-overlay{',
|
|
1953
|
+
' position:fixed;',
|
|
1954
|
+
' top:calc(var(--dsh-frame-chrome-top,0px) + 12px);',
|
|
1955
|
+
' right:16px;',
|
|
1956
|
+
' z-index:9998;',
|
|
1957
|
+
' display:flex;',
|
|
1958
|
+
' flex-direction:column;',
|
|
1959
|
+
' align-items:center;',
|
|
1960
|
+
' justify-content:flex-start;',
|
|
1961
|
+
' gap:4px;',
|
|
1962
|
+
' box-sizing:border-box;',
|
|
1963
|
+
' width:max-content;',
|
|
1964
|
+
' max-width:min(320px,calc(100vw - 32px));',
|
|
1965
|
+
' padding:14px 18px 16px;',
|
|
1966
|
+
' border:1px solid var(--dsw-alias-border-l1);',
|
|
1967
|
+
' border-radius:16px;',
|
|
1968
|
+
' background:var(--dsw-alias-bg-layer-1);',
|
|
1969
|
+
' box-shadow:0 10px 30px color-mix(in srgb, var(--dsw-alias-bg-mask-3) 22%, transparent);',
|
|
1970
|
+
' color:var(--dsw-alias-label-primary);',
|
|
1971
|
+
' text-align:center;',
|
|
1972
|
+
' pointer-events:none;',
|
|
1973
|
+
'}',
|
|
1974
|
+
'.dsh-autorestart-sleep-overlay__number{',
|
|
1975
|
+
' font-size:clamp(72px,9vmin,140px);',
|
|
1976
|
+
' font-weight:700;',
|
|
1977
|
+
' line-height:1;',
|
|
1978
|
+
' letter-spacing:-.02em;',
|
|
1979
|
+
' color:var(--dsw-alias-label-primary);',
|
|
1980
|
+
' animation:dsh-autorestart-sleep-tick 1s ease-out;',
|
|
1981
|
+
'}',
|
|
1982
|
+
'.dsh-autorestart-sleep-overlay--urgent .dsh-autorestart-sleep-overlay__number{',
|
|
1983
|
+
' color:var(--dsw-alias-danger,#d14343);',
|
|
1984
|
+
' animation:dsh-autorestart-sleep-tick .7s ease-out;',
|
|
1985
|
+
'}',
|
|
1986
|
+
// The whale is a guest in the card now, not the star of a full screen.
|
|
1987
|
+
'.dsh-autorestart-sleep-overlay .dsh-autorestart-whale{width:min(30vmin,150px);}',
|
|
1988
|
+
'.dsh-autorestart-sleep-overlay__caption{',
|
|
1989
|
+
' margin:4px 0 0;',
|
|
1990
|
+
' font-size:14px;',
|
|
1991
|
+
' font-weight:600;',
|
|
1992
|
+
' line-height:1.4;',
|
|
1993
|
+
' color:var(--dsw-alias-label-primary);',
|
|
1994
|
+
' animation:dsh-autorestart-sleep-fade .5s ease-out;',
|
|
1995
|
+
'}',
|
|
1996
|
+
'.dsh-autorestart-sleep-overlay__hint{',
|
|
1997
|
+
' margin:2px 0 0;',
|
|
1998
|
+
' font-size:13px;',
|
|
1999
|
+
' line-height:1.5;',
|
|
2000
|
+
' color:var(--dsw-alias-label-secondary);',
|
|
2001
|
+
'}',
|
|
2002
|
+
'.dsh-autorestart-sleep-overlay__error{',
|
|
2003
|
+
' margin:10px 0 0;',
|
|
2004
|
+
' font-size:12.5px;',
|
|
2005
|
+
' line-height:1.5;',
|
|
2006
|
+
' color:var(--dsw-alias-danger,#d14343);',
|
|
2007
|
+
'}',
|
|
2008
|
+
'.dsh-autorestart-sleep-overlay__cancel{',
|
|
2009
|
+
' margin-top:10px;',
|
|
2010
|
+
' min-height:32px;',
|
|
2011
|
+
' padding:6px 16px;',
|
|
2012
|
+
' border:1px solid var(--dsw-alias-border-l1);',
|
|
2013
|
+
' border-radius:9px;',
|
|
2014
|
+
' background:transparent;',
|
|
2015
|
+
' color:var(--dsw-alias-label-primary);',
|
|
2016
|
+
' font:inherit;',
|
|
2017
|
+
' font-size:13px;',
|
|
2018
|
+
' font-weight:600;',
|
|
2019
|
+
' line-height:20px;',
|
|
2020
|
+
' cursor:pointer;',
|
|
2021
|
+
// The card itself lets every click through; the cancel button is the
|
|
2022
|
+
// one thing in it that takes a click.
|
|
2023
|
+
' pointer-events:auto;',
|
|
2024
|
+
' transition:background-color .14s,border-color .14s,color .14s;',
|
|
2025
|
+
'}',
|
|
2026
|
+
'.dsh-autorestart-sleep-overlay__cancel:hover{',
|
|
2027
|
+
' border-color:var(--dsw-alias-state-business-primary,var(--dsw-alias-brand-primary,#4d6bfe));',
|
|
2028
|
+
' color:var(--dsw-alias-state-business-primary,var(--dsw-alias-brand-primary,#4d6bfe));',
|
|
2029
|
+
' background:var(--dsw-alias-bg-layer-2,color-mix(in srgb, var(--dsw-alias-label-primary) 6%, transparent));',
|
|
2030
|
+
'}',
|
|
2031
|
+
'.dsh-autorestart-sleep-overlay__cancel:focus-visible{',
|
|
2032
|
+
' outline:2px solid var(--dsw-alias-state-business-primary,var(--dsw-alias-brand-primary,#4d6bfe));',
|
|
2033
|
+
' outline-offset:2px;',
|
|
2034
|
+
'}',
|
|
2035
|
+
'.dsh-autorestart-sleep-overlay__cancel:disabled{cursor:default;opacity:.65;}',
|
|
2036
|
+
// The waiting card: a soft corner card that never covers the reading.
|
|
2037
|
+
// Like the countdown card it lets every click through; only its
|
|
2038
|
+
// cancel button takes clicks.
|
|
2039
|
+
'.dsh-autorestart-sleep-waiting{',
|
|
2040
|
+
' position:fixed;',
|
|
2041
|
+
' right:16px;',
|
|
2042
|
+
' bottom:16px;',
|
|
2043
|
+
' z-index:9998;',
|
|
2044
|
+
' box-sizing:border-box;',
|
|
2045
|
+
' max-width:min(320px,calc(100vw - 32px));',
|
|
2046
|
+
' padding:12px 14px;',
|
|
2047
|
+
' border:1px solid var(--dsw-alias-border-l1);',
|
|
2048
|
+
' border-radius:12px;',
|
|
2049
|
+
' background:var(--dsw-alias-bg-layer-1);',
|
|
2050
|
+
' box-shadow:0 8px 24px color-mix(in srgb, var(--dsw-alias-bg-mask-3) 18%, transparent);',
|
|
2051
|
+
' color:var(--dsw-alias-label-secondary);',
|
|
2052
|
+
' font-size:12.5px;',
|
|
2053
|
+
' line-height:18px;',
|
|
2054
|
+
' text-align:left;',
|
|
2055
|
+
' pointer-events:none;',
|
|
2056
|
+
'}',
|
|
2057
|
+
'.dsh-autorestart-sleep-waiting__title{',
|
|
2058
|
+
' margin:0 0 2px;',
|
|
2059
|
+
' font-size:13.5px;',
|
|
2060
|
+
' font-weight:600;',
|
|
2061
|
+
' line-height:19px;',
|
|
2062
|
+
' color:var(--dsw-alias-label-primary);',
|
|
2063
|
+
'}',
|
|
2064
|
+
'.dsh-autorestart-sleep-waiting__body{margin:0 0 6px;}',
|
|
2065
|
+
'.dsh-autorestart-sleep-waiting__list{margin:0 0 6px;padding-left:2px;list-style:none;}',
|
|
2066
|
+
'.dsh-autorestart-sleep-waiting__list li{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}',
|
|
2067
|
+
'.dsh-autorestart-sleep-waiting__none{margin:0 0 6px;opacity:.8;}',
|
|
2068
|
+
// The command a rehearsal WOULD have run: it has to stay readable,
|
|
2069
|
+
// whatever the shell command looks like.
|
|
2070
|
+
'.dsh-autorestart-sleep-dryrun__command{',
|
|
2071
|
+
' display:block;',
|
|
2072
|
+
' margin:0 0 8px;',
|
|
2073
|
+
' padding:6px 8px;',
|
|
2074
|
+
' border-radius:8px;',
|
|
2075
|
+
' background:var(--dsw-alias-bg-layer-2,color-mix(in srgb, var(--dsw-alias-label-primary) 6%, transparent));',
|
|
2076
|
+
' color:var(--dsw-alias-label-primary);',
|
|
2077
|
+
' font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;',
|
|
2078
|
+
' font-size:11.5px;',
|
|
2079
|
+
' line-height:17px;',
|
|
2080
|
+
' word-break:break-all;',
|
|
2081
|
+
'}',
|
|
2082
|
+
'.dsh-autorestart-sleep-waiting__count{margin:0 0 8px;opacity:.8;}',
|
|
2083
|
+
'.dsh-autorestart-sleep-waiting__cancel{',
|
|
2084
|
+
' min-height:28px;',
|
|
2085
|
+
' padding:4px 12px;',
|
|
2086
|
+
' border:1px solid var(--dsw-alias-border-l1);',
|
|
2087
|
+
' border-radius:8px;',
|
|
2088
|
+
' background:transparent;',
|
|
2089
|
+
' color:var(--dsw-alias-label-primary);',
|
|
2090
|
+
' font:inherit;',
|
|
2091
|
+
' font-size:12.5px;',
|
|
2092
|
+
' font-weight:600;',
|
|
2093
|
+
' line-height:18px;',
|
|
2094
|
+
' cursor:pointer;',
|
|
2095
|
+
' pointer-events:auto;',
|
|
2096
|
+
'}',
|
|
2097
|
+
'.dsh-autorestart-sleep-waiting__cancel:hover{',
|
|
2098
|
+
' border-color:var(--dsw-alias-state-business-primary,var(--dsw-alias-brand-primary,#4d6bfe));',
|
|
2099
|
+
' color:var(--dsw-alias-state-business-primary,var(--dsw-alias-brand-primary,#4d6bfe));',
|
|
2100
|
+
'}',
|
|
2101
|
+
// The configuration page of the plugin row.
|
|
2102
|
+
'.dsh-autorestart-settings{display:flex;flex-direction:column;gap:14px;}',
|
|
2103
|
+
'.dsh-autorestart-settings__field{display:flex;flex-direction:column;gap:4px;}',
|
|
2104
|
+
'.dsh-autorestart-settings__label{font-size:13px;font-weight:600;color:var(--dsw-alias-label-primary);}',
|
|
2105
|
+
'.dsh-autorestart-settings__select{',
|
|
2106
|
+
' min-height:30px;',
|
|
2107
|
+
' padding:4px 8px;',
|
|
2108
|
+
' border:1px solid var(--dsw-alias-border-l1);',
|
|
2109
|
+
' border-radius:8px;',
|
|
2110
|
+
' background:var(--dsw-alias-bg-layer-2,transparent);',
|
|
2111
|
+
' color:var(--dsw-alias-label-primary);',
|
|
2112
|
+
' font:inherit;',
|
|
2113
|
+
' font-size:13px;',
|
|
2114
|
+
'}',
|
|
2115
|
+
'.dsh-autorestart-settings__hint{font-size:12px;line-height:17px;color:var(--dsw-alias-label-secondary);}',
|
|
2116
|
+
// The two backup switches.
|
|
2117
|
+
'.dsh-autorestart-settings__switch{',
|
|
2118
|
+
' width:16px;',
|
|
2119
|
+
' height:16px;',
|
|
2120
|
+
' margin:0;',
|
|
2121
|
+
' accent-color:var(--dsw-alias-brand-primary,#4d6bfe);',
|
|
2122
|
+
' cursor:pointer;',
|
|
2123
|
+
'}',
|
|
2124
|
+
'.dsh-autorestart-settings__switch:disabled{cursor:not-allowed;opacity:.5;}',
|
|
2125
|
+
'.dsh-autorestart-settings__error{',
|
|
2126
|
+
' margin:0;',
|
|
2127
|
+
' color:var(--dsw-alias-danger,#d14343);',
|
|
2128
|
+
' font-size:12.5px;',
|
|
2129
|
+
' line-height:18px;',
|
|
2130
|
+
'}',
|
|
2131
|
+
// The whale.
|
|
2132
|
+
'.dsh-autorestart-whale{width:min(44vmin,240px);height:auto;display:block;overflow:visible;}',
|
|
2133
|
+
'.dsh-autorestart-whale__float{',
|
|
2134
|
+
' animation:dsh-autorestart-whale-float 3.2s ease-in-out infinite;',
|
|
2135
|
+
' transform-origin:90px 90px;',
|
|
2136
|
+
'}',
|
|
2137
|
+
'.dsh-autorestart-whale__eye{',
|
|
2138
|
+
' animation:dsh-autorestart-whale-blink 4.6s ease-in-out infinite;',
|
|
2139
|
+
' transform-origin:48px 74px;',
|
|
2140
|
+
'}',
|
|
2141
|
+
'.dsh-autorestart-whale__spout{animation:dsh-autorestart-whale-spout 3.6s ease-in-out infinite;}',
|
|
2142
|
+
'.dsh-autorestart-whale__z{',
|
|
2143
|
+
' font-family:inherit;',
|
|
2144
|
+
' font-size:15px;',
|
|
2145
|
+
' font-weight:600;',
|
|
2146
|
+
' fill:var(--dsw-alias-label-secondary);',
|
|
2147
|
+
' animation:dsh-autorestart-whale-z 2.8s ease-out infinite;',
|
|
2148
|
+
'}',
|
|
2149
|
+
'.dsh-autorestart-whale__z--2{animation-delay:.9s;}',
|
|
2150
|
+
'.dsh-autorestart-whale__z--3{animation-delay:1.8s;}',
|
|
2151
|
+
'.dsh-autorestart-whale--urgent .dsh-autorestart-whale__float{animation-duration:1.1s;}',
|
|
2152
|
+
'.dsh-autorestart-whale--urgent .dsh-autorestart-whale__body,',
|
|
2153
|
+
'.dsh-autorestart-whale--urgent .dsh-autorestart-whale__tail,',
|
|
2154
|
+
'.dsh-autorestart-whale--urgent .dsh-autorestart-whale__fin{fill:var(--dsw-alias-danger,#d14343);}',
|
|
2155
|
+
// The keyframes.
|
|
2156
|
+
'@keyframes dsh-autorestart-sleep-spin{to{transform:rotate(360deg);}}',
|
|
2157
|
+
'@keyframes dsh-autorestart-sleep-tick{',
|
|
2158
|
+
' 0%{transform:scale(.86);opacity:.55;}',
|
|
2159
|
+
' 40%{transform:scale(1.04);opacity:1;}',
|
|
2160
|
+
' 100%{transform:scale(1);opacity:1;}',
|
|
2161
|
+
'}',
|
|
2162
|
+
'@keyframes dsh-autorestart-sleep-fade{from{opacity:0;transform:translateY(6px);}to{opacity:1;transform:none;}}',
|
|
2163
|
+
'@keyframes dsh-autorestart-whale-float{',
|
|
2164
|
+
' 0%,100%{transform:translateY(0) rotate(0deg);}',
|
|
2165
|
+
' 50%{transform:translateY(-10px) rotate(-1.5deg);}',
|
|
2166
|
+
'}',
|
|
2167
|
+
'@keyframes dsh-autorestart-whale-blink{',
|
|
2168
|
+
' 0%,88%,100%{transform:scaleY(1);}',
|
|
2169
|
+
' 93%{transform:scaleY(.08);}',
|
|
2170
|
+
' 97%{transform:scaleY(1);}',
|
|
2171
|
+
'}',
|
|
2172
|
+
'@keyframes dsh-autorestart-whale-spout{',
|
|
2173
|
+
' 0%,70%,100%{opacity:.35;}',
|
|
2174
|
+
' 35%{opacity:1;}',
|
|
2175
|
+
'}',
|
|
2176
|
+
'@keyframes dsh-autorestart-whale-z{',
|
|
2177
|
+
' 0%{opacity:0;transform:translate(0,0) scale(.7);}',
|
|
2178
|
+
' 25%{opacity:.9;}',
|
|
2179
|
+
' 100%{opacity:0;transform:translate(12px,-26px) scale(1.15);}',
|
|
2180
|
+
'}',
|
|
2181
|
+
// A user who asked for less motion gets a still whale.
|
|
2182
|
+
'@media (prefers-reduced-motion: reduce){',
|
|
2183
|
+
' .dsh-autorestart-whale__float,',
|
|
2184
|
+
' .dsh-autorestart-whale__eye,',
|
|
2185
|
+
' .dsh-autorestart-whale__spout,',
|
|
2186
|
+
' .dsh-autorestart-whale__z,',
|
|
2187
|
+
' .dsh-autorestart-sleep-overlay__number,',
|
|
2188
|
+
' .dsh-autorestart-sleep-overlay__caption,',
|
|
2189
|
+
' .dsh-autorestart-sleep-button__spinner{animation:none !important;}',
|
|
2190
|
+
'}',
|
|
2191
|
+
].join('\n')
|
|
2192
|
+
|
|
2193
|
+
/** Inject the sleep styles exactly once. */
|
|
2194
|
+
function ensureSleepStyle() {
|
|
2195
|
+
if (typeof document === 'undefined' || !document.head) return
|
|
2196
|
+
try {
|
|
2197
|
+
if (document.getElementById(SLEEP_STYLE_ID)) return
|
|
2198
|
+
const style = document.createElement('style')
|
|
2199
|
+
style.id = SLEEP_STYLE_ID
|
|
2200
|
+
style.textContent = SLEEP_CSS
|
|
2201
|
+
document.head.appendChild(style)
|
|
2202
|
+
} catch {
|
|
2203
|
+
// Without the stylesheet the surfaces are still readable.
|
|
2204
|
+
}
|
|
2205
|
+
}
|
|
2206
|
+
|
|
2207
|
+
/**
|
|
2208
|
+
* Remove the sleep styles again.
|
|
2209
|
+
*
|
|
2210
|
+
* The stylesheet belongs to the sleep surfaces: when the last one leaves
|
|
2211
|
+
* (the plugin is unloaded, the conversation is closed), the CSS goes with
|
|
2212
|
+
* it instead of staying in the document forever.
|
|
2213
|
+
*/
|
|
2214
|
+
function removeSleepStyle() {
|
|
2215
|
+
if (typeof document === 'undefined') return
|
|
2216
|
+
try {
|
|
2217
|
+
const node = document.getElementById(SLEEP_STYLE_ID)
|
|
2218
|
+
if (node && node.parentNode) node.parentNode.removeChild(node)
|
|
2219
|
+
} catch {
|
|
2220
|
+
// Nothing to clean up.
|
|
2221
|
+
}
|
|
2222
|
+
}
|
|
2223
|
+
|
|
2224
|
+
/** The moon of the sidebar button: an inline SVG, no external image. */
|
|
2225
|
+
function moonIcon() {
|
|
2226
|
+
return h(
|
|
2227
|
+
'svg',
|
|
2228
|
+
{
|
|
2229
|
+
className: 'dsh-autorestart-sleep-button__icon',
|
|
2230
|
+
viewBox: '0 0 24 24',
|
|
2231
|
+
width: '16',
|
|
2232
|
+
height: '16',
|
|
2233
|
+
'aria-hidden': 'true',
|
|
2234
|
+
focusable: 'false',
|
|
2235
|
+
},
|
|
2236
|
+
h('path', {
|
|
2237
|
+
d: 'M20.5 14.6A8.6 8.6 0 0 1 9.4 3.5a8.6 8.6 0 1 0 11.1 11.1z',
|
|
2238
|
+
fill: 'currentColor',
|
|
2239
|
+
}),
|
|
2240
|
+
h('circle', { cx: '16.5', cy: '6.5', r: '1.3', fill: 'var(--dsw-alias-brand-primary,#4d6bfe)' }),
|
|
2241
|
+
)
|
|
2242
|
+
}
|
|
2243
|
+
|
|
2244
|
+
/**
|
|
2245
|
+
* The graceful-shutdown button, at the foot of the left sidebar.
|
|
2246
|
+
*
|
|
2247
|
+
* It renders NOTHING at all when the Host says the button must not be
|
|
2248
|
+
* shown: no placeholder, no empty space.
|
|
2249
|
+
*
|
|
2250
|
+
* The sidebar hands the column state to the slot: `wide` is false while
|
|
2251
|
+
* the sidebar is collapsed into its narrow rail, and there the button
|
|
2252
|
+
* shows its icon only, so the rail keeps its width.
|
|
2253
|
+
*
|
|
2254
|
+
* @param {{ wide?: boolean }} [props] the sidebar's column state
|
|
2255
|
+
*/
|
|
2256
|
+
/**
|
|
2257
|
+
* The settings section of the plugin (the "Auto-restart & shut down" entry
|
|
2258
|
+
* in the settings sidebar).
|
|
2259
|
+
*
|
|
2260
|
+
* The Host renders this section for the slot `settings.section` with the
|
|
2261
|
+
* id `dsh-autorestart`. It carries exactly the three settings that
|
|
2262
|
+
* matter:
|
|
2263
|
+
*
|
|
2264
|
+
* - how long the countdown runs (a dropdown),
|
|
2265
|
+
* - whether the pre-restart backup is taken at all (a switch),
|
|
2266
|
+
* - whether that backup also snapshots the installed plugins (a switch).
|
|
2267
|
+
*
|
|
2268
|
+
* A change is written to the profile configuration at once (through the
|
|
2269
|
+
* plugin's own choice route, which uses the official `configEditor`
|
|
2270
|
+
* service), and the section reads the new values back from the Host.
|
|
2271
|
+
*
|
|
2272
|
+
* @param {object} props the Host's props: `t` (the translator of the
|
|
2273
|
+
* registered locale, injected by the section registration)
|
|
2274
|
+
*/
|
|
2275
|
+
function AutorestartSection(props) {
|
|
2276
|
+
const status = useSleepStatus()
|
|
2277
|
+
const info = useSharedInfo(true)
|
|
2278
|
+
const [busy, setBusy] = useState(false)
|
|
2279
|
+
const [error, setError] = useState('')
|
|
2280
|
+
const t = props && typeof props.t === 'function'
|
|
2281
|
+
? props.t
|
|
2282
|
+
: (translate ?? ((name) => name))
|
|
2283
|
+
|
|
2284
|
+
// The current values come from the Host, so the panel always shows what
|
|
2285
|
+
// is really in effect.
|
|
2286
|
+
const reported = status && typeof status === 'object' && typeof status.countdownSeconds === 'number'
|
|
2287
|
+
? status.countdownSeconds
|
|
2288
|
+
: SLEEP_DEFAULT_COUNTDOWN_SECONDS
|
|
2289
|
+
const seconds = SLEEP_COUNTDOWN_CHOICES.indexOf(reported) === -1
|
|
2290
|
+
? SLEEP_DEFAULT_COUNTDOWN_SECONDS
|
|
2291
|
+
: reported
|
|
2292
|
+
// The two backup switches: the Host says what is in effect.
|
|
2293
|
+
const backupOnRestart = status && typeof status === 'object' && typeof status.backupOnRestart === 'boolean'
|
|
2294
|
+
? status.backupOnRestart
|
|
2295
|
+
: true
|
|
2296
|
+
const backupIncludePlugins = status && typeof status === 'object' && typeof status.backupIncludePlugins === 'boolean'
|
|
2297
|
+
? status.backupIncludePlugins
|
|
2298
|
+
: true
|
|
2299
|
+
|
|
2300
|
+
/**
|
|
2301
|
+
* Write one change into the profile configuration.
|
|
2302
|
+
*
|
|
2303
|
+
* The Host answers with the effective values, and the panel reads them
|
|
2304
|
+
* back with a fresh poll — so the controls never show a value the
|
|
2305
|
+
* Host did not take.
|
|
2306
|
+
*
|
|
2307
|
+
* @param {object} changes the keys to write
|
|
2308
|
+
*/
|
|
2309
|
+
const save = async (changes) => {
|
|
2310
|
+
if (busy) return
|
|
2311
|
+
setBusy(true)
|
|
2312
|
+
setError('')
|
|
2313
|
+
try {
|
|
2314
|
+
const response = await fetch(choiceUrlFor(info), {
|
|
2315
|
+
method: 'POST',
|
|
2316
|
+
headers: { 'content-type': 'application/json' },
|
|
2317
|
+
body: JSON.stringify(changes),
|
|
2318
|
+
})
|
|
2319
|
+
const body = await response.json().catch(() => null)
|
|
2320
|
+
if (!response.ok) {
|
|
2321
|
+
const message = body && typeof body === 'object'
|
|
2322
|
+
? (body.message || body.error || String(response.status))
|
|
2323
|
+
: String(response.status)
|
|
2324
|
+
setError(fill(t('settings.error'), { message }))
|
|
2325
|
+
return
|
|
2326
|
+
}
|
|
2327
|
+
// Read the new values back from the Host.
|
|
2328
|
+
await pollSleepOnce()
|
|
2329
|
+
} catch (failure) {
|
|
2330
|
+
setError(fill(t('settings.error'), { message: describeError(failure) }))
|
|
2331
|
+
} finally {
|
|
2332
|
+
setBusy(false)
|
|
2333
|
+
}
|
|
2334
|
+
}
|
|
2335
|
+
|
|
2336
|
+
ensureSleepStyle()
|
|
2337
|
+
|
|
2338
|
+
return h(
|
|
2339
|
+
'div',
|
|
2340
|
+
{ className: 'dsh-autorestart-settings' },
|
|
2341
|
+
h(
|
|
2342
|
+
'label',
|
|
2343
|
+
{ className: 'dsh-autorestart-settings__field' },
|
|
2344
|
+
h('span', { className: 'dsh-autorestart-settings__label' }, t('settings.seconds.label')),
|
|
2345
|
+
h(
|
|
2346
|
+
'select',
|
|
2347
|
+
{
|
|
2348
|
+
className: 'dsh-autorestart-settings__select',
|
|
2349
|
+
value: String(seconds),
|
|
2350
|
+
disabled: busy,
|
|
2351
|
+
'data-dsh-autorestart-setting': 'sleepCountdownSeconds',
|
|
2352
|
+
onChange: (event) => {
|
|
2353
|
+
const value = Number(event && event.target ? event.target.value : NaN)
|
|
2354
|
+
if (!Number.isFinite(value) || value === seconds) return
|
|
2355
|
+
void save({ sleepCountdownSeconds: value })
|
|
2356
|
+
},
|
|
2357
|
+
},
|
|
2358
|
+
SLEEP_COUNTDOWN_CHOICES.map((choice) => h(
|
|
2359
|
+
'option',
|
|
2360
|
+
{ key: String(choice), value: String(choice) },
|
|
2361
|
+
choice === 0 ? t('settings.seconds.immediate') : fill(t('settings.seconds.value'), { n: choice }),
|
|
2362
|
+
)),
|
|
2363
|
+
),
|
|
2364
|
+
h('span', { className: 'dsh-autorestart-settings__hint' }, t('settings.seconds.hint')),
|
|
2365
|
+
),
|
|
2366
|
+
h(
|
|
2367
|
+
'label',
|
|
2368
|
+
{ className: 'dsh-autorestart-settings__field' },
|
|
2369
|
+
h('span', { className: 'dsh-autorestart-settings__label' }, t('settings.backup.label')),
|
|
2370
|
+
h(
|
|
2371
|
+
'input',
|
|
2372
|
+
{
|
|
2373
|
+
type: 'checkbox',
|
|
2374
|
+
className: 'dsh-autorestart-settings__switch',
|
|
2375
|
+
checked: backupOnRestart,
|
|
2376
|
+
disabled: busy,
|
|
2377
|
+
'data-dsh-autorestart-setting': 'backupOnRestart',
|
|
2378
|
+
onChange: (event) => {
|
|
2379
|
+
const value = Boolean(event && event.target ? event.target.checked : false)
|
|
2380
|
+
if (value === backupOnRestart) return
|
|
2381
|
+
void save({ backupOnRestart: value })
|
|
2382
|
+
},
|
|
2383
|
+
},
|
|
2384
|
+
),
|
|
2385
|
+
h('span', { className: 'dsh-autorestart-settings__hint' }, t('settings.backup.hint')),
|
|
2386
|
+
),
|
|
2387
|
+
h(
|
|
2388
|
+
'label',
|
|
2389
|
+
{ className: 'dsh-autorestart-settings__field' },
|
|
2390
|
+
h('span', { className: 'dsh-autorestart-settings__label' }, t('settings.backupPlugins.label')),
|
|
2391
|
+
h(
|
|
2392
|
+
'input',
|
|
2393
|
+
{
|
|
2394
|
+
type: 'checkbox',
|
|
2395
|
+
className: 'dsh-autorestart-settings__switch',
|
|
2396
|
+
checked: backupIncludePlugins,
|
|
2397
|
+
// Without a pre-restart backup there is nothing to extend: the
|
|
2398
|
+
// switch is greyed out (and the Host never reads it).
|
|
2399
|
+
disabled: busy || !backupOnRestart,
|
|
2400
|
+
'data-dsh-autorestart-setting': 'backupIncludePlugins',
|
|
2401
|
+
onChange: (event) => {
|
|
2402
|
+
const value = Boolean(event && event.target ? event.target.checked : false)
|
|
2403
|
+
if (value === backupIncludePlugins) return
|
|
2404
|
+
void save({ backupIncludePlugins: value })
|
|
2405
|
+
},
|
|
2406
|
+
},
|
|
2407
|
+
),
|
|
2408
|
+
h('span', { className: 'dsh-autorestart-settings__hint' }, t('settings.backupPlugins.hint')),
|
|
2409
|
+
),
|
|
2410
|
+
error === ''
|
|
2411
|
+
? null
|
|
2412
|
+
: h('p', { className: 'dsh-autorestart-settings__error', role: 'alert' }, error),
|
|
2413
|
+
)
|
|
2414
|
+
}
|
|
2415
|
+
|
|
2416
|
+
function SleepButton(props) {
|
|
2417
|
+
const status = useSleepStatus()
|
|
2418
|
+
const [arming, setArming] = useState(false)
|
|
2419
|
+
const [error, setError] = useState('')
|
|
2420
|
+
const t = translate ?? ((name) => name)
|
|
2421
|
+
|
|
2422
|
+
// The Host's truth decides, and an UNKNOWN truth decides NOTHING: before
|
|
2423
|
+
// the first poll the button renders nothing at all, so a host that has
|
|
2424
|
+
// the button switched off never sees it flash for two seconds.
|
|
2425
|
+
const remoteKnown = Boolean(
|
|
2426
|
+
status && typeof status === 'object' && typeof status.showSleepButton === 'boolean',
|
|
2427
|
+
)
|
|
2428
|
+
const visible = remoteKnown && shouldShowSleepButton(status)
|
|
2429
|
+
|
|
2430
|
+
// The narrow rail shows the icon only; the wide column shows the label.
|
|
2431
|
+
const wide = !(props && props.wide === false)
|
|
2432
|
+
|
|
2433
|
+
// How long the countdown lasts (the settings page offers a dropdown).
|
|
2434
|
+
const reported = status && typeof status === 'object' && typeof status.countdownSeconds === 'number'
|
|
2435
|
+
? status.countdownSeconds
|
|
2436
|
+
: SLEEP_DEFAULT_COUNTDOWN_SECONDS
|
|
2437
|
+
const countdownSeconds = SLEEP_COUNTDOWN_CHOICES.indexOf(reported) === -1
|
|
2438
|
+
? SLEEP_DEFAULT_COUNTDOWN_SECONDS
|
|
2439
|
+
: reported
|
|
2440
|
+
const countdownHint = countdownSeconds > 0
|
|
2441
|
+
? fill(t('sleep.button.title'), { n: countdownSeconds })
|
|
2442
|
+
: t('sleep.button.titleImmediate')
|
|
2443
|
+
|
|
2444
|
+
// The button is a TOGGLE: a click arms the shutdown, and a click while
|
|
2445
|
+
// the shutdown is armed cancels it. That way there is ALWAYS a way out,
|
|
2446
|
+
// even when the countdown screen is not (or no longer) on top.
|
|
2447
|
+
const armed = Boolean(
|
|
2448
|
+
status && typeof status === 'object' && (
|
|
2449
|
+
status.armed === true
|
|
2450
|
+
|| status.phase === 'waiting'
|
|
2451
|
+
|| status.phase === 'countdown'
|
|
2452
|
+
|| status.phase === 'shutting-down'
|
|
2453
|
+
),
|
|
2454
|
+
)
|
|
2455
|
+
|
|
2456
|
+
/**
|
|
2457
|
+
* Ask the Host to stand by: it waits for every session to finish and
|
|
2458
|
+
* then counts down.
|
|
2459
|
+
*
|
|
2460
|
+
* That is all a click does: the Host arms the shutdown, waits for the
|
|
2461
|
+
* busy conversations and counts down. Clicking again (or the cancel
|
|
2462
|
+
* button on the card) takes it back.
|
|
2463
|
+
*/
|
|
2464
|
+
const arm = async () => {
|
|
2465
|
+
if (arming) return
|
|
2466
|
+
setArming(true)
|
|
2467
|
+
setError('')
|
|
2468
|
+
try {
|
|
2469
|
+
await driveSleep('arm')
|
|
2470
|
+
} catch (failure) {
|
|
2471
|
+
setError(fill(t('sleep.error'), { message: describeError(failure) }))
|
|
2472
|
+
} finally {
|
|
2473
|
+
setArming(false)
|
|
2474
|
+
}
|
|
2475
|
+
}
|
|
2476
|
+
|
|
2477
|
+
/** Take the shutdown back: the countdown stops and the machine stays on. */
|
|
2478
|
+
const cancel = async () => {
|
|
2479
|
+
if (arming) return
|
|
2480
|
+
setArming(true)
|
|
2481
|
+
setError('')
|
|
2482
|
+
try {
|
|
2483
|
+
await driveSleep('cancel')
|
|
2484
|
+
} catch (failure) {
|
|
2485
|
+
setError(fill(t('sleep.error'), { message: describeError(failure) }))
|
|
2486
|
+
} finally {
|
|
2487
|
+
setArming(false)
|
|
2488
|
+
}
|
|
2489
|
+
}
|
|
2490
|
+
|
|
2491
|
+
/**
|
|
2492
|
+
* The click itself is the action: the moon button arms the shutdown,
|
|
2493
|
+
* and while it is armed the same button cancels it again.
|
|
2494
|
+
*
|
|
2495
|
+
* @param {object} event the DOM event of the click
|
|
2496
|
+
*/
|
|
2497
|
+
const handleClick = (event) => {
|
|
2498
|
+
if (armed) cancel()
|
|
2499
|
+
else arm()
|
|
2500
|
+
}
|
|
2501
|
+
|
|
2502
|
+
if (!visible) return null
|
|
2503
|
+
ensureSleepStyle()
|
|
2504
|
+
|
|
2505
|
+
const button = h(
|
|
2506
|
+
'button',
|
|
2507
|
+
{
|
|
2508
|
+
type: 'button',
|
|
2509
|
+
className: 'dsh-autorestart-sleep-button'
|
|
2510
|
+
+ (armed ? ' dsh-autorestart-sleep-button--armed' : '')
|
|
2511
|
+
+ (wide ? '' : ' dsh-autorestart-sleep-button--rail'),
|
|
2512
|
+
'aria-label': armed ? t('sleep.button.cancel') : t('sleep.button.aria'),
|
|
2513
|
+
title: error || (armed ? t('sleep.button.cancel') : countdownHint),
|
|
2514
|
+
disabled: arming,
|
|
2515
|
+
onClick: handleClick,
|
|
2516
|
+
'data-dsh-autorestart-sleep-button': 'true',
|
|
2517
|
+
'data-armed': armed ? 'true' : 'false',
|
|
2518
|
+
'data-wide': wide ? 'true' : 'false',
|
|
2519
|
+
},
|
|
2520
|
+
arming
|
|
2521
|
+
? h('span', { className: 'dsh-autorestart-sleep-button__spinner', 'aria-hidden': 'true' })
|
|
2522
|
+
: moonIcon(),
|
|
2523
|
+
// The narrow rail has no room for a label: the icon alone, with the
|
|
2524
|
+
// title and the aria-label telling what it does.
|
|
2525
|
+
wide
|
|
2526
|
+
? h(
|
|
2527
|
+
'span',
|
|
2528
|
+
{ className: 'dsh-autorestart-sleep-button__label' },
|
|
2529
|
+
arming
|
|
2530
|
+
? t('sleep.button.busy')
|
|
2531
|
+
: (armed ? t('sleep.button.cancel') : t('sleep.button.label')),
|
|
2532
|
+
)
|
|
2533
|
+
: null,
|
|
2534
|
+
)
|
|
2535
|
+
|
|
2536
|
+
// A refused request is never silent: the reason is written under the
|
|
2537
|
+
// button.
|
|
2538
|
+
if (!wide || error === '') return button
|
|
2539
|
+
return h(
|
|
2540
|
+
'div',
|
|
2541
|
+
{ className: 'dsh-autorestart-sleep-button__wrap' },
|
|
2542
|
+
button,
|
|
2543
|
+
h(
|
|
2544
|
+
'span',
|
|
2545
|
+
{ className: 'dsh-autorestart-sleep-button__error', role: 'alert' },
|
|
2546
|
+
error,
|
|
2547
|
+
),
|
|
2548
|
+
)
|
|
2549
|
+
}
|
|
2550
|
+
|
|
2551
|
+
/**
|
|
2552
|
+
* A small sketch of the workspace with the shutdown button at the foot of
|
|
2553
|
+
* the left sidebar: the arrow points at the place the button appears.
|
|
2554
|
+
*
|
|
2555
|
+
* It carries no copy of its own: the words around it come from the
|
|
2556
|
+
* locale files.
|
|
2557
|
+
*/
|
|
2558
|
+
function sleepDiagram() {
|
|
2559
|
+
return h(
|
|
2560
|
+
'svg',
|
|
2561
|
+
{
|
|
2562
|
+
className: 'dsh-autorestart-choice__diagram__art',
|
|
2563
|
+
viewBox: '0 0 260 120',
|
|
2564
|
+
width: '260',
|
|
2565
|
+
height: '120',
|
|
2566
|
+
'aria-hidden': 'true',
|
|
2567
|
+
focusable: 'false',
|
|
2568
|
+
},
|
|
2569
|
+
// The window.
|
|
2570
|
+
h('rect', {
|
|
2571
|
+
x: '6',
|
|
2572
|
+
y: '6',
|
|
2573
|
+
width: '248',
|
|
2574
|
+
height: '108',
|
|
2575
|
+
rx: '10',
|
|
2576
|
+
fill: 'var(--dsw-alias-bg-layer-2,transparent)',
|
|
2577
|
+
stroke: 'var(--dsw-alias-border-l1)',
|
|
2578
|
+
}),
|
|
2579
|
+
h('line', {
|
|
2580
|
+
x1: '6',
|
|
2581
|
+
y1: '24',
|
|
2582
|
+
x2: '254',
|
|
2583
|
+
y2: '24',
|
|
2584
|
+
stroke: 'var(--dsw-alias-border-l1)',
|
|
2585
|
+
}),
|
|
2586
|
+
// The left sidebar.
|
|
2587
|
+
h('line', {
|
|
2588
|
+
x1: '78',
|
|
2589
|
+
y1: '24',
|
|
2590
|
+
x2: '78',
|
|
2591
|
+
y2: '114',
|
|
2592
|
+
stroke: 'var(--dsw-alias-border-l1)',
|
|
2593
|
+
}),
|
|
2594
|
+
// Two session rows in the sidebar.
|
|
2595
|
+
h('rect', {
|
|
2596
|
+
x: '16',
|
|
2597
|
+
y: '36',
|
|
2598
|
+
width: '46',
|
|
2599
|
+
height: '7',
|
|
2600
|
+
rx: '3.5',
|
|
2601
|
+
fill: 'var(--dsw-alias-border-l2,var(--dsw-alias-border-l1))',
|
|
2602
|
+
}),
|
|
2603
|
+
h('rect', {
|
|
2604
|
+
x: '16',
|
|
2605
|
+
y: '50',
|
|
2606
|
+
width: '52',
|
|
2607
|
+
height: '7',
|
|
2608
|
+
rx: '3.5',
|
|
2609
|
+
fill: 'var(--dsw-alias-border-l2,var(--dsw-alias-border-l1))',
|
|
2610
|
+
}),
|
|
2611
|
+
// The button at the foot of the sidebar, right above the Settings row.
|
|
2612
|
+
h('rect', {
|
|
2613
|
+
x: '14',
|
|
2614
|
+
y: '80',
|
|
2615
|
+
width: '52',
|
|
2616
|
+
height: '18',
|
|
2617
|
+
rx: '6',
|
|
2618
|
+
fill: 'var(--dsw-alias-state-business-primary,var(--dsw-alias-brand-primary,#4d6bfe))',
|
|
2619
|
+
}),
|
|
2620
|
+
h(
|
|
2621
|
+
'g',
|
|
2622
|
+
{ transform: 'translate(-4 45)' },
|
|
2623
|
+
h('path', {
|
|
2624
|
+
d: 'M28 44a6 6 0 0 1 8-8 6 6 0 1 0 7 7 6 6 0 0 1-8 8 6 6 0 0 1-7-7z',
|
|
2625
|
+
fill: 'var(--dsw-alias-bg-layer-1,#fff)',
|
|
2626
|
+
}),
|
|
2627
|
+
),
|
|
2628
|
+
// The Settings row underneath it.
|
|
2629
|
+
h('circle', {
|
|
2630
|
+
cx: '20',
|
|
2631
|
+
cy: '107',
|
|
2632
|
+
r: '4.5',
|
|
2633
|
+
fill: 'none',
|
|
2634
|
+
stroke: 'var(--dsw-alias-label-secondary)',
|
|
2635
|
+
'stroke-width': '1.5',
|
|
2636
|
+
}),
|
|
2637
|
+
h('rect', {
|
|
2638
|
+
x: '30',
|
|
2639
|
+
y: '103',
|
|
2640
|
+
width: '34',
|
|
2641
|
+
height: '7',
|
|
2642
|
+
rx: '3.5',
|
|
2643
|
+
fill: 'var(--dsw-alias-border-l2,var(--dsw-alias-border-l1))',
|
|
2644
|
+
}),
|
|
2645
|
+
// The arrow that points at the bottom-left corner.
|
|
2646
|
+
h('path', {
|
|
2647
|
+
d: 'M186 100 L74 92',
|
|
2648
|
+
fill: 'none',
|
|
2649
|
+
stroke: 'var(--dsw-alias-label-secondary)',
|
|
2650
|
+
'stroke-width': '2',
|
|
2651
|
+
'stroke-linecap': 'round',
|
|
2652
|
+
'stroke-dasharray': '5 4',
|
|
2653
|
+
}),
|
|
2654
|
+
h('path', {
|
|
2655
|
+
d: 'M74 92 l12-1 -5 10 z',
|
|
2656
|
+
fill: 'var(--dsw-alias-label-secondary)',
|
|
2657
|
+
}),
|
|
2658
|
+
)
|
|
2659
|
+
}
|
|
2660
|
+
|
|
2661
|
+
/**
|
|
2662
|
+
* The whale: an inline SVG animated with CSS only.
|
|
2663
|
+
*
|
|
2664
|
+
* No external image and no dependency on another plugin: the whale is
|
|
2665
|
+
* drawn here, floats, blinks and blows sleepy Zs.
|
|
2666
|
+
*/
|
|
2667
|
+
function whaleArt(urgent) {
|
|
2668
|
+
return h(
|
|
2669
|
+
'svg',
|
|
2670
|
+
{
|
|
2671
|
+
className: 'dsh-autorestart-whale' + (urgent ? ' dsh-autorestart-whale--urgent' : ''),
|
|
2672
|
+
viewBox: '0 0 200 140',
|
|
2673
|
+
width: '200',
|
|
2674
|
+
height: '140',
|
|
2675
|
+
'aria-hidden': 'true',
|
|
2676
|
+
focusable: 'false',
|
|
2677
|
+
},
|
|
2678
|
+
h(
|
|
2679
|
+
'g',
|
|
2680
|
+
{ className: 'dsh-autorestart-whale__float' },
|
|
2681
|
+
h('path', {
|
|
2682
|
+
className: 'dsh-autorestart-whale__tail',
|
|
2683
|
+
d: 'M142 84c14-16 26-30 44-38-6 12-9 22-9 30 0 8 3 18 9 30-18-8-30-16-44-22z',
|
|
2684
|
+
fill: 'var(--dsw-alias-brand-primary,#4d8bf0)',
|
|
2685
|
+
}),
|
|
2686
|
+
h('ellipse', {
|
|
2687
|
+
className: 'dsh-autorestart-whale__body',
|
|
2688
|
+
cx: '84',
|
|
2689
|
+
cy: '84',
|
|
2690
|
+
rx: '66',
|
|
2691
|
+
ry: '38',
|
|
2692
|
+
fill: 'var(--dsw-alias-brand-primary,#4d8bf0)',
|
|
2693
|
+
}),
|
|
2694
|
+
h('ellipse', {
|
|
2695
|
+
className: 'dsh-autorestart-whale__belly',
|
|
2696
|
+
cx: '90',
|
|
2697
|
+
cy: '97',
|
|
2698
|
+
rx: '50',
|
|
2699
|
+
ry: '23',
|
|
2700
|
+
fill: 'var(--dsw-alias-bg-layer-1,#fff)',
|
|
2701
|
+
opacity: '0.85',
|
|
2702
|
+
}),
|
|
2703
|
+
h('path', {
|
|
2704
|
+
className: 'dsh-autorestart-whale__fin',
|
|
2705
|
+
d: 'M72 110c8 10 18 14 30 12-6 8-16 12-26 10-8-2-12-11-4-22z',
|
|
2706
|
+
fill: 'var(--dsw-alias-brand-primary,#4d8bf0)',
|
|
2707
|
+
}),
|
|
2708
|
+
h(
|
|
2709
|
+
'g',
|
|
2710
|
+
{ className: 'dsh-autorestart-whale__eye' },
|
|
2711
|
+
h('circle', { cx: '48', cy: '74', r: '6.5', fill: 'var(--dsw-alias-bg-layer-1,#fff)' }),
|
|
2712
|
+
h('circle', { cx: '47', cy: '75', r: '3', fill: 'var(--dsw-alias-label-primary,#333)' }),
|
|
2713
|
+
),
|
|
2714
|
+
h('path', {
|
|
2715
|
+
className: 'dsh-autorestart-whale__mouth',
|
|
2716
|
+
d: 'M28 88c6 6 14 8 22 6',
|
|
2717
|
+
fill: 'none',
|
|
2718
|
+
stroke: 'var(--dsw-alias-label-secondary,#888)',
|
|
2719
|
+
'stroke-width': '2',
|
|
2720
|
+
'stroke-linecap': 'round',
|
|
2721
|
+
}),
|
|
2722
|
+
h(
|
|
2723
|
+
'g',
|
|
2724
|
+
{ className: 'dsh-autorestart-whale__spout' },
|
|
2725
|
+
h('circle', { cx: '62', cy: '38', r: '5', fill: 'var(--dsw-alias-label-secondary,#888)', opacity: '0.7' }),
|
|
2726
|
+
h('circle', { cx: '74', cy: '28', r: '3.5', fill: 'var(--dsw-alias-label-secondary,#888)', opacity: '0.55' }),
|
|
2727
|
+
h('circle', { cx: '52', cy: '26', r: '3', fill: 'var(--dsw-alias-label-secondary,#888)', opacity: '0.45' }),
|
|
2728
|
+
),
|
|
2729
|
+
),
|
|
2730
|
+
h('text', { className: 'dsh-autorestart-whale__z dsh-autorestart-whale__z--1', x: '148', y: '46' }, 'Z'),
|
|
2731
|
+
h('text', { className: 'dsh-autorestart-whale__z dsh-autorestart-whale__z--2', x: '166', y: '30' }, 'Z'),
|
|
2732
|
+
h('text', { className: 'dsh-autorestart-whale__z dsh-autorestart-whale__z--3', x: '182', y: '16' }, 'Z'),
|
|
2733
|
+
)
|
|
2734
|
+
}
|
|
2735
|
+
|
|
2736
|
+
/**
|
|
2737
|
+
* The countdown overlay, in the shell's overlay layer.
|
|
2738
|
+
*
|
|
2739
|
+
* It renders NOTHING unless the Host really is counting down, waiting
|
|
2740
|
+
* for the sessions or already powering off.
|
|
2741
|
+
*/
|
|
2742
|
+
function SleepOverlay() {
|
|
2743
|
+
const status = useSleepStatus()
|
|
2744
|
+
const [, setTick] = useState(0)
|
|
2745
|
+
// The overlay folds away locally as soon as the user cancels.
|
|
2746
|
+
const [hidden, setHidden] = useState(false)
|
|
2747
|
+
const [error, setError] = useState('')
|
|
2748
|
+
const [busy, setBusy] = useState(false)
|
|
2749
|
+
// The safety net of the "good night" screen: the user may close the
|
|
2750
|
+
// notice, and a screen that stays for too long offers the same exit.
|
|
2751
|
+
const [dismissed, setDismissed] = useState(false)
|
|
2752
|
+
const [stuck, setStuck] = useState(false)
|
|
2753
|
+
// The note of a finished rehearsal: what the host WOULD have run. It
|
|
2754
|
+
// folds away on its own (or when the user closes it).
|
|
2755
|
+
const [dryNotice, setDryNotice] = useState(null)
|
|
2756
|
+
const t = translate ?? ((name) => name)
|
|
2757
|
+
|
|
2758
|
+
const phase = status && typeof status === 'object' && typeof status.phase === 'string'
|
|
2759
|
+
? status.phase
|
|
2760
|
+
: ''
|
|
2761
|
+
|
|
2762
|
+
// The last run the host reports. A REHEARSAL (dry run) says what it
|
|
2763
|
+
// would have done and executed nothing; a real failure says what went
|
|
2764
|
+
// wrong. The two never look alike, so the notice can never mistake one
|
|
2765
|
+
// for the other.
|
|
2766
|
+
const lastRun = status && typeof status === 'object' && status.lastRun && typeof status.lastRun === 'object'
|
|
2767
|
+
? status.lastRun
|
|
2768
|
+
: null
|
|
2769
|
+
const dryRunAt = lastRun !== null && lastRun.dryRun === true && typeof lastRun.at === 'string'
|
|
2770
|
+
? lastRun.at
|
|
2771
|
+
: ''
|
|
2772
|
+
const planned = lastRun !== null && typeof lastRun.command === 'string' && lastRun.command !== ''
|
|
2773
|
+
? [lastRun.command].concat(Array.isArray(lastRun.args) ? lastRun.args : []).join(' ')
|
|
2774
|
+
: ''
|
|
2775
|
+
|
|
2776
|
+
// The shared poll runs every 2s, but the big number has to move every
|
|
2777
|
+
// second: the countdown owns a local 1s tick. The interval is cleared
|
|
2778
|
+
// when the phase changes and when the component leaves.
|
|
2779
|
+
useEffect(() => {
|
|
2780
|
+
if (phase !== 'countdown') return undefined
|
|
2781
|
+
const timer = setInterval(() => setTick((value) => value + 1), 1000)
|
|
2782
|
+
return () => clearInterval(timer)
|
|
2783
|
+
}, [phase])
|
|
2784
|
+
|
|
2785
|
+
// A NEW countdown always shows the overlay again: a cancellation that
|
|
2786
|
+
// happened earlier must never leave the layer folded away, or the next
|
|
2787
|
+
// countdown would run without the user being able to see (or stop) it.
|
|
2788
|
+
useEffect(() => {
|
|
2789
|
+
if (phase === 'countdown') setHidden(false)
|
|
2790
|
+
}, [phase])
|
|
2791
|
+
|
|
2792
|
+
// A finished rehearsal leaves a note: the command it WOULD have run.
|
|
2793
|
+
// Without it the user would see nothing at all and could not tell a
|
|
2794
|
+
// rehearsal from a real shutdown. The note folds away after eight
|
|
2795
|
+
// seconds (or when the user closes it); a new rehearsal shows it again.
|
|
2796
|
+
useEffect(() => {
|
|
2797
|
+
if (dryRunAt === '') return undefined
|
|
2798
|
+
setDryNotice({ at: dryRunAt, command: planned })
|
|
2799
|
+
const timer = setTimeout(() => setDryNotice(null), SLEEP_DRY_RUN_NOTICE_MS)
|
|
2800
|
+
return () => clearTimeout(timer)
|
|
2801
|
+
}, [dryRunAt])
|
|
2802
|
+
|
|
2803
|
+
/**
|
|
2804
|
+
* The time safety net of the "good night" screen.
|
|
2805
|
+
*
|
|
2806
|
+
* A machine that really powers off is killed by the system, so this
|
|
2807
|
+
* screen cannot last eight seconds — if it does, the command did not
|
|
2808
|
+
* work and the user gets a way out. The timer is cleared when the phase
|
|
2809
|
+
* changes and when the component leaves.
|
|
2810
|
+
*/
|
|
2811
|
+
useEffect(() => {
|
|
2812
|
+
if (phase !== 'shutting-down') {
|
|
2813
|
+
setStuck(false)
|
|
2814
|
+
setDismissed(false)
|
|
2815
|
+
return undefined
|
|
2816
|
+
}
|
|
2817
|
+
const timer = setTimeout(() => setStuck(true), 8000)
|
|
2818
|
+
return () => clearTimeout(timer)
|
|
2819
|
+
}, [phase])
|
|
2820
|
+
|
|
2821
|
+
// The remaining time is measured from the moment the status arrived,
|
|
2822
|
+
// so the number keeps ticking between two polls.
|
|
2823
|
+
const remainingMs = status && typeof status.remainingMs === 'number' && Number.isFinite(status.remainingMs)
|
|
2824
|
+
? status.remainingMs
|
|
2825
|
+
: 0
|
|
2826
|
+
const elapsed = sleepStore.at > 0 ? Date.now() - sleepStore.at : 0
|
|
2827
|
+
const seconds = formatRemainingMs(Math.max(0, remainingMs - elapsed))
|
|
2828
|
+
|
|
2829
|
+
// How long the countdown lasts (the settings page offers a dropdown).
|
|
2830
|
+
const reported = status && typeof status === 'object' && typeof status.countdownSeconds === 'number'
|
|
2831
|
+
? status.countdownSeconds
|
|
2832
|
+
: SLEEP_DEFAULT_COUNTDOWN_SECONDS
|
|
2833
|
+
const countdownSeconds = SLEEP_COUNTDOWN_CHOICES.indexOf(reported) === -1
|
|
2834
|
+
? SLEEP_DEFAULT_COUNTDOWN_SECONDS
|
|
2835
|
+
: reported
|
|
2836
|
+
// A countdown of zero seconds is NO countdown: the machine is switched
|
|
2837
|
+
// off the moment the gate opens, so there is no card to show (and no
|
|
2838
|
+
// whale). The waiting card takes over when the gate keeps it closed.
|
|
2839
|
+
const noCountdown = countdownSeconds === 0
|
|
2840
|
+
|
|
2841
|
+
/**
|
|
2842
|
+
* Cancel the shutdown.
|
|
2843
|
+
*
|
|
2844
|
+
* The layer folds away only when the Host CONFIRMS the cancellation
|
|
2845
|
+
* (`phase === 'cancelled'`): a request that fails leaves the countdown
|
|
2846
|
+
* on screen, so the user can simply try again instead of being stuck
|
|
2847
|
+
* behind a machine that is still counting down.
|
|
2848
|
+
*/
|
|
2849
|
+
const cancel = async () => {
|
|
2850
|
+
if (busy) return
|
|
2851
|
+
setBusy(true)
|
|
2852
|
+
setError('')
|
|
2853
|
+
try {
|
|
2854
|
+
const body = await driveSleep('cancel')
|
|
2855
|
+
if (body && typeof body === 'object' && body.phase === 'cancelled') setHidden(true)
|
|
2856
|
+
} catch (failure) {
|
|
2857
|
+
// The countdown keeps running: the button can be pressed again.
|
|
2858
|
+
setHidden(false)
|
|
2859
|
+
setError(fill(t('sleep.error'), { message: describeError(failure) }))
|
|
2860
|
+
} finally {
|
|
2861
|
+
setBusy(false)
|
|
2862
|
+
}
|
|
2863
|
+
}
|
|
2864
|
+
|
|
2865
|
+
// The line that tells the user WHY the last request failed.
|
|
2866
|
+
const errorLine = () => (error === ''
|
|
2867
|
+
? null
|
|
2868
|
+
: h('p', { className: 'dsh-autorestart-sleep-overlay__error', role: 'alert' }, error))
|
|
2869
|
+
|
|
2870
|
+
// The Host confirmed the cancellation: the layer is gone.
|
|
2871
|
+
if (hidden) return null
|
|
2872
|
+
|
|
2873
|
+
if (phase === 'waiting') {
|
|
2874
|
+
ensureSleepStyle()
|
|
2875
|
+
const blockers = status && Array.isArray(status.blockers) ? status.blockers : []
|
|
2876
|
+
return h(
|
|
2877
|
+
'div',
|
|
2878
|
+
{
|
|
2879
|
+
className: 'dsh-autorestart-sleep-waiting',
|
|
2880
|
+
role: 'status',
|
|
2881
|
+
'aria-live': 'polite',
|
|
2882
|
+
'data-dsh-autorestart-sleep-waiting': 'true',
|
|
2883
|
+
// The card never eats a click: only its cancel button does.
|
|
2884
|
+
style: { pointerEvents: 'none' },
|
|
2885
|
+
},
|
|
2886
|
+
h('p', { className: 'dsh-autorestart-sleep-waiting__title' }, t('sleep.waiting.title')),
|
|
2887
|
+
h('p', { className: 'dsh-autorestart-sleep-waiting__body' }, t('sleep.waiting.body')),
|
|
2888
|
+
blockers.length === 0
|
|
2889
|
+
? h('p', { className: 'dsh-autorestart-sleep-waiting__none' }, t('sleep.waiting.none'))
|
|
2890
|
+
: h(
|
|
2891
|
+
'ul',
|
|
2892
|
+
{ className: 'dsh-autorestart-sleep-waiting__list' },
|
|
2893
|
+
blockers.map((entry, index) => h(
|
|
2894
|
+
'li',
|
|
2895
|
+
{ key: 'blocker-' + index },
|
|
2896
|
+
fill(t('sleep.waiting.blocker'), {
|
|
2897
|
+
title: entry && typeof entry.title === 'string' && entry.title !== ''
|
|
2898
|
+
? entry.title
|
|
2899
|
+
: t('panelBlockedGeneric'),
|
|
2900
|
+
}),
|
|
2901
|
+
)),
|
|
2902
|
+
),
|
|
2903
|
+
h('p', { className: 'dsh-autorestart-sleep-waiting__count' }, blockers.length === 0
|
|
2904
|
+
? null
|
|
2905
|
+
: fill(t('sleep.waiting.count'), { n: blockers.length })),
|
|
2906
|
+
errorLine(),
|
|
2907
|
+
h(
|
|
2908
|
+
'button',
|
|
2909
|
+
{
|
|
2910
|
+
type: 'button',
|
|
2911
|
+
className: 'dsh-autorestart-sleep-waiting__cancel',
|
|
2912
|
+
onClick: cancel,
|
|
2913
|
+
disabled: busy,
|
|
2914
|
+
style: { pointerEvents: 'auto' },
|
|
2915
|
+
},
|
|
2916
|
+
t('sleep.waiting.cancel'),
|
|
2917
|
+
),
|
|
2918
|
+
)
|
|
2919
|
+
}
|
|
2920
|
+
|
|
2921
|
+
if (phase === 'countdown') {
|
|
2922
|
+
// A countdown of zero seconds shows NO card at all: the machine is
|
|
2923
|
+
// switched off the moment the gate opens, so there is nothing to
|
|
2924
|
+
// count down and nothing to cancel.
|
|
2925
|
+
if (noCountdown) return null
|
|
2926
|
+
ensureSleepStyle()
|
|
2927
|
+
const urgent = seconds <= 3
|
|
2928
|
+
const hint = fill(t('sleep.countdown.hint'), { n: seconds })
|
|
2929
|
+
return h(
|
|
2930
|
+
'div',
|
|
2931
|
+
{
|
|
2932
|
+
className: 'dsh-autorestart-sleep-overlay'
|
|
2933
|
+
+ (urgent ? ' dsh-autorestart-sleep-overlay--urgent' : ''),
|
|
2934
|
+
role: 'dialog',
|
|
2935
|
+
'aria-live': 'assertive',
|
|
2936
|
+
'aria-label': hint,
|
|
2937
|
+
'data-dsh-autorestart-sleep-countdown': 'true',
|
|
2938
|
+
// The card is a FLOATING card: it never covers the interface and
|
|
2939
|
+
// it never eats a click — only its cancel button does.
|
|
2940
|
+
style: { pointerEvents: 'none' },
|
|
2941
|
+
},
|
|
2942
|
+
// The key changes every second, so the number restarts its pulse
|
|
2943
|
+
// on every tick.
|
|
2944
|
+
h(
|
|
2945
|
+
'div',
|
|
2946
|
+
{ className: 'dsh-autorestart-sleep-overlay__number', key: 'number-' + seconds },
|
|
2947
|
+
String(Math.max(0, seconds)),
|
|
2948
|
+
),
|
|
2949
|
+
whaleArt(urgent),
|
|
2950
|
+
h(
|
|
2951
|
+
'p',
|
|
2952
|
+
{ className: 'dsh-autorestart-sleep-overlay__caption', key: 'caption-' + seconds },
|
|
2953
|
+
pickSleepCaption(seconds, t),
|
|
2954
|
+
),
|
|
2955
|
+
h(
|
|
2956
|
+
'button',
|
|
2957
|
+
{
|
|
2958
|
+
type: 'button',
|
|
2959
|
+
className: 'dsh-autorestart-sleep-overlay__cancel',
|
|
2960
|
+
onClick: cancel,
|
|
2961
|
+
disabled: busy,
|
|
2962
|
+
style: { pointerEvents: 'auto' },
|
|
2963
|
+
},
|
|
2964
|
+
t('sleep.countdown.cancel'),
|
|
2965
|
+
),
|
|
2966
|
+
h('p', { className: 'dsh-autorestart-sleep-overlay__hint' }, hint),
|
|
2967
|
+
errorLine(),
|
|
2968
|
+
)
|
|
2969
|
+
}
|
|
2970
|
+
|
|
2971
|
+
if (phase === 'shutting-down') {
|
|
2972
|
+
ensureSleepStyle()
|
|
2973
|
+
// The user closed the notice: the screen is gone, the Host keeps
|
|
2974
|
+
// whatever it thinks (this button never cancels anything).
|
|
2975
|
+
if (dismissed) return null
|
|
2976
|
+
// A dry run never powers the machine off, so the screen must say so
|
|
2977
|
+
// and offer the way out right away.
|
|
2978
|
+
const dryRun = (status && status.dryRun === true) || dryRunAt !== ''
|
|
2979
|
+
const closeButton = (dryRun || stuck)
|
|
2980
|
+
? h(
|
|
2981
|
+
'button',
|
|
2982
|
+
{
|
|
2983
|
+
type: 'button',
|
|
2984
|
+
className: 'dsh-autorestart-sleep-overlay__cancel',
|
|
2985
|
+
onClick: () => setDismissed(true),
|
|
2986
|
+
style: { pointerEvents: 'auto' },
|
|
2987
|
+
},
|
|
2988
|
+
t('sleep.shuttingDown.dismiss'),
|
|
2989
|
+
)
|
|
2990
|
+
: null
|
|
2991
|
+
return h(
|
|
2992
|
+
'div',
|
|
2993
|
+
{
|
|
2994
|
+
className: 'dsh-autorestart-sleep-overlay dsh-autorestart-sleep-overlay--goodnight',
|
|
2995
|
+
role: 'status',
|
|
2996
|
+
'aria-live': 'assertive',
|
|
2997
|
+
'data-dsh-autorestart-sleep-shutdown': 'true',
|
|
2998
|
+
'data-dsh-autorestart-sleep-dry-run': dryRun ? 'true' : 'false',
|
|
2999
|
+
// The good-night card is a floating card too: it never covers the
|
|
3000
|
+
// interface, only its dismiss button takes clicks.
|
|
3001
|
+
style: { pointerEvents: 'none' },
|
|
3002
|
+
},
|
|
3003
|
+
whaleArt(false),
|
|
3004
|
+
h(
|
|
3005
|
+
'p',
|
|
3006
|
+
{ className: 'dsh-autorestart-sleep-overlay__caption' },
|
|
3007
|
+
dryRun
|
|
3008
|
+
? fill(t('sleep.shuttingDown.dryRun'), { command: planned })
|
|
3009
|
+
: t('sleep.shuttingDown.title'),
|
|
3010
|
+
),
|
|
3011
|
+
h('p', { className: 'dsh-autorestart-sleep-overlay__hint' }, t('sleep.shuttingDown.body')),
|
|
3012
|
+
closeButton,
|
|
3013
|
+
)
|
|
3014
|
+
}
|
|
3015
|
+
|
|
3016
|
+
// The machine is idle again. A finished rehearsal leaves its note:
|
|
3017
|
+
// the command it WOULD have run. A real failure has no dryRun marker
|
|
3018
|
+
// and therefore never ends up here (the host says so in `message`).
|
|
3019
|
+
if (dryNotice !== null) {
|
|
3020
|
+
ensureSleepStyle()
|
|
3021
|
+
return h(
|
|
3022
|
+
'div',
|
|
3023
|
+
{
|
|
3024
|
+
className: 'dsh-autorestart-sleep-waiting dsh-autorestart-sleep-dryrun',
|
|
3025
|
+
role: 'status',
|
|
3026
|
+
'aria-live': 'polite',
|
|
3027
|
+
'data-dsh-autorestart-sleep-dryrun': 'true',
|
|
3028
|
+
// The note is a FLOATING card: it never covers the interface and
|
|
3029
|
+
// it never eats a click — only its close button does.
|
|
3030
|
+
style: { pointerEvents: 'none' },
|
|
3031
|
+
},
|
|
3032
|
+
h('p', { className: 'dsh-autorestart-sleep-waiting__title' }, t('sleep.dryRun.title')),
|
|
3033
|
+
h('p', { className: 'dsh-autorestart-sleep-waiting__body' }, t('sleep.dryRun.body')),
|
|
3034
|
+
h(
|
|
3035
|
+
'code',
|
|
3036
|
+
{ className: 'dsh-autorestart-sleep-dryrun__command' },
|
|
3037
|
+
dryNotice.command,
|
|
3038
|
+
),
|
|
3039
|
+
h(
|
|
3040
|
+
'button',
|
|
3041
|
+
{
|
|
3042
|
+
type: 'button',
|
|
3043
|
+
className: 'dsh-autorestart-sleep-waiting__cancel',
|
|
3044
|
+
onClick: () => setDryNotice(null),
|
|
3045
|
+
style: { pointerEvents: 'auto' },
|
|
3046
|
+
},
|
|
3047
|
+
t('sleep.dryRun.close'),
|
|
3048
|
+
),
|
|
3049
|
+
)
|
|
3050
|
+
}
|
|
3051
|
+
|
|
3052
|
+
return null
|
|
3053
|
+
}
|
|
3054
|
+
|
|
3055
|
+
/* ------------------------------------------------------------------ *
|
|
3056
|
+
* The one-time announcement of a new feature.
|
|
3057
|
+
* ------------------------------------------------------------------ */
|
|
3058
|
+
|
|
3059
|
+
// The route that reports the announcements the user has not seen yet and
|
|
3060
|
+
// records that it saw one. It is a sibling of the health route and shares
|
|
3061
|
+
// its prefix, so it is derived the very same way.
|
|
3062
|
+
const DEFAULT_ANNOUNCEMENTS_URL = ROUTE_PREFIX + '/announcements'
|
|
3063
|
+
|
|
3064
|
+
// The feature this client half can describe. The Host may know more
|
|
3065
|
+
// features, but only this one has a card here.
|
|
3066
|
+
const ANNOUNCED_FEATURE = 'sleep'
|
|
3067
|
+
|
|
3068
|
+
/**
|
|
3069
|
+
* The announcements the user has not seen yet.
|
|
3070
|
+
*
|
|
3071
|
+
* @returns {Promise<{ feature: string, version: string }[]>}
|
|
3072
|
+
*/
|
|
3073
|
+
async function fetchAnnouncements() {
|
|
3074
|
+
const response = await fetch(DEFAULT_ANNOUNCEMENTS_URL, { cache: 'no-store' })
|
|
3075
|
+
if (!response.ok) return []
|
|
3076
|
+
const body = await response.json().catch(() => null)
|
|
3077
|
+
if (!body || typeof body !== 'object' || !Array.isArray(body.pending)) return []
|
|
3078
|
+
return body.pending.filter((entry) => entry && typeof entry.feature === 'string')
|
|
3079
|
+
}
|
|
3080
|
+
|
|
3081
|
+
/**
|
|
3082
|
+
* The sleep status, read ONCE for the announcement.
|
|
3083
|
+
*
|
|
3084
|
+
* The card quotes the countdown length that is really in effect (it is
|
|
3085
|
+
* never written into the copy) and says whether the moon button is
|
|
3086
|
+
* switched on.
|
|
3087
|
+
*
|
|
3088
|
+
* @returns {Promise<object|null>}
|
|
3089
|
+
*/
|
|
3090
|
+
async function fetchSleepStatusOnce() {
|
|
3091
|
+
try {
|
|
3092
|
+
const response = await fetch(DEFAULT_SLEEP_URL, { cache: 'no-store' })
|
|
3093
|
+
if (!response.ok) return null
|
|
3094
|
+
const body = await response.json().catch(() => null)
|
|
3095
|
+
return body && typeof body === 'object' ? body : null
|
|
3096
|
+
} catch {
|
|
3097
|
+
return null
|
|
3098
|
+
}
|
|
3099
|
+
}
|
|
3100
|
+
|
|
3101
|
+
/**
|
|
3102
|
+
* Tell the Host that the user has seen the announcement of a feature.
|
|
3103
|
+
*
|
|
3104
|
+
* @param {string} feature
|
|
3105
|
+
* @returns {Promise<boolean>} true when the Host recorded it
|
|
3106
|
+
*/
|
|
3107
|
+
async function acknowledgeAnnouncement(feature) {
|
|
3108
|
+
const response = await fetch(DEFAULT_ANNOUNCEMENTS_URL, {
|
|
3109
|
+
method: 'POST',
|
|
3110
|
+
headers: { 'content-type': 'application/json' },
|
|
3111
|
+
body: JSON.stringify({ feature }),
|
|
3112
|
+
})
|
|
3113
|
+
return response.ok === true
|
|
3114
|
+
}
|
|
3115
|
+
|
|
3116
|
+
// The look of the card follows the install prompt: one heading, three
|
|
3117
|
+
// lines of copy and one button. Only theme tokens are used, so light and
|
|
3118
|
+
// dark themes both work.
|
|
3119
|
+
const ANNOUNCEMENT_STYLE_ID = 'dsh-autorestart-announcement'
|
|
3120
|
+
const ANNOUNCEMENT_CSS = [
|
|
3121
|
+
'.dsh-autorestart-announcement{',
|
|
3122
|
+
' display:flex;',
|
|
3123
|
+
' flex-direction:column;',
|
|
3124
|
+
' gap:6px;',
|
|
3125
|
+
' width:min(560px,94vw);',
|
|
3126
|
+
' max-width:100%;',
|
|
3127
|
+
' box-sizing:border-box;',
|
|
3128
|
+
' margin:0 0 6px;',
|
|
3129
|
+
' padding:14px 16px;',
|
|
3130
|
+
' border:1px solid var(--dsw-alias-border-l1);',
|
|
3131
|
+
' border-radius:12px;',
|
|
3132
|
+
' background:var(--dsw-alias-bg-layer-1);',
|
|
3133
|
+
' box-shadow:0 8px 24px color-mix(in srgb, var(--dsw-alias-bg-mask-3) 20%, transparent);',
|
|
3134
|
+
' color:var(--dsw-alias-label-secondary);',
|
|
3135
|
+
' font-size:13px;',
|
|
3136
|
+
' line-height:18px;',
|
|
3137
|
+
'}',
|
|
3138
|
+
'.dsh-autorestart-announcement h3{',
|
|
3139
|
+
' margin:0;',
|
|
3140
|
+
' font-size:14px;',
|
|
3141
|
+
' font-weight:600;',
|
|
3142
|
+
' line-height:20px;',
|
|
3143
|
+
' color:var(--dsw-alias-label-primary);',
|
|
3144
|
+
'}',
|
|
3145
|
+
'.dsh-autorestart-announcement p{margin:0;}',
|
|
3146
|
+
'.dsh-autorestart-announcement__note{',
|
|
3147
|
+
' color:var(--dsw-alias-label-tertiary,var(--dsw-alias-label-secondary));',
|
|
3148
|
+
'}',
|
|
3149
|
+
'.dsh-autorestart-announcement__error{',
|
|
3150
|
+
' color:var(--dsw-alias-state-error-fg,var(--dsw-alias-label-secondary));',
|
|
3151
|
+
'}',
|
|
3152
|
+
'.dsh-autorestart-announcement__cta{',
|
|
3153
|
+
' align-self:flex-start;',
|
|
3154
|
+
' margin-top:4px;',
|
|
3155
|
+
' padding:5px 14px;',
|
|
3156
|
+
' border:none;',
|
|
3157
|
+
' border-radius:var(--dsw-radius-md,8px);',
|
|
3158
|
+
' background:var(--dsw-alias-brand-primary,#5b8def);',
|
|
3159
|
+
' color:var(--dsw-alias-label-on-primary,#fff);',
|
|
3160
|
+
' font:inherit;',
|
|
3161
|
+
' font-size:13px;',
|
|
3162
|
+
' line-height:20px;',
|
|
3163
|
+
' cursor:pointer;',
|
|
3164
|
+
'}',
|
|
3165
|
+
'.dsh-autorestart-announcement__cta:disabled{',
|
|
3166
|
+
' opacity:.6;',
|
|
3167
|
+
' cursor:default;',
|
|
3168
|
+
'}',
|
|
3169
|
+
'.dsh-autorestart-announcement__cta:focus-visible{',
|
|
3170
|
+
' outline:2px solid var(--dsw-alias-state-business-primary,var(--dsw-alias-brand-primary,#4d6bfe));',
|
|
3171
|
+
' outline-offset:2px;',
|
|
3172
|
+
'}',
|
|
3173
|
+
].join('\n')
|
|
3174
|
+
|
|
3175
|
+
/**
|
|
3176
|
+
* Inject the announcement styles exactly once. The id keeps a second
|
|
3177
|
+
* injection from duplicating the rule.
|
|
3178
|
+
*/
|
|
3179
|
+
function ensureAnnouncementStyle() {
|
|
3180
|
+
if (typeof document === 'undefined' || !document.head) return
|
|
3181
|
+
try {
|
|
3182
|
+
if (document.getElementById(ANNOUNCEMENT_STYLE_ID)) return
|
|
3183
|
+
const style = document.createElement('style')
|
|
3184
|
+
style.id = ANNOUNCEMENT_STYLE_ID
|
|
3185
|
+
style.textContent = ANNOUNCEMENT_CSS
|
|
3186
|
+
document.head.appendChild(style)
|
|
3187
|
+
} catch {
|
|
3188
|
+
// Without the stylesheet the card is still readable, just plainer.
|
|
3189
|
+
}
|
|
3190
|
+
}
|
|
3191
|
+
|
|
3192
|
+
/**
|
|
3193
|
+
* The one-time card that announces a new feature.
|
|
3194
|
+
*
|
|
3195
|
+
* The install prompt only ever reaches a FRESH installation, so an
|
|
3196
|
+
* installation that already existed would never learn about the new moon
|
|
3197
|
+
* button — the feature would simply not exist for it. This card is that
|
|
3198
|
+
* missing half: the Host reports the announcements the user has not seen
|
|
3199
|
+
* yet, and the card disappears for good when the user presses "知道了".
|
|
3200
|
+
*
|
|
3201
|
+
* It is an ordinary block in the composer dock (the same place as the
|
|
3202
|
+
* install prompt): it never covers the interface, it never touches the
|
|
3203
|
+
* countdown, and it stays until the user answers it. An unanswered
|
|
3204
|
+
* announcement comes back with the next start — it is never swallowed
|
|
3205
|
+
* silently.
|
|
3206
|
+
*/
|
|
3207
|
+
function FeatureAnnouncementPanel() {
|
|
3208
|
+
const [state, setState] = useState({ loaded: false, pending: [], status: null, error: '' })
|
|
3209
|
+
const [busy, setBusy] = useState(false)
|
|
3210
|
+
const t = translate ?? ((name) => name)
|
|
3211
|
+
|
|
3212
|
+
useEffect(() => {
|
|
3213
|
+
let cancelled = false
|
|
3214
|
+
// The install prompt owns the dock while it is unanswered: a fresh
|
|
3215
|
+
// installation must never get two prompts at once.
|
|
3216
|
+
if (backupChoicePending() || sleepChoicePending()) return undefined
|
|
3217
|
+
const load = async () => {
|
|
3218
|
+
let pending = []
|
|
3219
|
+
let status = null
|
|
3220
|
+
try {
|
|
3221
|
+
const [announced, sleepStatus] = await Promise.all([
|
|
3222
|
+
fetchAnnouncements(),
|
|
3223
|
+
fetchSleepStatusOnce(),
|
|
3224
|
+
])
|
|
3225
|
+
pending = announced
|
|
3226
|
+
status = sleepStatus
|
|
3227
|
+
} catch {
|
|
3228
|
+
// A failed request shows nothing: the announcement comes back
|
|
3229
|
+
// with the next start (it is never swallowed silently).
|
|
3230
|
+
}
|
|
3231
|
+
if (cancelled) return
|
|
3232
|
+
setState({ loaded: true, pending, status, error: '' })
|
|
3233
|
+
}
|
|
3234
|
+
void load()
|
|
3235
|
+
return () => { cancelled = true }
|
|
3236
|
+
}, [])
|
|
3237
|
+
|
|
3238
|
+
/**
|
|
3239
|
+
* The "知道了" button: the Host records the announcement and the card
|
|
3240
|
+
* disappears. A refused request keeps the card (with the reason), so
|
|
3241
|
+
* the announcement is never lost.
|
|
3242
|
+
*/
|
|
3243
|
+
const acknowledge = () => {
|
|
3244
|
+
if (busy) return
|
|
3245
|
+
setBusy(true)
|
|
3246
|
+
setState((current) => ({ ...current, error: '' }))
|
|
3247
|
+
void (async () => {
|
|
3248
|
+
try {
|
|
3249
|
+
const recorded = await acknowledgeAnnouncement(ANNOUNCED_FEATURE)
|
|
3250
|
+
if (!recorded) throw new Error('the Host refused the announcement')
|
|
3251
|
+
} catch (failure) {
|
|
3252
|
+
setBusy(false)
|
|
3253
|
+
setState((current) => ({
|
|
3254
|
+
...current,
|
|
3255
|
+
error: fill(t('announcement.error'), { message: describeError(failure) }),
|
|
3256
|
+
}))
|
|
3257
|
+
return
|
|
3258
|
+
}
|
|
3259
|
+
setBusy(false)
|
|
3260
|
+
setState((current) => ({
|
|
3261
|
+
...current,
|
|
3262
|
+
pending: current.pending.filter((entry) => entry.feature !== ANNOUNCED_FEATURE),
|
|
3263
|
+
}))
|
|
3264
|
+
})()
|
|
3265
|
+
}
|
|
3266
|
+
|
|
3267
|
+
if (!state.loaded) return null
|
|
3268
|
+
const entry = state.pending.find((item) => item && item.feature === ANNOUNCED_FEATURE)
|
|
3269
|
+
if (!entry) return null
|
|
3270
|
+
|
|
3271
|
+
// The countdown length is read from the Host, never written into the
|
|
3272
|
+
// copy: the card always quotes what is really in effect.
|
|
3273
|
+
const seconds = state.status && typeof state.status.countdownSeconds === 'number'
|
|
3274
|
+
? state.status.countdownSeconds
|
|
3275
|
+
: null
|
|
3276
|
+
const hidden = Boolean(state.status && state.status.showSleepButton === false)
|
|
3277
|
+
ensureAnnouncementStyle()
|
|
3278
|
+
|
|
3279
|
+
return h(
|
|
3280
|
+
'div',
|
|
3281
|
+
{
|
|
3282
|
+
className: 'dsh-autorestart-announcement',
|
|
3283
|
+
role: 'status',
|
|
3284
|
+
'data-dsh-autorestart-announcement': 'true',
|
|
3285
|
+
'data-dsh-autorestart-announcement-feature': ANNOUNCED_FEATURE,
|
|
3286
|
+
},
|
|
3287
|
+
h('h3', { className: 'dsh-autorestart-announcement__title' }, t('announcement.title')),
|
|
3288
|
+
h('p', { className: 'dsh-autorestart-announcement__line' }, t('announcement.line1')),
|
|
3289
|
+
h(
|
|
3290
|
+
'p',
|
|
3291
|
+
{ className: 'dsh-autorestart-announcement__line' },
|
|
3292
|
+
seconds === null
|
|
3293
|
+
? t('announcement.line2.generic')
|
|
3294
|
+
: fill(t('announcement.line2'), { n: seconds }),
|
|
3295
|
+
),
|
|
3296
|
+
h('p', { className: 'dsh-autorestart-announcement__line' }, t('announcement.line3')),
|
|
3297
|
+
hidden
|
|
3298
|
+
? h('p', { className: 'dsh-autorestart-announcement__note' }, t('announcement.disabled'))
|
|
3299
|
+
: null,
|
|
3300
|
+
state.error === ''
|
|
3301
|
+
? null
|
|
3302
|
+
: h('p', { className: 'dsh-autorestart-announcement__error', role: 'alert' }, state.error),
|
|
3303
|
+
h(
|
|
3304
|
+
'button',
|
|
3305
|
+
{
|
|
3306
|
+
type: 'button',
|
|
3307
|
+
className: 'dsh-autorestart-announcement__cta',
|
|
3308
|
+
'data-dsh-autorestart-announcement-ack': 'true',
|
|
3309
|
+
disabled: busy,
|
|
3310
|
+
onClick: acknowledge,
|
|
3311
|
+
},
|
|
3312
|
+
t('announcement.button'),
|
|
3313
|
+
),
|
|
3314
|
+
)
|
|
3315
|
+
}
|
|
3316
|
+
|
|
3317
|
+
return {
|
|
3318
|
+
inject: ['slots', 'locale'],
|
|
3319
|
+
apply(ctx) {
|
|
3320
|
+
ctx.effect(
|
|
3321
|
+
() => ctx.locale.register(NS, { zh: ZH, en: EN }),
|
|
3322
|
+
'dsh-autorestart: client locale',
|
|
3323
|
+
)
|
|
3324
|
+
const t = ctx.locale.bind(NS)
|
|
3325
|
+
translate = t
|
|
3326
|
+
ctx.slots.inject('conversation.input.left', () => ctx.slots.register({ name: 'conversation.input.left', id: 'autorestart-restart-pill', order: 0 }, RestartPill))
|
|
3327
|
+
ctx.slots.inject('sidebar.session.row.leading', () => ctx.slots.register({ name: 'sidebar.session.row.leading', id: 'autorestart-restart-dot', order: 20 }, RestartDot))
|
|
3328
|
+
ctx.slots.inject('conversation.input.dock', () => ctx.slots.register({ name: 'conversation.input.dock', id: 'autorestart-backup-choice', order: 7, locale: NS }, BackupChoicePanel))
|
|
3329
|
+
// The one-time announcement of a new feature: the install prompt only
|
|
3330
|
+
// reaches a fresh installation, so an installation that already
|
|
3331
|
+
// existed would never hear of a new feature. The card sits in the
|
|
3332
|
+
// same dock (it never covers the interface and never touches the
|
|
3333
|
+
// countdown) and disappears for good once the user answered it.
|
|
3334
|
+
ctx.slots.inject('conversation.input.dock', () => ctx.slots.register({ name: 'conversation.input.dock', id: 'autorestart-feature-announcement', order: 8, locale: NS }, FeatureAnnouncementPanel))
|
|
3335
|
+
// The graceful-shutdown button: a LIST slot at the foot of the left
|
|
3336
|
+
// sidebar, beside the Settings row. The slot is a ROOT slot, so the
|
|
3337
|
+
// button is there even when no conversation is open — and the
|
|
3338
|
+
// sidebar hands the column state to it, so the collapsed rail shows
|
|
3339
|
+
// the icon only.
|
|
3340
|
+
ctx.slots.inject('sidebar.footer.action', () => ctx.slots.register({ name: 'sidebar.footer.action', id: 'autorestart-sleep-button', order: 10, locale: NS }, SleepButton))
|
|
3341
|
+
// The settings section of the plugin: an entry of its own in the
|
|
3342
|
+
// settings sidebar ("Auto-restart & shut down"), right below the
|
|
3343
|
+
// Agent presets. The three controls live inside it, so the user does
|
|
3344
|
+
// not have to dig through the plugin details.
|
|
3345
|
+
//
|
|
3346
|
+
// The order is computed at registration time: right after
|
|
3347
|
+
// `agent-presets` when that section exists, otherwise after the
|
|
3348
|
+
// sections that are registered already; 1000 when there is none.
|
|
3349
|
+
// That is "naturally last" without hard-coding a number.
|
|
3350
|
+
ctx.slots.inject('settings.section', function* () {
|
|
3351
|
+
const existing = ctx.slots.entries('settings.section')
|
|
3352
|
+
const agentPresets = existing.find((entry) => entry.options.id === 'agent-presets')
|
|
3353
|
+
const maxOrder = existing.reduce((max, entry) => Math.max(max, entry.options.order ?? 0), 0)
|
|
3354
|
+
const order = agentPresets !== undefined
|
|
3355
|
+
? (agentPresets.options.order ?? 0) + 1
|
|
3356
|
+
: existing.length > 0
|
|
3357
|
+
? maxOrder + 1
|
|
3358
|
+
: 1000
|
|
3359
|
+
yield ctx.slots.register({
|
|
3360
|
+
name: 'settings.section',
|
|
3361
|
+
id: 'dsh-autorestart',
|
|
3362
|
+
order,
|
|
3363
|
+
label: () => t('nav'),
|
|
3364
|
+
locale: NS,
|
|
3365
|
+
inject: () => ({ t }),
|
|
3366
|
+
}, AutorestartSection)
|
|
3367
|
+
})
|
|
3368
|
+
// The countdown overlay: a frame-wide floating layer above every
|
|
3369
|
+
// column.
|
|
3370
|
+
ctx.slots.inject('shell.overlay', () => ctx.slots.register({ name: 'shell.overlay', id: 'autorestart-sleep-overlay', order: 60 }, SleepOverlay))
|
|
1162
3371
|
},
|
|
3372
|
+
// The named exports of this module (the client module system hands
|
|
3373
|
+
// `factory(require) -> exports` to every importer).
|
|
3374
|
+
pickSleepCaption,
|
|
3375
|
+
shouldShowSleepButton,
|
|
3376
|
+
buildChoiceBody,
|
|
3377
|
+
formatRemainingMs,
|
|
1163
3378
|
}
|
|
1164
3379
|
},
|
|
1165
3380
|
})
|