kingkont 0.6.1 → 0.7.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/index.html +955 -193
- package/main.js +219 -4
- package/package.json +3 -1
- package/preload.js +13 -0
- package/server.js +492 -45
- package/settings.html +299 -0
package/main.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
// Electron main process: поднимает локальный сервер и открывает окно.
|
|
2
2
|
const { app, BrowserWindow, Menu, shell, ipcMain, session } = require('electron');
|
|
3
|
+
const http = require('node:http');
|
|
4
|
+
const crypto = require('node:crypto');
|
|
3
5
|
|
|
4
6
|
// Имя приложения для меню/about/Dock-tooltip — меняем как можно раньше,
|
|
5
7
|
// до старта меню и BrowserWindow.
|
|
@@ -136,6 +138,215 @@ ipcMain.handle('settings:save', (_e, partial) => {
|
|
|
136
138
|
return next;
|
|
137
139
|
});
|
|
138
140
|
|
|
141
|
+
// ===== Chatium login (loopback OAuth-style) =====
|
|
142
|
+
// Поток:
|
|
143
|
+
// 1) Renderer (settings-window) → IPC 'chatium:login'.
|
|
144
|
+
// 2) main.js поднимает временный http-listener на localhost:RANDOM.
|
|
145
|
+
// 3) Открывает в дефолтном браузере страницу /app/spaces/server
|
|
146
|
+
// с callback=http://localhost:RANDOM/cb&state=RAND.
|
|
147
|
+
// 4) Юзер логинится в Chatium и нажимает «Вернуться в приложение».
|
|
148
|
+
// 5) Сервер делает 302 на http://localhost:RANDOM/cb?token=...&state=...
|
|
149
|
+
// 6) Listener читает query, проверяет state, отвечает HTML «можно закрыть»,
|
|
150
|
+
// резолвит promise с {token, userId, appName}, сохраняет в settings.json.
|
|
151
|
+
//
|
|
152
|
+
// Таймаут 5 минут: если юзер закрыл вкладку — listener умирает, IPC отдаёт
|
|
153
|
+
// ошибку, settings-window показывает её.
|
|
154
|
+
|
|
155
|
+
const CHATIUM_BASE = 'https://kingkont.ru'; // TODO: вынести в config если будут другие инстансы
|
|
156
|
+
const CHATIUM_AUTH_TIMEOUT_MS = 5 * 60 * 1000;
|
|
157
|
+
|
|
158
|
+
let chatiumLoginInflight = null;
|
|
159
|
+
|
|
160
|
+
ipcMain.handle('chatium:login', async () => {
|
|
161
|
+
if (chatiumLoginInflight) {
|
|
162
|
+
return chatiumLoginInflight; // несколько кликов — один и тот же flow
|
|
163
|
+
}
|
|
164
|
+
chatiumLoginInflight = (async () => {
|
|
165
|
+
try {
|
|
166
|
+
const result = await runChatiumLoginFlow();
|
|
167
|
+
// Сохраняем токен в settings.json вместе с userId и appName.
|
|
168
|
+
const cur = readSettings();
|
|
169
|
+
const next = {
|
|
170
|
+
...cur,
|
|
171
|
+
chatium: {
|
|
172
|
+
token: result.token,
|
|
173
|
+
userId: result.userId,
|
|
174
|
+
appName: result.appName,
|
|
175
|
+
base: CHATIUM_BASE,
|
|
176
|
+
createdAt: Date.now(),
|
|
177
|
+
},
|
|
178
|
+
};
|
|
179
|
+
writeSettings(next);
|
|
180
|
+
return { ok: true, ...result };
|
|
181
|
+
} finally {
|
|
182
|
+
chatiumLoginInflight = null;
|
|
183
|
+
}
|
|
184
|
+
})();
|
|
185
|
+
return chatiumLoginInflight;
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
ipcMain.handle('chatium:logout', async () => {
|
|
189
|
+
const s = readSettings();
|
|
190
|
+
const token = s?.chatium?.token;
|
|
191
|
+
const base = s?.chatium?.base || CHATIUM_BASE;
|
|
192
|
+
if (token) {
|
|
193
|
+
// Best-effort revoke — даже если сервер недоступен, локально стираем.
|
|
194
|
+
try {
|
|
195
|
+
await fetch(`${base}/app/spaces/server/api/auth~revoke`, {
|
|
196
|
+
method: 'POST',
|
|
197
|
+
headers: { 'Authorization': `Bearer ${token}` },
|
|
198
|
+
});
|
|
199
|
+
} catch (e) {
|
|
200
|
+
console.warn('chatium revoke failed:', e.message);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
const next = { ...s };
|
|
204
|
+
delete next.chatium;
|
|
205
|
+
writeSettings(next);
|
|
206
|
+
return { ok: true };
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
ipcMain.handle('chatium:status', async () => {
|
|
210
|
+
const s = readSettings();
|
|
211
|
+
const token = s?.chatium?.token;
|
|
212
|
+
const base = s?.chatium?.base || CHATIUM_BASE;
|
|
213
|
+
if (!token) return { connected: false };
|
|
214
|
+
try {
|
|
215
|
+
const r = await fetch(`${base}/app/spaces/server/api/auth~me`, {
|
|
216
|
+
headers: { 'Authorization': `Bearer ${token}` },
|
|
217
|
+
});
|
|
218
|
+
if (r.ok) {
|
|
219
|
+
const me = await r.json().catch(() => ({}));
|
|
220
|
+
return { connected: true, ...me, base };
|
|
221
|
+
}
|
|
222
|
+
if (r.status === 401) {
|
|
223
|
+
// Токен протух/отозван — чистим.
|
|
224
|
+
const next = { ...s };
|
|
225
|
+
delete next.chatium;
|
|
226
|
+
writeSettings(next);
|
|
227
|
+
return { connected: false, reason: 'expired' };
|
|
228
|
+
}
|
|
229
|
+
return { connected: false, reason: `http_${r.status}` };
|
|
230
|
+
} catch (e) {
|
|
231
|
+
return { connected: false, reason: 'network_error', error: String(e?.message || e) };
|
|
232
|
+
}
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
function runChatiumLoginFlow() {
|
|
236
|
+
return new Promise((resolveOk, rejectErr) => {
|
|
237
|
+
const state = crypto.randomBytes(16).toString('hex');
|
|
238
|
+
const server = http.createServer((req, res) => {
|
|
239
|
+
const url = new URL(req.url, `http://localhost`);
|
|
240
|
+
if (url.pathname !== '/cb') {
|
|
241
|
+
res.writeHead(404); res.end('not found');
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
const token = url.searchParams.get('token');
|
|
245
|
+
const recvState = url.searchParams.get('state');
|
|
246
|
+
|
|
247
|
+
if (!token || recvState !== state) {
|
|
248
|
+
res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
249
|
+
res.end(authResultPage('error', 'Авторизация не удалась (state mismatch или токен пуст). Попробуйте снова.'));
|
|
250
|
+
cleanup();
|
|
251
|
+
rejectErr(new Error('State mismatch или token пуст'));
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Параллельно с показом страницы юзеру — запрашиваем /me чтобы вытащить
|
|
256
|
+
// userId и appName сразу, не делая дополнительный round-trip из renderer.
|
|
257
|
+
fetch(`${CHATIUM_BASE}/app/spaces/server/api/auth~me`, {
|
|
258
|
+
headers: { 'Authorization': `Bearer ${token}` },
|
|
259
|
+
})
|
|
260
|
+
.then(r => r.ok ? r.json() : null)
|
|
261
|
+
.catch(() => null)
|
|
262
|
+
.then(me => {
|
|
263
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
264
|
+
res.end(authResultPage('ok', 'Готово! Можно закрыть эту вкладку и вернуться в приложение.'));
|
|
265
|
+
cleanup();
|
|
266
|
+
resolveOk({
|
|
267
|
+
token,
|
|
268
|
+
userId: me?.userId || null,
|
|
269
|
+
appName: me?.appName || 'KingKont',
|
|
270
|
+
});
|
|
271
|
+
});
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
let timeoutId = null;
|
|
275
|
+
let cleaned = false;
|
|
276
|
+
function cleanup() {
|
|
277
|
+
if (cleaned) return;
|
|
278
|
+
cleaned = true;
|
|
279
|
+
if (timeoutId) clearTimeout(timeoutId);
|
|
280
|
+
try { server.close(); } catch {}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
server.on('error', (err) => {
|
|
284
|
+
cleanup();
|
|
285
|
+
rejectErr(err);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
server.listen(0, '127.0.0.1', () => {
|
|
289
|
+
const addr = server.address();
|
|
290
|
+
const callback = `http://localhost:${addr.port}/cb`;
|
|
291
|
+
// На стороне Chatium роут авторизации привязан к корню workspace (/),
|
|
292
|
+
// т.е. /app/spaces/server. После успеха он редиректит на наш callback с token.
|
|
293
|
+
const url = new URL(`${CHATIUM_BASE}/app/spaces/server`);
|
|
294
|
+
url.searchParams.set('callback', callback);
|
|
295
|
+
url.searchParams.set('state', state);
|
|
296
|
+
url.searchParams.set('app', 'KingKont');
|
|
297
|
+
|
|
298
|
+
shell.openExternal(url.toString()).catch((e) => {
|
|
299
|
+
cleanup();
|
|
300
|
+
rejectErr(new Error('Не удалось открыть браузер: ' + e.message));
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
timeoutId = setTimeout(() => {
|
|
304
|
+
cleanup();
|
|
305
|
+
rejectErr(new Error('Таймаут: авторизация не завершена за 5 минут'));
|
|
306
|
+
}, CHATIUM_AUTH_TIMEOUT_MS);
|
|
307
|
+
});
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function authResultPage(kind, message) {
|
|
312
|
+
const color = kind === 'ok' ? '#16a34a' : '#dc2626';
|
|
313
|
+
return `<!DOCTYPE html><html><head><meta charset="utf-8"><title>KingKont</title>
|
|
314
|
+
<style>body{font-family:-apple-system,BlinkMacSystemFont,sans-serif;max-width:520px;margin:80px auto;padding:0 20px;text-align:center;color:#222}
|
|
315
|
+
h1{color:${color}}p{color:#555;line-height:1.5}</style></head>
|
|
316
|
+
<body><h1>${kind === 'ok' ? 'Подключено' : 'Ошибка'}</h1><p>${message}</p></body></html>`;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// ===== Settings window =====
|
|
320
|
+
let settingsWin = null;
|
|
321
|
+
function openSettingsWindow() {
|
|
322
|
+
if (settingsWin && !settingsWin.isDestroyed()) {
|
|
323
|
+
settingsWin.focus();
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
settingsWin = new BrowserWindow({
|
|
327
|
+
width: 560,
|
|
328
|
+
height: 640,
|
|
329
|
+
title: 'Настройки',
|
|
330
|
+
parent: win || undefined,
|
|
331
|
+
modal: false,
|
|
332
|
+
resizable: false,
|
|
333
|
+
minimizable: false,
|
|
334
|
+
maximizable: false,
|
|
335
|
+
backgroundColor: '#1a1a1a',
|
|
336
|
+
webPreferences: {
|
|
337
|
+
contextIsolation: true,
|
|
338
|
+
nodeIntegration: false,
|
|
339
|
+
preload: path.join(__dirname, 'preload.js'),
|
|
340
|
+
},
|
|
341
|
+
});
|
|
342
|
+
settingsWin.removeMenu();
|
|
343
|
+
settingsWin.loadFile(path.join(__dirname, 'settings.html'));
|
|
344
|
+
settingsWin.on('closed', () => { settingsWin = null; });
|
|
345
|
+
}
|
|
346
|
+
ipcMain.handle('settings-window:close', () => {
|
|
347
|
+
if (settingsWin && !settingsWin.isDestroyed()) settingsWin.close();
|
|
348
|
+
});
|
|
349
|
+
|
|
139
350
|
// ===== Recents persisted в userData/recents.json (переживает quota-reset IDB) =====
|
|
140
351
|
function recentsPath() {
|
|
141
352
|
return path.join(app.getPath('userData'), 'recents.json');
|
|
@@ -195,7 +406,7 @@ function buildMenu() {
|
|
|
195
406
|
submenu: [
|
|
196
407
|
{ label: 'О программе KingKont', role: 'about' },
|
|
197
408
|
{ type: 'separator' },
|
|
198
|
-
{ label: 'Настройки…', accelerator: 'Cmd+,', click: () =>
|
|
409
|
+
{ label: 'Настройки…', accelerator: 'Cmd+,', click: () => openSettingsWindow() },
|
|
199
410
|
{ type: 'separator' },
|
|
200
411
|
{ role: 'services' },
|
|
201
412
|
{ type: 'separator' },
|
|
@@ -241,7 +452,7 @@ function buildMenu() {
|
|
|
241
452
|
// На Windows/Linux нужен явный «Настройки» и «Quit», т.к. App-меню нет.
|
|
242
453
|
...(isMac ? [] : [
|
|
243
454
|
{ type: 'separator' },
|
|
244
|
-
{ label: 'Настройки…', accelerator: 'CmdOrCtrl+,', click: () =>
|
|
455
|
+
{ label: 'Настройки…', accelerator: 'CmdOrCtrl+,', click: () => openSettingsWindow() },
|
|
245
456
|
{ type: 'separator' },
|
|
246
457
|
{ role: 'quit' },
|
|
247
458
|
]),
|
|
@@ -350,11 +561,15 @@ app.whenReady().then(async () => {
|
|
|
350
561
|
// per-origin (http://localhost:PORT). Со случайным портом каждый запуск
|
|
351
562
|
// получал бы новую пустую IDB. Если порт занят — fallback на случайный, но
|
|
352
563
|
// тогда recents-handle потеряется (это меньшая беда, чем падение запуска).
|
|
564
|
+
// server.js на каждый запрос читает live-настройки через getSettings — это
|
|
565
|
+
// позволяет переключать use*-флаги через UI без перезапуска. settings.json
|
|
566
|
+
// на диске единственный источник правды; readSettings() делает свежий read.
|
|
567
|
+
const startOpts = { getSettings: () => readSettings() };
|
|
353
568
|
try {
|
|
354
|
-
port = await start(17893);
|
|
569
|
+
port = await start(17893, startOpts);
|
|
355
570
|
} catch {
|
|
356
571
|
console.warn('port 17893 busy, falling back to random');
|
|
357
|
-
try { port = await start(0); }
|
|
572
|
+
try { port = await start(0, startOpts); }
|
|
358
573
|
catch (e) {
|
|
359
574
|
console.error('Server failed to start:', e);
|
|
360
575
|
app.quit();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kingkont",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "KingKont · Chatium — нод-редактор сцен с AI-генерацией (картинки/видео/голос/SFX/музыка/текст)",
|
|
5
5
|
"main": "main.js",
|
|
6
6
|
"bin": {
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
"preload.js",
|
|
12
12
|
"server.js",
|
|
13
13
|
"index.html",
|
|
14
|
+
"settings.html",
|
|
14
15
|
"assets/**/*",
|
|
15
16
|
"bin/**/*",
|
|
16
17
|
"scripts/**/*",
|
|
@@ -44,6 +45,7 @@
|
|
|
44
45
|
"preload.js",
|
|
45
46
|
"server.js",
|
|
46
47
|
"index.html",
|
|
48
|
+
"settings.html",
|
|
47
49
|
"assets/**/*",
|
|
48
50
|
"package.json"
|
|
49
51
|
],
|
package/preload.js
CHANGED
|
@@ -44,6 +44,19 @@ contextBridge.exposeInMainWorld('recentsStore', {
|
|
|
44
44
|
contextBridge.exposeInMainWorld('appSettings', {
|
|
45
45
|
get: () => ipcRenderer.invoke('settings:get'),
|
|
46
46
|
save: (partial) => ipcRenderer.invoke('settings:save', partial),
|
|
47
|
+
closeSettingsWindow: () => ipcRenderer.invoke('settings-window:close'),
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
// Авторизация в Chatium через loopback OAuth-flow.
|
|
51
|
+
// login() — открывает браузер на /app/server/api/auth/start, ждёт callback на
|
|
52
|
+
// временный localhost-listener, сохраняет token в settings.chatium.
|
|
53
|
+
// logout() — best-effort revoke на сервере, очистка локального токена.
|
|
54
|
+
// status() — проверка валидности токена через /api/auth/me; авто-cleanup
|
|
55
|
+
// если 401 (токен отозван/истёк).
|
|
56
|
+
contextBridge.exposeInMainWorld('appChatium', {
|
|
57
|
+
login: () => ipcRenderer.invoke('chatium:login'),
|
|
58
|
+
logout: () => ipcRenderer.invoke('chatium:logout'),
|
|
59
|
+
status: () => ipcRenderer.invoke('chatium:status'),
|
|
47
60
|
});
|
|
48
61
|
|
|
49
62
|
contextBridge.exposeInMainWorld('claudeMd', {
|