dsh-autorestart 0.3.5 → 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 +208 -4
- package/client.js +2764 -82
- package/cordis.patch.yml +19 -0
- package/docs/README.zh.md +122 -7
- package/index.js +1318 -72
- package/lib/checkpoint.js +38 -0
- package/lib/sleep.js +828 -0
- package/locale/en.json +113 -0
- package/locale/zh.json +113 -0
- 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')
|
|
@@ -60,6 +541,11 @@ window.__ModuleLoader__.load({
|
|
|
60
541
|
// is only the fallback for an older Host.
|
|
61
542
|
const ROUTE_PREFIX = HEALTH_URL.split('?')[0].replace(/\/[^/]*$/, '')
|
|
62
543
|
const DEFAULT_PARK_URL = ROUTE_PREFIX + '/park'
|
|
544
|
+
// The route that persists the one-time install choice.
|
|
545
|
+
const DEFAULT_CHOICE_URL = ROUTE_PREFIX + '/choice'
|
|
546
|
+
// The browser-side record of the one-time install choice. The prompt is
|
|
547
|
+
// shown only while this key is missing, so it is asked exactly once.
|
|
548
|
+
const BACKUP_CHOICE_KEY = 'dsh-autorestart:backup-choice-v1'
|
|
63
549
|
const POLL_MS = 2000
|
|
64
550
|
// The brand accent (blue-purple). The pending-restart marker is a brand
|
|
65
551
|
// signal, not a warning: no warning/yellow token is used here.
|
|
@@ -80,77 +566,6 @@ window.__ModuleLoader__.load({
|
|
|
80
566
|
'}',
|
|
81
567
|
].join('\n')
|
|
82
568
|
|
|
83
|
-
const ZH = {
|
|
84
|
-
label: '待重启',
|
|
85
|
-
busyPrefix: ' · 被 ',
|
|
86
|
-
busySuffix: ' 个会话占用',
|
|
87
|
-
idle: ' · 空闲后自动重启',
|
|
88
|
-
goalActive: ' · 被某个会话自动续跑的 goal 占着',
|
|
89
|
-
goalExhausted: ' · 某个会话的 goal 轮次额度已用完,无法续跑',
|
|
90
|
-
parkedGoals: ' · 已停泊 {n} 个 goal,重启后自动继续',
|
|
91
|
-
parkButton: '停泊并重启',
|
|
92
|
-
parkButtonAria: '停泊该会话的 goal,并立即请求重启',
|
|
93
|
-
parkBusy: '停泊中…',
|
|
94
|
-
parkError: '停泊失败:{message}',
|
|
95
|
-
tooltip: '等待其他会话空闲后自动重启;重启后会自动继续本会话进度',
|
|
96
|
-
tooltipBusy: '等待其他会话空闲后自动重启(计数含“排队但还没开始”的任务);重启后本会话会自动继续进度',
|
|
97
|
-
tooltipWithError: '等待其他会话空闲后自动重启;重启后会自动继续本会话进度。{message}',
|
|
98
|
-
dotAria: '待重启',
|
|
99
|
-
panelHeading: '待重启',
|
|
100
|
-
panelStateBusy: '等待其他会话空闲后重启',
|
|
101
|
-
panelStateIdle: '等待空闲窗口后重启',
|
|
102
|
-
panelStateGoalActive: '等待其他会话停泊它自动续跑的 goal',
|
|
103
|
-
panelStateGoalExhausted: '等待其他会话的 goal(轮次额度已用完)',
|
|
104
|
-
panelBlockedTitle: '占用会话:{title}',
|
|
105
|
-
panelBlockedGeneric: '有一个会话正在运行',
|
|
106
|
-
panelGoalActiveLine: '占用会话「{title}」:goal 正在自动续跑(goal-active)',
|
|
107
|
-
panelGoalExhaustedLine: '占用会话「{title}」:goal 轮次额度已用完,无法继续(goal-exhausted)',
|
|
108
|
-
panelParkedTitle: '已停泊的 goal(重启后自动继续):',
|
|
109
|
-
panelParkedGoal: '会话 {session} · {goal}',
|
|
110
|
-
panelWaiting: '已等待 {duration}',
|
|
111
|
-
panelResume: '重启后本会话会自动继续进度',
|
|
112
|
-
durationSeconds: '{n} 秒',
|
|
113
|
-
durationMinutes: '{n} 分 {m} 秒',
|
|
114
|
-
durationHours: '{n} 小时 {m} 分',
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
const EN = {
|
|
118
|
-
label: 'Pending restart',
|
|
119
|
-
busyPrefix: ' · blocked by ',
|
|
120
|
-
busySuffix: ' session(s)',
|
|
121
|
-
idle: ' · restarts once idle',
|
|
122
|
-
goalActive: ' · held by a self-continuing goal',
|
|
123
|
-
goalExhausted: ' · a goal ran out of rounds',
|
|
124
|
-
parkedGoals: ' · {n} goal(s) parked, they continue after the restart',
|
|
125
|
-
parkButton: 'Park & restart',
|
|
126
|
-
parkButtonAria: 'Park that session goal and request the restart now',
|
|
127
|
-
parkBusy: 'Parking…',
|
|
128
|
-
parkError: 'Park failed: {message}',
|
|
129
|
-
tooltip:
|
|
130
|
-
'Waits for the other sessions to become idle, then restarts automatically; this session resumes its progress after the restart.',
|
|
131
|
-
tooltipBusy:
|
|
132
|
-
'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.',
|
|
133
|
-
tooltipWithError:
|
|
134
|
-
'Waits for the other sessions to become idle, then restarts automatically; this session resumes its progress after the restart. {message}',
|
|
135
|
-
dotAria: 'Pending restart',
|
|
136
|
-
panelHeading: 'Pending restart',
|
|
137
|
-
panelStateBusy: 'Waiting for the other sessions to become idle',
|
|
138
|
-
panelStateIdle: 'Waiting for the idle window',
|
|
139
|
-
panelStateGoalActive: 'Waiting for the other sessions to park their self-continuing goal',
|
|
140
|
-
panelStateGoalExhausted: 'Waiting for the other sessions\' goal (its round budget is used up)',
|
|
141
|
-
panelBlockedTitle: 'Blocking session: {title}',
|
|
142
|
-
panelBlockedGeneric: 'One session is running',
|
|
143
|
-
panelGoalActiveLine: 'Blocking session "{title}": the goal keeps driving rounds by itself (goal-active)',
|
|
144
|
-
panelGoalExhaustedLine: 'Blocking session "{title}": the goal ran out of rounds and cannot continue (goal-exhausted)',
|
|
145
|
-
panelParkedTitle: 'Parked goals (they continue after the restart):',
|
|
146
|
-
panelParkedGoal: 'session {session} · {goal}',
|
|
147
|
-
panelWaiting: 'Waiting for {duration}',
|
|
148
|
-
panelResume: 'This session resumes its progress automatically after the restart.',
|
|
149
|
-
durationSeconds: '{n}s',
|
|
150
|
-
durationMinutes: '{n}m {m}s',
|
|
151
|
-
durationHours: '{n}h {m}m',
|
|
152
|
-
}
|
|
153
|
-
|
|
154
569
|
// Bound during `apply`. The slots are registered without a locale
|
|
155
570
|
// namespace, so the components read the translator through this
|
|
156
571
|
// factory-scoped handle instead of a standard `t` prop.
|
|
@@ -336,6 +751,49 @@ window.__ModuleLoader__.load({
|
|
|
336
751
|
return DEFAULT_PARK_URL
|
|
337
752
|
}
|
|
338
753
|
|
|
754
|
+
/**
|
|
755
|
+
* The route that persists the one-time install choice: the one the Host
|
|
756
|
+
* sent with the state payload, or the one derived from the health path.
|
|
757
|
+
*/
|
|
758
|
+
function choiceUrlFor(info) {
|
|
759
|
+
const sent = info && typeof info.choiceUrl === 'string' ? info.choiceUrl.trim() : ''
|
|
760
|
+
if (sent.startsWith('/')) return sent
|
|
761
|
+
return DEFAULT_CHOICE_URL
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
/**
|
|
765
|
+
* Whether the one-time install prompt may be shown.
|
|
766
|
+
*
|
|
767
|
+
* It may be shown only while the browser has no record of a choice yet,
|
|
768
|
+
* i.e. while `localStorage['dsh-autorestart:backup-choice-v1']` is
|
|
769
|
+
* missing. A browser without a usable localStorage answers "no": the
|
|
770
|
+
* prompt must not come back on every reload there.
|
|
771
|
+
*/
|
|
772
|
+
function backupChoicePending() {
|
|
773
|
+
try {
|
|
774
|
+
if (typeof window === 'undefined' || !window.localStorage) return false
|
|
775
|
+
return window.localStorage.getItem(BACKUP_CHOICE_KEY) === null
|
|
776
|
+
} catch {
|
|
777
|
+
return false
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
/**
|
|
782
|
+
* Record the one-time choice in the browser.
|
|
783
|
+
*
|
|
784
|
+
* The key is written as soon as the user picks an option (or closes the
|
|
785
|
+
* prompt), so the question is asked exactly once.
|
|
786
|
+
*/
|
|
787
|
+
function writeBackupChoice(value) {
|
|
788
|
+
try {
|
|
789
|
+
if (typeof window === 'undefined' || !window.localStorage) return false
|
|
790
|
+
window.localStorage.setItem(BACKUP_CHOICE_KEY, String(value))
|
|
791
|
+
return true
|
|
792
|
+
} catch {
|
|
793
|
+
return false
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
|
|
339
797
|
/** A short, human readable description of a failed request. */
|
|
340
798
|
function describeError(error) {
|
|
341
799
|
if (!error) return 'unknown error'
|
|
@@ -486,6 +944,187 @@ window.__ModuleLoader__.load({
|
|
|
486
944
|
color: 'var(--dsw-alias-danger, #d14343)',
|
|
487
945
|
}
|
|
488
946
|
|
|
947
|
+
// The one-time install prompt. It is NOT a modal: it is an ordinary block
|
|
948
|
+
// in the composer dock, so the input box stays usable and nothing is
|
|
949
|
+
// blocked. It never disappears on its own.
|
|
950
|
+
//
|
|
951
|
+
// The look follows the established DSH plugin styling: one 14px/600
|
|
952
|
+
// heading, a muted subtitle, two selectable rows (a name + a note on the
|
|
953
|
+
// left, the action on the right) and a muted footnote. The primary option
|
|
954
|
+
// carries a "recommended" pill and a solid button; the secondary one is
|
|
955
|
+
// outlined. Only theme tokens are used, so light and dark themes both
|
|
956
|
+
// work.
|
|
957
|
+
const CHOICE_STYLE_ID = 'dsh-autorestart-choice'
|
|
958
|
+
const CHOICE_CSS = [
|
|
959
|
+
'.dsh-autorestart-choice{',
|
|
960
|
+
' display:flex;',
|
|
961
|
+
' flex-direction:column;',
|
|
962
|
+
' gap:8px;',
|
|
963
|
+
' width:min(560px,94vw);',
|
|
964
|
+
' max-width:100%;',
|
|
965
|
+
' box-sizing:border-box;',
|
|
966
|
+
' margin:0 0 6px;',
|
|
967
|
+
' padding:16px 18px;',
|
|
968
|
+
' border:1px solid var(--dsw-alias-border-l1);',
|
|
969
|
+
' border-radius:12px;',
|
|
970
|
+
' background:var(--dsw-alias-bg-layer-1);',
|
|
971
|
+
' box-shadow:0 8px 24px color-mix(in srgb, var(--dsw-alias-bg-mask-3) 20%, transparent);',
|
|
972
|
+
' color:var(--dsw-alias-label-secondary);',
|
|
973
|
+
' font-size:13px;',
|
|
974
|
+
' line-height:18px;',
|
|
975
|
+
'}',
|
|
976
|
+
'.dsh-autorestart-choice h3{',
|
|
977
|
+
' margin:0;',
|
|
978
|
+
' font-size:14px;',
|
|
979
|
+
' font-weight:600;',
|
|
980
|
+
' line-height:20px;',
|
|
981
|
+
' color:var(--dsw-alias-label-primary);',
|
|
982
|
+
'}',
|
|
983
|
+
'.dsh-autorestart-choice p{margin:0;}',
|
|
984
|
+
'.dsh-autorestart-choice__head{',
|
|
985
|
+
' display:flex;',
|
|
986
|
+
' align-items:flex-start;',
|
|
987
|
+
' justify-content:space-between;',
|
|
988
|
+
' gap:10px;',
|
|
989
|
+
'}',
|
|
990
|
+
'.dsh-autorestart-choice__subtitle{',
|
|
991
|
+
' font-size:12.5px;',
|
|
992
|
+
' line-height:18px;',
|
|
993
|
+
' color:var(--dsw-alias-label-secondary);',
|
|
994
|
+
'}',
|
|
995
|
+
'.dsh-autorestart-choice__options{',
|
|
996
|
+
' display:flex;',
|
|
997
|
+
' flex-direction:column;',
|
|
998
|
+
' gap:10px;',
|
|
999
|
+
'}',
|
|
1000
|
+
'.dsh-autorestart-choice__row{',
|
|
1001
|
+
' display:flex;',
|
|
1002
|
+
' align-items:center;',
|
|
1003
|
+
' justify-content:space-between;',
|
|
1004
|
+
' gap:10px;',
|
|
1005
|
+
' padding:10px 12px;',
|
|
1006
|
+
' border:1px solid transparent;',
|
|
1007
|
+
' border-radius:10px;',
|
|
1008
|
+
' cursor:pointer;',
|
|
1009
|
+
' transition:background-color .14s,border-color .14s;',
|
|
1010
|
+
'}',
|
|
1011
|
+
'.dsh-autorestart-choice__row:hover,',
|
|
1012
|
+
'.dsh-autorestart-choice__row:focus-within{',
|
|
1013
|
+
' border-color:var(--dsw-alias-border-l1);',
|
|
1014
|
+
' background:var(--dsw-alias-bg-layer-2, color-mix(in srgb, var(--dsw-alias-label-primary) 4%, transparent));',
|
|
1015
|
+
'}',
|
|
1016
|
+
'.dsh-autorestart-choice__row:focus-visible{',
|
|
1017
|
+
' outline:2px solid var(--dsw-alias-state-business-primary,var(--dsw-alias-brand-primary,#4d6bfe));',
|
|
1018
|
+
' outline-offset:2px;',
|
|
1019
|
+
'}',
|
|
1020
|
+
'.dsh-autorestart-choice__text{flex:1 1 auto;min-width:0;}',
|
|
1021
|
+
'.dsh-autorestart-choice__title{',
|
|
1022
|
+
' display:flex;',
|
|
1023
|
+
' align-items:center;',
|
|
1024
|
+
' gap:6px;',
|
|
1025
|
+
' min-width:0;',
|
|
1026
|
+
'}',
|
|
1027
|
+
'.dsh-autorestart-choice__name{',
|
|
1028
|
+
' font-size:13px;',
|
|
1029
|
+
' font-weight:600;',
|
|
1030
|
+
' line-height:18px;',
|
|
1031
|
+
' color:var(--dsw-alias-label-primary);',
|
|
1032
|
+
'}',
|
|
1033
|
+
'.dsh-autorestart-choice__hint{',
|
|
1034
|
+
' display:block;',
|
|
1035
|
+
' margin-top:2px;',
|
|
1036
|
+
' font-size:12px;',
|
|
1037
|
+
' line-height:17px;',
|
|
1038
|
+
' color:var(--dsw-alias-label-secondary);',
|
|
1039
|
+
'}',
|
|
1040
|
+
'.dsh-autorestart-choice__badge{',
|
|
1041
|
+
' flex:none;',
|
|
1042
|
+
' display:inline-block;',
|
|
1043
|
+
' padding:1px 7px;',
|
|
1044
|
+
' border-radius:999px;',
|
|
1045
|
+
' font-size:11px;',
|
|
1046
|
+
' font-weight:600;',
|
|
1047
|
+
' line-height:16px;',
|
|
1048
|
+
' color:var(--dsw-alias-state-business-primary,var(--dsw-alias-brand-primary,#4d6bfe));',
|
|
1049
|
+
' background:color-mix(in srgb, var(--dsw-alias-state-business-primary,var(--dsw-alias-brand-primary,#4d6bfe)) 12%, transparent);',
|
|
1050
|
+
'}',
|
|
1051
|
+
'.dsh-autorestart-choice__cta{',
|
|
1052
|
+
' flex:none;',
|
|
1053
|
+
' min-width:28px;',
|
|
1054
|
+
' min-height:28px;',
|
|
1055
|
+
' padding:4px 12px;',
|
|
1056
|
+
' border-radius:8px;',
|
|
1057
|
+
' font:inherit;',
|
|
1058
|
+
' font-size:12.5px;',
|
|
1059
|
+
' font-weight:600;',
|
|
1060
|
+
' line-height:18px;',
|
|
1061
|
+
' cursor:pointer;',
|
|
1062
|
+
' transition:background-color .14s,border-color .14s,color .14s;',
|
|
1063
|
+
'}',
|
|
1064
|
+
'.dsh-autorestart-choice__cta:focus-visible{',
|
|
1065
|
+
' outline:2px solid var(--dsw-alias-state-business-primary,var(--dsw-alias-brand-primary,#4d6bfe));',
|
|
1066
|
+
' outline-offset:2px;',
|
|
1067
|
+
'}',
|
|
1068
|
+
'.dsh-autorestart-choice__cta--primary{',
|
|
1069
|
+
' border:1px solid transparent;',
|
|
1070
|
+
' background:var(--dsw-alias-state-business-primary,var(--dsw-alias-brand-primary,#4d6bfe));',
|
|
1071
|
+
' color:var(--dsw-alias-bg-layer-1,#fff);',
|
|
1072
|
+
'}',
|
|
1073
|
+
'.dsh-autorestart-choice__cta--secondary{',
|
|
1074
|
+
' border:1px solid var(--dsw-alias-border-l1);',
|
|
1075
|
+
' background:transparent;',
|
|
1076
|
+
' color:var(--dsw-alias-label-primary);',
|
|
1077
|
+
'}',
|
|
1078
|
+
'.dsh-autorestart-choice__cta--secondary:hover{',
|
|
1079
|
+
' border-color:var(--dsw-alias-state-business-primary,var(--dsw-alias-brand-primary,#4d6bfe));',
|
|
1080
|
+
' color:var(--dsw-alias-state-business-primary,var(--dsw-alias-brand-primary,#4d6bfe));',
|
|
1081
|
+
'}',
|
|
1082
|
+
'.dsh-autorestart-choice__close{',
|
|
1083
|
+
' flex:none;',
|
|
1084
|
+
' width:28px;',
|
|
1085
|
+
' height:28px;',
|
|
1086
|
+
' padding:0;',
|
|
1087
|
+
' border:1px solid transparent;',
|
|
1088
|
+
' border-radius:8px;',
|
|
1089
|
+
' background:transparent;',
|
|
1090
|
+
' color:var(--dsw-alias-label-secondary);',
|
|
1091
|
+
' font:inherit;',
|
|
1092
|
+
' font-size:16px;',
|
|
1093
|
+
' line-height:1;',
|
|
1094
|
+
' cursor:pointer;',
|
|
1095
|
+
' transition:background-color .14s;',
|
|
1096
|
+
'}',
|
|
1097
|
+
'.dsh-autorestart-choice__close:hover{',
|
|
1098
|
+
' background:var(--dsw-alias-bg-layer-2, color-mix(in srgb, var(--dsw-alias-label-primary) 6%, transparent));',
|
|
1099
|
+
'}',
|
|
1100
|
+
'.dsh-autorestart-choice__close:focus-visible{',
|
|
1101
|
+
' outline:2px solid var(--dsw-alias-state-business-primary,var(--dsw-alias-brand-primary,#4d6bfe));',
|
|
1102
|
+
' outline-offset:2px;',
|
|
1103
|
+
'}',
|
|
1104
|
+
'.dsh-autorestart-choice__footnote{',
|
|
1105
|
+
' margin-top:10px;',
|
|
1106
|
+
' padding-top:10px;',
|
|
1107
|
+
' border-top:1px solid var(--dsw-alias-border-l1);',
|
|
1108
|
+
' font-size:11px;',
|
|
1109
|
+
' line-height:16px;',
|
|
1110
|
+
' color:var(--dsw-alias-label-secondary);',
|
|
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
|
+
'}',
|
|
1119
|
+
'@media (max-width:1023px){',
|
|
1120
|
+
' .dsh-autorestart-choice{width:calc(100vw - 32px);}',
|
|
1121
|
+
'}',
|
|
1122
|
+
'@media (max-width:640px){',
|
|
1123
|
+
' .dsh-autorestart-choice__row{flex-direction:column;align-items:stretch;}',
|
|
1124
|
+
' .dsh-autorestart-choice__cta{width:100%;}',
|
|
1125
|
+
'}',
|
|
1126
|
+
].join('\n')
|
|
1127
|
+
|
|
489
1128
|
/**
|
|
490
1129
|
* One compact pill in the composer tool row, visible only while THIS
|
|
491
1130
|
* session is waiting for a pending restart. Clicking it (or pressing
|
|
@@ -681,18 +1320,2061 @@ window.__ModuleLoader__.load({
|
|
|
681
1320
|
})
|
|
682
1321
|
}
|
|
683
1322
|
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
1323
|
+
/**
|
|
1324
|
+
* Inject the prompt styles exactly once. The id keeps a second injection
|
|
1325
|
+
* from duplicating the rule.
|
|
1326
|
+
*/
|
|
1327
|
+
function ensureChoiceStyle() {
|
|
1328
|
+
if (typeof document === 'undefined' || !document.head) return
|
|
1329
|
+
try {
|
|
1330
|
+
if (document.getElementById(CHOICE_STYLE_ID)) return
|
|
1331
|
+
const style = document.createElement('style')
|
|
1332
|
+
style.id = CHOICE_STYLE_ID
|
|
1333
|
+
style.textContent = CHOICE_CSS
|
|
1334
|
+
document.head.appendChild(style)
|
|
1335
|
+
} catch {
|
|
1336
|
+
// Without the stylesheet the prompt is still readable, just plainer.
|
|
1337
|
+
}
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
/**
|
|
1341
|
+
* The one-time install prompt.
|
|
1342
|
+
*
|
|
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.
|
|
1353
|
+
*
|
|
1354
|
+
* "Backup" is the verb and BOTH options share the "configuration" base:
|
|
1355
|
+
* the plugins are an addition to the configuration, never a rival of it.
|
|
1356
|
+
*
|
|
1357
|
+
* It is deliberately NOT a modal: it is an ordinary block in the composer
|
|
1358
|
+
* dock (`role="status"`), so the input box stays usable, nothing is
|
|
1359
|
+
* blocked, and it never disappears on its own — it stays until the user
|
|
1360
|
+
* picks an option or closes it. It is keyboard reachable (focusable, Esc
|
|
1361
|
+
* closes it) and adapts to a narrow window.
|
|
1362
|
+
*
|
|
1363
|
+
* The answer goes to the Host, which writes it into the profile
|
|
1364
|
+
* configuration (the same key the settings page edits).
|
|
1365
|
+
*/
|
|
1366
|
+
function BackupChoicePanel() {
|
|
1367
|
+
const info = useSharedInfo(true)
|
|
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
|
+
)
|
|
1379
|
+
const [busy, setBusy] = useState(false)
|
|
1380
|
+
const [error, setError] = useState('')
|
|
1381
|
+
const t = translate ?? ((name) => name)
|
|
1382
|
+
|
|
1383
|
+
/**
|
|
1384
|
+
* Hand the FIRST answer to the Host.
|
|
1385
|
+
*
|
|
1386
|
+
* The browser records the choice FIRST (so the question is never asked
|
|
1387
|
+
* twice) and then posts it to the Host, which persists it in the
|
|
1388
|
+
* profile configuration. The SECOND question only appears once that
|
|
1389
|
+
* answer really left the browser.
|
|
1390
|
+
*/
|
|
1391
|
+
const choose = (includePlugins) => {
|
|
1392
|
+
if (busy) return
|
|
1393
|
+
setBusy(true)
|
|
1394
|
+
setError('')
|
|
1395
|
+
writeBackupChoice(includePlugins ? 'plugins' : 'config-only')
|
|
1396
|
+
setStep('persisting')
|
|
1397
|
+
void (async () => {
|
|
1398
|
+
let failed = false
|
|
1399
|
+
try {
|
|
1400
|
+
const response = await fetch(choiceUrlFor(info), {
|
|
1401
|
+
method: 'POST',
|
|
1402
|
+
headers: { 'content-type': 'application/json' },
|
|
1403
|
+
body: JSON.stringify(buildChoiceBody({ includePlugins })),
|
|
1404
|
+
})
|
|
1405
|
+
if (!response.ok) {
|
|
1406
|
+
failed = true
|
|
1407
|
+
setError(fill(t('backupChoiceError'), { message: String(response.status) }))
|
|
1408
|
+
}
|
|
1409
|
+
} catch (failure) {
|
|
1410
|
+
failed = true
|
|
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
|
|
1450
|
+
} finally {
|
|
1451
|
+
setBusy(false)
|
|
1452
|
+
}
|
|
1453
|
+
setStep('done')
|
|
1454
|
+
})()
|
|
1455
|
+
}
|
|
1456
|
+
|
|
1457
|
+
/**
|
|
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".
|
|
1462
|
+
*/
|
|
1463
|
+
const dismissBackup = () => {
|
|
1464
|
+
if (busy) return
|
|
1465
|
+
writeBackupChoice('dismissed')
|
|
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')
|
|
1483
|
+
}
|
|
1484
|
+
|
|
1485
|
+
/** Esc closes the prompt (the panel itself is focusable). */
|
|
1486
|
+
const onKeyDown = (event) => {
|
|
1487
|
+
if (!event) return
|
|
1488
|
+
if (event.key === 'Escape' || event.key === 'Esc') {
|
|
1489
|
+
if (step === 'sleep') dismissSleep()
|
|
1490
|
+
else dismissBackup()
|
|
1491
|
+
}
|
|
1492
|
+
}
|
|
1493
|
+
|
|
1494
|
+
if (step === 'done' || step === 'persisting') return null
|
|
1495
|
+
ensureChoiceStyle()
|
|
1496
|
+
|
|
1497
|
+
/**
|
|
1498
|
+
* One option row.
|
|
1499
|
+
*
|
|
1500
|
+
* The WHOLE row is the click target (and it is focusable, with the
|
|
1501
|
+
* radio semantics of a one-of-two choice), while the right side carries
|
|
1502
|
+
* the concrete action. The primary row wears a "recommended" pill and a
|
|
1503
|
+
* solid button; the secondary one an outlined button.
|
|
1504
|
+
*/
|
|
1505
|
+
const optionRow = (key, name, hint, cta, variant, recommended, pick, dataAttribute) => {
|
|
1506
|
+
const onRowClick = () => pick()
|
|
1507
|
+
const onRowKeyDown = (event) => {
|
|
1508
|
+
if (!event) return
|
|
1509
|
+
if (event.key === 'Enter' || event.key === ' ' || event.key === 'Spacebar') {
|
|
1510
|
+
if (typeof event.preventDefault === 'function') event.preventDefault()
|
|
1511
|
+
pick()
|
|
1512
|
+
}
|
|
1513
|
+
}
|
|
1514
|
+
// The button inside the row must not trigger the row a second time.
|
|
1515
|
+
const onCtaClick = (event) => {
|
|
1516
|
+
if (event && typeof event.stopPropagation === 'function') event.stopPropagation()
|
|
1517
|
+
pick()
|
|
1518
|
+
}
|
|
1519
|
+
return h(
|
|
1520
|
+
'div',
|
|
1521
|
+
{
|
|
1522
|
+
key,
|
|
1523
|
+
className: 'dsh-autorestart-choice__row',
|
|
1524
|
+
role: 'radio',
|
|
1525
|
+
'aria-checked': 'false',
|
|
1526
|
+
'aria-label': name,
|
|
1527
|
+
tabIndex: busy ? -1 : 0,
|
|
1528
|
+
[dataAttribute]: key,
|
|
1529
|
+
onClick: onRowClick,
|
|
1530
|
+
onKeyDown: onRowKeyDown,
|
|
1531
|
+
},
|
|
1532
|
+
h(
|
|
1533
|
+
'div',
|
|
1534
|
+
{ className: 'dsh-autorestart-choice__text' },
|
|
1535
|
+
h(
|
|
1536
|
+
'span',
|
|
1537
|
+
{ className: 'dsh-autorestart-choice__title' },
|
|
1538
|
+
recommended
|
|
1539
|
+
? h('span', { className: 'dsh-autorestart-choice__badge' }, t('backupChoiceRecommended'))
|
|
1540
|
+
: null,
|
|
1541
|
+
h('span', { className: 'dsh-autorestart-choice__name' }, name),
|
|
1542
|
+
),
|
|
1543
|
+
h('span', { className: 'dsh-autorestart-choice__hint' }, hint),
|
|
1544
|
+
),
|
|
1545
|
+
h(
|
|
1546
|
+
'button',
|
|
1547
|
+
{
|
|
1548
|
+
type: 'button',
|
|
1549
|
+
className: 'dsh-autorestart-choice__cta dsh-autorestart-choice__cta--' + variant,
|
|
1550
|
+
[dataAttribute]: key,
|
|
1551
|
+
'aria-label': cta,
|
|
1552
|
+
title: cta,
|
|
1553
|
+
disabled: busy,
|
|
1554
|
+
onClick: onCtaClick,
|
|
1555
|
+
},
|
|
1556
|
+
cta,
|
|
1557
|
+
),
|
|
690
1558
|
)
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
1559
|
+
}
|
|
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?
|
|
1622
|
+
return h(
|
|
1623
|
+
'div',
|
|
1624
|
+
{
|
|
1625
|
+
className: 'dsh-autorestart-choice',
|
|
1626
|
+
role: 'status',
|
|
1627
|
+
'aria-live': 'polite',
|
|
1628
|
+
tabIndex: 0,
|
|
1629
|
+
onKeyDown,
|
|
1630
|
+
'data-dsh-autorestart-backup-choice': 'true',
|
|
1631
|
+
'data-dsh-autorestart-choice-step': 'sleep',
|
|
1632
|
+
},
|
|
1633
|
+
h(
|
|
1634
|
+
'div',
|
|
1635
|
+
{ className: 'dsh-autorestart-choice__head' },
|
|
1636
|
+
h('h3', null, t('sleep.choice.title')),
|
|
1637
|
+
h(
|
|
1638
|
+
'button',
|
|
1639
|
+
{
|
|
1640
|
+
type: 'button',
|
|
1641
|
+
className: 'dsh-autorestart-choice__close',
|
|
1642
|
+
'aria-label': t('backupChoiceDismissAria'),
|
|
1643
|
+
title: t('backupChoiceDismissAria'),
|
|
1644
|
+
disabled: busy,
|
|
1645
|
+
onClick: dismissSleep,
|
|
1646
|
+
},
|
|
1647
|
+
'×',
|
|
1648
|
+
),
|
|
1649
|
+
),
|
|
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')),
|
|
1660
|
+
h(
|
|
1661
|
+
'div',
|
|
1662
|
+
{ className: 'dsh-autorestart-choice__options' },
|
|
1663
|
+
optionRow(
|
|
1664
|
+
'keep',
|
|
1665
|
+
t('sleep.choice.keep'),
|
|
1666
|
+
t('sleep.choice.keepHint'),
|
|
1667
|
+
t('sleep.choice.keepCta'),
|
|
1668
|
+
'primary',
|
|
1669
|
+
true,
|
|
1670
|
+
() => chooseSleep(true),
|
|
1671
|
+
'data-sleep-choice',
|
|
1672
|
+
),
|
|
1673
|
+
optionRow(
|
|
1674
|
+
'hide',
|
|
1675
|
+
t('sleep.choice.hide'),
|
|
1676
|
+
t('sleep.choice.hideHint'),
|
|
1677
|
+
t('sleep.choice.hideCta'),
|
|
1678
|
+
'secondary',
|
|
1679
|
+
false,
|
|
1680
|
+
() => chooseSleep(false),
|
|
1681
|
+
'data-sleep-choice',
|
|
1682
|
+
),
|
|
1683
|
+
),
|
|
1684
|
+
h('p', { className: 'dsh-autorestart-choice__footnote' }, t('sleep.choice.footnote')),
|
|
1685
|
+
error === '' ? null : h('p', { role: 'alert' }, error),
|
|
1686
|
+
)
|
|
1687
|
+
}
|
|
1688
|
+
|
|
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))
|
|
695
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,
|
|
696
3378
|
}
|
|
697
3379
|
},
|
|
698
3380
|
})
|