diff --git a/CHANGELOG.md b/CHANGELOG.md index 9578c52..fda564c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,31 @@ # Changelog +## [4.0.27] - 2026-09-17 + +### Added + +- 🐦 **Чубрики прилетели на поле ввода!** Теперь на панели с сообщениями живёт твой питомец — сидит на верхней границе поля и переезжает вместе с ним. Никаких слетающих координат при смене разрешения: место посадки хранится в долях поля :) + - два режима: **приклеен к полю** (по умолчанию) и **свободный** — можно мышкой перетащить куда угодно; + - размер меняется прямо на экране — потяни за синий уголок; + - посадить в нужную точку можно за секунду: жми **F8** и кликни туда, где должен сидеть чубрик. +- 🎨 **Выбор питомца из папки** — открываешь настройки → «Чубрики» → и видишь всех своих чубриков сеткой. Кликнул на любого — он сразу на поле. +- 📤 **Загрузить своего** — кнопка «Загрузить…» принимает любую картинку. Большие фото автоматом сжимаются до 600×600, чтобы не тормозить :3 +- 🎁 **2 чубрика из коробки** — не надо ничего искать, при первом запуске уже есть с кем поиграться. +- ✂️ **Вырезать фон у GIF** — если у гифки белый (или любой другой) фон, жми кнопку «🎨 Фон», пипеткой тыкай в ненужный цвет и жми «Применить». Фон исчезнет **во всех кадрах**, анимация останется ^_^ +- 🛠 **Debug-режим** — тумблер в настройках открывает горячие клавиши: + - **F8** — прицел (поставить чубрика в точку) + - **F9** — показать рамку поля ввода + - **F10** — переключить режим «приклеен / свободный» + - **F11** — сброс размера +- 🐦 **Чубрики в Telegram-боте** — теперь питомца можно менять прямо из телеги: /settings → 🐦 Чубрики. Выбрал — и он уже в приложении бегает! +- ⏱ **Свой таймаут для скриптов** — если AI запускает длинную задачу и она падает с «превышено 60 секунд», теперь можно поставить хоть 1000. Настройки → «Агент» → «Таймаут JS-скриптов». +- 📡 **Всё меняется на лету** — что бы ты ни менял в боте (пет, фон, размер, блюр панели), в приложении это сразу видно. Никаких перезапусков :D + +### Fixed + +- 🐛 **Чубрик не возвращался после выключения** — включаешь его обратно в настройках, а он невидимка. Починили — теперь появляется на своём месте. +- 🐛 **Текст в настройках рвался по буквам** — в узкой панели подписи типа «Выбрать пета» превращались в вертикальную мешанину. Теперь всё чинно. + ## [4.0.26] - 2026-09-17 ### Added diff --git a/botsrc/i18n.js b/botsrc/i18n.js index 40139c7..c18b3f0 100644 --- a/botsrc/i18n.js +++ b/botsrc/i18n.js @@ -60,6 +60,7 @@ const STRINGS = { "settings.page.ui": "🎨 Интерфейс", "settings.page.glass": "🪟 Стекло и панель", "settings.page.agent": "🤖 Агент и приватность", + "settings.page.pets": "🐦 Чубрики", "settings.page.tg": "📡 Telegram-бот", "settings.page.lang": "🌐 Язык", @@ -69,6 +70,12 @@ const STRINGS = { "settings.glass.text": "Прозрачность, размытие и цвета панели Cookie Code.", "settings.agent.title": "🤖 Агент и приватность", "settings.agent.text": "Подтверждения, служебные сообщения, форматтеры.", + "settings.pets.title": "🐦 Чубрики (петы)", + "settings.pets.text": "Выбор спрайта и debug-режим. Файлы — в папке pets.", + "label.petEnabled": "Пет включён", + "label.petId": "Пет (спрайт)", + "label.petDebugMode": "Debug-режим пета", + "label.jsTimeoutSec": "Таймаут JS-скриптов", "settings.tg.title": "📡 Telegram-бот", "settings.tg.text": "Управление ботом и уведомлениями.", "settings.lang.title": "🌐 Язык бота", @@ -289,6 +296,7 @@ const STRINGS = { "settings.page.ui": "🎨 Interface", "settings.page.glass": "🪟 Glass & panel", "settings.page.agent": "🤖 Agent & privacy", + "settings.page.pets": "🐦 Pets", "settings.page.tg": "📡 Telegram bot", "settings.page.lang": "🌐 Language", @@ -299,6 +307,13 @@ const STRINGS = { "Transparency, blur and colors of the Cookie Code panel.", "settings.agent.title": "🤖 Agent & privacy", "settings.agent.text": "Approvals, system messages, formatters.", + "settings.pets.title": "🐦 Pets", + "settings.pets.text": + "Sprite selection and debug mode. Files live in the pets folder.", + "label.petEnabled": "Pet enabled", + "label.petId": "Pet (sprite)", + "label.petDebugMode": "Pet debug mode", + "label.jsTimeoutSec": "JS script timeout", "settings.tg.title": "📡 Telegram bot", "settings.tg.text": "Bot control and notifications.", "settings.lang.title": "🌐 Bot language", diff --git a/botsrc/index.js b/botsrc/index.js index 89f2773..a3a0239 100644 --- a/botsrc/index.js +++ b/botsrc/index.js @@ -516,6 +516,7 @@ const SETTINGS_PAGES = { { goto: "ui", labelKey: "settings.page.ui" }, { goto: "glass", labelKey: "settings.page.glass" }, { goto: "agent", labelKey: "settings.page.agent" }, + { goto: "pets", labelKey: "settings.page.pets" }, { goto: "tg", labelKey: "settings.page.tg" }, { goto: "lang", labelKey: "settings.page.lang" }, ], @@ -697,6 +698,44 @@ const SETTINGS_PAGES = { labelKey: "label.dangerousPatterns", type: "multiline", }, + { + key: "jsTimeoutSec", + labelKey: "label.jsTimeoutSec", + type: "number", + step: 10, + min: 10, + max: 1000, + unit: "s", + }, + ], + back: true, + }, + pets: { + titleKey: "settings.pets.title", + textKey: "settings.pets.text", + items: [ + { + key: "petEnabled", + labelKey: "label.petEnabled", + type: "bool", + }, + { + key: "petId", + labelKey: "label.petId", + type: "enum", + dynamicValues: () => { + const pets = _listPets(); + if (!pets.length) { + return [["", _t("common.empty")]]; + } + return pets.map((p) => [p.id, p.id]); + }, + }, + { + key: "petDebugMode", + labelKey: "label.petDebugMode", + type: "bool", + }, ], back: true, }, @@ -770,6 +809,32 @@ function _findSetting(key) { return null; } +/** + * Список спрайтов петов из /pets (или [] при ошибке). + * Возвращает [{id, label, file}]. + */ +function _listPets() { + try { + const fs = require("fs"); + const path = require("path"); + const { app } = require("electron"); + const dir = path.join(app.getPath("userData"), "pets"); + if (!fs.existsSync(dir)) return []; + const EXTS = [".png", ".gif", ".webp", ".jpg", ".jpeg"]; + return fs + .readdirSync(dir) + .filter((f) => EXTS.includes(path.extname(f).toLowerCase())) + .sort() + .map((f) => { + const ext = path.extname(f); + const base = f.slice(0, -ext.length); + return { id: base, label: base, file: path.join(dir, f) }; + }); + } catch (_) { + return []; + } +} + function _getVal(key) { try { return settingsStore.readSettings()[key]; @@ -798,10 +863,27 @@ function _label(item) { return item.label || item.key || ""; } +/** + * Получить актуальный список значений для enum. + * Если у item задан dynamicValues (функция), — вызываем её и получаем свежий + * массив [value, label]. Иначе — статический item.values. + */ +function _enumValues(item) { + if (typeof item.dynamicValues === "function") { + try { + const v = item.dynamicValues(); + if (Array.isArray(v) && v.length) return v; + } catch (err) { + _log("error", "dynamicValues error:", err.message); + } + } + return item.values || []; +} + function _formatValue(item, value) { if (item.type === "bool") return value ? _t("common.on") : _t("common.off"); if (item.type === "enum") { - const found = (item.values || []).find(([v]) => v === value); + const found = _enumValues(item).find(([v]) => v === value); return found ? found[1] : String(value); } if (item.type === "number") return String(value) + (item.unit || ""); @@ -846,7 +928,7 @@ function _renderPage(pageName) { } else if (item.type === "enum") { const cur = _formatValue(item, v); keyboard.push([{ text: lbl + ": " + cur, callback_data: "st_noop" }]); - const opts = (item.values || []).map(([val, lblVal]) => ({ + const opts = _enumValues(item).map(([val, lblVal]) => ({ text: lblVal, callback_data: "st_s_" + item.key + "__" + val, })); @@ -978,6 +1060,7 @@ async function _handleSettingsCallback(data, cbq) { try { await applySettings(); } catch (_) {} + _notifySettingsChanged({ [key]: next }); } await _showSettingsMenu(chatId, state.page, msgId); return; @@ -991,10 +1074,14 @@ async function _handleSettingsCallback(data, cbq) { await telegramBot.answerCallbackQuery(cbq.id, { text: ok ? _t("cb.saved") : _t("common.error"), }); - if (ok && key === "language") { - try { - await applySettings(); - } catch (_) {} + if (ok) { + if (key === "language") { + try { + await applySettings(); + } catch (_) {} + } + // Пуш в UI: enum мог изменить выбор пета, язык UI, режим approval и т.д. + _notifySettingsChanged({ [key]: val }); } await _showSettingsMenu(chatId, state.page, msgId); return; @@ -1020,6 +1107,7 @@ async function _handleSettingsCallback(data, cbq) { await telegramBot.answerCallbackQuery(cbq.id, { text: ok ? String(val) + (item.unit || "") : _t("common.error"), }); + if (ok) _notifySettingsChanged({ [key]: val }); await _showSettingsMenu(chatId, state.page, msgId); return; } @@ -1576,6 +1664,7 @@ async function _handleSettingsInput(chatId, text) { await applySettings(); } catch (_) {} } + if (ok) _notifySettingsChanged({ [key]: value }); await telegramBot.sendMessage( ok ? _t("common.saved", { label: _esc(_label(item)) }) @@ -2074,6 +2163,27 @@ async function notifyToolResult(toolName, ok, detail) { } /** Применить настройки: пересоздать конфиг бота и (при необходимости) запустить polling. */ +/** + * Разослать всем открытым окнам приложения событие "настройки изменились". + * Вызывается после каждого изменения settings.json через TG-бота, чтобы UI + * мгновенно подхватил изменения (перечитал настройки и применил эффекты). + * + * @param {object} [patch] объект с изменёнными ключами { key: value }; + * если не задан — окна перечитают всё целиком. + */ +function _notifySettingsChanged(patch) { + try { + const wins = windowState.getAllWindows ? windowState.getAllWindows() : []; + for (const win of wins) { + try { + if (!win || win.isDestroyed()) continue; + if (!win.webContents || win.webContents.isDestroyed()) continue; + win.webContents.send("cuckoo-settings-changed", patch || null); + } catch (_) {} + } + } catch (_) {} +} + async function applySettings() { const cfg = _read(); telegramBot.configure(cfg.token, cfg.chatId, cfg.allowedUserId); diff --git a/src/main/gif-chroma.js b/src/main/gif-chroma.js new file mode 100644 index 0000000..885e01d --- /dev/null +++ b/src/main/gif-chroma.js @@ -0,0 +1,356 @@ +/** + * GIF chroma-key: делаем выбранный цвет прозрачным во ВСЕХ кадрах анимации. + * + * Использует: + * - gifuct-js (parseGIF/decompressFrames) — разбор GIF на кадры (RGBA) + * - самописный энкодер GIF89a — сборка обратно (без нативных зависимостей) + * + * Алгоритм: + * 1. Разбираем GIF на кадры RGBA (все одного размера — canvas W×H). + * 2. Для каждого кадра: пиксели, совпадающие с targetColor → alpha=0. + * 3. Квантуем RGBA в палитру 256 цветов (median cut — упрощённый). + * 4. Резервируем индекс 0 под прозрачность. + * 5. Кодируем каждый кадр LZW и собираем GIF89a. + * + * Важно: результат — статичная или анимированная GIF с одним прозрачным цветом. + * Полупрозрачность GIF не поддерживает (только 1-битная маска alpha). + */ +const fs = require("fs"); + +/** + * Проверить, совпадает ли пиксель (r,g,b) с целевым цветом. + * @param {number} r 0..255 + * @param {number} g 0..255 + * @param {number} b 0..255 + * @param {{r:number,g:number,b:number}} target + * @param {number} tolerance 0..255 (0 = точное совпадение) + */ +function matches(r, g, b, target, tolerance) { + const dr = Math.abs(r - target.r); + const dg = Math.abs(g - target.g); + const db = Math.abs(b - target.b); + // Евклидово расстояние в RGB (быстро и достаточно) + const dist = Math.sqrt(dr * dr + dg * dg + db * db); + return dist <= tolerance; +} + +/** + * Простой квантователь: строим палитру из уникальных цветов кадров. + * Для пета (маленькая картинка) обычно < 256 уникальных цветов, поэтому + * просто собираем их в map и если больше — жёстко квантуем по 5-битным уровням. + * + * Возвращает { palette: Uint8Array(768), colorMap: Map }. + * Индекс 0 всегда резервируется под прозрачность (RGBA 0,0,0,0). + */ +function buildPalette(frames) { + const colorToIndex = new Map(); + const palette = new Uint8Array(768); // 256*3 + // Индекс 0 — прозрачный (0,0,0) + palette[0] = 0; + palette[1] = 0; + palette[2] = 0; + let next = 1; // начнём с 1, 0 уже занят прозрачным + + const keyOf = (r, g, b) => (r << 16) | (g << 8) | b; + + for (const frame of frames) { + const d = frame.pixels; + for (let i = 0; i < d.length; i += 4) { + const a = d[i + 3]; + if (a < 128) continue; // прозрачные пропускаем — они пойдут в индекс 0 + const r = d[i]; + const g = d[i + 1]; + const b = d[i + 2]; + const key = keyOf(r, g, b); + if (colorToIndex.has(key)) continue; + if (next >= 256) { + // Палитра переполнена — квантуем к 5-битным уровням + const rq = r & 0xf8; + const gq = g & 0xf8; + const bq = b & 0xf8; + const qkey = keyOf(rq, gq, bq); + if (!colorToIndex.has(qkey)) { + if (next >= 256) continue; // и так забито — используем ближайший? упрощённо пропускаем + colorToIndex.set(qkey, next); + palette[next * 3] = rq; + palette[next * 3 + 1] = gq; + palette[next * 3 + 2] = bq; + next++; + } + colorToIndex.set(key, colorToIndex.get(qkey)); + continue; + } + colorToIndex.set(key, next); + palette[next * 3] = r; + palette[next * 3 + 1] = g; + palette[next * 3 + 2] = b; + next++; + } + } + return { palette, colorToIndex }; +} + +/** + * Найти ближайший индекс в палитре для цвета (fallback при переполнении). + */ +function nearestIndex(palette, count, r, g, b) { + let best = 0; + let bestDist = Infinity; + for (let i = 0; i < count; i++) { + const pr = palette[i * 3]; + const pg = palette[i * 3 + 1]; + const pb = palette[i * 3 + 2]; + const dr = pr - r; + const dg = pg - g; + const db = pb - b; + const dist = dr * dr + dg * dg + db * db; + if (dist < bestDist) { + bestDist = dist; + best = i; + } + } + return best; +} + +/** + * Закодировать последовательность индексов в LZW (GIF-вариант). + * @param {Uint8Array} indices + * @param {number} minCodeSize обычно 8 (256 цветов) + * @returns {Uint8Array} упакованные байты с sub-block-разбивкой + */ +function lzwEncode(indices, minCodeSize) { + const clearCode = 1 << minCodeSize; + const eoiCode = clearCode + 1; + let codeSize = minCodeSize + 1; + let nextCode = eoiCode + 1; + + const dict = new Map(); + const resetDict = () => { + dict.clear(); + codeSize = minCodeSize + 1; + nextCode = eoiCode + 1; + }; + + const out = []; + let bitBuf = 0; + let bitCount = 0; + const emit = (code) => { + bitBuf |= code << bitCount; + bitCount += codeSize; + while (bitCount >= 8) { + out.push(bitBuf & 0xff); + bitBuf >>= 8; + bitCount -= 8; + } + }; + + resetDict(); + emit(clearCode); + + let prefix = indices[0]; + for (let i = 1; i < indices.length; i++) { + const k = indices[i]; + const key = (prefix << 8) | k; + if (dict.has(key)) { + prefix = dict.get(key); + } else { + emit(prefix); + if (nextCode < 4096) { + dict.set(key, nextCode++); + if (nextCode - 1 === 1 << codeSize && codeSize < 12) { + codeSize++; + } + } else { + emit(clearCode); + resetDict(); + } + prefix = k; + } + } + emit(prefix); + emit(eoiCode); + if (bitCount > 0) out.push(bitBuf & 0xff); + + // Разбиваем на sub-blocks по 255 байт + const blocks = []; + for (let i = 0; i < out.length; i += 255) { + const chunk = out.slice(i, i + 255); + blocks.push(chunk.length); + blocks.push(...chunk); + } + blocks.push(0); + return Uint8Array.from(blocks); +} + +/** + * Собрать GIF89a из кадров. + * @param {Array<{indices: Uint8Array}>} frames + * @param {Uint8Array} palette 768 байт + * @param {number} width + * @param {number} height + * @param {number} delayMs задержка между кадрами (0 = без анимации) + * @returns {Buffer} + */ +function encodeGif(frames, palette, width, height, delayMs) { + const chunks = []; + const push = (...bytes) => chunks.push(...bytes); + const pushShort = (v) => push(v & 0xff, (v >> 8) & 0xff); + + // Header + for (const ch of "GIF89a") push(ch.charCodeAt(0)); + + // Logical Screen Descriptor + pushShort(width); + pushShort(height); + // Global Color Table Flag = 1, Color Resolution = 7 (8 бит/канал), Sort = 0, + // Size = 7 (256 цветов) + push(0xf7); + push(0); // background color index + push(0); // pixel aspect ratio + // Global Color Table (768 байт) + for (let i = 0; i < 768; i++) push(palette[i]); + + // Netscape Application Extension (для цикла анимации) + if (frames.length > 1) { + push(0x21, 0xff, 0x0b); + for (const ch of "NETSCAPE2.0") push(ch.charCodeAt(0)); + push(0x03, 0x01, 0x00, 0x00, 0x00); + } + + const delayCs = Math.max(0, Math.round(delayMs / 10)); + + for (const frame of frames) { + // Graphic Control Extension (прозрачность + задержка) + push(0x21, 0xf9, 0x04); + // Disposal = 2 (restore to background), transparent flag = 1 + push(0x09); // 0b000_01_0_01 → disposal=2, userInput=0, transparent=1 + pushShort(delayCs); + push(0x00); // transparent color index = 0 + push(0x00); // block terminator + + // Image Descriptor + push(0x2c); + pushShort(0); + pushShort(0); + pushShort(width); + pushShort(height); + push(0x00); // no local color table, not interlaced + + // LZW min code size = 8 + push(0x08); + const lzw = lzwEncode(frame.indices, 8); + for (const b of lzw) push(b); + } + + // Trailer + push(0x3b); + return Buffer.from(chunks); +} + +/** + * Основная функция: сделать targetColor прозрачным во всех кадрах GIF. + * @param {string} inputPath путь к GIF + * @param {{r:number,g:number,b:number}} targetColor 0..255 + * @param {number} [tolerance=16] 0..255 + * @returns {{ success: boolean, error?: string, outputPath?: string }} + */ +function chromaKeyGif(inputPath, targetColor, tolerance = 16) { + try { + const gifuct = require("gifuct-js"); + const buf = fs.readFileSync(inputPath); + const gif = gifuct.parseGIF( + buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength), + ); + const frames = gifuct.decompressFrames(gif, true); + + if (!frames.length) { + return { success: false, error: "GIF не содержит кадров" }; + } + + const width = gif.lsd.width; + const height = gif.lsd.height; + + // Применяем chroma-key и собираем общий список кадров. + // Для каждого кадра создаём полноразмерный RGBA-буфер и накладываем patch. + const canvasFrames = []; + const fullCanvas = new Uint8ClampedArray(width * height * 4); + for (const frame of frames) { + // frame.patch — RGBA размером dims.width × dims.height + const dims = frame.dims; + const patch = frame.patch; + // Накладываем patch на полный canvas по координатам dims.left/top + for (let y = 0; y < dims.height; y++) { + for (let x = 0; x < dims.width; x++) { + const srcIdx = (y * dims.width + x) * 4; + const dstIdx = ((dims.top + y) * width + (dims.left + x)) * 4; + fullCanvas[dstIdx] = patch[srcIdx]; + fullCanvas[dstIdx + 1] = patch[srcIdx + 1]; + fullCanvas[dstIdx + 2] = patch[srcIdx + 2]; + fullCanvas[dstIdx + 3] = patch[srcIdx + 3]; + } + } + // Копия кадра + chroma-key + const copy = new Uint8ClampedArray(fullCanvas); + for (let i = 0; i < copy.length; i += 4) { + if (copy[i + 3] === 0) continue; + const r = copy[i]; + const g = copy[i + 1]; + const b = copy[i + 2]; + if (matches(r, g, b, targetColor, tolerance)) { + copy[i + 3] = 0; // прозрачный + } + } + canvasFrames.push({ pixels: copy, delay: frame.delay || 100 }); + } + + // Собираем палитру + const { palette, colorToIndex } = buildPalette(canvasFrames); + let paletteCount = 1; + for (let i = 1; i < 256; i++) { + if (palette[i * 3] || palette[i * 3 + 1] || palette[i * 3 + 2]) + paletteCount = i + 1; + } + + // Кодируем каждый кадр в индексы палитры + const indexedFrames = []; + for (const f of canvasFrames) { + const d = f.pixels; + const indices = new Uint8Array(width * height); + for (let i = 0, p = 0; i < d.length; i += 4, p++) { + const a = d[i + 3]; + if (a < 128) { + indices[p] = 0; // прозрачный + continue; + } + const r = d[i]; + const g = d[i + 1]; + const b = d[i + 2]; + const key = (r << 16) | (g << 8) | b; + let idx = colorToIndex.get(key); + if (idx == null) { + idx = nearestIndex(palette, paletteCount, r, g, b); + } + indices[p] = idx; + } + indexedFrames.push({ indices }); + } + + // Кодируем GIF и перезаписываем файл + const isAnimated = indexedFrames.length > 1; + const firstDelay = canvasFrames[0].delay || 100; + const outBuf = encodeGif( + indexedFrames, + palette, + width, + height, + isAnimated ? firstDelay : 0, + ); + fs.writeFileSync(inputPath, outBuf); + return { success: true, outputPath: inputPath }; + } catch (err) { + console.error("[Cookie Code] chromaKeyGif error:", err.message); + return { success: false, error: err.message }; + } +} + +module.exports = { chromaKeyGif }; diff --git a/src/main/index.js b/src/main/index.js index eddb212..bbc1ef5 100644 --- a/src/main/index.js +++ b/src/main/index.js @@ -2,44 +2,106 @@ * Cookie Code 主进程入口(多窗口多 profile 版) * 由项目根目录 main.js 薄壳加载。 */ -const { app, BrowserWindow, Menu, dialog } = require('electron'); -const path = require('path'); -const fs = require('fs'); +const { app, BrowserWindow, Menu, dialog } = require("electron"); +const path = require("path"); +const fs = require("fs"); // Иконка приложения (окно + системные уведомления). // build/icon.ico (Windows) и build/icon.png включаются в сборку (см. package.json → files). -const APP_ICON = path.join(__dirname, '..', '..', 'build', process.platform === 'win32' ? 'icon.ico' : 'icon.png'); - -const windowState = require('./window'); -const profileManager = require('./profile-manager'); -const { createSessionStore } = require('./session-store'); -const planMode = require('./plan-mode'); -const { getProvider } = require('../providers'); -const updater = require('./updater'); +const APP_ICON = path.join( + __dirname, + "..", + "..", + "build", + process.platform === "win32" ? "icon.ico" : "icon.png", +); + +const windowState = require("./window"); +const profileManager = require("./profile-manager"); +const { createSessionStore } = require("./session-store"); +const planMode = require("./plan-mode"); +const { getProvider } = require("../providers"); +const updater = require("./updater"); // ========== 持久化会话配置 ========== -const SESSION_DIR = process.env.CUCKOO_SESSION_DIR || 'cuckoo-ai-pro-session'; -app.setPath('userData', path.join(app.getPath('appData'), SESSION_DIR)); -console.log('[Cookie Code] Session 数据目录:', app.getPath('userData')); +const SESSION_DIR = process.env.CUCKOO_SESSION_DIR || "cuckoo-ai-pro-session"; +app.setPath("userData", path.join(app.getPath("appData"), SESSION_DIR)); +console.log("[Cookie Code] Session 数据目录:", app.getPath("userData")); // Папка для пользовательских фонов: /backgrounds // Пользователь кладёт туда картинки — они появляются в выборе фонов в настройках. -const CUSTOM_BACKGROUNDS_DIR = path.join(app.getPath('userData'), 'backgrounds'); +const CUSTOM_BACKGROUNDS_DIR = path.join( + app.getPath("userData"), + "backgrounds", +); fs.mkdirSync(CUSTOM_BACKGROUNDS_DIR, { recursive: true }); +// Папка для спрайтов петов: /pets +// Пользователь кладёт туда PNG/GIF чубриков — они появляются в выборе пета. +const CUSTOM_PETS_DIR = path.join(app.getPath("userData"), "pets"); +fs.mkdirSync(CUSTOM_PETS_DIR, { recursive: true }); + +// Нормализация спрайтов петов: всё, что больше 600×600, сжимается на месте. +// Вызывается при старте, чтобы вручную кинутые 4K-фото не тормозили рендер. +try { + require("./pet-image").normalizePetsDir(CUSTOM_PETS_DIR); +} catch (err) { + console.error("[Cookie Code] Не удалось нормализовать петов:", err.message); +} + +// Встроенные петы из src/ui/pets/ — копируем в userData при первом запуске, +// чтобы у новых юзеров "из коробки" было 2 чубрика. Существующие файлы не +// трогаем (пользователь мог заменить/сжать их вручную). +(function seedBuiltinPets() { + try { + const builtinDir = path.join(__dirname, "..", "ui", "pets"); + if (!fs.existsSync(builtinDir)) return; + const EXTS = [".png", ".gif", ".webp", ".jpg", ".jpeg"]; + const files = fs + .readdirSync(builtinDir) + .filter((f) => EXTS.includes(path.extname(f).toLowerCase())); + let copied = 0; + for (const f of files) { + const src = path.join(builtinDir, f); + const dst = path.join(CUSTOM_PETS_DIR, f); + try { + if (!fs.existsSync(dst)) { + fs.copyFileSync(src, dst); + copied++; + } + } catch (err) { + console.error( + "[Cookie Code] Не удалось скопировать встроенного пета:", + f, + err.message, + ); + } + } + if (copied > 0) { + console.log( + "[Cookie Code] Встроенные петы скопированы в", + CUSTOM_PETS_DIR, + "(" + copied + " шт.)", + ); + } + } catch (err) { + console.error("[Cookie Code] seedBuiltinPets error:", err.message); + } +})(); + // Имя приложения в системных уведомлениях Windows (иначе показывается electron.app.Electron) -app.setAppUserModelId('Cookie Code'); +app.setAppUserModelId("Cookie Code"); // 渲染进程日志输出目录(仅开发环境持久化;打包版不写日志文件) const RENDERER_LOG_DIR = app.isPackaged ? null - : path.join(app.getPath('userData'), 'wyp', 'log'); + : path.join(app.getPath("userData"), "wyp", "log"); if (RENDERER_LOG_DIR) { fs.mkdirSync(RENDERER_LOG_DIR, { recursive: true }); } -const { registerIpcHandlers } = require('./ipc'); -const whatsNew = require('./whats-new'); +const { registerIpcHandlers } = require("./ipc"); +const whatsNew = require("./whats-new"); // Whats-new: проверить факт обновления ДО создания окна. // Внутри — атомарное сохранение новой lastSeenVersion, чтобы повторный @@ -52,12 +114,14 @@ const sessionsToFlush = new Set(); async function flushAllSessions() { const promises = []; for (const ses of sessionsToFlush) { - promises.push(ses.flushStorageData().catch(err => { - console.error('[Cookie Code] 刷新 session 失败:', err.message); - })); + promises.push( + ses.flushStorageData().catch((err) => { + console.error("[Cookie Code] 刷新 session 失败:", err.message); + }), + ); } await Promise.all(promises); - console.log('[Cookie Code] 全部 session 数据已刷新到磁盘'); + console.log("[Cookie Code] 全部 session 数据已刷新到磁盘"); } /** @@ -69,23 +133,27 @@ function createWindow(profile) { // Всегда DeepSeek — жёстко, независимо от providerId профиля. // Выбор платформы полностью отключён (см. запрос пользователя). // partition остаётся профильным, чтобы не потерять cookies/сессии. - const provider = getProvider('deepseek'); - const storeDir = app.getPath('userData'); - const sessionStore = createSessionStore(profileData.id, storeDir, windowState); + const provider = getProvider("deepseek"); + const storeDir = app.getPath("userData"); + const sessionStore = createSessionStore( + profileData.id, + storeDir, + windowState, + ); const mainWindow = new BrowserWindow({ width: 1280, height: 900, - title: 'Cookie Code Pro - ' + provider.name + ' - ' + profileData.name, + title: "Cookie Code Pro - " + provider.name + " - " + profileData.name, icon: APP_ICON, webPreferences: { - preload: path.join(__dirname, '..', '..', 'preload.js'), + preload: path.join(__dirname, "..", "..", "preload.js"), contextIsolation: true, nodeIntegration: false, sandbox: false, partition: profileData.partition, // 每个 profile 独立持久化 session backgroundThrottling: false, - additionalArguments: ['--cuckoo-user-data=' + app.getPath('userData')], + additionalArguments: ["--cuckoo-user-data=" + app.getPath("userData")], }, }); @@ -93,7 +161,7 @@ function createWindow(profile) { const winSession = mainWindow.webContents.session; // Регистрируем контекст окна — providerId жёстко 'deepseek'. - windowState.addWindow(mainWindow, profileData.id, 'deepseek', sessionStore); + windowState.addWindow(mainWindow, profileData.id, "deepseek", sessionStore); sessionsToFlush.add(winSession); // 更新主窗口引用 @@ -105,21 +173,28 @@ function createWindow(profile) { } // 转发渲染进程的 console.log 到主进程,并按平台写入独立日志文件 - mainWindow.webContents.on('console-message', (_event, level, message, _line, _sourceId) => { - console.log('[Renderer Console][' + profileData.name + ']', message); - - // 打包版不进行日志持久化 - if (!RENDERER_LOG_DIR) return; - - // 根据当前窗口上下文确定 providerId,未确定用 default - let providerId = profileData.providerId || 'default'; - const ctx = windowState.getContextByWebContents(mainWindow.webContents); - if (ctx && ctx.providerId) providerId = ctx.providerId; - - const logFile = path.join(RENDERER_LOG_DIR, providerId + '.log'); - const timeIso = new Date().toISOString(); - fs.appendFileSync(logFile, '[' + timeIso + '][' + profileData.name + '] ' + message + '\n', 'utf-8'); - }); + mainWindow.webContents.on( + "console-message", + (_event, level, message, _line, _sourceId) => { + console.log("[Renderer Console][" + profileData.name + "]", message); + + // 打包版不进行日志持久化 + if (!RENDERER_LOG_DIR) return; + + // 根据当前窗口上下文确定 providerId,未确定用 default + let providerId = profileData.providerId || "default"; + const ctx = windowState.getContextByWebContents(mainWindow.webContents); + if (ctx && ctx.providerId) providerId = ctx.providerId; + + const logFile = path.join(RENDERER_LOG_DIR, providerId + ".log"); + const timeIso = new Date().toISOString(); + fs.appendFileSync( + logFile, + "[" + timeIso + "][" + profileData.name + "] " + message + "\n", + "utf-8", + ); + }, + ); mainWindow.maximize(); @@ -127,7 +202,7 @@ function createWindow(profile) { // 1. 不带 Electron 标识,避免 DeepSeek 识别为第三方客户端 // 2. 与内核版本一致,避免 Google OAuth 因 UA/sec-ch-ua 不一致报“浏览器不安全” const userAgent = - 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36'; + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36"; mainWindow.webContents.setUserAgent(userAgent); // Всегда открываем homeUrl провайдера (DeepSeek). Выбор платформы отключён. @@ -139,16 +214,20 @@ function createWindow(profile) { const injectTokenInterceptor = () => { if (!mainWindow || mainWindow.isDestroyed()) return; try { - const { buildTokenInterceptorScript } = require('./token-interceptor-inject'); - mainWindow.webContents.executeJavaScript(buildTokenInterceptorScript(), true).catch(() => {}); + const { + buildTokenInterceptorScript, + } = require("./token-interceptor-inject"); + mainWindow.webContents + .executeJavaScript(buildTokenInterceptorScript(), true) + .catch(() => {}); } catch (_) {} }; - mainWindow.webContents.on('dom-ready', injectTokenInterceptor); + mainWindow.webContents.on("dom-ready", injectTokenInterceptor); - mainWindow.webContents.on('did-finish-load', () => { + mainWindow.webContents.on("did-finish-load", () => { if (mainWindow && !mainWindow.isDestroyed()) { injectTokenInterceptor(); - mainWindow.webContents.send('page-loaded'); + mainWindow.webContents.send("page-loaded"); sessionStore.tryRestoreSessionFromUrl(mainWindow); // Whats-new: отправить в окно один раз, если был апдейт. @@ -158,7 +237,7 @@ function createWindow(profile) { const pending = whatsNew.consumePendingForWindow(); if (pending) { try { - mainWindow.webContents.send('whats-new-show', pending); + mainWindow.webContents.send("whats-new-show", pending); } catch (_) {} } } @@ -169,26 +248,31 @@ function createWindow(profile) { if (!mainWindow || mainWindow.isDestroyed()) return; const sid = sessionStore.state.currentSessionId || null; const enabled = planMode.isPlanMode(mainWindow.webContents.id, sid); - try { mainWindow.webContents.send('plan-mode-changed', { enabled, sessionId: sid }); } catch (_) {} + try { + mainWindow.webContents.send("plan-mode-changed", { + enabled, + sessionId: sid, + }); + } catch (_) {} }; - mainWindow.webContents.on('did-navigate', (_event, url) => { + mainWindow.webContents.on("did-navigate", (_event, url) => { sessionStore.handleUrlChange(url, mainWindow); notifyPlanMode(); }); - mainWindow.webContents.on('did-navigate-in-page', (_event, url) => { + mainWindow.webContents.on("did-navigate-in-page", (_event, url) => { sessionStore.handleUrlChange(url, mainWindow); notifyPlanMode(); }); - mainWindow.webContents.on('before-input-event', (_event, input) => { - if (input.key === 'F12') { + mainWindow.webContents.on("before-input-event", (_event, input) => { + if (input.key === "F12") { mainWindow.webContents.toggleDevTools(); } }); - mainWindow.on('closed', () => { + mainWindow.on("closed", () => { sessionsToFlush.delete(winSession); windowState.removeWindow(mainWindow.id); }); @@ -198,117 +282,123 @@ function createWindow(profile) { function setupAppMenu() { const template = [ { - label: '文件', + label: "文件", submenu: [ { - label: '新建窗口', - accelerator: 'CmdOrCtrl+N', + label: "新建窗口", + accelerator: "CmdOrCtrl+N", click: () => { const profiles = profileManager.readProfiles(); - createWindow(profileManager.createProfile('窗口' + (profiles.length + 1), '')); - } + createWindow( + profileManager.createProfile("窗口" + (profiles.length + 1), ""), + ); + }, }, - { type: 'separator' }, - { role: 'quit', label: '退出' } - ] + { type: "separator" }, + { role: "quit", label: "退出" }, + ], }, { - label: '编辑', + label: "编辑", submenu: [ - { role: 'undo', label: '撤销' }, - { role: 'redo', label: '重做' }, - { type: 'separator' }, - { role: 'cut', label: '剪切' }, - { role: 'copy', label: '复制' }, - { role: 'paste', label: '粘贴' }, - { role: 'delete', label: '删除' }, - { type: 'separator' }, - { role: 'selectAll', label: '全选' } - ] + { role: "undo", label: "撤销" }, + { role: "redo", label: "重做" }, + { type: "separator" }, + { role: "cut", label: "剪切" }, + { role: "copy", label: "复制" }, + { role: "paste", label: "粘贴" }, + { role: "delete", label: "删除" }, + { type: "separator" }, + { role: "selectAll", label: "全选" }, + ], }, { - label: '导航', + label: "导航", submenu: [ { - label: '后退', - accelerator: 'Alt+Left', + label: "后退", + accelerator: "Alt+Left", click: (_item, focusedWindow) => { - if (focusedWindow) focusedWindow.webContents.navigationHistory.goBack(); - } + if (focusedWindow) + focusedWindow.webContents.navigationHistory.goBack(); + }, }, { - label: '前进', - accelerator: 'Alt+Right', + label: "前进", + accelerator: "Alt+Right", click: (_item, focusedWindow) => { - if (focusedWindow) focusedWindow.webContents.navigationHistory.goForward(); - } + if (focusedWindow) + focusedWindow.webContents.navigationHistory.goForward(); + }, }, - { type: 'separator' }, + { type: "separator" }, { - label: '重新加载', - accelerator: 'CmdOrCtrl+R', + label: "重新加载", + accelerator: "CmdOrCtrl+R", click: (_item, focusedWindow) => { if (focusedWindow) focusedWindow.reload(); - } + }, }, { - label: '停止加载', - accelerator: 'Esc', + label: "停止加载", + accelerator: "Esc", click: (_item, focusedWindow) => { if (focusedWindow) focusedWindow.webContents.stop(); - } + }, }, - { type: 'separator' }, + { type: "separator" }, { - label: '主页', + label: "主页", click: (_item, focusedWindow) => { if (focusedWindow) { - const ctx = windowState.getContextByWebContents(focusedWindow.webContents); + const ctx = windowState.getContextByWebContents( + focusedWindow.webContents, + ); if (ctx && ctx.providerId) { const provider = getProvider(ctx.providerId); if (provider) focusedWindow.loadURL(provider.homeUrl); } } - } - } - ] + }, + }, + ], }, { - label: '查看', + label: "查看", submenu: [ - { role: 'resetZoom', label: '重置缩放' }, - { role: 'zoomIn', label: '放大' }, - { role: 'zoomOut', label: '缩小' }, - { type: 'separator' }, - { role: 'togglefullscreen', label: '切换全屏' }, - { type: 'separator' }, - { role: 'toggleDevTools', label: '开发者工具' } - ] + { role: "resetZoom", label: "重置缩放" }, + { role: "zoomIn", label: "放大" }, + { role: "zoomOut", label: "缩小" }, + { type: "separator" }, + { role: "togglefullscreen", label: "切换全屏" }, + { type: "separator" }, + { role: "toggleDevTools", label: "开发者工具" }, + ], }, { - label: '窗口', + label: "窗口", submenu: [ - { role: 'minimize', label: '最小化' }, - { role: 'zoom', label: '缩放' }, - { type: 'separator' }, - { role: 'front', label: '全部置于顶层' }, - { type: 'separator' }, - { role: 'close', label: '关闭窗口' } - ] + { role: "minimize", label: "最小化" }, + { role: "zoom", label: "缩放" }, + { type: "separator" }, + { role: "front", label: "全部置于顶层" }, + { type: "separator" }, + { role: "close", label: "关闭窗口" }, + ], }, { - label: '帮助', + label: "帮助", submenu: [ { - label: '检查更新', + label: "检查更新", click: () => { updater.checkForUpdates(); - } + }, }, - { type: 'separator' }, - { role: 'about', label: '关于 Cookie Code' } - ] - } + { type: "separator" }, + { role: "about", label: "关于 Cookie Code" }, + ], + }, ]; const menu = Menu.buildFromTemplate(template); Menu.setApplicationMenu(menu); @@ -319,48 +409,56 @@ registerIpcHandlers(); // ========== Telegram-бот (botsrc/) — применяем настройки при старте ========== try { - require('../../botsrc').applySettings().then((st) => { - console.log('[Cookie Code] Telegram bot status:', JSON.stringify(st)); - }).catch((err) => { - console.error('[Cookie Code] Telegram bot start error:', err.message); - }); + require("../../botsrc") + .applySettings() + .then((st) => { + console.log("[Cookie Code] Telegram bot status:", JSON.stringify(st)); + }) + .catch((err) => { + console.error("[Cookie Code] Telegram bot start error:", err.message); + }); } catch (err) { - console.error('[Cookie Code] Telegram bot init error:', err.message); + console.error("[Cookie Code] Telegram bot init error:", err.message); } // 覆盖层"新建窗口"按钮触发 -const { ipcMain: ipcMainForProfile } = require('electron'); -ipcMainForProfile.handle('create-profile-window', async (_event, { providerId } = {}) => { - const profiles = profileManager.readProfiles(); - // Выбор платформы отключён: новые окна всегда открывают DeepSeek. - // providerId из аргумента игнорируем (кроме случая явного «deepseek» для совместимости). - const pid = 'deepseek'; - createWindow(profileManager.createProfile('窗口' + (profiles.length + 1), pid)); - return { success: true }; -}); +const { ipcMain: ipcMainForProfile } = require("electron"); +ipcMainForProfile.handle( + "create-profile-window", + async (_event, { providerId } = {}) => { + const profiles = profileManager.readProfiles(); + // Выбор платформы отключён: новые окна всегда открывают DeepSeek. + // providerId из аргумента игнорируем (кроме случая явного «deepseek» для совместимости). + const pid = "deepseek"; + createWindow( + profileManager.createProfile("窗口" + (profiles.length + 1), pid), + ); + return { success: true }; + }, +); // 列出所有 profiles -ipcMainForProfile.handle('list-profiles', async () => { +ipcMainForProfile.handle("list-profiles", async () => { return { success: true, profiles: profileManager.readProfiles() }; }); // 删除指定 profile(会关闭其窗口) -ipcMainForProfile.handle('delete-profile', async (_event, { profileId }) => { - if (!profileId) return { success: false, error: '缺少窗口ID' }; +ipcMainForProfile.handle("delete-profile", async (_event, { profileId }) => { + if (!profileId) return { success: false, error: "缺少窗口ID" }; const ctx = windowState.getWindowByProfileId(profileId); if (ctx && ctx.win && !ctx.win.isDestroyed()) { ctx.win.close(); } const ok = profileManager.deleteProfile(profileId); - return { success: ok, error: ok ? null : '窗口不存在' }; + return { success: ok, error: ok ? null : "窗口不存在" }; }); // 列出所有内置平台 -ipcMainForProfile.handle('list-providers', async () => { - const { getAllProviders } = require('../providers'); +ipcMainForProfile.handle("list-providers", async () => { + const { getAllProviders } = require("../providers"); return { success: true, - providers: getAllProviders().map(p => ({ + providers: getAllProviders().map((p) => ({ id: p.id, name: p.name, custom: !!p._customPath, @@ -370,102 +468,132 @@ ipcMainForProfile.handle('list-providers', async () => { }); // 导入自定义 Provider(弹文件选择框,复制到 userData,并处理重名) -ipcMainForProfile.handle('import-provider', async (event, { replace = false } = {}) => { - const win = windowState.getMainWindow(); - const result = dialog.showOpenDialogSync(win, { - properties: ['openFile'], - filters: [{ name: 'JavaScript', extensions: ['js'] }], - title: '选择自定义 Provider 文件', - }); - if (!result || result.length === 0) { - return { success: false, canceled: true }; - } +ipcMainForProfile.handle( + "import-provider", + async (event, { replace = false } = {}) => { + const win = windowState.getMainWindow(); + const result = dialog.showOpenDialogSync(win, { + properties: ["openFile"], + filters: [{ name: "JavaScript", extensions: ["js"] }], + title: "选择自定义 Provider 文件", + }); + if (!result || result.length === 0) { + return { success: false, canceled: true }; + } - const filePath = result[0]; - const { importCustomProvider } = require('../providers/custom/loader'); - try { - const res = importCustomProvider(filePath, { replace }); - if (res.exists && !replace) { - // 同名 provider 已存在,询问是否替换 - const confirmRes = await dialog.showMessageBox(win, { - type: 'question', - buttons: ['取消', '替换'], - defaultId: 0, - cancelId: 0, - title: 'Provider 已存在', - message: '已导入过 id 为 "' + res.provider.id + '" 的 Provider,是否替换?', - }); - if (confirmRes.response !== 1) { - return { success: false, canceled: true }; + const filePath = result[0]; + const { importCustomProvider } = require("../providers/custom/loader"); + try { + const res = importCustomProvider(filePath, { replace }); + if (res.exists && !replace) { + // 同名 provider 已存在,询问是否替换 + const confirmRes = await dialog.showMessageBox(win, { + type: "question", + buttons: ["取消", "替换"], + defaultId: 0, + cancelId: 0, + title: "Provider 已存在", + message: + '已导入过 id 为 "' + res.provider.id + '" 的 Provider,是否替换?', + }); + if (confirmRes.response !== 1) { + return { success: false, canceled: true }; + } + // 用户确认替换,重新导入 + const finalRes = importCustomProvider(filePath, { replace: true }); + return { + success: true, + provider: { + id: finalRes.provider.id, + name: finalRes.provider.name, + path: finalRes.targetPath, + }, + }; } - // 用户确认替换,重新导入 - const finalRes = importCustomProvider(filePath, { replace: true }); - return { success: true, provider: { id: finalRes.provider.id, name: finalRes.provider.name, path: finalRes.targetPath } }; + return { + success: true, + provider: { + id: res.provider.id, + name: res.provider.name, + path: res.targetPath, + }, + }; + } catch (err) { + return { success: false, error: "加载失败: " + err.message }; } - return { success: true, provider: { id: res.provider.id, name: res.provider.name, path: res.targetPath } }; - } catch (err) { - return { success: false, error: '加载失败: ' + err.message }; - } -}); + }, +); // 删除自定义 Provider(先检查是否有窗口在使用) -ipcMainForProfile.handle('remove-provider', async (_event, { path: filePath, providerId }) => { - if (!filePath) return { success: false, error: '缺少文件路径' }; - - // 检查是否有窗口正在使用该 provider - const usingContexts = windowState.getAllContexts().filter( - (ctx) => ctx.providerId === providerId - ); - - if (usingContexts.length > 0) { - const profileNames = usingContexts - .map((ctx) => { - const profile = profileManager.getProfileById(ctx.profileId); - return profile ? profile.name : ctx.profileId; - }) - .join('、'); - return { - success: false, - error: '以下窗口正在使用此 Provider,请先在窗口管理中更换这些窗口的平台再删除:' + profileNames, - }; - } +ipcMainForProfile.handle( + "remove-provider", + async (_event, { path: filePath, providerId }) => { + if (!filePath) return { success: false, error: "缺少文件路径" }; + + // 检查是否有窗口正在使用该 provider + const usingContexts = windowState + .getAllContexts() + .filter((ctx) => ctx.providerId === providerId); + + if (usingContexts.length > 0) { + const profileNames = usingContexts + .map((ctx) => { + const profile = profileManager.getProfileById(ctx.profileId); + return profile ? profile.name : ctx.profileId; + }) + .join("、"); + return { + success: false, + error: + "以下窗口正在使用此 Provider,请先在窗口管理中更换这些窗口的平台再删除:" + + profileNames, + }; + } - const { removeCustomProviderPath } = require('../providers/custom/loader'); - removeCustomProviderPath(filePath); - return { success: true }; -}); + const { removeCustomProviderPath } = require("../providers/custom/loader"); + removeCustomProviderPath(filePath); + return { success: true }; + }, +); // 替换自定义 Provider(弹文件选择框,校验 id 一致后覆盖) -ipcMainForProfile.handle('replace-provider', async (event, { providerId }) => { - if (!providerId) return { success: false, error: '缺少 providerId' }; +ipcMainForProfile.handle("replace-provider", async (event, { providerId }) => { + if (!providerId) return { success: false, error: "缺少 providerId" }; const win = windowState.getMainWindow(); const result = dialog.showOpenDialogSync(win, { - properties: ['openFile'], - filters: [{ name: 'JavaScript', extensions: ['js'] }], - title: '选择新的 Provider 文件(id 必须为 ' + providerId + ')', + properties: ["openFile"], + filters: [{ name: "JavaScript", extensions: ["js"] }], + title: "选择新的 Provider 文件(id 必须为 " + providerId + ")", }); if (!result || result.length === 0) { return { success: false, canceled: true }; } const filePath = result[0]; - const { replaceCustomProvider } = require('../providers/custom/loader'); + const { replaceCustomProvider } = require("../providers/custom/loader"); try { const res = replaceCustomProvider(providerId, filePath); - return { success: true, provider: { id: res.provider.id, name: res.provider.name, path: res.targetPath } }; + return { + success: true, + provider: { + id: res.provider.id, + name: res.provider.name, + path: res.targetPath, + }, + }; } catch (err) { - return { success: false, error: '替换失败: ' + err.message }; + return { success: false, error: "替换失败: " + err.message }; } }); // 用户在平台选择页选择平台后,绑定 profile 并加载平台首页 -ipcMainForProfile.handle('select-platform', async (event, { providerId }) => { - if (!providerId) return { success: false, error: '缺少平台ID' }; +ipcMainForProfile.handle("select-platform", async (event, { providerId }) => { + if (!providerId) return { success: false, error: "缺少平台ID" }; const ctx = windowState.getContextByWebContents(event.sender); - if (!ctx) return { success: false, error: '窗口上下文不存在' }; + if (!ctx) return { success: false, error: "窗口上下文不存在" }; const provider = getProvider(providerId); - if (!provider) return { success: false, error: '平台不存在: ' + providerId }; + if (!provider) return { success: false, error: "平台不存在: " + providerId }; // 更新该窗口 profile 的 providerId 和 partition profileManager.updateProfileProvider(ctx.profileId, providerId); @@ -481,83 +609,103 @@ ipcMainForProfile.handle('select-platform', async (event, { providerId }) => { }); // 打开指定 profile 的窗口(若已存在则聚焦) -ipcMainForProfile.handle('open-profile-window', async (_event, { profileId }) => { - const existing = windowState.getWindowByProfileId(profileId); - if (existing && existing.win && !existing.win.isDestroyed()) { - const win = existing.win; - if (win.isMinimized()) win.restore(); - win.focus(); - return { success: true, focused: true }; - } - const profile = profileManager.getProfileById(profileId); - if (!profile) return { success: false, error: '窗口不存在' }; - createWindow(profile); - return { success: true, focused: false }; -}); +ipcMainForProfile.handle( + "open-profile-window", + async (_event, { profileId }) => { + const existing = windowState.getWindowByProfileId(profileId); + if (existing && existing.win && !existing.win.isDestroyed()) { + const win = existing.win; + if (win.isMinimized()) win.restore(); + win.focus(); + return { success: true, focused: true }; + } + const profile = profileManager.getProfileById(profileId); + if (!profile) return { success: false, error: "窗口不存在" }; + createWindow(profile); + return { success: true, focused: false }; + }, +); // 更新窗口名称(提取到 DeepSeek 用户信息后) -ipcMainForProfile.handle('update-window-name', async (event, { displayName }) => { - if (!displayName || !displayName.trim()) return { success: false }; - const ctx = windowState.getContextByWebContents(event.sender); - if (!ctx) return { success: false, error: '窗口上下文不存在' }; - const updated = profileManager.updateProfileName(ctx.profileId, displayName); - if (updated && ctx.win && !ctx.win.isDestroyed()) { - ctx.win.setTitle('Cookie Code Pro - ' + updated.name); - } - return { success: !!updated, name: updated ? updated.name : null }; -}); +ipcMainForProfile.handle( + "update-window-name", + async (event, { displayName }) => { + if (!displayName || !displayName.trim()) return { success: false }; + const ctx = windowState.getContextByWebContents(event.sender); + if (!ctx) return { success: false, error: "窗口上下文不存在" }; + const updated = profileManager.updateProfileName( + ctx.profileId, + displayName, + ); + if (updated && ctx.win && !ctx.win.isDestroyed()) { + ctx.win.setTitle("Cookie Code Pro - " + updated.name); + } + return { success: !!updated, name: updated ? updated.name : null }; + }, +); // ========== MCP 相关 IPC ========== -const mcpConfig = require('./mcp-config'); -const mcpClient = require('./mcp-client'); +const mcpConfig = require("./mcp-config"); +const mcpClient = require("./mcp-client"); // 列出所有 MCP server(含启用状态) -ipcMainForProfile.handle('list-mcp-servers', async () => { +ipcMainForProfile.handle("list-mcp-servers", async () => { const servers = mcpConfig.getServers(); - const connected = new Set(mcpClient.getConnectedServers().map(s => s.name)); - console.log('[MCP DEBUG] servers:', JSON.stringify(servers.map(s => ({ name: s.name, enabled: s.enabled })))); - console.log('[MCP DEBUG] connected:', JSON.stringify(Array.from(connected))); - return { success: true, servers: servers.map(s => ({ ...s, connected: connected.has(s.name) })) }; + const connected = new Set(mcpClient.getConnectedServers().map((s) => s.name)); + console.log( + "[MCP DEBUG] servers:", + JSON.stringify(servers.map((s) => ({ name: s.name, enabled: s.enabled }))), + ); + console.log("[MCP DEBUG] connected:", JSON.stringify(Array.from(connected))); + return { + success: true, + servers: servers.map((s) => ({ ...s, connected: connected.has(s.name) })), + }; }); // 添加或更新 MCP server 配置 -ipcMainForProfile.handle('upsert-mcp-server', async (_event, { server }) => { +ipcMainForProfile.handle("upsert-mcp-server", async (_event, { server }) => { if (!server || !server.name || !server.type) { - return { success: false, error: 'server 配置不完整(需要 name 和 type)' }; + return { success: false, error: "server 配置不完整(需要 name 和 type)" }; } mcpConfig.upsertServer(server); return { success: true }; }); // 删除 MCP server -ipcMainForProfile.handle('remove-mcp-server', async (_event, { name }) => { +ipcMainForProfile.handle("remove-mcp-server", async (_event, { name }) => { await mcpClient.disconnectServerByName(name); mcpConfig.removeServer(name); return { success: true }; }); // 启用 MCP server(连接并拉取工具) -ipcMainForProfile.handle('enable-mcp-server', async (_event, { name }) => { +ipcMainForProfile.handle("enable-mcp-server", async (_event, { name }) => { try { mcpConfig.setServerEnabled(name, true); await mcpClient.connectServerByName(name); - console.log('[MCP DEBUG] enable 完成, connections:', JSON.stringify(Array.from(mcpClient.getConnectedServers().map(s => s.name)))); + console.log( + "[MCP DEBUG] enable 完成, connections:", + JSON.stringify( + Array.from(mcpClient.getConnectedServers().map((s) => s.name)), + ), + ); return { success: true }; } catch (err) { - console.error('[MCP DEBUG] enable 失败:', err); + console.error("[MCP DEBUG] enable 失败:", err); return { success: false, error: err.message }; } }); // 禁用 MCP server(断开连接) -ipcMainForProfile.handle('disable-mcp-server', async (_event, { name }) => { +ipcMainForProfile.handle("disable-mcp-server", async (_event, { name }) => { mcpConfig.setServerEnabled(name, false); await mcpClient.disconnectServerByName(name); return { success: true }; }); // 获取已启用 server 的工具列表(用于注入提示词) -ipcMainForProfile.handle('get-mcp-tools', async () => { +ipcMainForProfile.handle("get-mcp-tools", async () => { return { success: true, tools: mcpClient.getMcpToolList() }; }); @@ -566,7 +714,7 @@ const gotSingleInstanceLock = app.requestSingleInstanceLock(); if (!gotSingleInstanceLock) { app.quit(); } else { - app.on('second-instance', () => { + app.on("second-instance", () => { const mainWindow = windowState.getMainWindow(); if (mainWindow && !mainWindow.isDestroyed()) { if (mainWindow.isMinimized()) mainWindow.restore(); @@ -580,19 +728,19 @@ if (!gotSingleInstanceLock) { createWindow(null); // 后台连接已启用的 MCP server,不阻塞窗口创建 - mcpClient.connectEnabledServers().catch(err => { - console.error('[MCP] 初始化连接失败:', err.message); + mcpClient.connectEnabledServers().catch((err) => { + console.error("[MCP] 初始化连接失败:", err.message); }); }); } -app.on('window-all-closed', () => { +app.on("window-all-closed", () => { app.quit(); }); // 退出前刷新所有 session 数据 let quitFlushed = false; -app.on('before-quit', (event) => { +app.on("before-quit", (event) => { if (quitFlushed) return; event.preventDefault(); quitFlushed = true; @@ -601,7 +749,7 @@ app.on('before-quit', (event) => { }); }); -app.on('activate', () => { +app.on("activate", () => { if (windowState.getAllWindows().length === 0) { createWindow(null); } diff --git a/src/main/ipc.js b/src/main/ipc.js index 6211bd5..309c94b 100644 --- a/src/main/ipc.js +++ b/src/main/ipc.js @@ -1190,6 +1190,154 @@ function registerIpcHandlers() { } }); + // ========== Спрайты петов (userData/pets) ========== + // Папка: /pets — пользователь кладёт туда PNG/GIF чубриков. + const PET_EXT = [".webp", ".jpg", ".jpeg", ".png", ".gif"]; + const getPetsDir = () => path.join(app.getPath("userData"), "pets"); + + ipcMain.handle("cuckoo-pets-list", async () => { + try { + const fs = require("fs"); + const dir = getPetsDir(); + fs.mkdirSync(dir, { recursive: true }); + const files = fs.readdirSync(dir).filter((f) => { + return PET_EXT.includes(path.extname(f).toLowerCase()); + }); + const list = files.map((f) => { + const ext = path.extname(f); + const base = f.slice(0, -ext.length); + return { + id: base, + label: base, + file: path.join(dir, f), + }; + }); + return { success: true, dir, pets: list }; + } catch (err) { + console.error("[Cookie Code] 读取 петов失败:", err.message); + return { + success: false, + error: err.message, + dir: getPetsDir(), + pets: [], + }; + } + }); + + // Открыть папку со спрайтами петов в системном проводнике. + ipcMain.handle("cuckoo-pets-open-folder", async () => { + try { + const fs = require("fs"); + const dir = getPetsDir(); + fs.mkdirSync(dir, { recursive: true }); + const errMsg = await shell.openPath(dir); + if (errMsg) return { success: false, error: errMsg }; + return { success: true, path: dir }; + } catch (err) { + console.error("[Cookie Code] 打开 папку петов失败:", err.message); + return { success: false, error: err.message }; + } + }); + + // Chroma-key для GIF: сделать выбранный цвет прозрачным во всех кадрах. + // payload: { file: <абсолютный путь>, color: "#rrggbb", tolerance: 0..255 } + ipcMain.handle( + "cuckoo-pets-chroma", + async (_event, { file, color, tolerance } = {}) => { + try { + if (!file || typeof file !== "string") { + return { success: false, error: "Не указан файл" }; + } + const fs = require("fs"); + if (!fs.existsSync(file)) { + return { success: false, error: "Файл не найден: " + file }; + } + if (path.extname(file).toLowerCase() !== ".gif") { + return { + success: false, + error: "Chroma-key доступен только для GIF", + }; + } + // Парсим #rrggbb + const m = /^#?([0-9a-fA-F]{6})$/.exec(String(color || "").trim()); + if (!m) { + return { + success: false, + error: "Некорректный цвет (ожидается #rrggbb)", + }; + } + const hex = m[1]; + const target = { + r: parseInt(hex.slice(0, 2), 16), + g: parseInt(hex.slice(2, 4), 16), + b: parseInt(hex.slice(4, 6), 16), + }; + const tol = Math.max(0, Math.min(255, Number(tolerance) || 0)); + const { chromaKeyGif } = require("./gif-chroma"); + const res = chromaKeyGif(file, target, tol); + return res; + } catch (err) { + console.error("[Cookie Code] Chroma-key GIF失败:", err.message); + return { success: false, error: err.message }; + } + }, + ); + + // Импорт пета из произвольного файла: диалог выбора → копирование в pets + // → нормализация (ресайз до 600×600) → возврат имени для авто-выбора. + ipcMain.handle("cuckoo-pets-import", async (event) => { + try { + const fs = require("fs"); + const dir = getPetsDir(); + fs.mkdirSync(dir, { recursive: true }); + + // Диалог выбора файла-картинки + const parentWin = + require("electron").BrowserWindow.fromWebContents(event.sender) || null; + const dlg = await dialog.showOpenDialog(parentWin, { + title: "Выберите спрайт пета", + properties: ["openFile"], + filters: [ + { + name: "Изображения", + extensions: ["png", "gif", "webp", "jpg", "jpeg"], + }, + ], + }); + if (dlg.canceled || !dlg.filePaths || !dlg.filePaths[0]) { + return { success: false, canceled: true }; + } + const src = dlg.filePaths[0]; + + // Нормализуем имя: [a-zA-Z0-9_-], без пробелов/кириллицы. + const ext0 = path.extname(src).toLowerCase(); + let base = path.basename(src, ext0).replace(/[^a-zA-Z0-9_-]+/g, "_"); + if (!base) base = "pet_" + Date.now(); + const target = path.join(dir, base + ext0); + + // Копируем поверх (если файл с таким именем уже есть — перезапишем). + fs.copyFileSync(src, target); + + // Сжимаем до лимитов. + const { normalizePetFile } = require("./pet-image"); + const norm = normalizePetFile(target); + + return { + success: true, + id: base, + file: target, + label: base, + normalized: !!(norm && norm.changed), + before: (norm && norm.before) || null, + after: (norm && norm.after) || null, + warning: norm && !norm.success ? norm.error : null, + }; + } catch (err) { + console.error("[Cookie Code] Импорт пета失败:", err.message); + return { success: false, error: err.message }; + } + }); + // Карта иконок Material Icon Theme. // Отдаёт уникальные SVG + два маппинга (имя файла иконки → ext/имя файла), // чтобы не дублировать одинаковые SVG. Preload кеширует результат. diff --git a/src/main/pet-image.js b/src/main/pet-image.js new file mode 100644 index 0000000..42418f3 --- /dev/null +++ b/src/main/pet-image.js @@ -0,0 +1,135 @@ +/** + * Нормализация спрайтов пета: ресайз до PET_MAX_W × PET_MAX_H (вписывание + * пропорционально) и пересохранение PNG. Используется встроенный nativeImage + * из Electron — никаких внешних зависимостей. + * + * Вызывается: + * 1) при старте приложения — для всех файлов в /pets; + * 2) при импорте файла через диалог в настройках. + */ +const fs = require("fs"); +const path = require("path"); +const { nativeImage } = require("electron"); + +const PET_MAX_W = 600; +const PET_MAX_H = 600; + +/** + * Вписать (width, height) в рамку maxW × maxH пропорционально. + * Если обе стороны уже в лимите — вернёт те же значения. + * @returns {{ w: number, h: number, changed: boolean }} + */ +function fitWithin(width, height, maxW, maxH) { + if (width <= maxW && height <= maxH) { + return { w: width, h: height, changed: false }; + } + const ratio = Math.min(maxW / width, maxH / height); + const w = Math.max(1, Math.round(width * ratio)); + const h = Math.max(1, Math.round(height * ratio)); + return { w, h, changed: true }; +} + +/** + * Сжать файл-картинку до лимитов и перезаписать на месте (PNG). + * Если файл уже в лимите — не трогаем. + * + * @param {string} filePath абсолютный путь к PNG/GIF/WebP/JPG + * @returns {{ success: boolean, changed: boolean, before?: {w:number,h:number,size:number}, after?: {w:number,h:number,size:number}, error?: string }} + */ +function normalizePetFile(filePath) { + try { + if (!filePath || !fs.existsSync(filePath)) { + return { success: false, error: "Файл не найден: " + filePath }; + } + const ext = path.extname(filePath).toLowerCase(); + + // GIF не трогаем — у него анимация, nativeImage её не сохранит. + if (ext === ".gif") { + return { success: true, changed: false }; + } + + const beforeStat = fs.statSync(filePath); + const img = nativeImage.createFromPath(filePath); + if (img.isEmpty()) { + return { success: false, error: "Не удалось прочитать картинку" }; + } + const size = img.getSize(); // { width, height } + const fit = fitWithin(size.width, size.height, PET_MAX_W, PET_MAX_H); + if (!fit.changed) { + return { + success: true, + changed: false, + before: { w: size.width, h: size.height, size: beforeStat.size }, + }; + } + + const resized = img.resize({ + width: fit.w, + height: fit.h, + quality: "good", + }); + const buf = resized.toPNG(); + fs.writeFileSync(filePath, buf); + + const afterStat = fs.statSync(filePath); + console.log( + "[Cookie Code] Pet сжат:", + path.basename(filePath), + size.width + "x" + size.height + " → " + fit.w + "x" + fit.h, + "(" + + Math.round(beforeStat.size / 1024) + + "КБ → " + + Math.round(afterStat.size / 1024) + + "КБ)", + ); + return { + success: true, + changed: true, + before: { w: size.width, h: size.height, size: beforeStat.size }, + after: { w: fit.w, h: fit.h, size: afterStat.size }, + }; + } catch (err) { + console.error("[Cookie Code] normalizePetFile error:", err.message); + return { success: false, error: err.message }; + } +} + +/** + * Пройтись по папке и нормализовать все картинки-спрайты. + * @param {string} petsDir абсолютный путь к /pets + * @returns {{ total: number, changed: number, errors: number }} + */ +function normalizePetsDir(petsDir) { + const result = { total: 0, changed: 0, errors: 0 }; + try { + if (!fs.existsSync(petsDir)) return result; + const EXTS = [".png", ".webp", ".jpg", ".jpeg", ".gif"]; + const files = fs + .readdirSync(petsDir) + .filter((f) => EXTS.includes(path.extname(f).toLowerCase())); + for (const f of files) { + result.total++; + const r = normalizePetFile(path.join(petsDir, f)); + if (!r.success) result.errors++; + else if (r.changed) result.changed++; + } + if (result.changed > 0 || result.errors > 0) { + console.log( + "[Cookie Code] Pets нормализованы: всего=" + result.total, + "сжато=" + result.changed, + "ошибок=" + result.errors, + ); + } + } catch (err) { + console.error("[Cookie Code] normalizePetsDir error:", err.message); + } + return result; +} + +module.exports = { + PET_MAX_W, + PET_MAX_H, + normalizePetFile, + normalizePetsDir, + fitWithin, +}; diff --git a/src/main/settings-store.js b/src/main/settings-store.js index 04a98e1..cf6b7ba 100644 --- a/src/main/settings-store.js +++ b/src/main/settings-store.js @@ -70,6 +70,24 @@ const DEFAULTS = { showProducedFiles: true, // Показывать блок «Токены диалога» в оверлее (по умолчанию выключено). showConvTokens: false, + // ===== Пет (чубрик) на поле ввода ===== + // Режим: 'docked' — привязан к полю ввода, 'center' — свободно таскается. + petMode: "docked", + // Точка посадки в долях поля ввода (0..1). Y=0 — верхняя граница поля. + petRatioX: 0.939, + petRatioY: 0.016, + // Размер пета в px. + petSize: 64, + // Имя файла спрайта в /pets без расширения. "" — взять первый попавшийся. + petId: "", + // Пет включён (показывается на экране). По умолчанию — да. + petEnabled: true, + // Debug-режим пета: разблокирует горячие клавиши F8/F9/F10/F11. + petDebugMode: false, + // ===== Таймаут выполнения JS-скриптов (секунды) ===== + // Общий дедлайн для одного cuckoo-блока (RUN_DEADLINE в tools/JsRunner.js). + // Минимум 10 сек, максимум 1000 сек. Синхронный vm-таймаут остаётся 30 сек. + jsTimeoutSec: 60, }; let cachedPath = null; diff --git a/src/preload/api.js b/src/preload/api.js index 7f823c1..9b5ac13 100644 --- a/src/preload/api.js +++ b/src/preload/api.js @@ -134,6 +134,14 @@ let electronAPI = { openCuckooSettingsFile: () => { return ipcRenderer.invoke("cuckoo-settings-open-file"); }, + // ========== Событие «настройки изменились» (из TG-бота или других окон) ========== + // Подписка: callback получает объект-patch ({key: value}) или null (перечитать всё). + onSettingsChanged: (callback) => { + const listener = (_event, patch) => callback(patch); + ipcRenderer.on("cuckoo-settings-changed", listener); + return () => + ipcRenderer.removeListener("cuckoo-settings-changed", listener); + }, // ========== Пользовательские фоны (userData/backgrounds) ========== listCustomBackgrounds: () => { return ipcRenderer.invoke("cuckoo-backgrounds-list"); @@ -141,6 +149,19 @@ let electronAPI = { openCustomBackgroundsFolder: () => { return ipcRenderer.invoke("cuckoo-backgrounds-open-folder"); }, + // ========== Спрайты петов (userData/pets) ========== + listPets: () => { + return ipcRenderer.invoke("cuckoo-pets-list"); + }, + openPetsFolder: () => { + return ipcRenderer.invoke("cuckoo-pets-open-folder"); + }, + importPet: () => { + return ipcRenderer.invoke("cuckoo-pets-import"); + }, + chromaKeyPet: (file, color, tolerance) => { + return ipcRenderer.invoke("cuckoo-pets-chroma", { file, color, tolerance }); + }, // ========== Todo-задачи ========== getTodos: () => { return ipcRenderer.invoke("todo-get"); diff --git a/src/preload/dom/background.js b/src/preload/dom/background.js index f9832dd..133c498 100644 --- a/src/preload/dom/background.js +++ b/src/preload/dom/background.js @@ -6,54 +6,98 @@ * схема file:// заблокирована Chromium со страницы chat.deepseek.com, а * кастомная cuckoo-asset:// не проходит через fetch API даже с bypassCSP. */ -const fs = require('fs'); -const path = require('path'); +const fs = require("fs"); +const path = require("path"); // Директория с фонами (относительно preload): /src/ui/backgrounds -const BACKGROUNDS_DIR = path.join(__dirname, '..', '..', 'ui', 'backgrounds'); +const BACKGROUNDS_DIR = path.join(__dirname, "..", "..", "ui", "backgrounds"); /** * Список встроенных фонов — синхронизирован с src/ui/backgrounds/registry.json. * id = имя файла без расширения. */ const BUILTIN_BACKGROUNDS = [ - { id: 'miku', label: 'Мику', file: 'miku.webp' }, - { id: 'miku-light', label: 'Мику (светлая)', file: 'miku-light.jpg' }, - { id: 'abyssal-dark', label: 'Бездна (тёмная)', file: 'abyssal-dark.webp' }, - { id: 'abyssal-light', label: 'Бездна (светлая)', file: 'abyssal-light.webp' }, - { id: 'bee-eater', label: 'Синещёкая щурка', file: 'bee-eater.jpg' }, - { id: 'blue-fantasy', label: 'Синяя фантазия', file: 'blue-fantasy.jpg' }, - { id: 'cyber-night', label: 'Кибер-ночь', file: 'cyber-night.webp' }, - { id: 'dragon-heir-dark', label: 'Наследник дракона (тёмный)', file: 'dragon-heir-dark.webp' }, - { id: 'dragon-heir-light',label: 'Наследник дракона (светлый)',file: 'dragon-heir-light.webp' }, - { id: 'furina', label: 'Фурина', file: 'furina.jpg' }, - { id: 'harbor', label: 'Гавань', file: 'harbor.webp' }, - { id: 'hologram-dark', label: 'Голограмма (тёмная)', file: 'hologram-dark.webp' }, - { id: 'hologram-light', label: 'Голограмма (светлая)', file: 'hologram-light.webp' }, - { id: 'maid-day', label: 'Горничная — день', file: 'maid-day.webp' }, - { id: 'maid-night', label: 'Горничная — ночь', file: 'maid-night.webp' }, - { id: 'phoebe-dark', label: 'Фиби (тёмная)', file: 'phoebe-dark.webp' }, - { id: 'phoebe-light', label: 'Фиби (светлая)', file: 'phoebe-light.webp' }, - { id: 'starry-dark', label: 'Звёздная ночь (тёмная)', file: 'starry-dark.webp' }, - { id: 'starry-light', label: 'Звёздная ночь (светлая)', file: 'starry-light.webp' }, - { id: 'stellar-dark', label: 'Звёздная дива (тёмная)', file: 'stellar-dark.webp' }, - { id: 'stellar-light', label: 'Звёздная дива (светлая)', file: 'stellar-light.webp' }, - { id: 'summer', label: 'Летнее стекло', file: 'summer.jpg' }, - { id: 'tokyo-night', label: 'Токио ночью', file: 'tokyo-night.webp' }, - { id: 'war-thunder-dark', label: 'War Thunder (тёмный)', file: 'war-thunder-dark.webp' }, - { id: 'war-thunder-light',label: 'War Thunder (светлый)', file: 'war-thunder-light.webp' }, - { id: 'whale-mom', label: 'Мама-кит', file: 'whale-mom.jpg' }, - { id: 'whale-song', label: 'Песнь кита', file: 'whale-song.webp' }, + { id: "miku", label: "Мику", file: "miku.webp" }, + { id: "miku-light", label: "Мику (светлая)", file: "miku-light.jpg" }, + { id: "abyssal-dark", label: "Бездна (тёмная)", file: "abyssal-dark.webp" }, + { + id: "abyssal-light", + label: "Бездна (светлая)", + file: "abyssal-light.webp", + }, + { id: "bee-eater", label: "Синещёкая щурка", file: "bee-eater.jpg" }, + { id: "blue-fantasy", label: "Синяя фантазия", file: "blue-fantasy.jpg" }, + { id: "cyber-night", label: "Кибер-ночь", file: "cyber-night.webp" }, + { + id: "dragon-heir-dark", + label: "Наследник дракона (тёмный)", + file: "dragon-heir-dark.webp", + }, + { + id: "dragon-heir-light", + label: "Наследник дракона (светлый)", + file: "dragon-heir-light.webp", + }, + { id: "furina", label: "Фурина", file: "furina.jpg" }, + { id: "harbor", label: "Гавань", file: "harbor.webp" }, + { + id: "hologram-dark", + label: "Голограмма (тёмная)", + file: "hologram-dark.webp", + }, + { + id: "hologram-light", + label: "Голограмма (светлая)", + file: "hologram-light.webp", + }, + { id: "maid-day", label: "Горничная — день", file: "maid-day.webp" }, + { id: "maid-night", label: "Горничная — ночь", file: "maid-night.webp" }, + { id: "phoebe-dark", label: "Фиби (тёмная)", file: "phoebe-dark.webp" }, + { id: "phoebe-light", label: "Фиби (светлая)", file: "phoebe-light.webp" }, + { + id: "starry-dark", + label: "Звёздная ночь (тёмная)", + file: "starry-dark.webp", + }, + { + id: "starry-light", + label: "Звёздная ночь (светлая)", + file: "starry-light.webp", + }, + { + id: "stellar-dark", + label: "Звёздная дива (тёмная)", + file: "stellar-dark.webp", + }, + { + id: "stellar-light", + label: "Звёздная дива (светлая)", + file: "stellar-light.webp", + }, + { id: "summer", label: "Летнее стекло", file: "summer.jpg" }, + { id: "tokyo-night", label: "Токио ночью", file: "tokyo-night.webp" }, + { + id: "war-thunder-dark", + label: "War Thunder (тёмный)", + file: "war-thunder-dark.webp", + }, + { + id: "war-thunder-light", + label: "War Thunder (светлый)", + file: "war-thunder-light.webp", + }, + { id: "whale-mom", label: "Мама-кит", file: "whale-mom.jpg" }, + { id: "whale-song", label: "Песнь кита", file: "whale-song.webp" }, ]; -const DEFAULT_ID = 'miku'; +const DEFAULT_ID = "miku"; // Пользовательские фоны из /backgrounds (заполняется асинхронно // через window.electronAPI.listCustomBackgrounds()). // Каждый элемент: { id: 'custom:', label, file: <абсолютный путь>, custom: true } let customBackgrounds = []; // Абсолютный путь к папке с пользовательскими фонами (для UI). -let customBackgroundsDir = ''; +let customBackgroundsDir = ""; // Кэш data-URI: file → data:image/...;base64,... const dataUriCache = new Map(); @@ -70,7 +114,7 @@ function getAllBackgrounds() { * Найти запись фона по id во всём списке (встроенные + пользовательские). */ function findBackground(id) { - return getAllBackgrounds().find(b => b.id === id) || null; + return getAllBackgrounds().find((b) => b.id === id) || null; } /** @@ -82,12 +126,15 @@ async function loadCustomBackgrounds() { const res = await window.electronAPI.listCustomBackgrounds(); if (res && res.success) { customBackgrounds = res.backgrounds || []; - customBackgroundsDir = res.dir || ''; + customBackgroundsDir = res.dir || ""; } else { customBackgrounds = []; } } catch (err) { - console.error('[Cookie Code] Не удалось загрузить пользовательские фоны:', err.message); + console.error( + "[Cookie Code] Не удалось загрузить пользовательские фоны:", + err.message, + ); customBackgrounds = []; } return customBackgrounds; @@ -100,22 +147,25 @@ async function loadCustomBackgrounds() { function getDataUri(file) { if (dataUriCache.has(file)) return dataUriCache.get(file); try { - const fullPath = path.isAbsolute(file) ? file : path.join(BACKGROUNDS_DIR, file); + const fullPath = path.isAbsolute(file) + ? file + : path.join(BACKGROUNDS_DIR, file); const buf = fs.readFileSync(fullPath); const ext = path.extname(file).toLowerCase(); - const mime = { - '.webp': 'image/webp', - '.jpg': 'image/jpeg', - '.jpeg': 'image/jpeg', - '.png': 'image/png', - '.gif': 'image/gif', - }[ext] || 'application/octet-stream'; - const uri = 'data:' + mime + ';base64,' + buf.toString('base64'); + const mime = + { + ".webp": "image/webp", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".png": "image/png", + ".gif": "image/gif", + }[ext] || "application/octet-stream"; + const uri = "data:" + mime + ";base64," + buf.toString("base64"); dataUriCache.set(file, uri); return uri; } catch (err) { - console.error('[Cookie Code] Не удалось прочитать фон:', file, err.message); - return ''; + console.error("[Cookie Code] Не удалось прочитать фон:", file, err.message); + return ""; } } @@ -125,24 +175,39 @@ function getDataUri(file) { */ function apply(id) { const entry = findBackground(id); - const uri = entry ? getDataUri(entry.file) : ''; - const value = uri ? 'url("' + uri + '")' : 'none'; + const uri = entry ? getDataUri(entry.file) : ""; + const value = uri ? 'url("' + uri + '")' : "none"; try { - document.documentElement.style.setProperty('background-image', value, 'important'); - document.body.style.setProperty('background-image', value, 'important'); + document.documentElement.style.setProperty( + "background-image", + value, + "important", + ); + document.body.style.setProperty("background-image", value, "important"); // Дублируем URL в переменную — её читает reasoning-glass.js и CSS-шаблон. - document.documentElement.style.setProperty('--cuckoo-bg-image', uri ? value : 'none', 'important'); - console.log('[Cookie Code] Фон применён:', id, '(' + (uri ? Math.round(uri.length / 1024) + ' КБ' : 'нет') + ')'); + document.documentElement.style.setProperty( + "--cuckoo-bg-image", + uri ? value : "none", + "important", + ); + console.log( + "[Cookie Code] Фон применён:", + id, + "(" + (uri ? Math.round(uri.length / 1024) + " КБ" : "нет") + ")", + ); } catch (err) { - console.error('[Cookie Code] Не удалось применить фон:', err.message); + console.error("[Cookie Code] Не удалось применить фон:", err.message); } } function hexToRgb(hex, defaultVal = { r: 17, g: 19, b: 34 }) { - if (!hex || typeof hex !== 'string') return defaultVal; - let h = hex.trim().replace(/^#/, ''); + if (!hex || typeof hex !== "string") return defaultVal; + let h = hex.trim().replace(/^#/, ""); if (h.length === 3) { - h = h.split('').map(c => c + c).join(''); + h = h + .split("") + .map((c) => c + c) + .join(""); } if (h.length !== 6) return defaultVal; const num = parseInt(h, 16); @@ -175,25 +240,31 @@ function applyBlur(settings) { const tbBlurVal = isNaN(tbBlur) ? 0 : tbBlur; const tbOpVal = isNaN(tbOp) ? 55 : tbOp; - root.style.setProperty('--cuckoo-bg-blur', bg + 'px'); - root.style.setProperty('--cuckoo-header-blur', hdVal + 'px'); - root.style.setProperty('--cuckoo-sidebar-blur', sbVal + 'px'); - root.style.setProperty('--cuckoo-header-opacity', hdOpVal + '%'); - root.style.setProperty('--cuckoo-sidebar-opacity', sbOpVal + '%'); - root.style.setProperty('--cuckoo-toolblock-opacity', tbOpVal + '%'); - root.style.setProperty('--cuckoo-toolblock-blur', tbBlurVal + 'px'); + root.style.setProperty("--cuckoo-bg-blur", bg + "px"); + root.style.setProperty("--cuckoo-header-blur", hdVal + "px"); + root.style.setProperty("--cuckoo-sidebar-blur", sbVal + "px"); + root.style.setProperty("--cuckoo-header-opacity", hdOpVal + "%"); + root.style.setProperty("--cuckoo-sidebar-opacity", sbOpVal + "%"); + root.style.setProperty("--cuckoo-toolblock-opacity", tbOpVal + "%"); + root.style.setProperty("--cuckoo-toolblock-blur", tbBlurVal + "px"); // ===== Стеклянное поле ввода сообщения ===== const inBlur = Number(settings && settings.inputGlassBlur); const inBlurVal = isNaN(inBlur) ? 12 : inBlur; const inOp = Number(settings && settings.inputGlassOpacity); const inOpVal = isNaN(inOp) ? 55 : inOp; - root.style.setProperty('--cuckoo-input-glass-blur', inBlurVal + 'px'); - root.style.setProperty('--cuckoo-input-glass-opacity', inOpVal + '%'); + root.style.setProperty("--cuckoo-input-glass-blur", inBlurVal + "px"); + root.style.setProperty("--cuckoo-input-glass-opacity", inOpVal + "%"); // Плашка «Размышление» использует свой blur (дефолт 12px, если настройка toolBlockBlur = 0). - root.style.setProperty('--cuckoo-reasoning-blur', (tbBlurVal > 0 ? tbBlurVal : 12) + 'px'); + root.style.setProperty( + "--cuckoo-reasoning-blur", + (tbBlurVal > 0 ? tbBlurVal : 12) + "px", + ); // И свою (более лёгкую) плотность плёнки — тонкая плашка не должна выглядеть чёрной. - root.style.setProperty('--cuckoo-reasoning-opacity', Math.max(15, tbOpVal - 20) + '%'); + root.style.setProperty( + "--cuckoo-reasoning-opacity", + Math.max(15, tbOpVal - 20) + "%", + ); // ===== Кастомизация панели Cookie Code ===== const ovOpacity = Number(settings && settings.overlayOpacity); @@ -202,16 +273,19 @@ function applyBlur(settings) { const ovBlurVal = isNaN(ovBlur) ? 12 : ovBlur; const ovWidth = Number(settings && settings.overlayWidth); const ovWidthVal = isNaN(ovWidth) ? 300 : ovWidth; - const ovBgColor = (settings && settings.overlayBgColor) || '#111322'; + const ovBgColor = (settings && settings.overlayBgColor) || "#111322"; const ovRgb = hexToRgb(ovBgColor, { r: 17, g: 19, b: 34 }); const ovAlpha = (ovOpacityVal / 100).toFixed(2); - root.style.setProperty('--cuckoo-overlay-width', ovWidthVal + 'px'); - root.style.setProperty('--cuckoo-overlay-blur', ovBlurVal + 'px'); - root.style.setProperty('--cuckoo-overlay-bg', `rgba(${ovRgb.r}, ${ovRgb.g}, ${ovRgb.b}, ${ovAlpha})`); + root.style.setProperty("--cuckoo-overlay-width", ovWidthVal + "px"); + root.style.setProperty("--cuckoo-overlay-blur", ovBlurVal + "px"); + root.style.setProperty( + "--cuckoo-overlay-bg", + `rgba(${ovRgb.r}, ${ovRgb.g}, ${ovRgb.b}, ${ovAlpha})`, + ); // Кнопки оверлея - const ovPrimary = (settings && settings.overlayPrimaryColor) || '#8b93ff'; + const ovPrimary = (settings && settings.overlayPrimaryColor) || "#8b93ff"; const pRgb = hexToRgb(ovPrimary, { r: 139, g: 147, b: 255 }); // Темнее оттенок для градиента const pDarkR = Math.max(0, Math.floor(pRgb.r * 0.8)); @@ -220,17 +294,63 @@ function applyBlur(settings) { const ovRadius = Number(settings && settings.overlayBtnRadius); const ovRadiusVal = isNaN(ovRadius) ? 10 : ovRadius; - root.style.setProperty('--cuckoo-overlay-btn-radius', ovRadiusVal + 'px'); - root.style.setProperty('--cuckoo-overlay-primary-bg', `linear-gradient(135deg, rgb(${pRgb.r}, ${pRgb.g}, ${pRgb.b}), rgb(${pDarkR}, ${pDarkG}, ${pDarkB}))`); - root.style.setProperty('--cuckoo-overlay-primary-shadow', `rgba(${pDarkR}, ${pDarkG}, ${pDarkB}, 0.25)`); - root.style.setProperty('--cuckoo-overlay-primary-shadow-hover', `rgba(${pDarkR}, ${pDarkG}, ${pDarkB}, 0.45)`); - root.style.setProperty('--cuckoo-overlay-secondary-bg', `rgba(${pRgb.r}, ${pRgb.g}, ${pRgb.b}, 0.12)`); - root.style.setProperty('--cuckoo-overlay-secondary-text', `rgb(${Math.min(255, pRgb.r + 30)}, ${Math.min(255, pRgb.g + 30)}, 255)`); - root.style.setProperty('--cuckoo-overlay-secondary-border', `rgba(${pRgb.r}, ${pRgb.g}, ${pRgb.b}, 0.5)`); - root.style.setProperty('--cuckoo-overlay-secondary-hover-bg', `rgba(${pRgb.r}, ${pRgb.g}, ${pRgb.b}, 0.28)`); - root.style.setProperty('--cuckoo-overlay-secondary-hover-border', `rgba(${pRgb.r}, ${pRgb.g}, ${pRgb.b}, 0.75)`); + root.style.setProperty("--cuckoo-overlay-btn-radius", ovRadiusVal + "px"); + root.style.setProperty( + "--cuckoo-overlay-primary-bg", + `linear-gradient(135deg, rgb(${pRgb.r}, ${pRgb.g}, ${pRgb.b}), rgb(${pDarkR}, ${pDarkG}, ${pDarkB}))`, + ); + root.style.setProperty( + "--cuckoo-overlay-primary-shadow", + `rgba(${pDarkR}, ${pDarkG}, ${pDarkB}, 0.25)`, + ); + root.style.setProperty( + "--cuckoo-overlay-primary-shadow-hover", + `rgba(${pDarkR}, ${pDarkG}, ${pDarkB}, 0.45)`, + ); + root.style.setProperty( + "--cuckoo-overlay-secondary-bg", + `rgba(${pRgb.r}, ${pRgb.g}, ${pRgb.b}, 0.12)`, + ); + root.style.setProperty( + "--cuckoo-overlay-secondary-text", + `rgb(${Math.min(255, pRgb.r + 30)}, ${Math.min(255, pRgb.g + 30)}, 255)`, + ); + root.style.setProperty( + "--cuckoo-overlay-secondary-border", + `rgba(${pRgb.r}, ${pRgb.g}, ${pRgb.b}, 0.5)`, + ); + root.style.setProperty( + "--cuckoo-overlay-secondary-hover-bg", + `rgba(${pRgb.r}, ${pRgb.g}, ${pRgb.b}, 0.28)`, + ); + root.style.setProperty( + "--cuckoo-overlay-secondary-hover-border", + `rgba(${pRgb.r}, ${pRgb.g}, ${pRgb.b}, 0.75)`, + ); - console.log('[Cookie Code] Стили: фон=' + bg + 'px, шапка=' + hdVal + 'px/' + hdOpVal + '%, сайдбар=' + sbVal + 'px/' + sbOpVal + '%, tool=' + tbBlurVal + 'px/' + tbOpVal + '%, оверлей=' + ovBlurVal + 'px/' + ovOpacityVal + '%/' + ovWidthVal + 'px'); + console.log( + "[Cookie Code] Стили: фон=" + + bg + + "px, шапка=" + + hdVal + + "px/" + + hdOpVal + + "%, сайдбар=" + + sbVal + + "px/" + + sbOpVal + + "%, tool=" + + tbBlurVal + + "px/" + + tbOpVal + + "%, оверлей=" + + ovBlurVal + + "px/" + + ovOpacityVal + + "%/" + + ovWidthVal + + "px", + ); } /** @@ -246,9 +366,11 @@ async function loadAndApply() { apply(bgId); applyBlur(settings); applyRgbUsername(settings ? settings.rgbUsername : true); - applyInputGlassEnabled(Boolean(settings && settings.inputGlassEnabled === true)); + applyInputGlassEnabled( + Boolean(settings && settings.inputGlassEnabled === true), + ); } catch (err) { - console.error('[Cookie Code] Не удалось загрузить настройки:', err.message); + console.error("[Cookie Code] Не удалось загрузить настройки:", err.message); apply(DEFAULT_ID); applyBlur(null); applyRgbUsername(true); @@ -273,8 +395,8 @@ const RESET_DEFAULTS = { overlayOpacity: 72, overlayBlur: 12, overlayWidth: 300, - overlayBgColor: '#111322', - overlayPrimaryColor: '#8b93ff', + overlayBgColor: "#111322", + overlayPrimaryColor: "#8b93ff", overlayBtnRadius: 10, inputGlassBlur: 12, inputGlassOpacity: 55, @@ -292,9 +414,9 @@ async function resetAll() { for (const key of Object.keys(RESET_DEFAULTS)) { await window.electronAPI.setCuckooSetting(key, RESET_DEFAULTS[key]); } - console.log('[Cookie Code] Настройки сброшены к дефолтам'); + console.log("[Cookie Code] Настройки сброшены к дефолтам"); } catch (err) { - console.error('[Cookie Code] Не удалось сохранить дефолты:', err.message); + console.error("[Cookie Code] Не удалось сохранить дефолты:", err.message); } } @@ -306,12 +428,15 @@ async function resetAll() { function applyCustomizationEnabled(enabled) { try { if (enabled === false) { - document.documentElement.classList.add('cuckoo-customization-off'); + document.documentElement.classList.add("cuckoo-customization-off"); } else { - document.documentElement.classList.remove('cuckoo-customization-off'); + document.documentElement.classList.remove("cuckoo-customization-off"); } } catch (err) { - console.error('[Cookie Code] Не удалось применить состояние кастомизации:', err.message); + console.error( + "[Cookie Code] Не удалось применить состояние кастомизации:", + err.message, + ); } } @@ -323,12 +448,15 @@ function applyCustomizationEnabled(enabled) { function applyInputGlassEnabled(enabled) { try { if (enabled === false) { - document.documentElement.classList.add('cuckoo-input-glass-off'); + document.documentElement.classList.add("cuckoo-input-glass-off"); } else { - document.documentElement.classList.remove('cuckoo-input-glass-off'); + document.documentElement.classList.remove("cuckoo-input-glass-off"); } } catch (err) { - console.error('[Cookie Code] Не удалось применить состояние стекла поля ввода:', err.message); + console.error( + "[Cookie Code] Не удалось применить состояние стекла поля ввода:", + err.message, + ); } } @@ -338,14 +466,14 @@ function applyInputGlassEnabled(enabled) { */ function applyRgbUsername(enabled) { try { - if (enabled === false || enabled === 'false' || enabled === 0) { - document.body.classList.add('cuckoo-rgb-off'); + if (enabled === false || enabled === "false" || enabled === 0) { + document.body.classList.add("cuckoo-rgb-off"); } else { - document.body.classList.remove('cuckoo-rgb-off'); + document.body.classList.remove("cuckoo-rgb-off"); } - console.log('[Cookie Code] RGB-ник:', enabled === false ? 'выкл' : 'вкл'); + console.log("[Cookie Code] RGB-ник:", enabled === false ? "выкл" : "вкл"); } catch (err) { - console.error('[Cookie Code] Не удалось применить RGB-ник:', err.message); + console.error("[Cookie Code] Не удалось применить RGB-ник:", err.message); } } @@ -356,6 +484,21 @@ function getPreviewUri(file) { return getDataUri(file); } +/** + * Сбросить кэш data-URI для конкретного файла (или всего). + * Нужно после изменения картинки на диске (например, chroma-key GIF). + * @param {string} [file] путь файла; если не задан — сбрасывается всё. + */ +function invalidatePreviewCache(file) { + try { + if (file) { + dataUriCache.delete(file); + } else { + dataUriCache.clear(); + } + } catch (_) {} +} + /** * Очистить localStorage-хранилища Cookie Code: * - cuckoo-response-meta (мета ответов: время + токены) @@ -363,15 +506,72 @@ function getPreviewUri(file) { */ function clearLocalStorage() { try { - localStorage.removeItem('cuckoo-response-meta'); - localStorage.removeItem('cuckoo-errors'); - console.log('[Cookie Code] LocalStorage очищен (мета + ошибки)'); + localStorage.removeItem("cuckoo-response-meta"); + localStorage.removeItem("cuckoo-errors"); + console.log("[Cookie Code] LocalStorage очищен (мета + ошибки)"); } catch (err) { - console.error('[Cookie Code] Ошибка очистки localStorage:', err.message); + console.error("[Cookie Code] Ошибка очистки localStorage:", err.message); } } +/** + * Подписка на событие "настройки изменились" (из TG-бота или другого окна). + * При изменении — перечитываем настройки и мгновенно применяем фон/блюр/эффекты. + */ +function installSettingsListener() { + try { + if ( + !window.electronAPI || + typeof window.electronAPI.onSettingsChanged !== "function" + ) { + return; + } + window.electronAPI.onSettingsChanged((patch) => { + try { + // Список ключей, влияющих на визуал. Если patch не передан — применяем всё. + const VISUAL_KEYS = [ + "customizationEnabled", + "background", + "backgroundBlur", + "headerBlur", + "sidebarBlur", + "headerOpacity", + "sidebarOpacity", + "toolBlockOpacity", + "toolBlockBlur", + "inputGlassEnabled", + "inputGlassBlur", + "inputGlassOpacity", + "rgbUsername", + "overlayOpacity", + "overlayBlur", + "overlayWidth", + "overlayBgColor", + "overlayPrimaryColor", + "overlayBtnRadius", + ]; + const relevant = + !patch || Object.keys(patch).some((k) => VISUAL_KEYS.includes(k)); + if (!relevant) return; + // Мгновенно применяем: loadAndApply читает settings.json целиком. + loadAndApply().catch((err) => { + console.error( + "[Cookie Code] background: применениe из события не удалось:", + err.message, + ); + }); + } catch (err) { + console.error( + "[Cookie Code] onSettingsChanged(bg) error:", + err.message, + ); + } + }); + } catch (_) {} +} + module.exports = { + installSettingsListener, apply, applyBlur, applyRgbUsername, @@ -379,6 +579,7 @@ module.exports = { applyCustomizationEnabled, loadAndApply, getPreviewUri, + invalidatePreviewCache, resetAll, clearLocalStorage, RESET_DEFAULTS, diff --git a/src/preload/dom/pet.js b/src/preload/dom/pet.js new file mode 100644 index 0000000..4d46f6b --- /dev/null +++ b/src/preload/dom/pet.js @@ -0,0 +1,891 @@ +/** + * Пет (чубрик) на экране. + * + * Два режима: + * - 'center' — спавн по центру окна, перетаскивается мышкой (по умолчанию) + * - 'docked' — прилипает к верхней границе поля ввода (F10) + * + * Спрайт: /pets/.{png,gif,webp,jpg,jpeg} (base64 через fs). + * Если спрайта нет — рисуется жёлтая заглушка с подписью (видно всегда). + * + * Горячие клавиши: + * F8 — прицел: кликните в точку, куда посадить пета. + * Сохраняет позицию в ДОЛЯХ поля ввода (ratioX / ratioY от 0 до 1), + * поэтому пет садится в то же место при любом разрешении/размере окна. + * F9 — debug-рамка вокруг поля ввода (селектор _._77cefa5) + * F10 — переключить режим center ↔ docked + * F11 — сбросить размер пета к дефолтному + * Esc — отмена режима прицела + * + * Размер пета: наведи мышку на пета → появится синий уголок в правом + * нижнем углу → тяни его мышкой. Размер 24..400px. + */ + +const fs = require("fs"); +const path = require("path"); + +const INPUT_SELECTOR = "._77cefa5"; +const PET_SIZE_DEFAULT = 64; +const PET_SIZE_MIN = 24; +const PET_SIZE_MAX = 400; +// Значения, к которым сбрасывает кнопка «Сбросить» в настройках. +// Синхронизированы с src/main/settings-store.js (ключи pet*). +const PET_RESET_DEFAULTS = { + petMode: "docked", + petRatioX: 0.939, + petRatioY: 0.016, + petSize: PET_SIZE_DEFAULT, + petId: "", +}; +// Точка посадки пета — в ДОЛЯХ поля ввода, а не в пикселях. +// Это делает пет устойчивым к разным разрешениям, размерам окна и зуму: +// при ресайзе rect поля меняется, но доли те же → пет на том же месте поля. +let dockOffsetXRatio = 0.939; // подобрано прицелом F8: у правого края поля +let dockOffsetYRatio = 0.016; // чуть ниже верхней границы + +// ---- userData ---- +let USER_DATA_DIR = ""; +try { + const arg = (process.argv || []).find((a) => + a.startsWith("--cuckoo-user-data="), + ); + if (arg) USER_DATA_DIR = arg.slice("--cuckoo-user-data=".length); +} catch (_) {} +const PETS_DIR = USER_DATA_DIR ? path.join(USER_DATA_DIR, "pets") : ""; + +// ---- Состояние ---- +let started = false; +let petEl = null, + frameEl = null, + labelEl = null, + phEl = null; +let debugOn = false; +let mode = "docked"; // 'center' | 'docked' (старт сразу у поля; F10 — переключить) +let dataUriCache = null; +let currentPetId = "chubrik"; +let petSize = PET_SIZE_DEFAULT; // текущий размер пета (px) +let resizing = false; +let resizeStart = null; // { px, py, size } +let resizeHandleEl = null; +// Режим прицела (F8): следующий клик мышкой задаёт точку посадки пета. +let aiming = false; +let aimOverlayEl = null; +// Debug-режим: разрешает горячие клавиши F8/F9/F10/F11. +// Управляется тумблером в настройках Cookie Code. +let debugMode = false; +// Пет включён — показывается на экране. По умолчанию true. +let petEnabled = true; + +// Позиция в режиме 'center' (px, от левого верхнего угла окна) +let centerPos = null; // { x, y } — null значит «ещё не задана → центр экрана» +let dragging = false; +let dragOff = { x: 0, y: 0 }; + +const MIME = { + ".webp": "image/webp", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".png": "image/png", + ".gif": "image/gif", +}; + +function loadPetDataUri(petId) { + if (dataUriCache && dataUriCache.id === petId) return dataUriCache.uri; + if (!PETS_DIR) return ""; + try { + if (!fs.existsSync(PETS_DIR)) return ""; + const exts = [".png", ".gif", ".webp", ".jpg", ".jpeg"]; + for (const ext of exts) { + const full = path.join(PETS_DIR, petId + ext); + if (fs.existsSync(full)) { + const buf = fs.readFileSync(full); + const uri = + "data:" + + (MIME[ext] || "image/png") + + ";base64," + + buf.toString("base64"); + dataUriCache = { id: petId, uri }; + console.log("[Cookie Code] Спрайт пета:", full); + return uri; + } + } + + // Фолбэк: берём первый попавшийся файл-картинку в папке + const files = fs + .readdirSync(PETS_DIR) + .filter((f) => exts.includes(path.extname(f).toLowerCase())) + .sort(); + if (files.length > 0) { + const f = files[0]; + const full = path.join(PETS_DIR, f); + const ext = path.extname(f).toLowerCase(); + const buf = fs.readFileSync(full); + const uri = + "data:" + + (MIME[ext] || "image/png") + + ";base64," + + buf.toString("base64"); + dataUriCache = { id: petId, uri }; + console.log("[Cookie Code] Спрайт пета (фолбэк):", full); + return uri; + } + } catch (err) { + console.error("[Cookie Code] 读取 спрайт пета失败:", petId, err.message); + } + return ""; +} + +/** + * Контейнер пета:
с внутри + emoji-заглушка, если нет спрайта. + * Контейнер — таскаемый (pointer-events: auto), сам img — none. + */ +function ensureElements() { + if (!petEl) { + petEl = document.createElement("div"); + petEl.className = "cuckoo-pet"; + petEl.style.cssText = + "position: fixed; z-index: 2147483000; " + + "width: " + + petSize + + "px; height: " + + petSize + + "px; " + + "cursor: grab; user-select: none; -webkit-user-drag: none; " + + "transition: left 0.06s linear, top 0.06s linear; " + + "filter: drop-shadow(0 4px 8px rgba(0,0,0,0.45));" + + "box-sizing:border-box;"; + petEl.setAttribute("draggable", "false"); + + // Спрайт + const img = document.createElement("img"); + img.className = "cuckoo-pet-img"; + img.draggable = false; + img.style.cssText = + "width:100%;height:100%;image-rendering:pixelated;display:none;pointer-events:none;"; + petEl.appendChild(img); + + // Заглушка (когда спрайта нет) + const ph = document.createElement("div"); + ph.className = "cuckoo-pet-ph"; + ph.style.cssText = + "width:100%;height:100%;display:none;align-items:center;justify-content:center;" + + "background:rgba(255,204,0,0.85);border:2px dashed #a76a00;border-radius:10px;" + + "font:28px/1 sans-serif;color:#000;box-sizing:border-box;pointer-events:none;"; + ph.textContent = "🐦"; + petEl.appendChild(ph); + phEl = ph; + + // Уголок-ресайз (правый нижний) + const rh = document.createElement("div"); + rh.className = "cuckoo-pet-resize"; + rh.style.cssText = + "position:absolute;right:-4px;bottom:-4px;width:14px;height:14px;" + + "background:#8b93ff;border:2px solid #fff;border-radius:3px;" + + "cursor:nwse-resize;opacity:0;transition:opacity 0.15s;box-sizing:border-box;" + + "pointer-events:auto;"; + petEl.appendChild(rh); + resizeHandleEl = rh; + + petEl.addEventListener("pointerenter", () => { + // Уголок ресайза показываем только в debug-режиме + if (debugMode && resizeHandleEl) resizeHandleEl.style.opacity = "1"; + }); + petEl.addEventListener("pointerleave", () => { + if (resizeHandleEl && !resizing) resizeHandleEl.style.opacity = "0"; + }); + + document.body.appendChild(petEl); + + // Перетаскивание + petEl.addEventListener("pointerdown", onDragStart); + window.addEventListener("pointermove", onDragMove, true); + window.addEventListener("pointerup", onDragEnd, true); + + // Ресайз + rh.addEventListener("pointerdown", onResizeStart); + window.addEventListener("pointermove", onResizeMove, true); + window.addEventListener("pointerup", onResizeEnd, true); + } + if (!frameEl) { + frameEl = document.createElement("div"); + frameEl.className = "cuckoo-pet-frame"; + frameEl.style.cssText = + "position: fixed; z-index: 2147482999; pointer-events: none; " + + "border: 2px dashed #ff3355; background: rgba(255,51,85,0.06); " + + "box-sizing: border-box; display: none;"; + document.body.appendChild(frameEl); + + labelEl = document.createElement("div"); + labelEl.style.cssText = + "position: fixed; z-index: 2147483001; pointer-events: none; " + + "font: 11px/1.3 Consolas,monospace; color: #fff; " + + "background: rgba(255,51,85,0.92); padding: 3px 7px; border-radius: 6px; " + + "white-space: pre; display: none;"; + document.body.appendChild(labelEl); + } +} + +// ---- Drag ---- +function onDragStart(e) { + if (mode !== "center") return; + if (!centerPos) return; + dragging = true; + dragOff = { x: e.clientX - centerPos.x, y: e.clientY - centerPos.y }; + petEl.style.cursor = "grabbing"; + try { + petEl.setPointerCapture(e.pointerId); + } catch (_) {} + e.preventDefault(); +} +function onDragMove(e) { + if (!dragging) return; + centerPos = { x: e.clientX - dragOff.x, y: e.clientY - dragOff.y }; + reposition(); +} +function onDragEnd() { + if (!dragging) return; + dragging = false; + if (petEl) petEl.style.cursor = "grab"; +} + +// ---- Resize ---- +function onResizeStart(e) { + // Ресайз доступен только в debug-режиме + if (!debugMode) return; + resizing = true; + resizeStart = { px: e.clientX, py: e.clientY, size: petSize }; + if (resizeHandleEl) resizeHandleEl.style.opacity = "1"; + try { + e.target.setPointerCapture(e.pointerId); + } catch (_) {} + e.preventDefault(); + e.stopPropagation(); +} +function onResizeMove(e) { + if (!resizing || !resizeStart) return; + const dx = e.clientX - resizeStart.px; + const dy = e.clientY - resizeStart.py; + // Угол тянется — берём максимальную дельту (чтобы тянуть и по X, и по Y) + const delta = Math.max(dx, dy); + let next = resizeStart.size + delta; + next = Math.max(PET_SIZE_MIN, Math.min(PET_SIZE_MAX, Math.round(next))); + petSize = next; + applySize(); + reposition(); +} +function onResizeEnd() { + if (!resizing) return; + resizing = false; + if (resizeHandleEl) resizeHandleEl.style.opacity = "0"; + savePetSettings({ petSize: petSize }); + console.log("[Cookie Code] Размер пета:", petSize + "px"); +} + +function applySize() { + if (!petEl) return; + petEl.style.width = petSize + "px"; + petEl.style.height = petSize + "px"; +} + +function resetSize() { + petSize = PET_SIZE_DEFAULT; + applySize(); + reposition(); + savePetSettings({ petSize: petSize }); + console.log("[Cookie Code] Размер пета сброшен:", petSize + "px"); +} + +// ---- Сохранение/загрузка настроек пета ---- + +/** + * Сохранить один или несколько ключей настроек пета в settings.json. + * Не блокирует UI, ошибки глушим в консоль. + * @param {object} patch например { petRatioX: 0.42, petSize: 96 } + */ +function savePetSettings(patch) { + try { + const api = window.electronAPI; + if (!api || typeof api.setCuckooSetting !== "function") return; + for (const [k, v] of Object.entries(patch)) { + Promise.resolve(api.setCuckooSetting(k, v)).catch(() => {}); + } + } catch (_) {} +} + +/** + * Загрузить настройки пета из settings.json и применить. + * Вызывается один раз при старте, до первого reposition(). + */ +async function loadPetSettings() { + try { + const api = window.electronAPI; + if (!api || typeof api.getCuckooSettings !== "function") return; + const s = await api.getCuckooSettings(); + if (!s) return; + if (s.petMode === "center" || s.petMode === "docked") mode = s.petMode; + if (typeof s.petRatioX === "number") dockOffsetXRatio = s.petRatioX; + if (typeof s.petRatioY === "number") dockOffsetYRatio = s.petRatioY; + if (typeof s.petSize === "number") + petSize = Math.max(PET_SIZE_MIN, Math.min(PET_SIZE_MAX, s.petSize)); + if (typeof s.petId === "string") currentPetId = s.petId; + petEnabled = s.petEnabled !== false; // default true + debugMode = s.petDebugMode === true; + console.log( + "[Cookie Code] Настройки пета: mode=" + + mode + + " ratioX=" + + dockOffsetXRatio + + " ratioY=" + + dockOffsetYRatio + + " size=" + + petSize + + " id=" + + (currentPetId || "(auto)"), + ); + } catch (err) { + console.error( + "[Cookie Code] Не удалось загрузить настройки пета:", + err.message, + ); + } +} + +// ---- F8: прицел (выбор точки посадки в долях поля) ---- + +/** + * Включить/выключить режим прицела. При включении — оверлей на весь экран, + * перехватывающий клик. Следующий клик вычисляет доли от поля ввода, + * запоминает их, переключает в docked-режим и мгновенно пересаживает пета. + */ +function toggleAim() { + aiming = !aiming; + if (aiming) { + ensureAimOverlay(); + aimOverlayEl.style.display = ""; + aimOverlayEl.style.cursor = "crosshair"; + console.log( + "[Cookie Code] Прицел: кликните в точку, куда посадить пета (Esc — отмена)", + ); + } else { + if (aimOverlayEl) aimOverlayEl.style.display = "none"; + } + return aiming; +} + +function ensureAimOverlay() { + if (aimOverlayEl) return; + aimOverlayEl = document.createElement("div"); + aimOverlayEl.style.cssText = + "position:fixed;inset:0;z-index:2147483600;" + + "background:rgba(0,0,0,0.06);" + + "cursor:crosshair;display:none;"; + aimOverlayEl.addEventListener("click", onAimClick, true); + document.body.appendChild(aimOverlayEl); +} + +function onAimClick(e) { + if (!aiming) return; + e.preventDefault(); + e.stopPropagation(); + + // Ищем поле ввода и считаем доли от его левого верхнего угла + const input = document.querySelector(INPUT_SELECTOR); + if (!input) { + console.warn( + "[Cookie Code] Прицел: поле ввода не найдено (" + INPUT_SELECTOR + ")", + ); + return; + } + const r = input.getBoundingClientRect(); + if (r.width === 0 || r.height === 0) { + console.warn("[Cookie Code] Прицел: поле ввода нулевого размера"); + return; + } + + // Точка клика — центр пета. Пет садится телом ВЫШЕ точки (y - petSize). + // Значит точка посадки пета = (клик.x, клик.y + petSize). Переводим в доли. + const anchorX = e.clientX; + const anchorY = e.clientY + petSize; + const ratioX = (anchorX - r.left) / r.width; + const ratioY = (anchorY - r.top) / r.height; + + dockOffsetXRatio = ratioX; + dockOffsetYRatio = ratioY; + mode = "docked"; + + savePetSettings({ + petRatioX: ratioX, + petRatioY: ratioY, + petMode: "docked", + }); + + toggleAim(); // выключаем прицел + schedule(); + + const msg = + "Точка посадки: ratioX=" + + ratioX.toFixed(3) + + " ratioY=" + + ratioY.toFixed(3) + + " (px поля: left=" + + Math.round(r.left) + + " top=" + + Math.round(r.top) + + " w=" + + Math.round(r.width) + + " h=" + + Math.round(r.height) + + ")"; + console.log("[Cookie Code] " + msg); + // Дублируем в подпись, если debug включён + if (debugOn && labelEl) { + labelEl.style.display = ""; + labelEl.textContent = + "ПРИЦЕЛ ВЫСТАВЛЕН" + + String.fromCharCode(10) + + msg + + String.fromCharCode(10) + + "F9 — показать рамку"; + labelEl.style.left = Math.round(r.left) + "px"; + labelEl.style.top = Math.round(r.top - 72) + "px"; + } +} + +/** + * Позиция пета. + */ +function computePos() { + const r = petEl.getBoundingClientRect(); + // клиентская область вьюпорта + const vw = window.innerWidth, + vh = window.innerHeight; + + if (mode === "center") { + if (!centerPos) { + centerPos = { + x: Math.round((vw - petSize) / 2), + y: Math.round((vh - petSize) / 2), + }; + } + return { x: centerPos.x, y: centerPos.y, docked: false }; + } + + // docked + const input = document.querySelector(INPUT_SELECTOR); + if (!input) + return { + x: centerPos ? centerPos.x : (vw - petSize) / 2, + y: centerPos ? centerPos.y : (vh - petSize) / 2, + docked: false, + }; + const ir = input.getBoundingClientRect(); + if (ir.width === 0 || ir.height === 0) { + return { + x: centerPos ? centerPos.x : (vw - petSize) / 2, + y: centerPos ? centerPos.y : (vh - petSize) / 2, + docked: false, + }; + } + // Точка посадки в пикселях — из долей и текущих размеров поля. + // X — доля ширины. Y — доля высоты (0 = верх поля). + const anchorX = ir.left + ir.width * dockOffsetXRatio; + const anchorY = ir.top + ir.height * dockOffsetYRatio; + // Пет «сидит» телом над точкой посадки, по центру горизонтально. + let x = anchorX - petSize / 2; + let y = anchorY - petSize; + // Защита: пет не должен вылезать за горизонтальные границы поля. + // Если поле сузилось и пет не влезает — прижимаем к ближнему краю. + const minX = ir.left; + const maxX = ir.left + ir.width - petSize; + if (x < minX) x = minX; + if (x > maxX) x = maxX; + // Та же защита по вертикали: не улетать за верх окна. + if (y < 0) y = 0; + return { x, y, docked: true, rect: ir, anchorX, anchorY }; +} + +function reposition() { + ensureElements(); + + // Пет выключен — прячем всё (pet, debug-рамку, подпись) и выходим. + if (!petEnabled) { + if (petEl) petEl.style.display = "none"; + if (frameEl) frameEl.style.display = "none"; + if (labelEl) labelEl.style.display = "none"; + return; + } + + // Пет включён — сбрасываем display у контейнера (мог быть "none" после выключения) + if (petEl) petEl.style.display = ""; + + const pos = computePos(); + + // Спрайт или заглушка + const uri = loadPetDataUri(currentPetId); + const img = petEl.querySelector(".cuckoo-pet-img"); + if (uri) { + if (img.src !== uri) img.src = uri; + img.style.display = ""; + phEl.style.display = "none"; + } else { + img.style.display = "none"; + phEl.style.display = "flex"; + } + + petEl.style.left = Math.round(pos.x) + "px"; + petEl.style.top = Math.round(pos.y) + "px"; + + // Debug-рамка + if (debugOn) { + const input = document.querySelector(INPUT_SELECTOR); + if (input) { + const r = input.getBoundingClientRect(); + if (r.width > 0 && r.height > 0) { + frameEl.style.display = ""; + frameEl.style.left = r.left + "px"; + frameEl.style.top = r.top + "px"; + frameEl.style.width = r.width + "px"; + frameEl.style.height = r.height + "px"; + + const markX = r.left + r.width * dockOffsetXRatio; + const markY = r.top + r.height * dockOffsetYRatio; + labelEl.style.display = ""; + labelEl.textContent = + "mode=" + + mode + + " F8=aim F9=frame F10=mode F11=reset" + + String.fromCharCode(10) + + "sel=" + + INPUT_SELECTOR + + String.fromCharCode(10) + + "left=" + + Math.round(r.left) + + " top=" + + Math.round(r.top) + + " w=" + + Math.round(r.width) + + " h=" + + Math.round(r.height) + + String.fromCharCode(10) + + "ratioX=" + + dockOffsetXRatio.toFixed(3) + + " ratioY=" + + dockOffsetYRatio.toFixed(3) + + String.fromCharCode(10) + + "anchor=(" + + Math.round(markX) + + "," + + Math.round(markY) + + ")"; + labelEl.style.left = Math.round(r.left) + "px"; + labelEl.style.top = Math.round(r.top - 72) + "px"; + } else { + frameEl.style.display = "none"; + labelEl.style.display = "none"; + } + } else { + frameEl.style.display = "none"; + labelEl.style.display = "none"; + } + } else { + frameEl.style.display = "none"; + labelEl.style.display = "none"; + } +} + +let pending = false; +function schedule() { + if (pending) return; + pending = true; + requestAnimationFrame(() => { + pending = false; + try { + reposition(); + } catch (err) { + console.error("[Cookie Code] pet reposition err:", err.message); + } + }); +} + +function toggleDebug() { + debugOn = !debugOn; + console.log("[Cookie Code] pet debug:", debugOn ? "ON" : "OFF"); + schedule(); + return debugOn; +} + +function toggleMode() { + mode = mode === "center" ? "docked" : "center"; + console.log("[Cookie Code] pet mode:", mode); + savePetSettings({ petMode: mode }); + schedule(); + return mode; +} + +function setPetId(id) { + currentPetId = String(id || ""); + dataUriCache = null; + savePetSettings({ petId: currentPetId }); + schedule(); +} + +/** + * Включить/выключить debug-режим (разблокирует F8/F9/F10/F11). + * Вызывается из вкладки настроек Cookie Code. + */ +/** + * Включить/выключить показ пета. Вызывается из настроек (UI или TG). + */ +function setEnabled(on) { + petEnabled = !!on; + schedule(); + console.log("[Cookie Code] Pet enabled:", petEnabled ? "ON" : "OFF"); + return petEnabled; +} + +function setDebugMode(on) { + debugMode = !!on; + // Прячем/показываем уголок ресайза в зависимости от режима. + // При включении — не показываем сразу, только при наведении на пета. + if (resizeHandleEl && !debugMode) resizeHandleEl.style.opacity = "0"; + if (!debugMode) { + // Выключаем прицел и debug-рамку при выходе из debug-режима + if (aiming) toggleAim(); + if (debugOn) { + debugOn = false; + schedule(); + } + } + console.log("[Cookie Code] Pet debug mode:", debugMode ? "ON" : "OFF"); + return debugMode; +} + +/** + * Перечитать настройки пета из settings.json (для вызова из настроек после + * изменения petId / petDebugMode / petSize и т.д.). + */ +async function reloadSettings() { + await loadPetSettings(); + dataUriCache = null; + applySize(); + schedule(); +} + +/** + * Сбросить все настройки пета (кроме petDebugMode) к дефолтам + * и записать их в settings.json. После — пересадить пета. + * Вызывается кнопкой «Сбросить» в настройках Cookie Code. + */ +async function resetAllPetSettings() { + const patch = { ...PET_RESET_DEFAULTS }; + // Записываем в settings.json + try { + const api = window.electronAPI; + if (api && typeof api.setCuckooSetting === "function") { + for (const [k, v] of Object.entries(patch)) { + try { + await api.setCuckooSetting(k, v); + } catch (_) {} + } + } + } catch (_) {} + + // Применяем локально + mode = patch.petMode; + dockOffsetXRatio = patch.petRatioX; + dockOffsetYRatio = patch.petRatioY; + petSize = patch.petSize; + currentPetId = patch.petId; + dataUriCache = null; + centerPos = null; // сбросить и «центр», если был в ручном режиме + + applySize(); + schedule(); + + console.log( + "[Cookie Code] Настройки пета сброшены к дефолтам:", + JSON.stringify(patch), + ); + return patch; +} + +function start() { + if (started) return; + started = true; + + // Сначала подтягиваем сохранённые настройки (mode / ratioX / ratioY / size / id), + // потом показываем пета — чтобы он сразу оказался на своём месте. + loadPetSettings().finally(() => { + const tryInit = () => { + if (!document.body) { + setTimeout(tryInit, 200); + return; + } + ensureElements(); + applySize(); + // Спавн по центру как fallback, если поле ввода недоступно + if (!centerPos) { + centerPos = { + x: Math.round((window.innerWidth - petSize) / 2), + y: Math.round((window.innerHeight - petSize) / 2), + }; + } + reposition(); + }; + tryInit(); + }); + + window.addEventListener("scroll", schedule, { passive: true, capture: true }); + window.addEventListener( + "resize", + () => { + // при ресайзе — если позиция не задана вручную, пересчитаем «центр» + if (mode === "center" && !dragging && centerPos) { + // не двигаем — пусть пользователь сам решит; но если вышли за границы — вернём + const vw = window.innerWidth, + vh = window.innerHeight; + if ( + centerPos.x > vw - 20 || + centerPos.y > vh - 20 || + centerPos.x < -petSize + 20 || + centerPos.y < -petSize + 20 + ) { + centerPos = { + x: Math.round((vw - petSize) / 2), + y: Math.round((vh - petSize) / 2), + }; + } + } + schedule(); + }, + { passive: true }, + ); + + const mo = new MutationObserver(schedule); + try { + mo.observe(document.documentElement, { childList: true, subtree: true }); + } catch (_) {} + setInterval(schedule, 300); + + window.addEventListener( + "keydown", + (e) => { + // Escape всегда работает — выйти из прицела можно даже с выключенным debug + if (e.key === "Escape" && aiming) { + e.preventDefault(); + toggleAim(); + return; + } + // Остальные клавиши — только в debug-режиме + if (!debugMode) return; + if (e.key === "F8") { + e.preventDefault(); + toggleAim(); + } else if (e.key === "F9") { + e.preventDefault(); + toggleDebug(); + } else if (e.key === "F10") { + e.preventDefault(); + toggleMode(); + } else if (e.key === "F11") { + e.preventDefault(); + resetSize(); + } + }, + true, + ); + + // Реакция на изменение настроек из TG-бота / другого окна: + // применяем всё, что касается пета, не дожидаясь ручного перезахода в настройки. + try { + if ( + window.electronAPI && + typeof window.electronAPI.onSettingsChanged === "function" + ) { + window.electronAPI.onSettingsChanged((patch) => { + try { + if (!patch) { + // Без патча — перечитываем все настройки целиком. + reloadSettings(); + return; + } + let changed = false; + if ("petEnabled" in patch) { + petEnabled = patch.petEnabled !== false; + changed = true; + } + if ("petSize" in patch) { + const n = Number(patch.petSize); + if (Number.isFinite(n)) { + petSize = Math.max(PET_SIZE_MIN, Math.min(PET_SIZE_MAX, n)); + applySize(); + changed = true; + } + } + if ( + "petMode" in patch && + (patch.petMode === "center" || patch.petMode === "docked") + ) { + mode = patch.petMode; + changed = true; + } + if ("petRatioX" in patch && typeof patch.petRatioX === "number") { + dockOffsetXRatio = patch.petRatioX; + changed = true; + } + if ("petRatioY" in patch && typeof patch.petRatioY === "number") { + dockOffsetYRatio = patch.petRatioY; + changed = true; + } + if ("petId" in patch) { + currentPetId = String(patch.petId || ""); + dataUriCache = null; + changed = true; + } + if ("petDebugMode" in patch) { + debugMode = patch.petDebugMode === true; + if (resizeHandleEl && !debugMode) + resizeHandleEl.style.opacity = "0"; + changed = true; + } + if (changed) { + console.log( + "[Cookie Code] Pet: применены настройки из события", + patch, + ); + schedule(); + } + } catch (err) { + console.error( + "[Cookie Code] onSettingsChanged(pet) error:", + err.message, + ); + } + }); + } + } catch (_) {} + + // ВАЖНО: чтобы точно увидеть пета даже если body ещё перезаписывается + console.log( + "[Cookie Code] Pet started. pets dir =", + PETS_DIR || "(unknown)", + "| F8=aim, F9=debug frame, F10=mode, F11=reset size, drag=move", + ); +} + +module.exports = { + start, + schedule, + toggleDebug, + toggleMode, + toggleAim, + setPetId, + resetSize, + resetAllPetSettings, + setEnabled, + setDebugMode, + reloadSettings, + loadPetSettings, + savePetSettings, + PET_RESET_DEFAULTS, +}; diff --git a/src/preload/dom/settings-tab.js b/src/preload/dom/settings-tab.js index c548a33..e30d4fc 100644 --- a/src/preload/dom/settings-tab.js +++ b/src/preload/dom/settings-tab.js @@ -44,15 +44,23 @@ function activateCuckooTab() { // Скрываем родной контент if (nativeScroll) nativeScroll.style.display = "none"; - // Вставляем свой блок, если его ещё нет + // Всегда пересоздаём контент, чтобы подхватить свежие CSS/HTML. + // Старый удаляем. let ourContent = document.getElementById(TAB_CONTENT_ID); + if (ourContent) { + try { + ourContent.remove(); + } catch (_) {} + ourContent = null; + } if (!ourContent) { ourContent = document.createElement("div"); ourContent.id = TAB_CONTENT_ID; ourContent.style.cssText = "display: flex; flex-direction: column; gap: 16px; " + - "width: 100%; height: 100%; overflow-y: auto; " + + "width: 100%; min-width: 0; height: 100%; overflow-y: auto; " + "padding: 20px 24px; box-sizing: border-box; " + + "align-items: stretch; " + 'font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif; ' + "color: #dde1ff;"; @@ -89,6 +97,8 @@ function activateCuckooTab() { // Подтверждение инструментов + скрытие служебных сообщений bindAgentSettings(); refreshAgentSettings(); + // Чубрики (петы) + bindPetsSection(); } ourContent.style.display = ""; @@ -111,11 +121,20 @@ function buildContentHTML() { " border-radius: 14px; box-shadow: 0 6px 28px rgba(0,0,0,0.28); overflow: hidden; }" + " .ck-stack { display: flex; flex-direction: column; }" + " .ck-stack > * + * { border-top: 1px solid rgba(255,255,255,0.06); }" + - " .ck-row { padding: 14px 16px; transition: background 0.16s ease; }" + + " .ck-row { padding: 14px 16px; transition: background 0.16s ease; min-width: 0; }" + " .ck-row:hover { background: rgba(139,147,255,0.05); }" + - " .ck-row-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; }" + - " .ck-row-title { font-size: 13.5px; font-weight: 600; color: #e8eaff; }" + - " .ck-row-hint { font-size: 11.5px; color: #8a90b8; margin-top: 3px; line-height: 1.45; max-width: 520px; }" + + " .ck-row-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; min-width: 0; }" + + " .ck-row-head > * { min-width: 0; }" + + " .ck-row-head > *:first-child { flex: 1 1 auto; }" + + " .ck-row-head > *:last-child { flex: 0 0 auto; }" + + " .ck-row-title { font-size: 13.5px; font-weight: 600; color: #e8eaff; " + + " word-break: normal; overflow-wrap: break-word; }" + + " .ck-row-hint { font-size: 11.5px; color: #8a90b8; margin-top: 3px; line-height: 1.45; max-width: 520px; " + + " word-break: normal; overflow-wrap: break-word; white-space: normal; }" + + " .ck-card, .ck-stack { min-width: 0; }" + + " #cuckoo-settings-content { min-width: 0; width: 100%; align-items: stretch; }" + + " #cuckoo-settings-content > div { min-width: 0; width: 100%; flex-shrink: 0; }" + + " #cuckoo-settings-content * { word-break: normal; overflow-wrap: break-word; }" + " .ck-badge { display: inline-block; font-size: 10px; font-weight: 700; letter-spacing: 0.04em; " + " text-transform: uppercase; padding: 2px 7px; border-radius: 999px; " + " background: rgba(139,147,255,0.16); color: #bec2ff; margin-left: 8px; vertical-align: middle; }" + @@ -404,6 +423,24 @@ function buildContentHTML() { "" + "
" + " " + + // ===== Таймаут выполнения JS-скриптов ===== + '
' + + '
' + + t("settings.jsTimeout.title") + + "
" + + '
' + + t("settings.jsTimeout.hint") + + "
" + + '
' + + ' ' + + ' ' + + t("settings.jsTimeout.unit") + + "" + + ' " + + "
" + + "
" + '
' + '
" + + // ===== Чубрики (петы) ===== + buildPetsSection() + "
" + '
' + t("settings.section.background") + @@ -591,6 +630,73 @@ function buildContentHTML() { ); } +/** + * HTML-секция «Чубрики (петы)» — карточка в настройках Cookie Code. + * Список спрайтов + тумблер debug-режима + кнопка открыть папку. + * Данные подгружаются асинхронно через window.electronAPI.listPets(). + */ +function buildPetsSection() { + return ( + "
" + + '
' + + t("settings.section.pets") + + "
" + + '
' + + ' " + + '
' + + "
" + + '
' + + t("settings.pets.pickTitle") + + "
" + + '
' + + t("settings.pets.pickHint") + + "
" + + "
" + + '
' + + ' " + + ' " + + ' " + + ' " + + "
" + + '
' + + ' " + + "
" + + ' " + + "
" + + "
" + ); +} + function escapeHtml(s) { return String(s) .replace(/&/g, "&") @@ -633,6 +739,529 @@ function bindBackgroundGrid() { }); } +// ===== Чубрики (петы) ===== + +/** + * Обработчики секции «Чубрики»: список спрайтов, тумблер debug-режима, + * кнопки «Обновить» / «Открыть папку», клики по превью. + */ +function bindPetsSection() { + const grid = document.getElementById("cuckoo-pets-grid"); + const empty = document.getElementById("cuckoo-pets-empty"); + const refreshBtn = document.getElementById("cuckoo-pets-refresh"); + const openBtn = document.getElementById("cuckoo-pets-open-folder"); + const importBtn = document.getElementById("cuckoo-pets-import"); + const resetBtn = document.getElementById("cuckoo-pets-reset"); + const debugChk = document.getElementById("cuckoo-pet-debug-mode"); + const enabledChk = document.getElementById("cuckoo-pet-enabled"); + if (!grid) return; + + let currentPetId = ""; + let currentPetFile = ""; + + const renderPets = (pets) => { + grid.innerHTML = ""; + if (!pets || pets.length === 0) { + empty.style.display = ""; + return; + } + empty.style.display = "none"; + pets.forEach((p) => { + const item = document.createElement("div"); + item.className = "cuckoo-bg-item"; + item.setAttribute("data-pet-id", p.id); + item.setAttribute("data-pet-file", p.file); + const active = + p.id === currentPetId || p.file === currentPetFile + ? " cuckoo-bg-selected" + : ""; + item.className += active; + item.style.position = "relative"; + item.title = p.label; + // Превью + const prev = document.createElement("div"); + prev.className = "cuckoo-bg-preview"; + prev.style.backgroundImage = + 'url("' + background.getPreviewUri(p.file) + '")'; + prev.style.backgroundSize = "contain"; + prev.style.backgroundRepeat = "no-repeat"; + item.appendChild(prev); + // Подпись + const lbl = document.createElement("div"); + lbl.className = "cuckoo-bg-label"; + lbl.textContent = p.label; + item.appendChild(lbl); + + // Кнопка «Вырезать фон» — только для GIF + const isGif = /\.gif$/i.test(p.file); + if (isGif) { + const chromaBtn = document.createElement("button"); + chromaBtn.className = "ck-btn"; + chromaBtn.style.cssText = + "position:absolute;top:4px;right:4px;padding:3px 6px;font-size:10px;" + + "border-radius:6px;background:rgba(139,147,255,0.85);color:#fff;" + + "border:none;cursor:pointer;z-index:2;opacity:0.9;"; + chromaBtn.textContent = t("settings.pets.chromaBtn"); + chromaBtn.title = t("settings.pets.chromaTitle"); + chromaBtn.addEventListener("click", (e) => { + e.stopPropagation(); + openChromaModal(p); + }); + item.appendChild(chromaBtn); + } + + // Клик — выбрать + item.addEventListener("click", async () => { + try { + currentPetId = p.id; + currentPetFile = p.file; + await window.electronAPI.setCuckooSetting("petId", p.id); + // Просим пет перечитать настройки (если preload уже отрисовал пета) + try { + const pet = require("./pet"); + if (pet && typeof pet.reloadSettings === "function") { + await pet.reloadSettings(); + } + } catch (_) {} + // Перерисовать сетку с активной рамкой + grid.querySelectorAll(".cuckoo-bg-item").forEach((el) => { + el.classList.toggle( + "cuckoo-bg-selected", + el.getAttribute("data-pet-id") === p.id, + ); + }); + } catch (err) { + console.error("[Cookie Code] Не удалось выбрать пета:", err.message); + } + }); + grid.appendChild(item); + }); + }; + + const loadPets = async () => { + try { + const settings = await window.electronAPI.getCuckooSettings(); + currentPetId = (settings && settings.petId) || ""; + const res = await window.electronAPI.listPets(); + const pets = (res && res.pets) || []; + // Если petId пустой — пометим первый файл как выбранный + if (!currentPetId && pets.length > 0) currentPetId = pets[0].id; + renderPets(pets); + } catch (err) { + console.error( + "[Cookie Code] Не удалось загрузить список петов:", + err.message, + ); + empty.style.display = ""; + } + }; + + if (refreshBtn) refreshBtn.addEventListener("click", loadPets); + + if (resetBtn) + resetBtn.addEventListener("click", async () => { + try { + try { + const pet = require("./pet"); + if (pet && typeof pet.resetAllPetSettings === "function") { + await pet.resetAllPetSettings(); + } + } catch (_) {} + window.electronAPI.showBannerNotification( + t("settings.pets.resetDone"), + { duration: 3500 }, + ); + await loadPets(); + } catch (err) { + console.error("[Cookie Code] Сброс настроек пета失败:", err.message); + } + }); + + if (importBtn) + importBtn.addEventListener("click", async () => { + try { + const res = await window.electronAPI.importPet(); + if (!res || res.canceled) return; + if (!res.success) { + window.electronAPI.showBannerNotification( + t("settings.pets.importError").replace( + "{msg}", + res.error || "unknown", + ), + { duration: 6000 }, + ); + return; + } + + // Если файл был сжат — сообщим размеры до/после + if (res.normalized && res.before && res.after) { + window.electronAPI.showBannerNotification( + t("settings.pets.importCompressed") + .replace("{w1}", String(res.before.w)) + .replace("{h1}", String(res.before.h)) + .replace("{w2}", String(res.after.w)) + .replace("{h2}", String(res.after.h)), + { duration: 5000 }, + ); + } + + // Автоматически выбираем загруженного пета + try { + await window.electronAPI.setCuckooSetting("petId", res.id); + currentPetId = res.id; + try { + const pet = require("./pet"); + if (pet && typeof pet.reloadSettings === "function") { + await pet.reloadSettings(); + } + } catch (_) {} + } catch (_) {} + + await loadPets(); + } catch (err) { + console.error("[Cookie Code] Импорт пета失败:", err.message); + } + }); + + if (openBtn) + openBtn.addEventListener("click", async () => { + try { + await window.electronAPI.openPetsFolder(); + // после открытия — обновим список через небольшую задержку + setTimeout(loadPets, 800); + } catch (err) { + console.error( + "[Cookie Code] Не удалось открыть папку петов:", + err.message, + ); + } + }); + + if (enabledChk) { + enabledChk.addEventListener("change", async () => { + const on = enabledChk.checked; + try { + await window.electronAPI.setCuckooSetting("petEnabled", on); + try { + const pet = require("./pet"); + if (pet && typeof pet.setEnabled === "function") pet.setEnabled(on); + } catch (_) {} + } catch (err) { + console.error( + "[Cookie Code] Не удалось сохранить petEnabled:", + err.message, + ); + } + }); + } + + if (debugChk) { + debugChk.addEventListener("change", async () => { + const on = debugChk.checked; + try { + await window.electronAPI.setCuckooSetting("petDebugMode", on); + try { + const pet = require("./pet"); + if (pet && typeof pet.setDebugMode === "function") + pet.setDebugMode(on); + } catch (_) {} + } catch (err) { + console.error( + "[Cookie Code] Не удалось сохранить petDebugMode:", + err.message, + ); + } + }); + } + + // Начальное состояние тумблера debug-режима + загрузка списка + (async () => { + try { + const settings = await window.electronAPI.getCuckooSettings(); + if (debugChk) debugChk.checked = !!(settings && settings.petDebugMode); + // petEnabled: по умолчанию true → галочка стоит, если не выставлено false. + if (enabledChk) + enabledChk.checked = !settings || settings.petEnabled !== false; + } catch (_) {} + await loadPets(); + })(); +} + +// ===== Chroma-key редактор для GIF ===== + +/** + * Открыть модалку редактора фона для GIF. + * Показывает первый кадр, даёт пипетку и слайдер tolerance, применяет + * через main-процесс (IPC cuckoo-pets-chroma). + * + * @param {{id: string, label: string, file: string}} pet + */ +function openChromaModal(pet) { + // Уже есть — закрываем старую + const old = document.getElementById("cuckoo-chroma-modal"); + if (old) old.remove(); + + const modal = document.createElement("div"); + modal.id = "cuckoo-chroma-modal"; + modal.style.cssText = + "position:fixed;inset:0;z-index:2147483650;background:rgba(6,8,18,0.78);" + + "display:flex;align-items:center;justify-content:center;padding:20px;"; + + const inner = document.createElement("div"); + inner.style.cssText = + "background:#141726;border:1px solid rgba(139,147,255,0.35);border-radius:14px;" + + "width:min(640px,100%);max-height:90vh;display:flex;flex-direction:column;" + + "box-shadow:0 20px 60px rgba(0,0,0,0.6);overflow:hidden;"; + + // Header + const header = document.createElement("div"); + header.style.cssText = + "padding:14px 18px;border-bottom:1px solid rgba(255,255,255,0.07);" + + "display:flex;justify-content:space-between;align-items:center;"; + const title = document.createElement("div"); + title.style.cssText = "font-size:15px;font-weight:700;color:#eef0ff;"; + title.textContent = t("settings.pets.chromaModalTitle"); + const closeBtn = document.createElement("button"); + closeBtn.className = "ck-btn"; + closeBtn.style.cssText = "padding:6px 12px;font-size:12px;"; + closeBtn.textContent = "✕"; + closeBtn.addEventListener("click", () => modal.remove()); + header.appendChild(title); + header.appendChild(closeBtn); + inner.appendChild(header); + + // Body + const body = document.createElement("div"); + body.style.cssText = + "padding:14px 18px;overflow-y:auto;display:flex;flex-direction:column;gap:12px;"; + + const hint = document.createElement("div"); + hint.className = "ck-row-hint"; + hint.style.margin = "0"; + hint.textContent = t("settings.pets.chromaHint"); + body.appendChild(hint); + + // Canvas + const canvasWrap = document.createElement("div"); + canvasWrap.style.cssText = + "background:repeating-conic-gradient(#2a2d40 0% 25%, #1d2030 0% 50%) 50% / 16px 16px;" + + "border-radius:10px;padding:8px;display:flex;justify-content:center;"; + const canvas = document.createElement("canvas"); + canvas.style.cssText = + "max-width:100%;max-height:320px;image-rendering:pixelated;cursor:crosshair;" + + "border-radius:6px;"; + canvasWrap.appendChild(canvas); + body.appendChild(canvasWrap); + + const previewHint = document.createElement("div"); + previewHint.className = "ck-row-hint"; + previewHint.style.margin = "0"; + previewHint.textContent = t("settings.pets.chromaPreviewHint"); + body.appendChild(previewHint); + + // Выбранный цвет + const colorRow = document.createElement("div"); + colorRow.style.cssText = + "display:flex;align-items:center;gap:10px;font-size:12px;color:#cfd3ff;"; + const colorSwatch = document.createElement("div"); + colorSwatch.style.cssText = + "width:24px;height:24px;border-radius:6px;border:1px solid rgba(255,255,255,0.25);" + + "background:#000;flex-shrink:0;"; + const colorLabel = document.createElement("span"); + colorLabel.textContent = t("settings.pets.chromaPicked").replace( + "{color}", + "—", + ); + colorRow.appendChild(colorSwatch); + colorRow.appendChild(colorLabel); + body.appendChild(colorRow); + + // Tolerance + const tolRow = document.createElement("div"); + tolRow.className = "cuckoo-blur-row"; + tolRow.style.cssText = + "display:flex;flex-direction:column;gap:6px;padding:10px 12px;" + + "background:rgba(255,255,255,0.035);border:1px solid rgba(255,255,255,0.07);" + + "border-radius:10px;"; + const tolLabel = document.createElement("div"); + tolLabel.className = "cuckoo-blur-label"; + tolLabel.innerHTML = + "" + + t("settings.pets.chromaTolerance") + + "" + + '16'; + const tolInput = document.createElement("input"); + tolInput.type = "range"; + tolInput.min = "0"; + tolInput.max = "128"; + tolInput.value = "16"; + tolInput.className = "cuckoo-blur-slider"; + tolInput.id = "cuckoo-chroma-tol"; + tolInput.addEventListener("input", () => { + document.getElementById("cuckoo-chroma-tol-val").textContent = + tolInput.value; + }); + tolRow.appendChild(tolLabel); + tolRow.appendChild(tolInput); + body.appendChild(tolRow); + + // Footer + const footer = document.createElement("div"); + footer.style.cssText = + "padding:12px 18px;border-top:1px solid rgba(255,255,255,0.07);" + + "display:flex;gap:8px;justify-content:flex-end;"; + const cancelBtn = document.createElement("button"); + cancelBtn.className = "ck-btn"; + cancelBtn.textContent = t("settings.pets.chromaCancel"); + cancelBtn.addEventListener("click", () => modal.remove()); + const applyBtn = document.createElement("button"); + applyBtn.className = "ck-btn"; + applyBtn.style.background = "rgba(139,147,255,0.35)"; + applyBtn.textContent = t("settings.pets.chromaApply"); + footer.appendChild(cancelBtn); + footer.appendChild(applyBtn); + + inner.appendChild(body); + inner.appendChild(footer); + modal.appendChild(inner); + document.body.appendChild(modal); + + // ===== Загрузка GIF в canvas ===== + const img = new Image(); + const dataUri = background.getPreviewUri(pet.file); + let pickedColor = null; // {r,g,b,hex} + + const redraw = () => { + if (!img.naturalWidth) return; + canvas.width = img.naturalWidth; + canvas.height = img.naturalHeight; + const ctx = canvas.getContext("2d"); + ctx.clearRect(0, 0, canvas.width, canvas.height); + ctx.drawImage(img, 0, 0); + }; + + img.onload = () => { + redraw(); + }; + img.onerror = () => { + console.error("[Cookie Code] Не удалось загрузить GIF:", pet.file); + }; + img.src = dataUri; + + // ===== Пипетка ===== + canvas.addEventListener("click", (e) => { + const rect = canvas.getBoundingClientRect(); + const x = Math.floor(((e.clientX - rect.left) / rect.width) * canvas.width); + const y = Math.floor( + ((e.clientY - rect.top) / rect.height) * canvas.height, + ); + if (x < 0 || y < 0 || x >= canvas.width || y >= canvas.height) return; + const ctx = canvas.getContext("2d"); + const px = ctx.getImageData(x, y, 1, 1).data; + const r = px[0]; + const g = px[1]; + const b = px[2]; + const hex = + "#" + + ((1 << 24) | (r << 16) | (g << 8) | b) + .toString(16) + .slice(1) + .toUpperCase(); + pickedColor = { r, g, b, hex }; + colorSwatch.style.background = hex; + colorLabel.textContent = t("settings.pets.chromaPicked").replace( + "{color}", + hex, + ); + }); + + // ===== Применить ===== + applyBtn.addEventListener("click", async () => { + if (!pickedColor) { + await window.electronAPI.showBannerNotification( + t("settings.pets.chromaNoColor"), + { duration: 4000 }, + ); + return; + } + applyBtn.disabled = true; + const oldText = applyBtn.textContent; + applyBtn.textContent = t("settings.pets.chromaProcessing"); + try { + const tolerance = Number(tolInput.value) || 0; + const res = await window.electronAPI.chromaKeyPet( + pet.file, + pickedColor.hex, + tolerance, + ); + if (res && res.success) { + await window.electronAPI.showBannerNotification( + t("settings.pets.chromaDone"), + { duration: 4000 }, + ); + modal.remove(); + // Сброс кэша data-URI в background, перерисовка превью и пета. + try { + if (typeof background.invalidatePreviewCache === "function") { + background.invalidatePreviewCache(pet.file); + } + } catch (_) {} + try { + const itemEl = document.querySelector( + '#cuckoo-pets-grid .cuckoo-bg-item[data-pet-file="' + + String(pet.file).replace(/"/g, '\\"') + + '"]', + ); + if (itemEl) { + const prev = itemEl.querySelector(".cuckoo-bg-preview"); + if (prev) { + const newUri = background.getPreviewUri(pet.file); + prev.style.backgroundImage = 'url("' + newUri + '")'; + } + } + } catch (err) { + console.error( + "[Cookie Code] Не удалось обновить превью:", + err.message, + ); + } + try { + const petMod = require("./pet"); + if (petMod && typeof petMod.reloadSettings === "function") { + await petMod.reloadSettings(); + } + } catch (_) {} + } else { + await window.electronAPI.showBannerNotification( + t("settings.pets.chromaError").replace( + "{msg}", + (res && res.error) || "unknown", + ), + { duration: 6000 }, + ); + applyBtn.disabled = false; + applyBtn.textContent = oldText; + } + } catch (err) { + await window.electronAPI.showBannerNotification( + t("settings.pets.chromaError").replace("{msg}", err.message), + { duration: 6000 }, + ); + applyBtn.disabled = false; + applyBtn.textContent = oldText; + } + }); + + // Esc — закрыть + const onKey = (e) => { + if (e.key === "Escape") { + modal.remove(); + window.removeEventListener("keydown", onKey, true); + } + }; + window.addEventListener("keydown", onKey, true); +} + /** * Собрать HTML одного превью фона. */ @@ -1390,6 +2019,43 @@ function bindAgentSettings() { }); }); + // ===== Таймаут JS-скриптов ===== + const timeoutInput = document.getElementById("cuckoo-js-timeout"); + const timeoutSave = document.getElementById("cuckoo-js-timeout-save"); + if (timeoutInput && timeoutSave) { + const trySave = async () => { + const raw = Number(timeoutInput.value); + if (!Number.isFinite(raw) || raw < 10 || raw > 1000) { + window.electronAPI.showBannerNotification( + t("settings.jsTimeout.invalid"), + { duration: 4000 }, + ); + return; + } + const sec = Math.round(raw); + timeoutInput.value = sec; + try { + await window.electronAPI.setCuckooSetting("jsTimeoutSec", sec); + window.electronAPI.showBannerNotification( + t("settings.jsTimeout.saved").replace("{sec}", String(sec)), + { duration: 3500 }, + ); + } catch (err) { + console.error( + "[Cookie Code] Не удалось сохранить jsTimeoutSec:", + err.message, + ); + } + }; + timeoutSave.addEventListener("click", trySave); + timeoutInput.addEventListener("keydown", (e) => { + if (e.key === "Enter") { + e.preventDefault(); + trySave(); + } + }); + } + const cb = document.getElementById("cuckoo-hide-system-messages"); if (cb) { cb.addEventListener("change", async () => { @@ -1565,6 +2231,14 @@ async function refreshAgentSettings() { applyConvTokensVisibility(convTokensCb.checked); } catch (_) {} } + // Таймаут JS-скриптов + const timeoutInput = document.getElementById("cuckoo-js-timeout"); + if (timeoutInput) { + const sec = Number(s && s.jsTimeoutSec); + timeoutInput.value = String( + Number.isFinite(sec) && sec >= 10 && sec <= 1000 ? Math.round(sec) : 60, + ); + } } catch (_) {} } diff --git a/src/preload/i18n/i18n.js b/src/preload/i18n/i18n.js index 9f41611..a805900 100644 --- a/src/preload/i18n/i18n.js +++ b/src/preload/i18n/i18n.js @@ -321,6 +321,108 @@ const KEYS = { en: "Dangerous commands (regex, one per line)", }, "settings.section.background": { ru: "Фон страницы", en: "Page background" }, + // ---- Чубрики (петы) ---- + "settings.section.pets": { ru: "Чубрики", en: "Pets" }, + "settings.pets.chromaBtn": { ru: "🎨 Фон", en: "🎨 BG" }, + "settings.pets.chromaTitle": { + ru: "Вырезать цвет фона (chroma-key)", + en: "Remove background color (chroma-key)", + }, + "settings.pets.chromaModalTitle": { + ru: "Вырезать фон GIF", + en: "Remove GIF background", + }, + "settings.pets.chromaHint": { + ru: "Кликните по картинке, чтобы выбрать цвет фона. Все пиксели этого цвета станут прозрачными во всех кадрах.", + en: "Click the image to pick the background color. All pixels of that color become transparent in every frame.", + }, + "settings.pets.chromaPicked": { + ru: "Выбран цвет: {color}", + en: "Picked color: {color}", + }, + "settings.pets.chromaTolerance": { + ru: "Допуск (похожесть оттенков)", + en: "Tolerance (color similarity)", + }, + "settings.pets.chromaApply": { ru: "Применить", en: "Apply" }, + "settings.pets.chromaCancel": { ru: "Отмена", en: "Cancel" }, + "settings.pets.chromaNoColor": { + ru: "Сначала выберите цвет кликом по картинке", + en: "Pick a color by clicking the image first", + }, + "settings.pets.chromaDone": { + ru: "Готово! Фон удалён во всех кадрах.", + en: "Done! Background removed in all frames.", + }, + "settings.pets.chromaError": { + ru: "Ошибка: {msg}", + en: "Error: {msg}", + }, + "settings.pets.chromaProcessing": { + ru: "Обработка…", + en: "Processing…", + }, + "settings.pets.chromaPreviewHint": { + ru: "Превью — 1-й кадр. Цвет применяется ко всем кадрам.", + en: "Preview is frame 1. Color is applied to every frame.", + }, + "settings.pets.enabledTitle": { ru: "Пет включён", en: "Pet enabled" }, + "settings.pets.enabledHint": { + ru: "Показывать чубрика на экране. По умолчанию — да.", + en: "Show the pet on screen. Enabled by default.", + }, + "settings.pets.pickTitle": { ru: "Выбрать пета", en: "Choose a pet" }, + "settings.pets.pickHint": { + ru: "PNG/GIF/WebP/JPG. Большие картинки автоматически сжимаются до 600×600. Кликните на превью, чтобы выбрать.", + en: "PNG/GIF/WebP/JPG. Large images are auto-resized to 600×600. Click a preview to select.", + }, + "settings.pets.import": { ru: "Загрузить…", en: "Upload…" }, + "settings.pets.reset": { ru: "Сбросить", en: "Reset" }, + // ---- Таймаут JS-скриптов ---- + "settings.jsTimeout.title": { + ru: "Таймаут выполнения JS-скриптов", + en: "JS script execution timeout", + }, + "settings.jsTimeout.hint": { + ru: "Максимальное время выполнения одного блока кода от AI. От 10 до 1000 секунд. По умолчанию 60 сек.", + en: "Maximum execution time for a single AI code block. From 10 to 1000 seconds. Default is 60 sec.", + }, + "settings.jsTimeout.unit": { ru: "сек", en: "sec" }, + "settings.jsTimeout.save": { ru: "Сохранить", en: "Save" }, + "settings.jsTimeout.saved": { + ru: "Таймаут сохранён: {sec} сек", + en: "Timeout saved: {sec} sec", + }, + "settings.jsTimeout.invalid": { + ru: "Введите число от 10 до 1000", + en: "Enter a number between 10 and 1000", + }, + "settings.pets.resetDone": { + ru: "Настройки пета сброшены к дефолтам", + en: "Pet settings reset to defaults", + }, + "settings.pets.refresh": { ru: "Обновить", en: "Refresh" }, + "settings.pets.openFolder": { ru: "Открыть папку", en: "Open folder" }, + "settings.pets.empty": { + ru: "В папке пока нет PNG/GIF. Нажмите «Открыть папку» и положите туда спрайт.", + en: 'No PNG/GIF in the folder yet. Click "Open folder" and drop a sprite there.', + }, + "settings.pets.debugTitle": { + ru: "Debug-режим пета", + en: "Pet debug mode", + }, + "settings.pets.debugHint": { + ru: "Разблокирует горячие клавиши: F8 — прицел (посадить пета), F9 — рамка поля, F10 — режим, F11 — сброс размера.", + en: "Unlocks hotkeys: F8 — aim (place the pet), F9 — field frame, F10 — mode, F11 — reset size.", + }, + "settings.pets.importError": { + ru: "Не удалось загрузить пета: {msg}", + en: "Failed to upload pet: {msg}", + }, + "settings.pets.importCompressed": { + ru: "Пет сжат: {w1}×{h1} → {w2}×{h2}", + en: "Pet compressed: {w1}×{h1} → {w2}×{h2}", + }, "settings.bg.openFolder": { ru: "Открыть папку фонов", en: "Open backgrounds folder", diff --git a/src/preload/index.js b/src/preload/index.js index 6a50a50..3c9ed04 100644 --- a/src/preload/index.js +++ b/src/preload/index.js @@ -6,35 +6,35 @@ * Каждый шаг обёрнут в safe(): сбой одного модуля (например, из-за изменений * вёрстки DeepSeek) не валит init() и не мешает остальным модулям. */ -console.log('[Cookie Code] Preload script 开始执行'); +console.log("[Cookie Code] Preload script 开始执行"); // 暴露 electronAPI 到渲染进程(contextBridge + window 兜底) -require('./api'); - -const ui = require('./overlay/ui'); -const projectDir = require('./overlay/project-dir'); -const bindEvents = require('./overlay/events'); -const observer = require('./dom/observer'); -const chatExport = require('./dom/chat-export'); -const chatInput = require('./dom/chat-input'); -const askUserQuestion = require('./dom/ask-user-question'); -const exitPlanMode = require('./dom/exit-plan-mode'); -const planModeToggle = require('./dom/plan-mode-toggle'); -const stealth = require('./dom/stealth'); -const settingsTab = require('./dom/settings-tab'); -const commands = require('./dom/commands'); -const background = require('./dom/background'); -const reasoningGlass = require('./dom/reasoning-glass'); -const inputGlass = require('./dom/input-glass'); -const forceDarkTheme = require('./dom/force-dark-theme'); -const qrOverride = require('./dom/qr-override'); -const fileChip = require('./dom/file-chip'); -const whatsNew = require('./dom/whats-new'); -const i18n = require('./i18n/i18n'); -const state = require('./dom/state'); -const tokenInterceptor = require('./dom/token-interceptor'); -const { getProviderByUrl } = require('../providers'); -const { safe } = require('./dom/safe'); +require("./api"); + +const ui = require("./overlay/ui"); +const projectDir = require("./overlay/project-dir"); +const bindEvents = require("./overlay/events"); +const observer = require("./dom/observer"); +const chatExport = require("./dom/chat-export"); +const chatInput = require("./dom/chat-input"); +const askUserQuestion = require("./dom/ask-user-question"); +const exitPlanMode = require("./dom/exit-plan-mode"); +const planModeToggle = require("./dom/plan-mode-toggle"); +const stealth = require("./dom/stealth"); +const settingsTab = require("./dom/settings-tab"); +const commands = require("./dom/commands"); +const background = require("./dom/background"); +const reasoningGlass = require("./dom/reasoning-glass"); +const inputGlass = require("./dom/input-glass"); +const forceDarkTheme = require("./dom/force-dark-theme"); +const qrOverride = require("./dom/qr-override"); +const fileChip = require("./dom/file-chip"); +const whatsNew = require("./dom/whats-new"); +const i18n = require("./i18n/i18n"); +const state = require("./dom/state"); +const tokenInterceptor = require("./dom/token-interceptor"); +const { getProviderByUrl } = require("../providers"); +const { safe } = require("./dom/safe"); // ========== 初始化 ========== @@ -45,22 +45,28 @@ const { safe } = require('./dom/safe'); async function init() { try { // Загружаем язык до инъекции HTML — тексты в template.js строятся через t() - await safe('init.loadLanguage', () => i18n.loadLanguage()); + await safe("init.loadLanguage", () => i18n.loadLanguage()); let customizationEnabled = true; try { const settings = await window.electronAPI.getCuckooSettings(); - customizationEnabled = !settings || settings.customizationEnabled !== false; + customizationEnabled = + !settings || settings.customizationEnabled !== false; // Approval gate: режим подтверждения tool-вызовов ('off' | 'risky' | 'all') - state.toolApprovalMode = (settings && settings.toolApprovalMode) || 'off'; + state.toolApprovalMode = (settings && settings.toolApprovalMode) || "off"; // Скрытие служебных сообщений (по умолчанию выключено) - state.hideSystemMessages = Boolean(settings && settings.hideSystemMessages === true); + state.hideSystemMessages = Boolean( + settings && settings.hideSystemMessages === true, + ); // Чипы файловых путей (по умолчанию включено) state.fileChipEnabled = !settings || settings.fileChipEnabled !== false; // Блок «Затронуто» под ответом (по умолчанию включено) - state.showProducedFiles = !settings || settings.showProducedFiles !== false; + state.showProducedFiles = + !settings || settings.showProducedFiles !== false; // Блок «Токены диалога» в панели (по умолчанию выключено) - state.showConvTokens = Boolean(settings && settings.showConvTokens === true); + state.showConvTokens = Boolean( + settings && settings.showConvTokens === true, + ); } catch (_) {} // Прокидываем флаг в shared state: парсинг работает всегда, @@ -69,119 +75,167 @@ async function init() { // Перехват серверных токенов DeepSeek: ставим как можно раньше, // чтобы поймать первый же запрос completion. Работает через safe(). - safe('init.tokenInterceptor', () => tokenInterceptor.install()); + safe("init.tokenInterceptor", () => tokenInterceptor.install()); // Регистрируем IPC-листенеры всегда — от них зависит ввод и парсинг tool-блоков - safe('init.registerIpcListeners', () => chatInput.registerIpcListeners()); - safe('init.registerAskUserQuestionListener', () => askUserQuestion.registerAskUserQuestionListener()); - safe('init.registerExitPlanModeListener', () => exitPlanMode.registerExitPlanModeListener()); - safe('init.registerWhatsNewListener', () => whatsNew.registerWhatsNewListener()); - safe('init.planModeToggleStart', () => planModeToggle.startWatch()); + safe("init.registerIpcListeners", () => chatInput.registerIpcListeners()); + safe("init.registerAskUserQuestionListener", () => + askUserQuestion.registerAskUserQuestionListener(), + ); + safe("init.registerExitPlanModeListener", () => + exitPlanMode.registerExitPlanModeListener(), + ); + safe("init.registerWhatsNewListener", () => + whatsNew.registerWhatsNewListener(), + ); + safe("init.planModeToggleStart", () => planModeToggle.startWatch()); // Базовая UI-инфраструктура нужна всегда: оверлей (кнопка), стили, события - safe('init.injectCSS', () => ui.injectCSS()); - safe('init.injectOverlay', () => ui.injectOverlay()); + safe("init.injectCSS", () => ui.injectCSS()); + safe("init.injectOverlay", () => ui.injectOverlay()); // Скрыть блок «Токены диалога», если он выключен в настройках (по умолчанию). - safe('init.applyConvTokensVisibility', () => { + safe("init.applyConvTokensVisibility", () => { if (state.showConvTokens === true) return; - const section = document.querySelector('.cuckoo-token-section'); + const section = document.querySelector(".cuckoo-token-section"); if (section) { - section.style.display = 'none'; + section.style.display = "none"; const prev = section.previousElementSibling; - if (prev && prev.classList && prev.classList.contains('cuckoo-divider')) prev.style.display = 'none'; + if (prev && prev.classList && prev.classList.contains("cuckoo-divider")) + prev.style.display = "none"; } }); - safe('init.initProjectDirSection', () => projectDir.initProjectDirSection()); - safe('init.bindEvents', () => bindEvents()); - safe('init.updateHomeMode', () => ui.updateHomeMode()); + safe("init.initProjectDirSection", () => + projectDir.initProjectDirSection(), + ); + safe("init.bindEvents", () => bindEvents()); + safe("init.updateHomeMode", () => ui.updateHomeMode()); // Сохранить токены текущего диалога перед уходом/закрытием. - window.addEventListener('beforeunload', () => { - safe('init.saveTokensBeforeUnload', () => bindEvents.computeAndSaveConversationTokens()); + window.addEventListener("beforeunload", () => { + safe("init.saveTokensBeforeUnload", () => + bindEvents.computeAndSaveConversationTokens(), + ); }); // 监听 URL 变化(SPA 路由) - window.addEventListener('popstate', () => { - safe('init.updateHomeModePop', () => ui.updateHomeMode()); - safe('init.refreshTokensPop', () => bindEvents.updateConversationTokenDisplay()); + window.addEventListener("popstate", () => { + safe("init.updateHomeModePop", () => ui.updateHomeMode()); + safe("init.refreshTokensPop", () => + bindEvents.updateConversationTokenDisplay(), + ); }); - window.addEventListener('hashchange', () => { - safe('init.updateHomeModeHash', () => ui.updateHomeMode()); - safe('init.refreshTokensHash', () => bindEvents.updateConversationTokenDisplay()); + window.addEventListener("hashchange", () => { + safe("init.updateHomeModeHash", () => ui.updateHomeMode()); + safe("init.refreshTokensHash", () => + bindEvents.updateConversationTokenDisplay(), + ); }); - setInterval(() => safe('init.updateHomeModeInterval', () => ui.updateHomeMode()), 5000); + setInterval( + () => safe("init.updateHomeModeInterval", () => ui.updateHomeMode()), + 5000, + ); // 首次延迟执行,确保 overlay 已注入 - setTimeout(() => safe('init.updateHomeModeDelayed', () => ui.updateHomeMode()), 500); + setTimeout( + () => safe("init.updateHomeModeDelayed", () => ui.updateHomeMode()), + 500, + ); // 默认显示覆盖层 - 兜底强制显示 - safe('init.forceShowOverlay', () => ui.forceShowOverlay()); + safe("init.forceShowOverlay", () => ui.forceShowOverlay()); // 延迟启动观察器,等待页面框架渲染 - setTimeout(() => safe('init.startObserver', () => observer.startObserver()), 2000); + setTimeout( + () => safe("init.startObserver", () => observer.startObserver()), + 2000, + ); // Скрытие служебных сообщений (результаты инструментов, системный промпт). // Поведенческая фича — работает независимо от customizationEnabled. - safe('init.startStealthWatcher', () => stealth.startStealthWatcher()); + safe("init.startStealthWatcher", () => stealth.startStealthWatcher()); // 启动设置面板标签注入 - safe('init.settingsTabStart', () => settingsTab.start()); + safe("init.settingsTabStart", () => settingsTab.start()); // Slash-команды: автодополнение (review/summarize) - safe('init.commandsStart', () => commands.start()); + safe("init.commandsStart", () => commands.start()); // Принудительно держим тёмную тему DeepSeek - safe('init.forceDarkThemeStart', () => forceDarkTheme.startWatch()); + safe("init.forceDarkThemeStart", () => forceDarkTheme.startWatch()); // Подмена QR-кода в попапе «Скачать приложение» - safe('init.qrOverrideStart', () => qrOverride.startWatch()); + safe("init.qrOverrideStart", () => qrOverride.startWatch()); + + // Подписка на изменения настроек из TG-бота / других окон: + // фон, блюр и стекло применяются мгновенно без перезахода в настройки. + safe("init.backgroundSettingsListener", () => + background.installSettingsListener(), + ); // Стилизация абсолютных путей к файлам как чипов с открытием в системе - safe('init.fileChipSetEnabled', () => fileChip.setEnabled(state.fileChipEnabled !== false)); - safe('init.fileChipStart', () => fileChip.startWatch()); + safe("init.fileChipSetEnabled", () => + fileChip.setEnabled(state.fileChipEnabled !== false), + ); + safe("init.fileChipStart", () => fileChip.startWatch()); // Блок «Затронуто» под ответом AI — вкл/выкл через настройки - safe('init.producedFilesSetEnabled', () => { - const rm = require('./dom/response-meta'); - if (typeof rm.setEnabled === 'function') rm.setEnabled(state.showProducedFiles !== false); + safe("init.producedFilesSetEnabled", () => { + const rm = require("./dom/response-meta"); + if (typeof rm.setEnabled === "function") + rm.setEnabled(state.showProducedFiles !== false); }); // Кнопка экспорта ответа в PDF/DOCX под каждым ответом AI - safe('init.chatExportStart', () => chatExport.startWatch()); + safe("init.chatExportStart", () => chatExport.startWatch()); // Визуальные эффекты применяем только при включённой кастомизации if (customizationEnabled) { // Загружаем настройки и применяем фон - safe('init.backgroundLoadAndApply', () => background.loadAndApply()); + safe("init.backgroundLoadAndApply", () => background.loadAndApply()); // Матовое стекло для плашки «Размышление N секунд» - safe('init.reasoningGlassStart', () => reasoningGlass.startWatch()); + safe("init.reasoningGlassStart", () => reasoningGlass.startWatch()); // Матовое стекло для поля ввода сообщения - safe('init.inputGlassStart', () => inputGlass.startWatch()); + safe("init.inputGlassStart", () => inputGlass.startWatch()); + + // Пет (чубрик) на поле ввода + debug-рамка на F9 + safe("init.petStart", () => require("./dom/pet").start()); } else { // Сбрасываем возможные визуальные эффекты (фон, блюры, RGB-ник) - safe('init.backgroundReset', () => background.apply('none')); - safe('init.backgroundBlurReset', () => background.applyBlur({ backgroundBlur: 0, headerBlur: 0, sidebarBlur: 0, headerOpacity: 0, sidebarOpacity: 0, toolBlockOpacity: 0, toolBlockBlur: 0 })); - safe('init.backgroundRgbReset', () => background.applyRgbUsername(false)); + safe("init.backgroundReset", () => background.apply("none")); + safe("init.backgroundBlurReset", () => + background.applyBlur({ + backgroundBlur: 0, + headerBlur: 0, + sidebarBlur: 0, + headerOpacity: 0, + sidebarOpacity: 0, + toolBlockOpacity: 0, + toolBlockBlur: 0, + }), + ); + safe("init.backgroundRgbReset", () => background.applyRgbUsername(false)); } // Снимаем базовый цвет фона/::before-слой при выключенной кастомизации - safe('init.applyCustomizationEnabled', () => background.applyCustomizationEnabled(customizationEnabled)); + safe("init.applyCustomizationEnabled", () => + background.applyCustomizationEnabled(customizationEnabled), + ); } catch (err) { - console.error('[Cookie Code] init() 出错:', err); + console.error("[Cookie Code] init() 出错:", err); // 兜底:即使出错也强制显示面板 - safe('init.forceShowOverlayFallback', () => ui.forceShowOverlay()); + safe("init.forceShowOverlayFallback", () => ui.forceShowOverlay()); } // 定期巡检:防止面板被意外隐藏 - safe('init.startOverlayWatcher', () => ui.startOverlayWatcher()); + safe("init.startOverlayWatcher", () => ui.startOverlayWatcher()); // 定期提取当前平台用户信息并更新窗口名 - let lastSentUserName = ''; + let lastSentUserName = ""; setInterval(() => { - safe('init.updateWindowName', () => { + safe("init.updateWindowName", () => { const provider = getProviderByUrl(window.location.href); - if (!provider || typeof provider.extractUserInfo !== 'function') return; + if (!provider || typeof provider.extractUserInfo !== "function") return; const text = provider.extractUserInfo(); if (text && text !== lastSentUserName) { lastSentUserName = text; @@ -191,8 +245,10 @@ async function init() { }, 3000); } -if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', () => { init().catch(err => console.error('[Cookie Code] init error:', err)); }); +if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", () => { + init().catch((err) => console.error("[Cookie Code] init error:", err)); + }); } else { - init().catch(err => console.error('[Cookie Code] init error:', err)); + init().catch((err) => console.error("[Cookie Code] init error:", err)); } diff --git a/src/ui/pets/20693346_proof.png b/src/ui/pets/20693346_proof.png new file mode 100644 index 0000000..0575b88 Binary files /dev/null and b/src/ui/pets/20693346_proof.png differ diff --git a/src/ui/pets/chubrik.png b/src/ui/pets/chubrik.png new file mode 100644 index 0000000..558b80d Binary files /dev/null and b/src/ui/pets/chubrik.png differ diff --git a/tools/JsRunner.js b/tools/JsRunner.js index 8d20ddb..e4dba2c 100644 --- a/tools/JsRunner.js +++ b/tools/JsRunner.js @@ -17,11 +17,34 @@ const { decodeOutput, normalizeCommand } = require("./decodeOutput"); // 同步执行超时(vm timeout,覆盖无 await 的死循环) const SYNC_TIMEOUT = 30 * 1000; -// 整体运行截止时间(配合宿主桥接检查,覆盖 async 死循环) -const RUN_DEADLINE = 60 * 1000; +// 整体运行截止时间的默认值(мс);реальное значение читается из настроек. +const RUN_DEADLINE_DEFAULT = 60 * 1000; +const RUN_DEADLINE_MIN = 10 * 1000; +const RUN_DEADLINE_MAX = 1000 * 1000; // 输出长度上限 const OUTPUT_LIMIT = 20000; +/** + * Текущий дедлайн выполнения одного JS-блока (мс). + * Читается из settings.json (ключ jsTimeoutSec, в секундах). + * При ошибке чтения — дефолт 60 сек. + * Клампится в [RUN_DEADLINE_MIN, RUN_DEADLINE_MAX]. + */ +function getRunDeadlineMs() { + try { + const settingsStore = require("../src/main/settings-store"); + const s = settingsStore.readSettings(); + const sec = Number(s && s.jsTimeoutSec); + if (!Number.isFinite(sec) || sec <= 0) return RUN_DEADLINE_DEFAULT; + const ms = sec * 1000; + if (ms < RUN_DEADLINE_MIN) return RUN_DEADLINE_MIN; + if (ms > RUN_DEADLINE_MAX) return RUN_DEADLINE_MAX; + return ms; + } catch (_) { + return RUN_DEADLINE_DEFAULT; + } +} + /** * 沙箱初始化脚本:在沙箱上下文内定义所有工具函数 * 注意:该脚本运行在沙箱 realm 内,其抛出的 Error 也是沙箱 realm 对象,无逃逸风险 @@ -327,7 +350,7 @@ class JsRunner { } const startTime = Date.now(); - const deadlineMs = RUN_DEADLINE; + const deadlineMs = getRunDeadlineMs(); const collectedStats = []; // 唯一跨域桥接函数:AI 代码中的每个工具调用都通过它回到主进程执行。 @@ -544,4 +567,4 @@ class JsRunner { } } -module.exports = { JsRunner }; +module.exports = { JsRunner, getRunDeadlineMs, RUN_DEADLINE_DEFAULT };