tensorgrid-ui 1.2.0 → 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.
- package/lib/auth-bridge.js +46 -10
- package/lib/client.js +60 -8
- package/lib/http-runtime.js +146 -15
- package/lib/index.js +67 -39
- package/package.json +1 -1
package/lib/auth-bridge.js
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
165
|
-
|
|
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
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
@@ -1947,10 +1947,41 @@ window.__ModuleLoader__.load({
|
|
|
1947
1947
|
// браузер начинает попытку и затем опрашивает состояние.
|
|
1948
1948
|
const AUTH_PATH = '/api/tensorgrid.auth'
|
|
1949
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
|
+
|
|
1950
1971
|
function AuthRow({ t }) {
|
|
1951
1972
|
const [state, setState] = React.useState(null)
|
|
1973
|
+
// Занятость от ДЕЙСТВИЯ пользователя и фоновый опрос — разные вещи.
|
|
1974
|
+
//
|
|
1975
|
+
// Раньше опрос тоже поднимал этот признак, а поле ввода и кнопки на
|
|
1976
|
+
// нём завязаны: каждые две секунды поле гасло прямо под руками. Это
|
|
1977
|
+
// ломало ровно тот случай, ради которого опрос и заведён, — ввод кода.
|
|
1952
1978
|
const [busy, setBusy] = React.useState(false)
|
|
1953
1979
|
const [draft, setDraft] = React.useState('')
|
|
1980
|
+
// Ответы приходят не в том порядке, в каком ушли: медленный опрос
|
|
1981
|
+
// может вернуться после быстрого ответа и откатить состояние назад.
|
|
1982
|
+
// Применяем только тот ответ, который свежее уже показанного.
|
|
1983
|
+
const seq = React.useRef(0)
|
|
1984
|
+
const applied = React.useRef(0)
|
|
1954
1985
|
|
|
1955
1986
|
// Функция перевода приходит от рантайма НОВОЙ при каждом рендере —
|
|
1956
1987
|
// в поставочном коде это обычная стрелка, не мемоизированная. Если
|
|
@@ -1991,14 +2022,35 @@ window.__ModuleLoader__.load({
|
|
|
1991
2022
|
return response.json()
|
|
1992
2023
|
}, [])
|
|
1993
2024
|
|
|
1994
|
-
|
|
1995
|
-
|
|
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)
|
|
1996
2036
|
try {
|
|
1997
|
-
|
|
2037
|
+
const next = await send(body)
|
|
2038
|
+
// Обгонять уже применённый ответ нельзя: иначе запоздавший опрос
|
|
2039
|
+
// вернёт вопрос, на который только что ответили.
|
|
2040
|
+
if (ticket > applied.current) {
|
|
2041
|
+
applied.current = ticket
|
|
2042
|
+
setState(next)
|
|
2043
|
+
}
|
|
1998
2044
|
} catch (error) {
|
|
1999
|
-
|
|
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
|
+
}
|
|
2000
2052
|
} finally {
|
|
2001
|
-
setBusy(false)
|
|
2053
|
+
if (!background) setBusy(false)
|
|
2002
2054
|
}
|
|
2003
2055
|
}, [send])
|
|
2004
2056
|
|
|
@@ -2009,7 +2061,7 @@ window.__ModuleLoader__.load({
|
|
|
2009
2061
|
const active = state !== null && state.attempt !== undefined && state.attempt !== null && state.attempt.active === true
|
|
2010
2062
|
React.useEffect(() => {
|
|
2011
2063
|
if (!active) return undefined
|
|
2012
|
-
const id = setInterval(() => { refresh({ action: 'poll' }) }, 2000)
|
|
2064
|
+
const id = setInterval(() => { refresh({ action: 'poll' }, true) }, 2000)
|
|
2013
2065
|
return () => clearInterval(id)
|
|
2014
2066
|
}, [active, refresh])
|
|
2015
2067
|
|
|
@@ -2038,9 +2090,9 @@ window.__ModuleLoader__.load({
|
|
|
2038
2090
|
className: 'dsx-auth__notice',
|
|
2039
2091
|
children: [
|
|
2040
2092
|
jsx('div', { children: notice.message }),
|
|
2041
|
-
notice.url === null ? null : jsx('a', {
|
|
2093
|
+
safeUrl(notice.url) === null ? null : jsx('a', {
|
|
2042
2094
|
className: 'dsx-auth__link',
|
|
2043
|
-
href: notice.url,
|
|
2095
|
+
href: safeUrl(notice.url),
|
|
2044
2096
|
target: '_blank',
|
|
2045
2097
|
rel: 'noreferrer',
|
|
2046
2098
|
children: t('auth.openLink'),
|
package/lib/http-runtime.js
CHANGED
|
@@ -27,30 +27,55 @@ function reply(res, code, value) {
|
|
|
27
27
|
* Принять маленькое JSON-тело.
|
|
28
28
|
*
|
|
29
29
|
* Здесь всегда только действие, ключ провайдера или ответ на вопрос.
|
|
30
|
-
*
|
|
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
|
-
|
|
36
|
-
let
|
|
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 (
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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 (
|
|
70
|
+
if (settled) return
|
|
71
|
+
const text = Buffer.concat(chunks).toString('utf8')
|
|
47
72
|
try {
|
|
48
|
-
|
|
73
|
+
finish(JSON.parse(text === '' ? '{}' : text))
|
|
49
74
|
} catch {
|
|
50
|
-
|
|
75
|
+
finish(null)
|
|
51
76
|
}
|
|
52
77
|
})
|
|
53
|
-
req.on('error', () =>
|
|
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
|
-
|
|
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
|
-
*
|
|
77
|
-
*
|
|
74
|
+
* Манифест задаёт не только иконку, но и ИМЯ установленного приложения: то,
|
|
75
|
+
* что видно в панели задач, меню «Пуск» и заголовке окна. Поставочный
|
|
76
|
+
* называет продукт чужим именем, поэтому эти поля мы переопределяем.
|
|
77
|
+
*
|
|
78
|
+
* Остальное НЕ трогаем. Поставочный манифест читается из файла фронтенда и
|
|
79
|
+
* дополняется этими полями, так что `id`, `start_url`, `scope`, `display` и
|
|
80
|
+
* всё, что dsh добавит завтра, проходят как есть. Полная подмена заморозила
|
|
81
|
+
* бы чужой файл на сегодняшней версии.
|
|
78
82
|
*/
|
|
79
|
-
const
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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