ddeck-remote 0.0.0-stage → 0.9.7-beta

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/lib/client.js ADDED
@@ -0,0 +1,751 @@
1
+
2
+ /** Язык панели: русский или английский. */
3
+ function uiLang() {
4
+ let raw = ''
5
+ try { raw = new URLSearchParams(location.search).get('lang') || '' } catch { raw = '' }
6
+ raw = raw || document.documentElement.getAttribute('lang') || navigator.language || 'ru'
7
+ return raw.toLowerCase().startsWith('en') ? 'en' : 'ru'
8
+ }
9
+
10
+ /** Английские строки панели; ключ — русский текст. */
11
+ const EN_TEXT = {
12
+ 'Загружаем…': 'Loading…',
13
+ 'Не удалось получить код': 'Could not get the code',
14
+ 'Проверьте журнал DSH.': 'Check the DSH log.',
15
+ 'Подключить ещё один телефон': 'Connect another phone',
16
+ 'Подключить телефон': 'Connect phone',
17
+ 'Новый код': 'New code',
18
+ 'Откройте DDeck на телефоне и наведите камеру на код.': 'Open DDeck on your phone and point it at the code.',
19
+ 'Пока ни одного устройства.': 'No devices yet.',
20
+ 'Подключено:': 'Connected:',
21
+ 'и ещё': 'and more:',
22
+ 'телефон': 'phone',
23
+ 'Отключить': 'Disconnect',
24
+ 'Закрыть': 'Close',
25
+ 'Код действует до': 'Code expires at',
26
+ 'Не удалось получить код (': 'Could not get the code (',
27
+ 'Как подключиться?': 'How to connect?',
28
+ 'Свой сервер': 'Own server',
29
+ 'Введите адрес VPS или SSH-хост из ~/.ssh/config. Публичный адрес и туннель настроятся сами.': 'Enter a VPS address or an SSH host from ~/.ssh/config. The public address and tunnel will be configured automatically.',
30
+ 'Сейчас:': 'Current:',
31
+ 'туннель активен': 'tunnel active',
32
+ 'Настроить сервер': 'Set up server',
33
+ 'Настраиваю…': 'Setting up…',
34
+ 'Ошибка': 'Error',
35
+ 'Полная инструкция': 'Full guide',
36
+ 'Понятно': 'Got it',
37
+ '1. На компьютере откройте DSH → панель «DDeck».': '1. On the computer open DSH → the “DDeck” panel.',
38
+ '2. Включите Tailscale или поднимите свой сервер (docs/RELAY).': '2. Turn on Tailscale or set up your own server (docs/RELAY).',
39
+ '3. Нажмите «Новый код» и отсканируйте QR телефоном.': '3. Tap “New code” and scan the QR with the phone.',
40
+ 'Код живёт 5 минут. Старый QR не подойдёт — возьмите свежий.': 'The code lives for five minutes. An old QR will not work — get a fresh one.',
41
+ 'Ошибка про отпечаток: «Забыть» старый компьютер и сопрягите заново.': 'Fingerprint error: forget the old computer and pair again.',
42
+ 'Timeout: проверьте, что Mac не спит, DSH запущен, Tailscale/сервер доступен.': 'Timeout: check the Mac is awake, DSH is running and Tailscale/your server is reachable.',
43
+ 'Шлюз не запущен — этот код не сработает. Перезапустите DSH и откройте панель заново.': 'The gateway is not running — this code will not work. Restart DSH and reopen the panel.',
44
+ 'Второй телефон': 'Second phone',
45
+ 'Пароль задан. На телефоне откройте «Подключить телефон» и введите его.': 'The password is set. On the phone open “Connect a phone” and enter it.',
46
+ 'Задайте пароль, чтобы подключить второй телефон с первого. Без пароля это окно закрыто.': 'Set a password to connect a second phone from the first. Without it this window stays closed.',
47
+ 'Пароль от 6 до 64 символов': 'Password, 6 to 64 characters',
48
+ 'Сохранить пароль': 'Save password',
49
+ 'Сохраняю…': 'Saving…',
50
+ 'Пароль сохранён. Теперь его можно ввести на телефоне.': 'Password saved. You can now enter it on the phone.',
51
+ }
52
+
53
+ /** @param {string} text русская подпись. @returns {string} подпись на языке панели. */
54
+ function tr(text) {
55
+ return uiLang() === 'en' ? (EN_TEXT[text] ?? text) : text
56
+ }
57
+
58
+ /** Экранировать строку для вставки в innerHTML. */
59
+ function escapeHtml(value) {
60
+ return String(value).replace(/[&<>"']/gu, (character) => ({
61
+ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
62
+ }[character]))
63
+ }
64
+
65
+ /**
66
+ * DDeck — панель внутри приложения.
67
+ *
68
+ * Человек не должен искать адрес и открывать браузер: панель живёт там же, где настройки,
69
+ * и показывает то, что нужно в этот момент — код, если телефон ещё не подключён, или
70
+ * список подключённых, если он уже подключён.
71
+ *
72
+ * Это клиентская половина плагина. Она не рисует интерфейс заново: данные берутся с того
73
+ * же маршрута плагина (`/dsh-remote/pairing`), который отдаёт страницу, поэтому панель и
74
+ * страница не могут разойтись.
75
+ *
76
+ * @module dsh-remote/client
77
+ */
78
+ window.__ModuleLoader__.load({
79
+ // Модуль обязан представиться ровно именем пакета: клиентский загрузчик сверяет
80
+ // зарегистрированный id с найденным package.json и иначе отказывается грузить модуль.
81
+ id: 'ddeck-remote',
82
+ factory: (require) => {
83
+ const HELP_URL = 'https://github.com/nemezida1337/ddeck-client/blob/main/docs/CONNECT.md'
84
+ const HELP_STEPS = [
85
+ '1. На компьютере откройте DSH → панель «DDeck».',
86
+ '2. Включите Tailscale или поднимите свой сервер (docs/RELAY).',
87
+ '3. Нажмите «Новый код» и отсканируйте QR телефоном.',
88
+ ]
89
+ const HELP_TROUBLE = [
90
+ 'Код живёт 5 минут. Старый QR не подойдёт — возьмите свежий.',
91
+ 'Ошибка про отпечаток: «Забыть» старый компьютер и сопрягите заново.',
92
+ 'Timeout: проверьте, что Mac не спит, DSH запущен, Tailscale/сервер доступен.',
93
+ ]
94
+ const module = { exports: {} }
95
+ const react = require('react')
96
+ const h = react.createElement
97
+
98
+ /** Стили карточки: держим их при ней, чтобы панель не зависела от темы приложения. */
99
+ const STYLE = {
100
+ row: { display: 'flex', flexDirection: 'column', gap: '0.75rem', padding: '0.25rem 0' },
101
+ head: { display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: '0.75rem' },
102
+ title: { fontWeight: 600 },
103
+ note: { opacity: 0.72, fontSize: '0.875rem' },
104
+ qr: { width: '10rem', height: '10rem', background: '#fff', padding: '0.5rem', borderRadius: '0.75rem', lineHeight: 0 },
105
+ code: { fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace', fontSize: '1.5rem', letterSpacing: '0.28em' },
106
+ button: { alignSelf: 'flex-start', padding: '0.4375rem 0.75rem', borderRadius: '0.5rem', border: '1px solid currentColor', background: 'transparent', color: 'inherit', cursor: 'pointer', fontSize: '0.875rem' },
107
+ cell: { fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace', fontSize: '0.8125rem', wordBreak: 'break-all' },
108
+ }
109
+
110
+ /** Стили панели DDeck; одна инъекция на окно DSH. */
111
+ const PANEL_CSS = `
112
+ .dshm-dsh-panel {
113
+ position: fixed; z-index: 1200; left: 50%; top: 50%; transform: translate(-50%, -50%);
114
+ width: min(34rem, calc(100vw - 2rem)); max-height: min(46rem, calc(100vh - 2rem));
115
+ overflow: auto; box-sizing: border-box; padding: 0;
116
+ background: #1a1b1f; color: #f5f6f8;
117
+ border: 1px solid #30323a; border-radius: 1rem;
118
+ box-shadow: 0 24px 80px rgba(0,0,0,.55);
119
+ font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
120
+ }
121
+ .dshm-dsh-panel, .dshm-dsh-panel * { box-sizing: border-box; }
122
+ .dshm-dsh-scrim { position: fixed; z-index: 1190; inset: 0; border: 0; padding: 0;
123
+ background: rgba(8,9,12,.58); backdrop-filter: blur(3px); }
124
+ .dshm-dsh-head { display: flex; align-items: flex-start; justify-content: space-between;
125
+ gap: 1rem; padding: 1.25rem 1.375rem 1rem; border-bottom: 1px solid #2b2d34; }
126
+ .dshm-dsh-brand { display: flex; align-items: center; gap: .7rem; min-width: 0; }
127
+ .dshm-dsh-mark { width: 2.4rem; height: 2.4rem; border-radius: .7rem; display: grid; place-items: center;
128
+ background: #252833; border: 1px solid #353947; color: #dbe2ff; flex: none; }
129
+ .dshm-dsh-title { font-size: 1.05rem; font-weight: 650; line-height: 1.2; }
130
+ .dshm-dsh-sub { margin-top: .15rem; color: #969aa8; font-size: .8125rem; }
131
+ .dshm-dsh-head-actions { display: flex; align-items: center; gap: .5rem; flex: none; }
132
+ .dshm-dsh-close { width: 2.25rem; height: 2.25rem; border-radius: .55rem; border: 1px solid #343743;
133
+ background: transparent; color: #b9bdc9; font-size: 1.2rem; line-height: 1; cursor: pointer; }
134
+ .dshm-dsh-close:hover { background: #292c35; color: #fff; }
135
+ .dshm-dsh-body { padding: 1.125rem 1.375rem 1.25rem; display: flex; flex-direction: column; gap: 1.1rem; }
136
+ .dshm-dsh-pair { display: grid; grid-template-columns: 10.5rem minmax(0, 1fr); gap: 1.2rem; align-items: center; }
137
+ .dshm-dsh-qr { width: 10.5rem; height: 10.5rem; padding: .45rem; border-radius: .85rem; background: #fff; line-height: 0; }
138
+ .dshm-dsh-qr svg { display: block; width: 100%; height: 100%; }
139
+ .dshm-dsh-qr-hint { margin-top: .5rem; color: #898e9c; font-size: .75rem; line-height: 1.35; }
140
+ .dshm-dsh-kicker { color: #8f94a3; font-size: .6875rem; font-weight: 650; letter-spacing: .08em; text-transform: uppercase; }
141
+ .dshm-dsh-code { margin-top: .2rem; font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
142
+ font-size: 2.15rem; font-weight: 600; letter-spacing: .06em; line-height: 1.15; color: #fff; }
143
+ .dshm-dsh-expiry { margin-top: .25rem; color: #969aa8; font-size: .8125rem; }
144
+ .dshm-dsh-instruct { margin: 0; color: #b9becb; font-size: .84rem; line-height: 1.45; }
145
+ .dshm-dsh-url { margin-top: .55rem; padding: .45rem .55rem; border-radius: .5rem; background: #121318;
146
+ border: 1px solid #2b2e38; color: #c8ccda; font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
147
+ font-size: .72rem; overflow-wrap: anywhere; }
148
+ .dshm-dsh-actions { margin-top: .7rem; display: flex; flex-wrap: wrap; gap: .5rem; align-items: center; }
149
+ .dshm-dsh-status { display: inline-flex; align-items: center; gap: .4rem; color: #a6d7a9; font-size: .78rem; }
150
+ .dshm-dsh-status.bad { color: #e7a3a3; }
151
+ .dshm-dsh-dot { width: .42rem; height: .42rem; border-radius: 50%; background: currentColor; flex: none; }
152
+ .dshm-dsh-warning { margin: 0; padding: .65rem .75rem; border: 1px solid #6d4545; border-radius: .6rem;
153
+ background: #2a1d1f; color: #e7b1b1; font-size: .82rem; line-height: 1.4; }
154
+ .dshm-dsh-section-head { display: flex; align-items: center; justify-content: space-between; gap: 1rem; margin-bottom: .55rem; }
155
+ .dshm-dsh-section-title { font-size: .82rem; font-weight: 650; color: #d7dae3; }
156
+ .dshm-dsh-count { min-width: 1.4rem; padding: .1rem .45rem; border-radius: 999px; background: #292c35;
157
+ color: #b9becb; font-size: .72rem; text-align: center; }
158
+ .dshm-dsh-devices { display: flex; flex-direction: column; gap: .4rem; }
159
+ .dshm-dsh-device { display: flex; align-items: center; gap: .65rem; min-height: 2.6rem; padding: .45rem .65rem;
160
+ border: 1px solid #2d3039; border-radius: .65rem; background: #1d1f25; }
161
+ .dshm-dsh-device-dot { width: .45rem; height: .45rem; border-radius: 50%; background: #86d69a; flex: none; }
162
+ .dshm-dsh-device-main { min-width: 0; flex: 1 1 auto; }
163
+ .dshm-dsh-device-name { font-size: .84rem; color: #eef0f6; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
164
+ .dshm-dsh-device-meta { margin-top: .1rem; color: #8f94a3; font-size: .72rem; }
165
+ .dshm-dsh-empty { margin: 0; padding: .7rem .8rem; border: 1px dashed #393c47; border-radius: .6rem;
166
+ color: #9ba0ae; font-size: .82rem; }
167
+ .dshm-dsh-rest { margin: .4rem 0 0; color: #828796; font-size: .75rem; }
168
+ .dshm-dsh-links { display: flex; flex-wrap: wrap; gap: .35rem 1rem; padding-top: .9rem; border-top: 1px solid #2b2d34; }
169
+ .dshm-dsh-link { border: 0; padding: 0; background: transparent; color: #aeb4c4; font: inherit;
170
+ font-size: .8rem; text-decoration: underline; text-underline-offset: .15rem; cursor: pointer; }
171
+ .dshm-dsh-link:hover { color: #fff; }
172
+ .dshm-dsh-btn { display: inline-flex; align-items: center; justify-content: center; min-height: 2.25rem;
173
+ padding: .35rem .7rem; border-radius: .55rem; font: inherit; font-size: .82rem; cursor: pointer;
174
+ border: 1px solid #3a3e4a; background: transparent; color: #e8eaf0; }
175
+ .dshm-dsh-btn:hover { background: #292c35; }
176
+ .dshm-dsh-btn.primary { background: #6b8afd; border-color: #6b8afd; color: #fff; font-weight: 600; }
177
+ .dshm-dsh-btn.primary:hover { background: #5d7cf0; }
178
+ .dshm-dsh-btn.danger { color: #e7a3a3; font-size: .75rem; }
179
+ .dshm-dsh-btn:disabled { opacity: .5; cursor: default; }
180
+ .dshm-dsh-form { padding: .9rem; border: 1px solid #2d3039; border-radius: .75rem; background: #1c1e24;
181
+ display: flex; flex-direction: column; gap: .55rem; }
182
+ .dshm-dsh-form-title { font-size: .86rem; font-weight: 650; }
183
+ .dshm-dsh-note { margin: 0; color: #9ba0ae; font-size: .8rem; line-height: 1.45; }
184
+ .dshm-dsh-input { width: 100%; padding: .55rem .6rem; border-radius: .5rem; border: 1px solid #3a3e4a;
185
+ background: #15171c; color: #f5f6f8; font: inherit; font-size: .84rem; outline: none; }
186
+ .dshm-dsh-input:focus { border-color: #6b8afd; box-shadow: 0 0 0 3px rgba(107,138,253,.15); }
187
+ .dshm-dsh-form-actions { display: flex; gap: .5rem; flex-wrap: wrap; align-items: center; }
188
+ .dshm-dsh-help { position: fixed; z-index: 9999; inset: 0; background: rgba(0,0,0,.62);
189
+ display: flex; align-items: center; justify-content: center; padding: 1rem; }
190
+ .dshm-dsh-help-card { width: min(30rem, calc(100vw - 2rem)); max-height: calc(100vh - 2rem); overflow: auto;
191
+ background: #1b1d22; border: 1px solid #343743; border-radius: .9rem; padding: 1.1rem 1.2rem;
192
+ display: flex; flex-direction: column; gap: .65rem; box-shadow: 0 24px 80px rgba(0,0,0,.5); }
193
+ .dshm-dsh-help-title { font-size: 1rem; font-weight: 650; }
194
+ .dshm-dsh-divider { height: 1px; background: #2b2d34; margin: .1rem 0; }
195
+ @media (max-width: 560px) {
196
+ .dshm-dsh-panel { width: calc(100vw - 1rem); max-height: calc(100vh - 1rem); }
197
+ .dshm-dsh-head { padding: 1rem 1rem .85rem; }
198
+ .dshm-dsh-body { padding: 1rem; gap: .9rem; }
199
+ .dshm-dsh-pair { grid-template-columns: 7.6rem minmax(0, 1fr); gap: .8rem; }
200
+ .dshm-dsh-qr { width: 7.6rem; height: 7.6rem; }
201
+ .dshm-dsh-code { font-size: 1.7rem; }
202
+ .dshm-dsh-qr-hint { display: none; }
203
+ }
204
+ `
205
+
206
+ /** Вставить стили панели один раз. */
207
+ function injectPanelStyles() {
208
+ if (document.getElementById('dshm-dsh-styles') !== null) return
209
+ const style = document.createElement('style')
210
+ style.id = 'dshm-dsh-styles'
211
+ style.textContent = PANEL_CSS
212
+ document.head.appendChild(style)
213
+ }
214
+
215
+ /** Мини-визард «Свой сервер»: один адрес, остальное — автоматически. */
216
+ function ServerForm({ onDone }) {
217
+ const [address, setAddress] = react.useState('')
218
+ const [status, setStatus] = react.useState(null)
219
+ const [message, setMessage] = react.useState('')
220
+ const [busy, setBusy] = react.useState(false)
221
+ react.useEffect(() => {
222
+ let alive = true
223
+ fetch('/dsh-remote/server', { cache: 'no-store' })
224
+ .then((response) => response.ok ? response.json() : null)
225
+ .then((data) => { if (alive) setStatus(data) })
226
+ .catch(() => {})
227
+ return () => { alive = false }
228
+ }, [])
229
+ const hosts = Array.isArray(status?.sshHosts) ? status.sshHosts : []
230
+ const submit = async () => {
231
+ setBusy(true)
232
+ setMessage(tr('Настраиваю…'))
233
+ try {
234
+ const response = await fetch('/dsh-remote/server', {
235
+ method: 'POST',
236
+ headers: { 'content-type': 'application/json' },
237
+ body: JSON.stringify({ address: address.trim() }),
238
+ })
239
+ const data = await response.json().catch(() => ({}))
240
+ if (!response.ok || data.ok === false) throw new Error(data.error || `HTTP ${response.status}`)
241
+ setBusy(false)
242
+ setMessage((data.steps || []).join(' → ') || 'Готово: сервер настроен.')
243
+ onDone?.()
244
+ } catch (error) {
245
+ setBusy(false)
246
+ setMessage(`${tr('Ошибка')}: ${String(error instanceof Error ? error.message : error)}`)
247
+ }
248
+ }
249
+ return h('div', { style: { border: '1px solid #2b2d34', borderRadius: '0.75rem', padding: '0.75rem', display: 'flex', flexDirection: 'column', gap: '0.5rem' } },
250
+ h('div', { style: STYLE.title }, tr('Свой сервер')),
251
+ h('div', { style: STYLE.note }, tr('Введите адрес VPS или SSH-хост из ~/.ssh/config. Публичный адрес и туннель настроятся сами.')),
252
+ status?.origin ? h('div', { style: STYLE.note }, `${tr('Сейчас:')} ${status.origin}${status.tunnel ? ` · ${tr('туннель активен')}` : ''}`) : null,
253
+ h('input', {
254
+ value: address,
255
+ list: 'dsh-ssh-hosts',
256
+ placeholder: 'relay.example.com или relay-vds',
257
+ onChange: (event) => setAddress(event.target.value),
258
+ style: { padding: '0.5rem', borderRadius: '0.5rem', border: '1px solid #2b2d34', background: '#12151C', color: '#f5f6f8' },
259
+ }),
260
+ h('datalist', { id: 'dsh-ssh-hosts' }, ...hosts.map((host) => h('option', { key: host.alias, value: host.alias }, host.host))),
261
+ h('div', { style: { display: 'flex', gap: '0.5rem', alignItems: 'center' } },
262
+ h('button', { type: 'button', style: { ...STYLE.button, background: '#6B8AFD', borderColor: '#6B8AFD', color: '#fff' }, disabled: busy, onClick: submit }, busy ? tr('Настраиваю…') : tr('Настроить сервер')),
263
+ h('button', { type: 'button', style: STYLE.button, onClick: () => onDone?.() }, tr('Закрыть'))),
264
+ message === '' ? null : h('div', { style: { ...STYLE.note, whiteSpace: 'pre-wrap', maxHeight: '9rem', overflow: 'auto' } }, message),
265
+ )
266
+ }
267
+
268
+ /** Пароль для подключения второго телефона с уже подключённого. */
269
+ function MobilePairForm({ onDone }) {
270
+ const [password, setPassword] = react.useState('')
271
+ const [configured, setConfigured] = react.useState(null)
272
+ const [message, setMessage] = react.useState('')
273
+ const [busy, setBusy] = react.useState(false)
274
+ react.useEffect(() => {
275
+ let alive = true
276
+ fetch('/dsh-remote/mobile-pin', { cache: 'no-store' })
277
+ .then((response) => response.ok ? response.json() : null)
278
+ .then((data) => { if (alive) setConfigured(data?.set === true) })
279
+ .catch(() => {})
280
+ return () => { alive = false }
281
+ }, [])
282
+ const submit = async () => {
283
+ setBusy(true)
284
+ setMessage(tr('Сохраняю…'))
285
+ try {
286
+ const response = await fetch('/dsh-remote/mobile-pin', {
287
+ method: 'POST',
288
+ headers: { 'content-type': 'application/json' },
289
+ body: JSON.stringify({ password }),
290
+ })
291
+ const data = await response.json().catch(() => ({}))
292
+ if (!response.ok) throw new Error(data.message || data.error || `HTTP ${response.status}`)
293
+ setConfigured(true)
294
+ setPassword('')
295
+ setMessage(tr('Пароль сохранён. Теперь его можно ввести на телефоне.'))
296
+ onDone?.()
297
+ } catch (error) {
298
+ setMessage(`${tr('Ошибка')}: ${String(error instanceof Error ? error.message : error)}`)
299
+ } finally {
300
+ setBusy(false)
301
+ }
302
+ }
303
+ return h('div', { style: { border: '1px solid #2b2d34', borderRadius: '0.75rem', padding: '0.75rem', display: 'flex', flexDirection: 'column', gap: '0.5rem' } },
304
+ h('div', { style: STYLE.title }, tr('Второй телефон')),
305
+ h('div', { style: STYLE.note }, configured === true
306
+ ? tr('Пароль задан. На телефоне откройте «Подключить телефон» и введите его.')
307
+ : tr('Задайте пароль, чтобы подключить второй телефон с первого. Без пароля это окно закрыто.')),
308
+ h('input', {
309
+ type: 'password',
310
+ value: password,
311
+ autoComplete: 'new-password',
312
+ placeholder: tr('Пароль от 6 до 64 символов'),
313
+ onChange: (event) => setPassword(event.target.value),
314
+ style: { padding: '0.5rem', borderRadius: '0.5rem', border: '1px solid #2b2d34', background: '#12151C', color: '#f5f6f8' },
315
+ }),
316
+ h('div', { style: { display: 'flex', gap: '0.5rem' } },
317
+ h('button', { type: 'button', style: { ...STYLE.button, background: '#6B8AFD', borderColor: '#6B8AFD', color: '#fff' }, disabled: busy || password.length < 6, onClick: submit }, busy ? tr('Сохраняю…') : tr('Сохранить пароль')),
318
+ h('button', { type: 'button', style: STYLE.button, onClick: () => onDone?.() }, tr('Закрыть'))),
319
+ message === '' ? null : h('div', { style: STYLE.note }, message),
320
+ )
321
+ }
322
+
323
+ /**
324
+ * Панель «DDeck».
325
+ *
326
+ * Состояние одно и берётся целиком с сервера: тип устройства, код и адрес приходят
327
+ * вместе, иначе панель могла бы показать код от одного шлюза с адресом другого.
328
+ */
329
+ function RemoteRow() {
330
+ const [state, setState] = react.useState({ status: 'loading' })
331
+ const [help, setHelp] = react.useState(false)
332
+ const [serverOpen, setServerOpen] = react.useState(false)
333
+ const [pinOpen, setPinOpen] = react.useState(false)
334
+ // Язык DSH может смениться без перезагрузки — перерисовываем подписи.
335
+ react.useEffect(() => {
336
+ const observer = new MutationObserver(() => setState((current) => ({ ...current })))
337
+ observer.observe(document.documentElement, { attributes: true, attributeFilter: ['lang'] })
338
+ return () => observer.disconnect()
339
+ }, [])
340
+
341
+ const load = react.useCallback((force = false) => {
342
+ let alive = true
343
+ fetch('/dsh-remote/pairing', { cache: 'no-store', method: force ? 'POST' : 'GET' })
344
+ .then((response) => (response.ok ? response.json() : Promise.reject(new Error(String(response.status)))))
345
+ .then((data) => { if (alive) setState({ status: 'ready', data }) })
346
+ .catch((error) => { if (alive) setState({ status: 'error', message: String(error.message ?? error) }) })
347
+ return () => { alive = false }
348
+ }, [])
349
+
350
+ react.useEffect(() => {
351
+ const cancel = load()
352
+ // Код живёт пять минут; панель сама меняет его к истечению и обновляет список
353
+ // устройств раз в минуту. «Обновить» рядом с QR — принудительная смена кода.
354
+ const timer = setInterval(() => load(false), 60000)
355
+ return () => { clearInterval(timer); cancel() }
356
+ }, [load])
357
+
358
+ // Когда код истекает, сразу открываем новое окно: старый QR на экране бесполезен.
359
+ const expiresAt = state.status === 'ready' ? Number(state.data?.expiresAt ?? 0) : 0
360
+ react.useEffect(() => {
361
+ if (expiresAt <= 0) return undefined
362
+ const delay = Math.max(1000, expiresAt - Date.now() - 2000)
363
+ const timer = setTimeout(() => load(true), delay)
364
+ return () => clearTimeout(timer)
365
+ }, [expiresAt, load])
366
+
367
+ if (state.status === 'loading') {
368
+ return h('div', { style: STYLE.row }, h('div', { style: STYLE.note }, tr('Загружаем…')))
369
+ }
370
+ if (state.status === 'error') {
371
+ return h('div', { style: STYLE.row },
372
+ h('div', { style: STYLE.title }, 'DDeck'),
373
+ h('div', { style: STYLE.note }, `${tr('Не удалось получить код')} (${state.message}). ${tr('Проверьте журнал DSH.')}`))
374
+ }
375
+
376
+ const { data } = state
377
+ const devices = Array.isArray(data.devices) ? data.devices : []
378
+ const expiresLabel = Number(data.expiresAt) > 0
379
+ ? `${tr('Код действует до')} ${new Date(Number(data.expiresAt)).toLocaleTimeString(uiLang() === 'en' ? 'en-US' : 'ru-RU', { hour: '2-digit', minute: '2-digit' })}`
380
+ : ''
381
+
382
+ const revoke = (deviceId) => {
383
+ fetch('/dsh-remote/revoke', {
384
+ method: 'POST',
385
+ headers: { 'content-type': 'application/json' },
386
+ body: JSON.stringify({ deviceId }),
387
+ }).then(() => load()).catch(() => {})
388
+ }
389
+
390
+ return h('div', { style: STYLE.row },
391
+ data.gateway === false
392
+ ? h('div', { style: { ...STYLE.note, color: '#e0a3a3', border: '1px solid #7a4a4a', borderRadius: '0.5rem', padding: '0.5rem 0.625rem' } },
393
+ tr('Шлюз не запущен — этот код не сработает. Перезапустите DSH и откройте панель заново.'))
394
+ : null,
395
+ h('div', { style: STYLE.head },
396
+ h('div', { style: STYLE.title }, devices.length > 0 ? tr('Подключить ещё один телефон') : tr('Подключить телефон')),
397
+ h('button', { type: 'button', style: STYLE.button, onClick: () => load(true) }, tr('Новый код'))),
398
+ h('div', { style: STYLE.note }, tr('Откройте DDeck на телефоне и наведите камеру на код.')),
399
+ h('div', { style: { display: 'flex', gap: '0.5rem', flexWrap: 'wrap' } },
400
+ h('button', { type: 'button', style: STYLE.button, onClick: () => setHelp(true) }, tr('Как подключиться?')),
401
+ h('button', { type: 'button', style: STYLE.button, onClick: () => setServerOpen((open) => !open) }, tr('Свой сервер')),
402
+ h('button', { type: 'button', style: STYLE.button, onClick: () => setPinOpen((open) => !open) }, tr('Второй телефон'))),
403
+ serverOpen ? h(ServerForm, { onDone: () => { setServerOpen(false); load(true) } }) : null,
404
+ pinOpen ? h(MobilePairForm, { onDone: () => setPinOpen(false) }) : null,
405
+ help ? h('div', {
406
+ role: 'dialog',
407
+ 'aria-label': tr('Как подключиться?'),
408
+ style: { position: 'fixed', inset: 0, zIndex: 9999, background: 'rgba(0,0,0,0.62)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '1rem' },
409
+ onClick: (event) => { if (event.target === event.currentTarget) setHelp(false) },
410
+ }, h('div', {
411
+ style: { maxWidth: '26rem', width: '100%', background: '#1b1b1c', color: '#f5f6f8', border: '1px solid #2b2d34', borderRadius: '1rem', padding: '1rem 1.125rem', display: 'flex', flexDirection: 'column', gap: '0.625rem' },
412
+ },
413
+ h('div', { style: { ...STYLE.title, fontSize: '1.05rem' } }, tr('Как подключиться?')),
414
+ ...HELP_STEPS.map((line) => h('div', { key: line, style: STYLE.note }, tr(line))),
415
+ h('div', { style: { height: '1px', background: '#2b2d34', margin: '0.25rem 0' } }),
416
+ ...HELP_TROUBLE.map((line) => h('div', { key: line, style: STYLE.note }, tr(line))),
417
+ h('div', { style: { display: 'flex', gap: '0.5rem', justifyContent: 'flex-end', marginTop: '0.25rem' } },
418
+ h('a', { href: HELP_URL, target: '_blank', rel: 'noreferrer', style: { ...STYLE.button, textDecoration: 'none' } }, tr('Полная инструкция')),
419
+ h('button', { type: 'button', style: { ...STYLE.button, background: '#6B8AFD', borderColor: '#6B8AFD', color: '#fff' }, onClick: () => setHelp(false) }, tr('Понятно'))),
420
+ )) : null,
421
+ expiresLabel === '' ? null : h('div', { style: { ...STYLE.note, fontSize: '0.75rem' } }, expiresLabel),
422
+ // QR виден всегда: «подключить второй телефон» — обычное дело, и прятать код за
423
+ // дополнительным нажатием значит заставлять человека искать его.
424
+ h('div', {
425
+ style: STYLE.qr,
426
+ dangerouslySetInnerHTML: { __html: typeof data.qrSvg === 'string' ? data.qrSvg : '' },
427
+ }),
428
+ h('div', { style: STYLE.code }, String(data.code ?? '')),
429
+ h('div', { style: STYLE.cell }, (() => {
430
+ const origins = Array.isArray(data.origins) && data.origins.length > 0 ? data.origins : [data.origin]
431
+ return origins.filter(Boolean).join(' · ')
432
+ })()),
433
+ devices.length === 0
434
+ ? h('div', { style: STYLE.note }, tr('Пока ни одного устройства.'))
435
+ : h('div', { style: { display: 'flex', flexDirection: 'column', gap: '0.375rem' } },
436
+ h('div', { style: STYLE.note }, `${tr('Подключено:')} ${devices.length}`),
437
+ ...devices.map((device) => h('div', {
438
+ key: device.id,
439
+ style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '0.5rem',
440
+ padding: '0.375rem 0.5rem', border: '1px solid rgba(255,255,255,0.12)', borderRadius: '0.5rem' },
441
+ },
442
+ h('span', { style: { fontSize: '0.8125rem', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } }, String(device.label ?? tr('телефон'))),
443
+ h('button', { type: 'button', style: { ...STYLE.button, padding: '0.1875rem 0.5rem', fontSize: '0.75rem' }, onClick: () => revoke(device.id) }, tr('Отключить')))),
444
+ ),
445
+ )
446
+ }
447
+
448
+ /**
449
+ * Кнопка в подвале боковой панели и панель поверх интерфейса.
450
+ *
451
+ * Панель висит на `document.body`, а не рядом с кнопкой: кнопка живёт внутри боковой
452
+ * панели, которую на телефоне сдвигают за край экрана, и панель уезжала вместе с ней
453
+ * (замер: x = −297 при экране 360). Отдельный элемент на body не зависит от того,
454
+ * что делает drawer.
455
+ */
456
+ const PANEL_ID = 'dshm-remote-panel'
457
+ const SCRIM_ID = 'dshm-remote-scrim'
458
+
459
+ /** Затемнение под панелью: модальное окно должно читаться как модальное. */
460
+ function scrimElement(onClose) {
461
+ let scrim = document.getElementById(SCRIM_ID)
462
+ if (scrim !== null) return scrim
463
+ scrim = document.createElement('button')
464
+ scrim.id = SCRIM_ID
465
+ scrim.type = 'button'
466
+ scrim.setAttribute('aria-label', tr('Закрыть'))
467
+ scrim.className = 'dshm-dsh-scrim'
468
+ scrim.style.display = 'none'
469
+ scrim.addEventListener('click', onClose)
470
+ document.body.appendChild(scrim)
471
+ return scrim
472
+ }
473
+
474
+ /** Панель: создаётся один раз, дальше только обновляется содержимое. */
475
+ function panelElement() {
476
+ let panel = document.getElementById(PANEL_ID)
477
+ if (panel !== null) return panel
478
+ injectPanelStyles()
479
+ panel = document.createElement('div')
480
+ panel.id = PANEL_ID
481
+ panel.className = 'dshm-dsh-panel'
482
+ panel.setAttribute('role', 'dialog')
483
+ panel.setAttribute('aria-modal', 'true')
484
+ panel.setAttribute('aria-label', 'DDeck')
485
+ panel.style.display = 'none'
486
+ document.body.appendChild(panel)
487
+ return panel
488
+ }
489
+
490
+ /** Перерисовать панель по данным плагина. */
491
+ function fillPanel(panel, data, onRevoke, onClose) {
492
+ // Панель опрашивает сервер раз в две секунды. Полная замена innerHTML на
493
+ // каждом тике сбрасывала фокус/выделение и закрывала раскрытые контролы
494
+ // (так «Дополнительно» открывалось и мгновенно закрывалось). Перерисовываем
495
+ // только когда данные действительно изменились; язык входит в подпись,
496
+ // чтобы live-переключение RU/EN обновляло подписи.
497
+ const signature = JSON.stringify({
498
+ code: data.code,
499
+ expiresAt: data.expiresAt,
500
+ devices: Array.isArray(data.devices) ? data.devices.map((device) => [device.id, device.label, device.lastSeenAt]) : [],
501
+ gateway: data.gateway,
502
+ lang: uiLang(),
503
+ })
504
+ if (panel.dataset.dshmSignature === signature) return
505
+ panel.dataset.dshmSignature = signature
506
+ injectPanelStyles()
507
+ const gatewayDown = data.gateway === false
508
+ const devices = (Array.isArray(data.devices) ? data.devices : [])
509
+ .slice()
510
+ .sort((a, b) => Number(b.lastSeenAt ?? 0) - Number(a.lastSeenAt ?? 0))
511
+ const shown = devices.slice(0, 5)
512
+ const rest = devices.length - shown.length
513
+ const origins = (Array.isArray(data.origins) && data.origins.length > 0 ? data.origins : [data.origin])
514
+ .filter(Boolean)
515
+ .join(' · ')
516
+ const rawCode = String(data.code ?? '')
517
+ const code = /^\d{6}$/u.test(rawCode) ? `${rawCode.slice(0, 3)} ${rawCode.slice(3)}` : rawCode
518
+ const expires = Number(data.expiresAt) > 0
519
+ ? `${tr('Код действует до')} ${new Date(Number(data.expiresAt)).toLocaleTimeString(uiLang() === 'en' ? 'en-US' : 'ru-RU', { hour: '2-digit', minute: '2-digit' })}`
520
+ : ''
521
+ const lastSeen = (value) => {
522
+ const numeric = Number(value)
523
+ if (!Number.isFinite(numeric) || numeric <= 0) return ''
524
+ try {
525
+ return new Date(numeric).toLocaleString(uiLang() === 'en' ? 'en-US' : 'ru-RU', {
526
+ day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit',
527
+ })
528
+ } catch { return '' }
529
+ }
530
+ const close = () => {
531
+ if (onClose !== undefined) onClose()
532
+ else {
533
+ panel.style.display = 'none'
534
+ const scrim = document.getElementById(SCRIM_ID)
535
+ if (scrim !== null) scrim.style.display = 'none'
536
+ }
537
+ }
538
+ const deviceRows = shown.map((device) => {
539
+ const seen = lastSeen(device.lastSeenAt)
540
+ return `
541
+ <div class="dshm-dsh-device">
542
+ <span class="dshm-dsh-device-dot" aria-hidden="true"></span>
543
+ <div class="dshm-dsh-device-main">
544
+ <div class="dshm-dsh-device-name">${escapeHtml(device.label ?? tr('телефон'))}</div>
545
+ ${seen === '' ? '' : `<div class="dshm-dsh-device-meta">${escapeHtml(seen)}</div>`}
546
+ </div>
547
+ <button type="button" class="dshm-dsh-btn danger" data-revoke="${escapeHtml(device.id)}">${tr('Отключить')}</button>
548
+ </div>`
549
+ }).join('')
550
+ panel.innerHTML = `
551
+ <div class="dshm-dsh-head">
552
+ <div class="dshm-dsh-brand">
553
+ <span class="dshm-dsh-mark" aria-hidden="true">
554
+ <svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
555
+ <rect x="6.5" y="2.5" width="11" height="19" rx="2.4"></rect><path d="M10 5.2h4"></path><path d="M10.5 19.4h3"></path>
556
+ </svg>
557
+ </span>
558
+ <div>
559
+ <div class="dshm-dsh-title">DDeck</div>
560
+ <div class="dshm-dsh-sub">${tr('Подключение телефона')}</div>
561
+ </div>
562
+ </div>
563
+ <div class="dshm-dsh-head-actions">
564
+ <button type="button" class="dshm-dsh-btn" data-refresh>${tr('Новый код')}</button>
565
+ <button type="button" class="dshm-dsh-close" data-close aria-label="${tr('Закрыть')}">×</button>
566
+ </div>
567
+ </div>
568
+ <div class="dshm-dsh-body">
569
+ <div class="dshm-dsh-pair">
570
+ <div>
571
+ <div class="dshm-dsh-qr">${data.qrSvg ?? ''}</div>
572
+ <div class="dshm-dsh-qr-hint">${tr('Откройте DDeck на телефоне и наведите камеру на код.')}</div>
573
+ </div>
574
+ <div>
575
+ <div class="dshm-dsh-kicker">${tr('Код подключения')}</div>
576
+ <div class="dshm-dsh-code">${escapeHtml(code)}</div>
577
+ <div class="dshm-dsh-expiry">${escapeHtml(expires)}</div>
578
+ <div class="dshm-dsh-url">${escapeHtml(origins)}</div>
579
+ <div class="dshm-dsh-actions">
580
+ <span class="dshm-dsh-status ${gatewayDown ? 'bad' : ''}"><span class="dshm-dsh-dot" aria-hidden="true"></span>${gatewayDown ? tr('Шлюз не запущен') : tr('Шлюз активен')}</span>
581
+ </div>
582
+ </div>
583
+ </div>
584
+ ${gatewayDown ? `<p class="dshm-dsh-warning">${tr('Шлюз не запущен — этот код не сработает. Перезапустите DSH и откройте панель заново.')}</p>` : ''}
585
+ <section>
586
+ <div class="dshm-dsh-section-head">
587
+ <span class="dshm-dsh-section-title">${tr('Подключённые устройства')}</span>
588
+ <span class="dshm-dsh-count">${devices.length}</span>
589
+ </div>
590
+ <div class="dshm-dsh-devices">
591
+ ${devices.length === 0 ? `<p class="dshm-dsh-empty">${tr('Пока ни одного устройства.')}</p>` : deviceRows}
592
+ </div>
593
+ ${rest > 0 ? `<p class="dshm-dsh-rest">${tr('и ещё')} ${rest}</p>` : ''}
594
+ </section>
595
+ <div class="dshm-dsh-links">
596
+ <button type="button" class="dshm-dsh-link" data-help>${tr('Как подключиться?')}</button>
597
+ <button type="button" class="dshm-dsh-link" data-server>${tr('Свой сервер')}</button>
598
+ </div>
599
+ <div class="dshm-dsh-form" data-server-layer style="display:none">
600
+ <div class="dshm-dsh-form-title">${tr('Свой сервер')}</div>
601
+ <p class="dshm-dsh-note">${tr('Введите адрес VPS или SSH-хост из ~/.ssh/config. Публичный адрес и туннель настроятся сами.')}</p>
602
+ <input class="dshm-dsh-input" data-server-address placeholder="relay.example.com или relay-vds">
603
+ <div class="dshm-dsh-form-actions">
604
+ <button type="button" class="dshm-dsh-btn primary" data-server-run>${tr('Настроить сервер')}</button>
605
+ <button type="button" class="dshm-dsh-btn" data-server-close>${tr('Закрыть')}</button>
606
+ </div>
607
+ <div class="dshm-dsh-note" data-server-message></div>
608
+ </div>
609
+ <div class="dshm-dsh-help" data-help-layer style="display:none">
610
+ <div class="dshm-dsh-help-card" role="dialog" aria-label="${tr('Как подключиться?')}">
611
+ <div class="dshm-dsh-help-title">${tr('Как подключиться?')}</div>
612
+ ${HELP_STEPS.map((line) => `<p class="dshm-dsh-note">${escapeHtml(tr(line))}</p>`).join('')}
613
+ <div class="dshm-dsh-divider"></div>
614
+ ${HELP_TROUBLE.map((line) => `<p class="dshm-dsh-note">${escapeHtml(tr(line))}</p>`).join('')}
615
+ <div class="dshm-dsh-form-actions" style="justify-content:flex-end">
616
+ <a href="${HELP_URL}" target="_blank" rel="noreferrer" class="dshm-dsh-link">${tr('Полная инструкция')}</a>
617
+ <button type="button" class="dshm-dsh-btn primary" data-help-close>${tr('Понятно')}</button>
618
+ </div>
619
+ </div>
620
+ </div>
621
+ </div>
622
+ `
623
+ panel.querySelector('[data-close]')?.addEventListener('click', close)
624
+ panel.querySelector('[data-refresh]')?.addEventListener('click', () => loadPanel(panel, onRevoke, true, onClose))
625
+ const helpLayer = panel.querySelector('[data-help-layer]')
626
+ panel.querySelector('[data-help]')?.addEventListener('click', () => { if (helpLayer !== null) helpLayer.style.display = 'flex' })
627
+ panel.querySelector('[data-help-close]')?.addEventListener('click', () => { if (helpLayer !== null) helpLayer.style.display = 'none' })
628
+ helpLayer?.addEventListener('click', (event) => { if (event.target === helpLayer) helpLayer.style.display = 'none' })
629
+ const serverLayer = panel.querySelector('[data-server-layer]')
630
+ panel.querySelector('[data-server]')?.addEventListener('click', () => {
631
+ if (serverLayer !== null) serverLayer.style.display = serverLayer.style.display === 'none' ? 'flex' : 'none'
632
+ })
633
+ panel.querySelector('[data-server-close]')?.addEventListener('click', () => { if (serverLayer !== null) serverLayer.style.display = 'none' })
634
+ panel.querySelector('[data-server-run]')?.addEventListener('click', async () => {
635
+ const address = panel.querySelector('[data-server-address]')?.value?.trim() || ''
636
+ const message = panel.querySelector('[data-server-message]')
637
+ if (message !== null) message.textContent = tr('Настраиваю…')
638
+ try {
639
+ const response = await fetch('/dsh-remote/server', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ address }) })
640
+ const data = await response.json().catch(() => ({}))
641
+ if (!response.ok || data.ok === false) throw new Error(data.error || `HTTP ${response.status}`)
642
+ if (message !== null) message.textContent = data.output || 'Готово: сервер настроен.'
643
+ loadPanel(panel, onRevoke, true, onClose)
644
+ } catch (error) {
645
+ if (message !== null) message.textContent = `${tr('Ошибка')}: ${String(error instanceof Error ? error.message : error)}`
646
+ }
647
+ })
648
+ for (const button of panel.querySelectorAll('[data-revoke]')) {
649
+ button.addEventListener('click', () => onRevoke(button.getAttribute('data-revoke')))
650
+ }
651
+ }
652
+
653
+ /** Загрузить данные и показать панель. */
654
+ function loadPanel(panel, onRevoke, force = false, onClose) {
655
+ fetch('/dsh-remote/pairing', { cache: 'no-store', method: force ? 'POST' : 'GET' })
656
+ .then((response) => (response.ok ? response.json() : Promise.reject(new Error(String(response.status)))))
657
+ .then((data) => fillPanel(panel, data, onRevoke, onClose))
658
+ .catch((error) => {
659
+ delete panel.dataset.dshmSignature
660
+ injectPanelStyles()
661
+ panel.innerHTML = `
662
+ <div class="dshm-dsh-head">
663
+ <div><div class="dshm-dsh-title">DDeck</div><div class="dshm-dsh-sub">${tr('Подключение телефона')}</div></div>
664
+ <button type="button" class="dshm-dsh-close" data-close aria-label="${tr('Закрыть')}">×</button>
665
+ </div>
666
+ <div class="dshm-dsh-body"><p class="dshm-dsh-warning">${tr('Не удалось получить код (')}${escapeHtml(String(error.message ?? error))}). ${tr('Проверьте журнал DSH.')}</p></div>`
667
+ panel.querySelector('[data-close]')?.addEventListener('click', () => {
668
+ if (onClose !== undefined) onClose()
669
+ else { panel.style.display = 'none'; const scrim = document.getElementById(SCRIM_ID); if (scrim !== null) scrim.style.display = 'none' }
670
+ })
671
+ })
672
+ }
673
+
674
+ /** Кнопка в подвале боковой панели. */
675
+ function RemoteLauncher({ wide }) {
676
+ const [open, setOpen] = react.useState(false)
677
+ const panel = react.useRef(null)
678
+
679
+ react.useEffect(() => {
680
+ if (!open) {
681
+ if (panel.current !== null) panel.current.style.display = 'none'
682
+ const existing = document.getElementById(SCRIM_ID)
683
+ if (existing !== null) existing.style.display = 'none'
684
+ return undefined
685
+ }
686
+ const element = panelElement()
687
+ panel.current = element
688
+ element.style.display = 'block'
689
+ const scrim = scrimElement(() => setOpen(false))
690
+ scrim.style.display = 'block'
691
+ const closePanel = () => setOpen(false)
692
+ const revoke = (deviceId) => {
693
+ fetch('/dsh-remote/revoke', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ deviceId }) })
694
+ .then(() => loadPanel(element, revoke, false, closePanel))
695
+ .catch(() => {})
696
+ }
697
+ loadPanel(element, revoke, false, closePanel)
698
+ // Код живёт 5 минут и сервер вращает окно сам: открытая панель должна
699
+ // подхватывать новый код, а не показывать просроченный QR.
700
+ const timer = window.setInterval(() => {
701
+ if (!document.hidden) loadPanel(element, revoke, false, closePanel)
702
+ }, 2_000)
703
+ const onKey = (event) => { if (event.key === 'Escape') setOpen(false) }
704
+ window.addEventListener('keydown', onKey)
705
+ return () => {
706
+ window.clearInterval(timer)
707
+ window.removeEventListener('keydown', onKey)
708
+ }
709
+ }, [open])
710
+
711
+ return h(react.Fragment, null,
712
+ h('button', {
713
+ type: 'button',
714
+ 'aria-expanded': open,
715
+ 'aria-label': 'DDeck',
716
+ title: 'DDeck',
717
+ onClick: () => setOpen((value) => !value),
718
+ style: {
719
+ display: 'flex', alignItems: 'center', gap: '0.5rem', width: wide ? '100%' : 'auto',
720
+ padding: '0.5rem', borderRadius: '0.5rem', border: 0, background: 'transparent',
721
+ color: 'inherit', cursor: 'pointer', fontSize: 'inherit', textAlign: 'left',
722
+ },
723
+ },
724
+ h('svg', { viewBox: '0 0 24 24', width: 18, height: 18, fill: 'none', stroke: 'currentColor', strokeWidth: 1.8, 'aria-hidden': 'true' },
725
+ h('rect', { x: 3, y: 5, width: 13, height: 11, rx: 2 }),
726
+ h('path', { d: 'M7 10l2 2-2 2M11 14h3' }),
727
+ h('circle', { cx: 19.5, cy: 6, r: 1.5 })),
728
+ wide ? h('span', null, 'DDeck') : null))
729
+ }
730
+
731
+ module.exports = {
732
+ name: 'ddeck-remote',
733
+ inject: ['slots'],
734
+ apply(ctx) {
735
+ ctx.slots.inject('settings.general.item', () => ctx.slots.register({
736
+ name: 'settings.general.item',
737
+ id: 'dsh-remote',
738
+ order: 40,
739
+ }, RemoteRow))
740
+ // Подвал боковой панели: привычное место, где живёт «Мобильный доступ» у штатного
741
+ // плагина. Слот отдаёт `wide` — в узкой рейке кнопка остаётся иконкой.
742
+ ctx.slots.inject('sidebar.footer.action', () => ctx.slots.register({
743
+ name: 'sidebar.footer.action',
744
+ id: 'dsh-remote',
745
+ order: 10,
746
+ }, RemoteLauncher))
747
+ },
748
+ }
749
+ return module.exports
750
+ },
751
+ })