kingkont 0.6.2 → 0.7.1
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 +661 -123
- package/main.js +304 -4
- package/package.json +3 -1
- package/preload.js +20 -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,294 @@ 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
|
+
ipcMain.handle('settings-window:open', () => openSettingsWindow());
|
|
350
|
+
|
|
351
|
+
// ===== Updates window + npm version check =====
|
|
352
|
+
let updatesWin = null;
|
|
353
|
+
function openUpdatesWindow() {
|
|
354
|
+
if (updatesWin && !updatesWin.isDestroyed()) {
|
|
355
|
+
updatesWin.focus();
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
updatesWin = new BrowserWindow({
|
|
359
|
+
width: 480,
|
|
360
|
+
height: 380,
|
|
361
|
+
title: 'Обновления',
|
|
362
|
+
parent: win || undefined,
|
|
363
|
+
modal: false,
|
|
364
|
+
resizable: false,
|
|
365
|
+
minimizable: false,
|
|
366
|
+
maximizable: false,
|
|
367
|
+
backgroundColor: '#1a1a1a',
|
|
368
|
+
webPreferences: {
|
|
369
|
+
contextIsolation: true,
|
|
370
|
+
nodeIntegration: false,
|
|
371
|
+
preload: path.join(__dirname, 'preload.js'),
|
|
372
|
+
},
|
|
373
|
+
});
|
|
374
|
+
updatesWin.removeMenu();
|
|
375
|
+
updatesWin.loadFile(path.join(__dirname, 'updates.html'));
|
|
376
|
+
updatesWin.on('closed', () => { updatesWin = null; });
|
|
377
|
+
}
|
|
378
|
+
ipcMain.handle('updates-window:close', () => {
|
|
379
|
+
if (updatesWin && !updatesWin.isDestroyed()) updatesWin.close();
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
// Простой semver-сравнитель: возвращает true если b > a (есть более свежая).
|
|
383
|
+
// Поддерживает X.Y.Z и pre-release suffix через '-' (rc/beta пропускаем как not-newer).
|
|
384
|
+
function isNewerVersion(a, b) {
|
|
385
|
+
const pa = String(a).split('-')[0].split('.').map(n => parseInt(n, 10) || 0);
|
|
386
|
+
const pb = String(b).split('-')[0].split('.').map(n => parseInt(n, 10) || 0);
|
|
387
|
+
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
|
388
|
+
const x = pa[i] || 0, y = pb[i] || 0;
|
|
389
|
+
if (y > x) return true;
|
|
390
|
+
if (y < x) return false;
|
|
391
|
+
}
|
|
392
|
+
return false;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
async function fetchLatestVersion() {
|
|
396
|
+
// npm registry GET /<pkg>/latest → { version, ... }
|
|
397
|
+
const r = await fetch('https://registry.npmjs.org/kingkont/latest', {
|
|
398
|
+
headers: { 'Accept': 'application/json' },
|
|
399
|
+
});
|
|
400
|
+
if (!r.ok) throw new Error(`npm registry HTTP ${r.status}`);
|
|
401
|
+
const d = await r.json();
|
|
402
|
+
if (!d.version) throw new Error('registry без поля version');
|
|
403
|
+
return d.version;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
ipcMain.handle('updates:check', async () => {
|
|
407
|
+
const current = app.getVersion();
|
|
408
|
+
const latest = await fetchLatestVersion();
|
|
409
|
+
return { current, latest, isNew: isNewerVersion(current, latest) };
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
// Фоновая проверка при старте: если есть свежая — открываем окно автоматом.
|
|
413
|
+
// Чтобы не доставать юзера до открытия проекта, ждём 5 сек после createWindow.
|
|
414
|
+
async function backgroundCheckUpdates() {
|
|
415
|
+
try {
|
|
416
|
+
const current = app.getVersion();
|
|
417
|
+
const latest = await fetchLatestVersion();
|
|
418
|
+
if (isNewerVersion(current, latest)) {
|
|
419
|
+
console.log(`[updates] новая версия ${latest} (текущая ${current})`);
|
|
420
|
+
openUpdatesWindow();
|
|
421
|
+
} else {
|
|
422
|
+
console.log(`[updates] актуально (${current})`);
|
|
423
|
+
}
|
|
424
|
+
} catch (e) {
|
|
425
|
+
console.warn('[updates] check failed:', e?.message);
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
139
429
|
// ===== Recents persisted в userData/recents.json (переживает quota-reset IDB) =====
|
|
140
430
|
function recentsPath() {
|
|
141
431
|
return path.join(app.getPath('userData'), 'recents.json');
|
|
@@ -194,8 +484,9 @@ function buildMenu() {
|
|
|
194
484
|
label: 'KingKont',
|
|
195
485
|
submenu: [
|
|
196
486
|
{ label: 'О программе KingKont', role: 'about' },
|
|
487
|
+
{ label: 'Проверить обновления…', click: () => openUpdatesWindow() },
|
|
197
488
|
{ type: 'separator' },
|
|
198
|
-
{ label: 'Настройки…', accelerator: 'Cmd+,', click: () =>
|
|
489
|
+
{ label: 'Настройки…', accelerator: 'Cmd+,', click: () => openSettingsWindow() },
|
|
199
490
|
{ type: 'separator' },
|
|
200
491
|
{ role: 'services' },
|
|
201
492
|
{ type: 'separator' },
|
|
@@ -241,7 +532,8 @@ function buildMenu() {
|
|
|
241
532
|
// На Windows/Linux нужен явный «Настройки» и «Quit», т.к. App-меню нет.
|
|
242
533
|
...(isMac ? [] : [
|
|
243
534
|
{ type: 'separator' },
|
|
244
|
-
{ label: 'Настройки…', accelerator: 'CmdOrCtrl+,', click: () =>
|
|
535
|
+
{ label: 'Настройки…', accelerator: 'CmdOrCtrl+,', click: () => openSettingsWindow() },
|
|
536
|
+
{ label: 'Проверить обновления…', click: () => openUpdatesWindow() },
|
|
245
537
|
{ type: 'separator' },
|
|
246
538
|
{ role: 'quit' },
|
|
247
539
|
]),
|
|
@@ -350,11 +642,15 @@ app.whenReady().then(async () => {
|
|
|
350
642
|
// per-origin (http://localhost:PORT). Со случайным портом каждый запуск
|
|
351
643
|
// получал бы новую пустую IDB. Если порт занят — fallback на случайный, но
|
|
352
644
|
// тогда recents-handle потеряется (это меньшая беда, чем падение запуска).
|
|
645
|
+
// server.js на каждый запрос читает live-настройки через getSettings — это
|
|
646
|
+
// позволяет переключать use*-флаги через UI без перезапуска. settings.json
|
|
647
|
+
// на диске единственный источник правды; readSettings() делает свежий read.
|
|
648
|
+
const startOpts = { getSettings: () => readSettings() };
|
|
353
649
|
try {
|
|
354
|
-
port = await start(17893);
|
|
650
|
+
port = await start(17893, startOpts);
|
|
355
651
|
} catch {
|
|
356
652
|
console.warn('port 17893 busy, falling back to random');
|
|
357
|
-
try { port = await start(0); }
|
|
653
|
+
try { port = await start(0, startOpts); }
|
|
358
654
|
catch (e) {
|
|
359
655
|
console.error('Server failed to start:', e);
|
|
360
656
|
app.quit();
|
|
@@ -364,6 +660,10 @@ app.whenReady().then(async () => {
|
|
|
364
660
|
await createWindow();
|
|
365
661
|
buildMenu();
|
|
366
662
|
|
|
663
|
+
// Background check обновлений: ждём чтобы юзер увидел welcome/restore,
|
|
664
|
+
// потом тихо ходим в npm registry. Если есть свежая — показываем окно.
|
|
665
|
+
setTimeout(() => { backgroundCheckUpdates(); }, 5000);
|
|
666
|
+
|
|
367
667
|
app.on('activate', () => {
|
|
368
668
|
if (BrowserWindow.getAllWindows().length === 0) createWindow();
|
|
369
669
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kingkont",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.1",
|
|
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,26 @@ 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
|
+
openSettingsWindow: () => ipcRenderer.invoke('settings-window:open'),
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
// Проверка обновлений через npm registry (в main-процессе).
|
|
52
|
+
contextBridge.exposeInMainWorld('appUpdates', {
|
|
53
|
+
check: () => ipcRenderer.invoke('updates:check'),
|
|
54
|
+
closeWindow: () => ipcRenderer.invoke('updates-window:close'),
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
// Авторизация в Chatium через loopback OAuth-flow.
|
|
58
|
+
// login() — открывает браузер на /app/server/api/auth/start, ждёт callback на
|
|
59
|
+
// временный localhost-listener, сохраняет token в settings.chatium.
|
|
60
|
+
// logout() — best-effort revoke на сервере, очистка локального токена.
|
|
61
|
+
// status() — проверка валидности токена через /api/auth/me; авто-cleanup
|
|
62
|
+
// если 401 (токен отозван/истёк).
|
|
63
|
+
contextBridge.exposeInMainWorld('appChatium', {
|
|
64
|
+
login: () => ipcRenderer.invoke('chatium:login'),
|
|
65
|
+
logout: () => ipcRenderer.invoke('chatium:logout'),
|
|
66
|
+
status: () => ipcRenderer.invoke('chatium:status'),
|
|
47
67
|
});
|
|
48
68
|
|
|
49
69
|
contextBridge.exposeInMainWorld('claudeMd', {
|