tensorgrid-ui 1.0.4 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/auth-bridge.js +173 -0
- package/lib/client.js +226 -1
- package/lib/index.js +167 -2
- package/package.json +1 -1
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Мост между браузером и стойкой входа — Host-сторона.
|
|
3
|
+
*
|
|
4
|
+
* Стойка `ctx.authorization` ведёт разговор: показывает ссылку, иногда
|
|
5
|
+
* просит код, иногда задаёт вопрос с выбором. Разговор асинхронный и длится
|
|
6
|
+
* минуты, поэтому одним запросом его не обслужить: браузер начинает
|
|
7
|
+
* попытку, затем опрашивает состояние и отвечает на вопросы.
|
|
8
|
+
*
|
|
9
|
+
* Состояние живёт здесь, в одном экземпляре. Это не упрощение: сама стойка
|
|
10
|
+
* допускает ровно одну попытку на ключ, и держать больше было бы враньём о
|
|
11
|
+
* её возможностях.
|
|
12
|
+
*
|
|
13
|
+
* Импортируются только встроенные модули Node — строка композиции грузится
|
|
14
|
+
* раньше, чем о пакете что-либо известно.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** Текущая попытка. null, пока никто не входил. */
|
|
18
|
+
let attempt = null
|
|
19
|
+
|
|
20
|
+
/** Обрезаем историю сообщений: разговор входа короткий, а расти без предела нечему. */
|
|
21
|
+
const MAX_NOTICES = 20
|
|
22
|
+
|
|
23
|
+
function freshAttempt(key, method) {
|
|
24
|
+
return {
|
|
25
|
+
key,
|
|
26
|
+
method: method ?? null,
|
|
27
|
+
notices: [],
|
|
28
|
+
prompt: null,
|
|
29
|
+
resolvePrompt: null,
|
|
30
|
+
outcome: null,
|
|
31
|
+
problem: null,
|
|
32
|
+
startedAt: new Date().toISOString(),
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Состояние попытки в виде, пригодном для браузера: только простые
|
|
38
|
+
* значения, никаких живых объектов и функций.
|
|
39
|
+
*/
|
|
40
|
+
function view() {
|
|
41
|
+
if (attempt === null) return { active: false }
|
|
42
|
+
return {
|
|
43
|
+
active: attempt.outcome === null && attempt.problem === null,
|
|
44
|
+
key: attempt.key,
|
|
45
|
+
method: attempt.method,
|
|
46
|
+
startedAt: attempt.startedAt,
|
|
47
|
+
notices: attempt.notices.map((notice) => ({
|
|
48
|
+
message: String(notice.message ?? ''),
|
|
49
|
+
url: typeof notice.url === 'string' ? notice.url : null,
|
|
50
|
+
code: typeof notice.code === 'string' ? notice.code : null,
|
|
51
|
+
})),
|
|
52
|
+
prompt: attempt.prompt === null ? null : {
|
|
53
|
+
kind: String(attempt.prompt.kind ?? 'text'),
|
|
54
|
+
message: String(attempt.prompt.message ?? ''),
|
|
55
|
+
options: Array.isArray(attempt.prompt.options)
|
|
56
|
+
? attempt.prompt.options.map((option) => ({
|
|
57
|
+
id: String(option.id),
|
|
58
|
+
label: String(option.label),
|
|
59
|
+
description: typeof option.description === 'string' ? option.description : null,
|
|
60
|
+
}))
|
|
61
|
+
: null,
|
|
62
|
+
},
|
|
63
|
+
outcome: attempt.outcome,
|
|
64
|
+
problem: attempt.problem,
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Список провайдеров: что предлагает вход и что уже настроено.
|
|
70
|
+
*
|
|
71
|
+
* Настроенность спрашивается у хранилища учётных данных, а не выводится из
|
|
72
|
+
* попыток: пользователь мог войти в прошлый раз, в другом профиле или
|
|
73
|
+
* вообще через терминал.
|
|
74
|
+
*/
|
|
75
|
+
export async function listProviders(ctx) {
|
|
76
|
+
const entries = ctx.authorization.list()
|
|
77
|
+
const credentials = ctx.get('credentials')
|
|
78
|
+
|
|
79
|
+
let configured = new Set()
|
|
80
|
+
if (credentials !== undefined) {
|
|
81
|
+
try {
|
|
82
|
+
const records = await credentials.listRecords()
|
|
83
|
+
configured = new Set(records.map((record) => String(record.key)))
|
|
84
|
+
} catch {
|
|
85
|
+
// Хранилище недоступно — покажем список без отметок, это лучше отказа.
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
providers: entries.map((entry) => ({
|
|
91
|
+
key: String(entry.key),
|
|
92
|
+
label: String(entry.label),
|
|
93
|
+
inFlight: entry.inFlight === true,
|
|
94
|
+
configured: configured.has(String(entry.key)),
|
|
95
|
+
methods: entry.methods.map((method) => ({ id: String(method.id), label: String(method.label) })),
|
|
96
|
+
})),
|
|
97
|
+
attempt: view(),
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Начинает вход. Предыдущая завершённая попытка забывается. */
|
|
102
|
+
export function begin(ctx, key, method) {
|
|
103
|
+
if (attempt !== null && attempt.outcome === null && attempt.problem === null) {
|
|
104
|
+
return { problem: 'одна попытка уже идёт', attempt: view() }
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
attempt = freshAttempt(key, method)
|
|
108
|
+
const current = attempt
|
|
109
|
+
|
|
110
|
+
const request = {
|
|
111
|
+
key,
|
|
112
|
+
interaction: {
|
|
113
|
+
notify(notice) {
|
|
114
|
+
current.notices.push(notice)
|
|
115
|
+
if (current.notices.length > MAX_NOTICES) current.notices.shift()
|
|
116
|
+
},
|
|
117
|
+
prompt(prompt) {
|
|
118
|
+
current.prompt = prompt
|
|
119
|
+
return new Promise((resolve) => { current.resolvePrompt = resolve })
|
|
120
|
+
},
|
|
121
|
+
},
|
|
122
|
+
}
|
|
123
|
+
if (typeof method === 'string' && method !== '') request.method = method
|
|
124
|
+
|
|
125
|
+
ctx.authorization.begin(request).then(
|
|
126
|
+
(outcome) => {
|
|
127
|
+
current.outcome = String(outcome.status)
|
|
128
|
+
current.prompt = null
|
|
129
|
+
current.resolvePrompt = null
|
|
130
|
+
},
|
|
131
|
+
(error) => {
|
|
132
|
+
current.problem = String(error?.message ?? error)
|
|
133
|
+
current.prompt = null
|
|
134
|
+
current.resolvePrompt = null
|
|
135
|
+
},
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
return { attempt: view() }
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Отвечает на вопрос, который задал поток. */
|
|
142
|
+
export function answer(value) {
|
|
143
|
+
if (attempt === null || attempt.resolvePrompt === null) {
|
|
144
|
+
return { problem: 'поток ничего не спрашивает', attempt: view() }
|
|
145
|
+
}
|
|
146
|
+
const resolve = attempt.resolvePrompt
|
|
147
|
+
attempt.resolvePrompt = null
|
|
148
|
+
attempt.prompt = null
|
|
149
|
+
resolve(String(value))
|
|
150
|
+
return { attempt: view() }
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Снимает попытку. Нужно не только по желанию пользователя: стойка держит
|
|
155
|
+
* ключ занятым, и осиротевшая попытка иначе блокирует повторный вход до
|
|
156
|
+
* перезапуска.
|
|
157
|
+
*/
|
|
158
|
+
export function cancel(ctx, key) {
|
|
159
|
+
const target = key ?? attempt?.key
|
|
160
|
+
if (typeof target !== 'string') return { problem: 'нечего отменять', attempt: view() }
|
|
161
|
+
ctx.authorization.cancel(target)
|
|
162
|
+
if (attempt !== null && attempt.key === target) {
|
|
163
|
+
attempt.problem = attempt.problem ?? 'отменено'
|
|
164
|
+
attempt.prompt = null
|
|
165
|
+
attempt.resolvePrompt = null
|
|
166
|
+
}
|
|
167
|
+
return { attempt: view() }
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Текущее состояние без изменений. */
|
|
171
|
+
export function poll() {
|
|
172
|
+
return { attempt: view() }
|
|
173
|
+
}
|
package/lib/client.js
CHANGED
|
@@ -177,6 +177,17 @@ 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.title': 'Provider sign-in',
|
|
181
|
+
'auth.hint': 'Sign in with a subscription instead of an API key. The account stays on this computer.',
|
|
182
|
+
'auth.signIn': 'Sign in',
|
|
183
|
+
'auth.again': 'Sign in again',
|
|
184
|
+
'auth.connected': 'connected',
|
|
185
|
+
'auth.openLink': 'Open the sign-in page',
|
|
186
|
+
'auth.send': 'Send',
|
|
187
|
+
'auth.cancel': 'Cancel',
|
|
188
|
+
'auth.done': 'Signed in.',
|
|
189
|
+
'auth.cancelled': 'Sign-in cancelled.',
|
|
190
|
+
'auth.unavailable': 'Sign-in is unavailable: the authorization row is not mounted in this profile.',
|
|
180
191
|
'update.title': 'Updates',
|
|
181
192
|
'update.check': 'Check',
|
|
182
193
|
'update.apply': 'Update',
|
|
@@ -208,6 +219,17 @@ window.__ModuleLoader__.load({
|
|
|
208
219
|
'intensity.vivid': '强烈',
|
|
209
220
|
'accent.title': 'Obsidian / Ion 主色',
|
|
210
221
|
'accent.hint': '界面与光晕的主色调',
|
|
222
|
+
'auth.title': '登录服务商',
|
|
223
|
+
'auth.hint': '使用订阅登录,而非 API 密钥。账号仅保存在本机。',
|
|
224
|
+
'auth.signIn': '登录',
|
|
225
|
+
'auth.again': '重新登录',
|
|
226
|
+
'auth.connected': '已连接',
|
|
227
|
+
'auth.openLink': '打开登录页面',
|
|
228
|
+
'auth.send': '发送',
|
|
229
|
+
'auth.cancel': '取消',
|
|
230
|
+
'auth.done': '已登录。',
|
|
231
|
+
'auth.cancelled': '登录已取消。',
|
|
232
|
+
'auth.unavailable': '无法登录:此配置文件未挂载授权行。',
|
|
211
233
|
'update.title': '更新',
|
|
212
234
|
'update.check': '检查',
|
|
213
235
|
'update.apply': '更新',
|
|
@@ -1536,6 +1558,17 @@ window.__ModuleLoader__.load({
|
|
|
1536
1558
|
'intensity.vivid': 'Ярко',
|
|
1537
1559
|
'accent.title': 'Акцент Obsidian / Ion',
|
|
1538
1560
|
'accent.hint': 'Ведущий тон интерфейса и свечения',
|
|
1561
|
+
'auth.title': 'Вход к провайдерам',
|
|
1562
|
+
'auth.hint': 'Войти по подписке вместо ключа API. Учётная запись остаётся на этом компьютере.',
|
|
1563
|
+
'auth.signIn': 'Войти',
|
|
1564
|
+
'auth.again': 'Войти заново',
|
|
1565
|
+
'auth.connected': 'подключено',
|
|
1566
|
+
'auth.openLink': 'Открыть страницу входа',
|
|
1567
|
+
'auth.send': 'Отправить',
|
|
1568
|
+
'auth.cancel': 'Отменить',
|
|
1569
|
+
'auth.done': 'Вход выполнен.',
|
|
1570
|
+
'auth.cancelled': 'Вход отменён.',
|
|
1571
|
+
'auth.unavailable': 'Вход недоступен: в профиле не смонтирована строка авторизации.',
|
|
1539
1572
|
'update.title': 'Обновления',
|
|
1540
1573
|
'update.check': 'Проверить',
|
|
1541
1574
|
'update.apply': 'Обновить',
|
|
@@ -1668,7 +1701,7 @@ window.__ModuleLoader__.load({
|
|
|
1668
1701
|
// поэтому строка остаётся согласованной при любой палитре.
|
|
1669
1702
|
'.dsx-setting{display:flex;align-items:center;justify-content:space-between;gap:16px;width:100%;}',
|
|
1670
1703
|
'.dsx-setting__title{color:var(--dsw-alias-label-primary);font-size:13px;font-weight:500;}',
|
|
1671
|
-
'.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;}',
|
|
1704
|
+
'.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;}',
|
|
1672
1705
|
'.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;}',
|
|
1673
1706
|
'.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;}',
|
|
1674
1707
|
'.dsx-seg:hover{color:var(--dsw-alias-label-primary);}',
|
|
@@ -1892,6 +1925,191 @@ window.__ModuleLoader__.load({
|
|
|
1892
1925
|
})
|
|
1893
1926
|
}
|
|
1894
1927
|
|
|
1928
|
+
// ── Вход по подписке ─────────────────────────────────────────────────
|
|
1929
|
+
//
|
|
1930
|
+
// Стойка входа ведёт разговор: показывает ссылку, иногда просит код,
|
|
1931
|
+
// иногда задаёт вопрос с выбором. Разговор длится минуты, поэтому
|
|
1932
|
+
// браузер начинает попытку и затем опрашивает состояние.
|
|
1933
|
+
const AUTH_PATH = '/api/tensorgrid.auth'
|
|
1934
|
+
|
|
1935
|
+
function AuthRow({ t }) {
|
|
1936
|
+
const [state, setState] = React.useState(null)
|
|
1937
|
+
const [busy, setBusy] = React.useState(false)
|
|
1938
|
+
const [draft, setDraft] = React.useState('')
|
|
1939
|
+
|
|
1940
|
+
const send = React.useCallback(async (body) => {
|
|
1941
|
+
const response = body === undefined
|
|
1942
|
+
? await fetch(AUTH_PATH)
|
|
1943
|
+
: await fetch(AUTH_PATH, {
|
|
1944
|
+
method: 'POST',
|
|
1945
|
+
headers: { 'content-type': 'application/json' },
|
|
1946
|
+
body: JSON.stringify(body),
|
|
1947
|
+
})
|
|
1948
|
+
return response.json()
|
|
1949
|
+
}, [])
|
|
1950
|
+
|
|
1951
|
+
const refresh = React.useCallback(async (body) => {
|
|
1952
|
+
setBusy(true)
|
|
1953
|
+
try {
|
|
1954
|
+
setState(await send(body))
|
|
1955
|
+
} catch (error) {
|
|
1956
|
+
setState({ available: true, problem: String(error && error.message ? error.message : error) })
|
|
1957
|
+
} finally {
|
|
1958
|
+
setBusy(false)
|
|
1959
|
+
}
|
|
1960
|
+
}, [send])
|
|
1961
|
+
|
|
1962
|
+
React.useEffect(() => { refresh() }, [refresh])
|
|
1963
|
+
|
|
1964
|
+
// Пока попытка идёт, состояние спрашивается повторно: поток отвечает
|
|
1965
|
+
// не сразу, а ссылка и вопросы приходят по ходу дела.
|
|
1966
|
+
const active = state !== null && state.attempt !== undefined && state.attempt !== null && state.attempt.active === true
|
|
1967
|
+
React.useEffect(() => {
|
|
1968
|
+
if (!active) return undefined
|
|
1969
|
+
const id = setInterval(() => { refresh({ action: 'poll' }) }, 2000)
|
|
1970
|
+
return () => clearInterval(id)
|
|
1971
|
+
}, [active, refresh])
|
|
1972
|
+
|
|
1973
|
+
if (state !== null && state.available === false) {
|
|
1974
|
+
return jsx('div', {
|
|
1975
|
+
className: 'dsx-setting',
|
|
1976
|
+
children: jsxs('div', {
|
|
1977
|
+
children: [
|
|
1978
|
+
jsx('div', { className: 'dsx-setting__title', children: t('auth.title') }),
|
|
1979
|
+
jsx('div', { className: 'dsx-setting__hint', children: t('auth.unavailable') }),
|
|
1980
|
+
],
|
|
1981
|
+
}),
|
|
1982
|
+
})
|
|
1983
|
+
}
|
|
1984
|
+
|
|
1985
|
+
const attempt = state === null || !state.attempt ? null : state.attempt
|
|
1986
|
+
const providers = state === null || !Array.isArray(state.providers) ? [] : state.providers
|
|
1987
|
+
// Показываем только вход по подписке: ключи API у настроек моделей свои.
|
|
1988
|
+
const subscription = providers.filter((p) => p.methods.some((m) => m.id === 'oauth'))
|
|
1989
|
+
|
|
1990
|
+
const rows = []
|
|
1991
|
+
|
|
1992
|
+
if (attempt !== null && (attempt.active || attempt.outcome || attempt.problem)) {
|
|
1993
|
+
for (const notice of attempt.notices || []) {
|
|
1994
|
+
rows.push(jsxs('div', {
|
|
1995
|
+
className: 'dsx-auth__notice',
|
|
1996
|
+
children: [
|
|
1997
|
+
jsx('div', { children: notice.message }),
|
|
1998
|
+
notice.url === null ? null : jsx('a', {
|
|
1999
|
+
className: 'dsx-auth__link',
|
|
2000
|
+
href: notice.url,
|
|
2001
|
+
target: '_blank',
|
|
2002
|
+
rel: 'noreferrer',
|
|
2003
|
+
children: t('auth.openLink'),
|
|
2004
|
+
}),
|
|
2005
|
+
notice.code === null ? null : jsx('code', { className: 'dsx-auth__code', children: notice.code }),
|
|
2006
|
+
],
|
|
2007
|
+
}, 'notice-' + rows.length))
|
|
2008
|
+
}
|
|
2009
|
+
|
|
2010
|
+
if (attempt.prompt !== null) {
|
|
2011
|
+
const prompt = attempt.prompt
|
|
2012
|
+
rows.push(jsxs('div', {
|
|
2013
|
+
className: 'dsx-auth__prompt',
|
|
2014
|
+
children: [
|
|
2015
|
+
jsx('div', { children: prompt.message }),
|
|
2016
|
+
prompt.options !== null
|
|
2017
|
+
? jsx('div', {
|
|
2018
|
+
className: 'dsx-setting__control',
|
|
2019
|
+
children: prompt.options.map((option) =>
|
|
2020
|
+
jsx('button', {
|
|
2021
|
+
type: 'button',
|
|
2022
|
+
className: 'dsx-seg',
|
|
2023
|
+
disabled: busy,
|
|
2024
|
+
onClick: () => refresh({ action: 'answer', value: option.id }),
|
|
2025
|
+
children: option.label,
|
|
2026
|
+
}, option.id),
|
|
2027
|
+
),
|
|
2028
|
+
})
|
|
2029
|
+
: jsxs('div', {
|
|
2030
|
+
className: 'dsx-setting__control',
|
|
2031
|
+
children: [
|
|
2032
|
+
jsx('input', {
|
|
2033
|
+
className: 'dsx-auth__input',
|
|
2034
|
+
type: prompt.kind === 'secret' ? 'password' : 'text',
|
|
2035
|
+
value: draft,
|
|
2036
|
+
disabled: busy,
|
|
2037
|
+
onChange: (event) => setDraft(event.target.value),
|
|
2038
|
+
}),
|
|
2039
|
+
jsx('button', {
|
|
2040
|
+
type: 'button',
|
|
2041
|
+
className: 'dsx-seg dsx-seg--on',
|
|
2042
|
+
disabled: busy || draft === '',
|
|
2043
|
+
onClick: () => { refresh({ action: 'answer', value: draft }); setDraft('') },
|
|
2044
|
+
children: t('auth.send'),
|
|
2045
|
+
}),
|
|
2046
|
+
],
|
|
2047
|
+
}),
|
|
2048
|
+
],
|
|
2049
|
+
}, 'prompt'))
|
|
2050
|
+
}
|
|
2051
|
+
|
|
2052
|
+
if (attempt.outcome) {
|
|
2053
|
+
rows.push(jsx('div', {
|
|
2054
|
+
className: 'dsx-setting__hint dsx-setting__hint--strong',
|
|
2055
|
+
children: attempt.outcome === 'authorized' ? t('auth.done') : t('auth.cancelled'),
|
|
2056
|
+
}, 'outcome'))
|
|
2057
|
+
}
|
|
2058
|
+
if (attempt.problem) {
|
|
2059
|
+
rows.push(jsx('div', { className: 'dsx-setting__hint dsx-setting__hint--warn', children: attempt.problem }, 'problem'))
|
|
2060
|
+
}
|
|
2061
|
+
}
|
|
2062
|
+
|
|
2063
|
+
return jsxs('div', {
|
|
2064
|
+
className: 'dsx-setting dsx-setting--stack',
|
|
2065
|
+
children: [
|
|
2066
|
+
jsxs('div', {
|
|
2067
|
+
children: [
|
|
2068
|
+
jsx('div', { className: 'dsx-setting__title', children: t('auth.title') }),
|
|
2069
|
+
jsx('div', { className: 'dsx-setting__hint', children: t('auth.hint') }),
|
|
2070
|
+
],
|
|
2071
|
+
}),
|
|
2072
|
+
jsx('div', {
|
|
2073
|
+
className: 'dsx-auth__list',
|
|
2074
|
+
children: subscription.map((provider) =>
|
|
2075
|
+
jsxs('div', {
|
|
2076
|
+
className: 'dsx-auth__row',
|
|
2077
|
+
children: [
|
|
2078
|
+
jsxs('div', {
|
|
2079
|
+
children: [
|
|
2080
|
+
jsx('span', { className: 'dsx-auth__name', children: provider.label }),
|
|
2081
|
+
provider.configured
|
|
2082
|
+
? jsx('span', { className: 'dsx-auth__badge', children: t('auth.connected') })
|
|
2083
|
+
: null,
|
|
2084
|
+
],
|
|
2085
|
+
}),
|
|
2086
|
+
jsx('button', {
|
|
2087
|
+
type: 'button',
|
|
2088
|
+
className: 'dsx-seg',
|
|
2089
|
+
disabled: busy || (attempt !== null && attempt.active),
|
|
2090
|
+
onClick: () => refresh({ action: 'begin', key: provider.key, method: 'oauth' }),
|
|
2091
|
+
children: provider.configured ? t('auth.again') : t('auth.signIn'),
|
|
2092
|
+
}),
|
|
2093
|
+
],
|
|
2094
|
+
}, provider.key),
|
|
2095
|
+
),
|
|
2096
|
+
}),
|
|
2097
|
+
rows.length === 0 ? null : jsx('div', { className: 'dsx-auth__panel', children: rows }),
|
|
2098
|
+
attempt !== null && attempt.active
|
|
2099
|
+
? jsx('div', {
|
|
2100
|
+
className: 'dsx-setting__control',
|
|
2101
|
+
children: jsx('button', {
|
|
2102
|
+
type: 'button',
|
|
2103
|
+
className: 'dsx-seg',
|
|
2104
|
+
onClick: () => refresh({ action: 'cancel' }),
|
|
2105
|
+
children: t('auth.cancel'),
|
|
2106
|
+
}),
|
|
2107
|
+
})
|
|
2108
|
+
: null,
|
|
2109
|
+
],
|
|
2110
|
+
})
|
|
2111
|
+
}
|
|
2112
|
+
|
|
1895
2113
|
// ── Обновления ───────────────────────────────────────────────────────
|
|
1896
2114
|
//
|
|
1897
2115
|
// Маршрут поднимает host-половина пакета. Путь относительный, токен не
|
|
@@ -2100,6 +2318,13 @@ window.__ModuleLoader__.load({
|
|
|
2100
2318
|
),
|
|
2101
2319
|
)
|
|
2102
2320
|
|
|
2321
|
+
ctx.slots.inject('settings.general.item', () =>
|
|
2322
|
+
ctx.slots.register(
|
|
2323
|
+
{ name: 'settings.general.item', id: 'tensorgrid-auth', order: 10, locale: LOCALE_NS },
|
|
2324
|
+
AuthRow,
|
|
2325
|
+
),
|
|
2326
|
+
)
|
|
2327
|
+
|
|
2103
2328
|
ctx.slots.inject('settings.general.item', () =>
|
|
2104
2329
|
ctx.slots.register(
|
|
2105
2330
|
{ name: 'settings.general.item', id: 'obsidian-ion-update', order: 11, locale: LOCALE_NS },
|
package/lib/index.js
CHANGED
|
@@ -39,6 +39,64 @@ const FAVICON =
|
|
|
39
39
|
BRAND_PATHS.map((d) => `<path d="${d}"/>`).join('') +
|
|
40
40
|
'</svg>'
|
|
41
41
|
|
|
42
|
+
/**
|
|
43
|
+
* Иконка установленного приложения: знак на тёмной плашке.
|
|
44
|
+
*
|
|
45
|
+
* Отличается от фавиконки не прихотью. Фавиконка живёт на вкладке рядом с
|
|
46
|
+
* чужими и подстраивается под схему ОС; иконка приложения стоит в панели
|
|
47
|
+
* задач и меню «Пуск», где голый знак без фона выглядит потерянным.
|
|
48
|
+
*
|
|
49
|
+
* Здесь скругление рисуется прямо в SVG — в отличие от файла .ico, который
|
|
50
|
+
* проходит через конвертер и теряет прозрачность. Браузер получает SVG как
|
|
51
|
+
* есть, портить нечему.
|
|
52
|
+
*/
|
|
53
|
+
const APP_ICON = (() => {
|
|
54
|
+
const size = 1024
|
|
55
|
+
const [vx, vy, vw, vh] = BRAND_VIEWBOX.split(' ').map(Number)
|
|
56
|
+
const padding = size * 0.16
|
|
57
|
+
const scale = (size - padding * 2) / vw
|
|
58
|
+
const shiftX = padding - vx * scale
|
|
59
|
+
const shiftY = (size - vh * scale) / 2 - vy * scale
|
|
60
|
+
return (
|
|
61
|
+
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${size} ${size}" width="${size}" height="${size}" role="img" aria-label="TENSOR GRID">` +
|
|
62
|
+
`<rect width="${size}" height="${size}" rx="${Math.round(size * 0.22)}" fill="#0B0D12"/>` +
|
|
63
|
+
`<g transform="translate(${shiftX.toFixed(2)} ${shiftY.toFixed(2)}) scale(${scale.toFixed(5)})">` +
|
|
64
|
+
BRAND_PATHS.map((d) => `<path d="${d}" fill="#FFFFFF"/>`).join('') +
|
|
65
|
+
'</g></svg>'
|
|
66
|
+
)
|
|
67
|
+
})()
|
|
68
|
+
|
|
69
|
+
const APP_ICON_PATH = '/tensorgrid-icon.svg'
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Веб-манифест. Он задаёт не только иконку, но и ИМЯ установленного
|
|
73
|
+
* приложения — то, что видно в панели задач, меню «Пуск» и заголовке окна.
|
|
74
|
+
* Поставочный называет продукт чужим именем, поэтому манифест свой.
|
|
75
|
+
*
|
|
76
|
+
* Поля id, start_url, scope и display повторяют поставочные: они определяют
|
|
77
|
+
* поведение установленного приложения, и менять их значило бы чинить не то.
|
|
78
|
+
*/
|
|
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
|
+
)
|
|
99
|
+
|
|
42
100
|
export const name = 'tensorgrid-ui'
|
|
43
101
|
|
|
44
102
|
/**
|
|
@@ -63,6 +121,34 @@ export function apply(ctx) {
|
|
|
63
121
|
}),
|
|
64
122
|
)
|
|
65
123
|
|
|
124
|
+
web.effect(() =>
|
|
125
|
+
web.webServer.register({
|
|
126
|
+
kind: 'exact',
|
|
127
|
+
path: APP_ICON_PATH,
|
|
128
|
+
handler(_req, res) {
|
|
129
|
+
res.writeHead(200, {
|
|
130
|
+
'content-type': 'image/svg+xml; charset=utf-8',
|
|
131
|
+
'cache-control': 'no-cache',
|
|
132
|
+
})
|
|
133
|
+
res.end(APP_ICON)
|
|
134
|
+
},
|
|
135
|
+
}),
|
|
136
|
+
)
|
|
137
|
+
|
|
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
|
+
|
|
66
152
|
// Состояние обновлений держим в памяти: проверка ходит в сеть и в git,
|
|
67
153
|
// и дёргать её на каждый рендер настроек незачем.
|
|
68
154
|
let cached = null
|
|
@@ -73,8 +159,8 @@ export function apply(ctx) {
|
|
|
73
159
|
// модуль по адресу, и после замены файла Host продолжал бы работать по
|
|
74
160
|
// старой логике до перезапуска. Ключ меняется только когда файл
|
|
75
161
|
// действительно изменился, поэтому лишних копий в памяти не остаётся.
|
|
76
|
-
async function loadModule() {
|
|
77
|
-
const path = new URL(
|
|
162
|
+
async function loadModule(name = './update-check.js') {
|
|
163
|
+
const path = new URL(name, import.meta.url)
|
|
78
164
|
let version = ''
|
|
79
165
|
try {
|
|
80
166
|
const { statSync } = await import('node:fs')
|
|
@@ -101,6 +187,63 @@ export function apply(ctx) {
|
|
|
101
187
|
}, 8000)
|
|
102
188
|
web.effect(() => () => clearTimeout(timer))
|
|
103
189
|
|
|
190
|
+
// ── Вход по подписке ────────────────────────────────────────────────
|
|
191
|
+
//
|
|
192
|
+
// Стойка входа монтируется отдельной строкой профиля, и её может не
|
|
193
|
+
// быть. Берём мягко: без неё маршрут честно отвечает, что вход
|
|
194
|
+
// недоступен, вместо того чтобы уронить весь ряд.
|
|
195
|
+
web.effect(() =>
|
|
196
|
+
web.webServer.register({
|
|
197
|
+
kind: 'exact',
|
|
198
|
+
path: '/api/tensorgrid.auth',
|
|
199
|
+
async handler(req, res) {
|
|
200
|
+
try {
|
|
201
|
+
const authorization = web.get('authorization')
|
|
202
|
+
if (authorization === undefined) {
|
|
203
|
+
reply(res, 200, { available: false, problem: 'стойка входа не смонтирована' })
|
|
204
|
+
return
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const bridge = await loadModule('./auth-bridge.js')
|
|
208
|
+
|
|
209
|
+
if (req.method === 'GET') {
|
|
210
|
+
reply(res, 200, { available: true, ...(await bridge.listProviders(web)) })
|
|
211
|
+
return
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (req.method !== 'POST') {
|
|
215
|
+
reply(res, 405, { problem: 'поддерживаются GET и POST' })
|
|
216
|
+
return
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Тот же заслон, что у обновлений: браузер не отправит запрос с
|
|
220
|
+
// JSON-заголовком с чужой страницы без предварительной проверки.
|
|
221
|
+
if (!String(req.headers['content-type'] ?? '').includes('application/json')) {
|
|
222
|
+
reply(res, 415, { problem: 'ожидается content-type: application/json' })
|
|
223
|
+
return
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const body = await readJsonBody(req)
|
|
227
|
+
if (body === null) {
|
|
228
|
+
reply(res, 400, { problem: 'тело запроса не разобрано' })
|
|
229
|
+
return
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
let result
|
|
233
|
+
if (body.action === 'begin') result = bridge.begin(web, String(body.key), body.method)
|
|
234
|
+
else if (body.action === 'answer') result = bridge.answer(body.value)
|
|
235
|
+
else if (body.action === 'cancel') result = bridge.cancel(web, body.key)
|
|
236
|
+
else if (body.action === 'poll') result = bridge.poll()
|
|
237
|
+
else { reply(res, 400, { problem: `неизвестное действие: ${String(body.action)}` }); return }
|
|
238
|
+
|
|
239
|
+
reply(res, 200, { available: true, ...result })
|
|
240
|
+
} catch (error) {
|
|
241
|
+
reply(res, 500, { problem: String(error?.message ?? error) })
|
|
242
|
+
}
|
|
243
|
+
},
|
|
244
|
+
}),
|
|
245
|
+
)
|
|
246
|
+
|
|
104
247
|
function reply(res, code, value) {
|
|
105
248
|
const body = JSON.stringify(value)
|
|
106
249
|
res.writeHead(code, {
|
|
@@ -110,6 +253,28 @@ export function apply(ctx) {
|
|
|
110
253
|
res.end(body)
|
|
111
254
|
}
|
|
112
255
|
|
|
256
|
+
/**
|
|
257
|
+
* Читает тело запроса как JSON. Размер ограничен: тело здесь всегда
|
|
258
|
+
* крошечное — действие, ключ, ответ на вопрос, — и принимать больше
|
|
259
|
+
* значило бы держать открытой дверь без нужды.
|
|
260
|
+
*/
|
|
261
|
+
function readJsonBody(req) {
|
|
262
|
+
return new Promise((resolve) => {
|
|
263
|
+
let text = ''
|
|
264
|
+
let tooBig = false
|
|
265
|
+
req.on('data', (chunk) => {
|
|
266
|
+
if (tooBig) return
|
|
267
|
+
text += chunk
|
|
268
|
+
if (text.length > 16384) { tooBig = true; resolve(null) }
|
|
269
|
+
})
|
|
270
|
+
req.on('end', () => {
|
|
271
|
+
if (tooBig) return
|
|
272
|
+
try { resolve(JSON.parse(text === '' ? '{}' : text)) } catch { resolve(null) }
|
|
273
|
+
})
|
|
274
|
+
req.on('error', () => resolve(null))
|
|
275
|
+
})
|
|
276
|
+
}
|
|
277
|
+
|
|
113
278
|
web.effect(() =>
|
|
114
279
|
web.webServer.register({
|
|
115
280
|
kind: 'exact',
|
package/package.json
CHANGED