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/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: () => sendToRenderer('menu:open-settings') },
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: () => sendToRenderer('menu:open-settings') },
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.6.1",
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', {