tensorgrid-ui 1.1.5 → 1.2.2

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.
@@ -120,6 +120,14 @@ export function begin(authorization, key, method) {
120
120
  if (current.notices.length > MAX_NOTICES) current.notices.shift()
121
121
  },
122
122
  prompt(prompt) {
123
+ // Поток задал новый вопрос, не дождавшись ответа на прежний. Прежнее
124
+ // обещание нужно закрыть, иначе оно повисает навсегда, а вместе с ним
125
+ // и вся попытка: ключ занят, отвечать уже некому.
126
+ if (current.resolvePrompt !== null) {
127
+ const orphan = current.resolvePrompt
128
+ current.resolvePrompt = null
129
+ orphan('')
130
+ }
123
131
  current.prompt = prompt
124
132
  return new Promise((resolve) => { current.resolvePrompt = resolve })
125
133
  },
@@ -143,31 +151,59 @@ export function begin(authorization, key, method) {
143
151
  return { attempt: view() }
144
152
  }
145
153
 
146
- /** Отвечает на вопрос, который задал поток. */
154
+ /**
155
+ * Отвечает на вопрос, который задал поток.
156
+ *
157
+ * Значение приводится к строке ДО того, как вопрос снят. Обратный порядок
158
+ * подвешивал разговор: тело вида `{"value":{"toString":null}}` роняло
159
+ * приведение уже после очистки, обещание оставалось без ответа, а функция
160
+ * его разрешения терялась — попытка числилась активной, но вопрос исчезал.
161
+ */
147
162
  export function answer(value) {
148
163
  if (attempt === null || attempt.resolvePrompt === null) {
149
164
  return { problem: 'поток ничего не спрашивает', attempt: view() }
150
165
  }
166
+
167
+ let text
168
+ try {
169
+ text = typeof value === 'string' ? value : String(value ?? '')
170
+ } catch {
171
+ return { problem: 'ответ не приводится к тексту', attempt: view() }
172
+ }
173
+
151
174
  const resolve = attempt.resolvePrompt
152
175
  attempt.resolvePrompt = null
153
176
  attempt.prompt = null
154
- resolve(String(value))
177
+ resolve(text)
155
178
  return { attempt: view() }
156
179
  }
157
180
 
158
181
  /**
159
- * Снимает попытку. Нужно не только по желанию пользователя: стойка держит
160
- * ключ занятым, и осиротевшая попытка иначе блокирует повторный вход до
161
- * перезапуска.
182
+ * Снимает текущую попытку. Нужно не только по желанию пользователя: стойка
183
+ * держит ключ занятым, и осиротевшая попытка иначе блокирует повторный вход
184
+ * до перезапуска.
185
+ *
186
+ * Ключ из запроса НЕ принимается: снимать можно только то, что идёт сейчас.
187
+ * Иначе вторая вкладка — или посторонний запрос — снимала бы чужую попытку
188
+ * по произвольному имени.
162
189
  */
163
190
  export function cancel(authorization, key) {
164
- const target = key ?? attempt?.key
165
- if (typeof target !== 'string') return { problem: 'нечего отменять', attempt: view() }
191
+ if (attempt === null) return { problem: 'нечего отменять', attempt: view() }
192
+
193
+ const target = attempt.key
194
+ if (typeof key === 'string' && key !== '' && key !== target) {
195
+ return { problem: 'идёт попытка для другого провайдера', attempt: view() }
196
+ }
197
+
166
198
  authorization.cancel(target)
167
- if (attempt !== null && attempt.key === target) {
168
- attempt.problem = attempt.problem ?? 'отменено'
169
- attempt.prompt = null
199
+ attempt.problem = attempt.problem ?? 'отменено'
200
+ attempt.prompt = null
201
+ if (attempt.resolvePrompt !== null) {
202
+ // Ожидающее обещание закрываем сами: иначе поток останется висеть на
203
+ // вопросе, на который уже никто не ответит.
204
+ const pending = attempt.resolvePrompt
170
205
  attempt.resolvePrompt = null
206
+ pending('')
171
207
  }
172
208
  return { attempt: view() }
173
209
  }
package/lib/client.js CHANGED
@@ -177,7 +177,9 @@ window.__ModuleLoader__.load({
177
177
  'intensity.vivid': 'Vivid',
178
178
  'accent.title': 'Obsidian / Ion accent',
179
179
  'accent.hint': 'Leading tone of the interface and the glow',
180
+ 'auth.nav': 'Subscriptions',
180
181
  'auth.title': 'Provider sign-in',
182
+ 'auth.others': 'Only providers offering a subscription sign-in are listed. The rest — Z.AI among them — offer an API key only; configure those under Models.',
181
183
  'auth.hint': 'Sign in with a subscription instead of an API key. The account stays on this computer.',
182
184
  'auth.signIn': 'Sign in',
183
185
  'auth.again': 'Sign in again',
@@ -222,7 +224,9 @@ window.__ModuleLoader__.load({
222
224
  'intensity.vivid': '强烈',
223
225
  'accent.title': 'Obsidian / Ion 主色',
224
226
  'accent.hint': '界面与光晕的主色调',
227
+ 'auth.nav': '订阅',
225
228
  'auth.title': '登录服务商',
229
+ 'auth.others': '这里只列出提供订阅登录的服务商。其余(包括 Z.AI)只提供 API 密钥,请在「模型」中配置。',
226
230
  'auth.hint': '使用订阅登录,而非 API 密钥。账号仅保存在本机。',
227
231
  'auth.signIn': '登录',
228
232
  'auth.again': '重新登录',
@@ -1564,7 +1568,9 @@ window.__ModuleLoader__.load({
1564
1568
  'intensity.vivid': 'Ярко',
1565
1569
  'accent.title': 'Акцент Obsidian / Ion',
1566
1570
  'accent.hint': 'Ведущий тон интерфейса и свечения',
1571
+ 'auth.nav': 'Подписки',
1567
1572
  'auth.title': 'Вход к провайдерам',
1573
+ 'auth.others': 'Здесь только провайдеры, у которых есть вход по подписке. Остальные — в том числе Z.AI — предлагают лишь ключ API; их место в разделе «Модели».',
1568
1574
  'auth.hint': 'Войти по подписке вместо ключа API. Учётная запись остаётся на этом компьютере.',
1569
1575
  'auth.signIn': 'Войти',
1570
1576
  'auth.again': 'Войти заново',
@@ -1710,7 +1716,7 @@ window.__ModuleLoader__.load({
1710
1716
  // поэтому строка остаётся согласованной при любой палитре.
1711
1717
  '.dsx-setting{display:flex;align-items:center;justify-content:space-between;gap:16px;width:100%;}',
1712
1718
  '.dsx-setting__title{color:var(--dsw-alias-label-primary);font-size:13px;font-weight:500;}',
1713
- '.dsx-setting--stack{flex-direction:column;align-items:stretch;gap:10px}.dsx-auth__list{display:flex;flex-direction:column;gap:6px}.dsx-auth__row{display:flex;align-items:center;justify-content:space-between;gap:12px}.dsx-auth__name{color:var(--dsw-alias-label-primary)}.dsx-auth__badge{margin-left:8px;padding:1px 6px;border-radius:999px;font-size:11px;color:var(--dsw-alias-state-success-primary);border:1px solid var(--dsw-alias-border-l2)}.dsx-auth__panel{display:flex;flex-direction:column;gap:8px;padding:10px;border-radius:8px;background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l1)}.dsx-auth__notice,.dsx-auth__prompt{display:flex;flex-direction:column;gap:6px;color:var(--dsw-alias-label-secondary)}.dsx-auth__link{color:var(--dsw-alias-brand-primary);word-break:break-all}.dsx-auth__code{font-family:ui-monospace,monospace;letter-spacing:.08em;color:var(--dsw-alias-label-primary)}.dsx-auth__input{flex:1;min-width:0;padding:4px 8px;border-radius:6px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary)}.dsx-setting__hint--strong{color:var(--dsw-alias-label-primary)}.dsx-setting__hint--warn{color:var(--dsw-alias-state-warn-primary)}.dsx-setting__hint{color:var(--dsw-alias-label-secondary);font-size:12px;margin-top:2px;}',
1719
+ '.dsx-subs{display:flex;flex-direction:column;gap:14px;padding:4px 0}.dsx-subs__head{display:flex;flex-direction:column;gap:4px}.dsx-subs__title{font-weight:600;font-size:15px;color:var(--dsw-alias-label-primary)}.dsx-subs__note{padding-top:6px;border-top:1px solid var(--dsw-alias-border-l1);color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:1.5}.dsx-setting--stack{flex-direction:column;align-items:stretch;gap:10px}.dsx-auth__list{display:flex;flex-direction:column;gap:6px}.dsx-auth__row{display:flex;align-items:center;justify-content:space-between;gap:12px}.dsx-auth__name{color:var(--dsw-alias-label-primary)}.dsx-auth__badge{margin-left:8px;padding:1px 6px;border-radius:999px;font-size:11px;color:var(--dsw-alias-state-success-primary);border:1px solid var(--dsw-alias-border-l2)}.dsx-auth__panel{display:flex;flex-direction:column;gap:8px;padding:10px;border-radius:8px;background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l1)}.dsx-auth__notice,.dsx-auth__prompt{display:flex;flex-direction:column;gap:6px;color:var(--dsw-alias-label-secondary)}.dsx-auth__link{color:var(--dsw-alias-brand-primary);word-break:break-all}.dsx-auth__code{font-family:ui-monospace,monospace;letter-spacing:.08em;color:var(--dsw-alias-label-primary)}.dsx-auth__input{flex:1;min-width:0;padding:4px 8px;border-radius:6px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary)}.dsx-setting__hint--strong{color:var(--dsw-alias-label-primary)}.dsx-setting__hint--warn{color:var(--dsw-alias-state-warn-primary)}.dsx-setting__hint{color:var(--dsw-alias-label-secondary);font-size:12px;margin-top:2px;}',
1714
1720
  '.dsx-setting__control{display:inline-flex;padding:2px;gap:2px;border:1px solid var(--dsw-alias-border-l1);border-radius:8px;background:var(--dsw-alias-bg-layer-2);flex:none;}',
1715
1721
  '.dsx-seg{appearance:none;border:0;cursor:pointer;padding:4px 10px;border-radius:6px;font-size:12px;line-height:18px;background:transparent;color:var(--dsw-alias-label-secondary);transition:background .16s ease,color .16s ease;}',
1716
1722
  '.dsx-seg:hover{color:var(--dsw-alias-label-primary);}',
@@ -1941,10 +1947,41 @@ window.__ModuleLoader__.load({
1941
1947
  // браузер начинает попытку и затем опрашивает состояние.
1942
1948
  const AUTH_PATH = '/api/tensorgrid.auth'
1943
1949
 
1950
+ /**
1951
+ * Пропустить только настоящий веб-адрес.
1952
+ *
1953
+ * Ссылка приходит от стойки входа, то есть в конечном счёте от внешнего
1954
+ * провайдера. Подставлять её в `href` как есть нельзя: схема
1955
+ * `javascript:` превратила бы «Открыть страницу входа» в запуск чужого
1956
+ * кода прямо в приложении. Разрешаем http и https, остальное прячем.
1957
+ *
1958
+ * @param value - адрес из уведомления.
1959
+ * @returns адрес, пригодный для ссылки, или null.
1960
+ */
1961
+ function safeUrl(value) {
1962
+ if (typeof value !== 'string' || value === '') return null
1963
+ try {
1964
+ const parsed = new URL(value)
1965
+ return parsed.protocol === 'http:' || parsed.protocol === 'https:' ? value : null
1966
+ } catch {
1967
+ return null
1968
+ }
1969
+ }
1970
+
1944
1971
  function AuthRow({ t }) {
1945
1972
  const [state, setState] = React.useState(null)
1973
+ // Занятость от ДЕЙСТВИЯ пользователя и фоновый опрос — разные вещи.
1974
+ //
1975
+ // Раньше опрос тоже поднимал этот признак, а поле ввода и кнопки на
1976
+ // нём завязаны: каждые две секунды поле гасло прямо под руками. Это
1977
+ // ломало ровно тот случай, ради которого опрос и заведён, — ввод кода.
1946
1978
  const [busy, setBusy] = React.useState(false)
1947
1979
  const [draft, setDraft] = React.useState('')
1980
+ // Ответы приходят не в том порядке, в каком ушли: медленный опрос
1981
+ // может вернуться после быстрого ответа и откатить состояние назад.
1982
+ // Применяем только тот ответ, который свежее уже показанного.
1983
+ const seq = React.useRef(0)
1984
+ const applied = React.useRef(0)
1948
1985
 
1949
1986
  // Функция перевода приходит от рантайма НОВОЙ при каждом рендере —
1950
1987
  // в поставочном коде это обычная стрелка, не мемоизированная. Если
@@ -1985,14 +2022,35 @@ window.__ModuleLoader__.load({
1985
2022
  return response.json()
1986
2023
  }, [])
1987
2024
 
1988
- const refresh = React.useCallback(async (body) => {
1989
- setBusy(true)
2025
+ /**
2026
+ * Спросить Host и показать ответ.
2027
+ *
2028
+ * @param body - тело действия; без него идёт GET за списком.
2029
+ * @param background - фоновый опрос: не блокирует поля и не стирает
2030
+ * показанное состояние, если сеть отвалилась. Пользователь в этот
2031
+ * момент вводит код, и мигание под руками недопустимо.
2032
+ */
2033
+ const refresh = React.useCallback(async (body, background) => {
2034
+ const ticket = ++seq.current
2035
+ if (!background) setBusy(true)
1990
2036
  try {
1991
- setState(await send(body))
2037
+ const next = await send(body)
2038
+ // Обгонять уже применённый ответ нельзя: иначе запоздавший опрос
2039
+ // вернёт вопрос, на который только что ответили.
2040
+ if (ticket > applied.current) {
2041
+ applied.current = ticket
2042
+ setState(next)
2043
+ }
1992
2044
  } catch (error) {
1993
- setState({ available: true, problem: String(error && error.message ? error.message : error) })
2045
+ // Сбой фонового опроса состояние НЕ трогает. Раньше он затирал его
2046
+ // объектом без попытки, из-за чего опрос останавливался навсегда:
2047
+ // интернет возвращался, а строка молчала до перезагрузки.
2048
+ if (!background && ticket > applied.current) {
2049
+ applied.current = ticket
2050
+ setState({ available: true, problem: String(error && error.message ? error.message : error) })
2051
+ }
1994
2052
  } finally {
1995
- setBusy(false)
2053
+ if (!background) setBusy(false)
1996
2054
  }
1997
2055
  }, [send])
1998
2056
 
@@ -2003,7 +2061,7 @@ window.__ModuleLoader__.load({
2003
2061
  const active = state !== null && state.attempt !== undefined && state.attempt !== null && state.attempt.active === true
2004
2062
  React.useEffect(() => {
2005
2063
  if (!active) return undefined
2006
- const id = setInterval(() => { refresh({ action: 'poll' }) }, 2000)
2064
+ const id = setInterval(() => { refresh({ action: 'poll' }, true) }, 2000)
2007
2065
  return () => clearInterval(id)
2008
2066
  }, [active, refresh])
2009
2067
 
@@ -2032,9 +2090,9 @@ window.__ModuleLoader__.load({
2032
2090
  className: 'dsx-auth__notice',
2033
2091
  children: [
2034
2092
  jsx('div', { children: notice.message }),
2035
- notice.url === null ? null : jsx('a', {
2093
+ safeUrl(notice.url) === null ? null : jsx('a', {
2036
2094
  className: 'dsx-auth__link',
2037
- href: notice.url,
2095
+ href: safeUrl(notice.url),
2038
2096
  target: '_blank',
2039
2097
  rel: 'noreferrer',
2040
2098
  children: t('auth.openLink'),
@@ -2098,11 +2156,12 @@ window.__ModuleLoader__.load({
2098
2156
  }
2099
2157
 
2100
2158
  return jsxs('div', {
2101
- className: 'dsx-setting dsx-setting--stack',
2159
+ className: 'dsx-subs',
2102
2160
  children: [
2103
2161
  jsxs('div', {
2162
+ className: 'dsx-subs__head',
2104
2163
  children: [
2105
- jsx('div', { className: 'dsx-setting__title', children: t('auth.title') }),
2164
+ jsx('div', { className: 'dsx-subs__title', children: t('auth.title') }),
2106
2165
  jsx('div', { className: 'dsx-setting__hint', children: t('auth.hint') }),
2107
2166
  ],
2108
2167
  }),
@@ -2149,6 +2208,11 @@ window.__ModuleLoader__.load({
2149
2208
  }),
2150
2209
  })
2151
2210
  : null,
2211
+ // Здесь только провайдеры, у которых есть вход по подписке. У
2212
+ // остальных — включая Z.AI — поставщик предлагает единственный
2213
+ // способ, ключ API, и его место в разделе «Модели». Без этой
2214
+ // строки отсутствие знакомого имени выглядит как недоработка.
2215
+ jsx('div', { className: 'dsx-subs__note', children: t('auth.others') }),
2152
2216
  ],
2153
2217
  })
2154
2218
  }
@@ -2361,9 +2425,21 @@ window.__ModuleLoader__.load({
2361
2425
  ),
2362
2426
  )
2363
2427
 
2364
- ctx.slots.inject('settings.general.item', () =>
2428
+ // Подписки — собственный раздел настроек, а не строка среди прочих.
2429
+ //
2430
+ // `settings.section` — аддитивный список: своя запись добавляет свою
2431
+ // вкладку и ничего не замещает. Порядок 12 ставит её между «Моделями»
2432
+ // (10) и «Плагинами» (15): вход к провайдерам — сосед моделей, а не
2433
+ // оформления.
2434
+ ctx.slots.inject('settings.section', () =>
2365
2435
  ctx.slots.register(
2366
- { name: 'settings.general.item', id: 'tensorgrid-auth', order: 10, locale: LOCALE_NS },
2436
+ {
2437
+ name: 'settings.section',
2438
+ id: 'tensorgrid-subscriptions',
2439
+ order: 12,
2440
+ locale: LOCALE_NS,
2441
+ label: () => ctx.locale.translate(LOCALE_NS, 'auth.nav'),
2442
+ },
2367
2443
  AuthRow,
2368
2444
  ),
2369
2445
  )
@@ -27,30 +27,55 @@ function reply(res, code, value) {
27
27
  * Принять маленькое JSON-тело.
28
28
  *
29
29
  * Здесь всегда только действие, ключ провайдера или ответ на вопрос.
30
- * Ограничение 16 КБ не даёт случайному клиенту держать в памяти тело без
31
- * практической причины.
30
+ *
31
+ * Куски накапливаются БУФЕРАМИ, а не строкой. Склейка строкой вызывает
32
+ * `toString()` на каждом куске, и символ UTF-8, разорванный по границе
33
+ * сетевого пакета, превращается в «замену» — ответ с кириллицей изредка
34
+ * приезжал бы битым. Предел считается в БАЙТАХ по той же причине: длина
35
+ * строки и размер тела — разные величины.
36
+ *
37
+ * Тайм-аут обязателен: клиент, открывший запрос и не закрывший его, иначе
38
+ * держал бы обработчик и сокет бесконечно.
32
39
  */
33
- function readJsonBody(req) {
40
+ function readJsonBody(req, limitBytes = 16384, timeoutMs = 15000) {
34
41
  return new Promise((resolve) => {
35
- let text = ''
36
- let tooBig = false
42
+ const chunks = []
43
+ let size = 0
44
+ let settled = false
45
+
46
+ const finish = (value) => {
47
+ if (settled) return
48
+ settled = true
49
+ clearTimeout(timer)
50
+ resolve(value)
51
+ }
52
+
53
+ const timer = setTimeout(() => {
54
+ req.destroy()
55
+ finish(null)
56
+ }, timeoutMs)
57
+
37
58
  req.on('data', (chunk) => {
38
- if (tooBig) return
39
- text += chunk
40
- if (text.length > 16384) {
41
- tooBig = true
42
- resolve(null)
59
+ if (settled) return
60
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
61
+ size += buffer.length
62
+ if (size > limitBytes) {
63
+ req.destroy()
64
+ finish(null)
65
+ return
43
66
  }
67
+ chunks.push(buffer)
44
68
  })
45
69
  req.on('end', () => {
46
- if (tooBig) return
70
+ if (settled) return
71
+ const text = Buffer.concat(chunks).toString('utf8')
47
72
  try {
48
- resolve(JSON.parse(text === '' ? '{}' : text))
73
+ finish(JSON.parse(text === '' ? '{}' : text))
49
74
  } catch {
50
- resolve(null)
75
+ finish(null)
51
76
  }
52
77
  })
53
- req.on('error', () => resolve(null))
78
+ req.on('error', () => finish(null))
54
79
  })
55
80
  }
56
81
 
@@ -59,6 +84,95 @@ function acceptsJson(req) {
59
84
  return String(req.headers['content-type'] ?? '').includes('application/json')
60
85
  }
61
86
 
87
+ /**
88
+ * Запрос пришёл со своей же страницы?
89
+ *
90
+ * Межсайтовый POST с нашим типом содержимого браузер и так не пустит без
91
+ * предварительной проверки CORS, которую мы не проходим. Но держать защиту
92
+ * только на чужом поведении неправильно: явная сверка источника дешева и
93
+ * делает намерение видимым в коде.
94
+ *
95
+ * Отсутствие заголовка допускается намеренно: его не шлют не-браузерные
96
+ * клиенты, а локальный процесс и без того читает файл с учётными данными
97
+ * напрямую — запрет ему ничего бы не добавил.
98
+ */
99
+ function sameOrigin(req) {
100
+ const origin = req.headers.origin
101
+ if (typeof origin !== 'string' || origin === '') return true
102
+ const host = String(req.headers.host ?? '')
103
+ if (host === '') return false
104
+ try {
105
+ return new URL(origin).host === host
106
+ } catch {
107
+ return false
108
+ }
109
+ }
110
+
111
+ /**
112
+ * Найти поставочный файл фронтенда.
113
+ *
114
+ * Профили делят общий `node_modules`, поэтому от нашего кода до него —
115
+ * подъём на несколько уровней. Ищем перебором вверх, а не по заданному
116
+ * пути: глубина вложения зависит от того, как pnpm разложил пакеты.
117
+ *
118
+ * @param fromDir - каталог, от которого подниматься.
119
+ * @param relative - путь внутри `node_modules`.
120
+ * @returns полный путь или null, если не нашли.
121
+ */
122
+ async function findShipped(fromDir, relative) {
123
+ const { existsSync } = await import('node:fs')
124
+ const { dirname, join } = await import('node:path')
125
+ let dir = fromDir
126
+ for (let depth = 0; depth < 8; depth += 1) {
127
+ const candidate = join(dir, 'node_modules', relative)
128
+ if (existsSync(candidate)) return candidate
129
+ const parent = dirname(dir)
130
+ if (parent === dir) break
131
+ dir = parent
132
+ }
133
+ return null
134
+ }
135
+
136
+ /**
137
+ * Отдать веб-манифест: поставочный, но с нашим именем и иконкой.
138
+ *
139
+ * Полная подмена была бы заморозкой чужого файла на сегодняшней версии:
140
+ * dsh добавит поле — ярлыки, снимки экрана, обработчики ссылок, — а мы
141
+ * продолжим отдавать форму без него, и установленное приложение молча
142
+ * потеряет возможности.
143
+ *
144
+ * Поэтому поставочный манифест читается и ДОПОЛНЯЕТСЯ. Своими остаются
145
+ * ровно те поля, ради которых всё затевалось: имя и иконка. Всё прочее —
146
+ * `id`, `start_url`, `scope`, `display` и любое будущее — проходит как есть.
147
+ *
148
+ * Если файл не найден, отдаётся наш запасной: лучше своё имя без чужих
149
+ * полей, чем отказ.
150
+ *
151
+ * @param baseDir - каталог нашего модуля, от него ищется поставочный файл.
152
+ * @param overrides - наши поля.
153
+ * @param fallback - что отдать, если поставочного файла нет.
154
+ */
155
+ export async function handleManifest(req, res, baseDir, overrides, fallback) {
156
+ let shipped = null
157
+ try {
158
+ const path = await findShipped(baseDir, '@deepseek-ai/dsh-web-frontend/dist/manifest.webmanifest')
159
+ if (path !== null) {
160
+ const { readFile } = await import('node:fs/promises')
161
+ shipped = JSON.parse(await readFile(path, 'utf8'))
162
+ }
163
+ } catch {
164
+ // Испорченный или недоступный файл — не повод отказывать.
165
+ shipped = null
166
+ }
167
+
168
+ const value = shipped === null ? fallback : { ...shipped, ...overrides }
169
+ res.writeHead(200, {
170
+ 'content-type': 'application/manifest+json; charset=utf-8',
171
+ 'cache-control': 'no-cache',
172
+ })
173
+ res.end(JSON.stringify(value, null, 2))
174
+ }
175
+
62
176
  /** Обновить кеш состояния версий. Вызывается и фоновым таймером. */
63
177
  export async function refreshUpdate(state, loadModule) {
64
178
  const module = await loadModule('./update-check.js')
@@ -84,6 +198,10 @@ export async function handleUpdate(req, res, state, loadModule) {
84
198
  reply(res, 415, { ok: false, problem: 'ожидается content-type: application/json' })
85
199
  return
86
200
  }
201
+ if (!sameOrigin(req)) {
202
+ reply(res, 403, { ok: false, problem: 'запрос с чужой страницы' })
203
+ return
204
+ }
87
205
  const module = await loadModule('./update-check.js')
88
206
  const result = await module.applyUpdate()
89
207
  state.cached = result.status ?? state.cached
@@ -125,6 +243,11 @@ export async function handleAuth(req, res, services, loadModule) {
125
243
  return
126
244
  }
127
245
 
246
+ if (!sameOrigin(req)) {
247
+ reply(res, 403, { problem: 'запрос с чужой страницы' })
248
+ return
249
+ }
250
+
128
251
  const body = await readJsonBody(req)
129
252
  if (body === null) {
130
253
  reply(res, 400, { problem: 'тело запроса не разобрано' })
@@ -145,7 +268,15 @@ export async function handleAuth(req, res, services, loadModule) {
145
268
  return
146
269
  }
147
270
 
148
- reply(res, 200, { available: true, ...result })
271
+ // Список провайдеров прикладывается к КАЖДОМУ ответу, а не только к GET.
272
+ //
273
+ // Браузер заменяет состояние ответом целиком, и ответ без списка обнулял
274
+ // его: после первого же нажатия «Войти» кнопки провайдеров исчезали и не
275
+ // возвращались до перезагрузки страницы. Отметка «подключено» вдобавок
276
+ // меняется как раз по итогу входа — значит список и должен приходить
277
+ // вместе с ним.
278
+ const listing = await bridge.listProviders(services.authorization, services.credentials)
279
+ reply(res, 200, { available: true, ...listing, ...result })
149
280
  } catch (error) {
150
281
  reply(res, 500, { problem: String(error?.message ?? error) })
151
282
  }
package/lib/index.js CHANGED
@@ -69,33 +69,42 @@ const APP_ICON = (() => {
69
69
  const APP_ICON_PATH = '/tensorgrid-icon.svg'
70
70
 
71
71
  /**
72
- * Веб-манифест. Он задаёт не только иконку, но и ИМЯ установленного
73
- * приложения — то, что видно в панели задач, меню «Пуск» и заголовке окна.
74
- * Поставочный называет продукт чужим именем, поэтому манифест свой.
72
+ * Наши поля веб-манифеста и только они.
75
73
  *
76
- * Поля id, start_url, scope и display повторяют поставочные: они определяют
77
- * поведение установленного приложения, и менять их значило бы чинить не то.
74
+ * Манифест задаёт не только иконку, но и ИМЯ установленного приложения: то,
75
+ * что видно в панели задач, меню «Пуск» и заголовке окна. Поставочный
76
+ * называет продукт чужим именем, поэтому эти поля мы переопределяем.
77
+ *
78
+ * Остальное НЕ трогаем. Поставочный манифест читается из файла фронтенда и
79
+ * дополняется этими полями, так что `id`, `start_url`, `scope`, `display` и
80
+ * всё, что dsh добавит завтра, проходят как есть. Полная подмена заморозила
81
+ * бы чужой файл на сегодняшней версии.
78
82
  */
79
- const MANIFEST = JSON.stringify(
80
- {
81
- id: '/',
82
- name: 'TENSOR GRID',
83
- short_name: 'TENSOR GRID',
84
- start_url: '/',
85
- scope: '/',
86
- display: 'fullscreen',
87
- background_color: '#0B0D12',
88
- theme_color: '#0B0D12',
89
- icons: [
90
- { src: APP_ICON_PATH, sizes: 'any', type: 'image/svg+xml', purpose: 'any' },
91
- // maskable разрешает системе обрезать иконку под свою форму. Знак
92
- // отступает от краёв на 16%, поэтому обрезка его не заденет.
93
- { src: APP_ICON_PATH, sizes: 'any', type: 'image/svg+xml', purpose: 'maskable' },
94
- ],
95
- },
96
- null,
97
- 2,
98
- )
83
+ const MANIFEST_OVERRIDES = {
84
+ name: 'TENSOR GRID',
85
+ short_name: 'TENSOR GRID',
86
+ background_color: '#0B0D12',
87
+ theme_color: '#0B0D12',
88
+ icons: [
89
+ { src: APP_ICON_PATH, sizes: 'any', type: 'image/svg+xml', purpose: 'any' },
90
+ // maskable разрешает системе обрезать иконку под свою форму. Знак
91
+ // отступает от краёв на 16%, поэтому обрезка его не заденет.
92
+ { src: APP_ICON_PATH, sizes: 'any', type: 'image/svg+xml', purpose: 'maskable' },
93
+ ],
94
+ }
95
+
96
+ /**
97
+ * Запасной манифест на случай, если поставочного файла не нашлось.
98
+ * Повторяет поля, которые поставка задавала на момент проверки: лучше своё
99
+ * имя без чужих новшеств, чем отказ отдать манифест вовсе.
100
+ */
101
+ const MANIFEST_FALLBACK = {
102
+ id: '/',
103
+ start_url: '/',
104
+ scope: '/',
105
+ display: 'fullscreen',
106
+ ...MANIFEST_OVERRIDES,
107
+ }
99
108
 
100
109
  export const name = 'tensorgrid-ui'
101
110
 
@@ -135,20 +144,6 @@ export function apply(ctx) {
135
144
  }),
136
145
  )
137
146
 
138
- web.effect(() =>
139
- web.webServer.register({
140
- kind: 'exact',
141
- path: '/manifest.webmanifest',
142
- handler(_req, res) {
143
- res.writeHead(200, {
144
- 'content-type': 'application/manifest+json; charset=utf-8',
145
- 'cache-control': 'no-cache',
146
- })
147
- res.end(MANIFEST)
148
- },
149
- }),
150
- )
151
-
152
147
  // Стабильная оболочка маршрутов. Вся бизнес-логика живёт в
153
148
  // `http-runtime.js`, который перечитывается по времени правки файла.
154
149
  // Поэтому исправление обработчика доезжает со следующим запросом — без
@@ -168,6 +163,19 @@ export function apply(ctx) {
168
163
  return import(`${path.href}${version}`)
169
164
  }
170
165
 
166
+ /**
167
+ * Превратить file-адрес в путь файловой системы без импорта модуля.
168
+ *
169
+ * `node:url` импортировать здесь нельзя: строка композиции грузится
170
+ * раньше, чем о пакете что-либо известно, и host-половина обязана
171
+ * оставаться без импортов. На Windows адрес выглядит как
172
+ * `file:///C:/…`, поэтому ведущая косая убирается.
173
+ */
174
+ function fileURLToPathSafe(url) {
175
+ const decoded = decodeURIComponent(String(url.pathname))
176
+ return /^\/[A-Za-z]:/.test(decoded) ? decoded.slice(1) : decoded
177
+ }
178
+
171
179
  /** Минимальный запасной ответ, если горячий модуль не загрузился вовсе. */
172
180
  function fail(res, error) {
173
181
  const body = JSON.stringify({ problem: `обработчик не загрузился: ${String(error?.message ?? error)}` })
@@ -212,6 +220,26 @@ export function apply(ctx) {
212
220
  })
213
221
  })
214
222
 
223
+ // Манифест собирается в горячем модуле: он ЧИТАЕТ поставочный файл и
224
+ // дополняет его нашими полями, а значит зависит от чужой формы, которая
225
+ // может поменяться. Такую логику правильнее держать там, где её можно
226
+ // поправить без перезапуска.
227
+ web.effect(() =>
228
+ web.webServer.register({
229
+ kind: 'exact',
230
+ path: '/manifest.webmanifest',
231
+ async handler(req, res) {
232
+ try {
233
+ const runtime = await loadModule('./http-runtime.js')
234
+ const here = new URL('.', import.meta.url)
235
+ await runtime.handleManifest(req, res, fileURLToPathSafe(here), MANIFEST_OVERRIDES, MANIFEST_FALLBACK)
236
+ } catch (error) {
237
+ fail(res, error)
238
+ }
239
+ },
240
+ }),
241
+ )
242
+
215
243
  web.effect(() =>
216
244
  web.webServer.register({
217
245
  kind: 'exact',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tensorgrid-ui",
3
- "version": "1.1.5",
3
+ "version": "1.2.2",
4
4
  "description": "TENSOR GRID — фирменный интерфейс поверх DeepSeek Harness: палитра, живой ambient-слой, айдентика и русский язык-пакет",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",