From d64a7b06cdaf258d5cab01827be3629bcaf50c45 Mon Sep 17 00:00:00 2001 From: MerfiDEV Date: Thu, 17 Sep 2026 17:41:49 +0300 Subject: [PATCH 1/4] Add user pets (chubrik) feature MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce user pet ("чубрик") support: create /pets, normalize/seed sprites and add pet-image utilities. Add IPC handlers (cuckoo-pets-list, cuckoo-pets-open-folder, cuckoo-pets-import) and expose APIs in preload. Add settings keys (petMode, petRatioX, petRatioY, petSize, petId, petDebugMode) and settings-tab UI for listing/importing/selecting pets and toggling debug. Implement DOM pet renderer (draggable/resizable, docked/center modes, hotkeys F8/F9/F10/F11) in src/preload/dom/pet.js. Minor preload and IPC cleanups and i18n strings for pet UI. New files: src/main/pet-image.js, src/preload/dom/pet.js; modified settings-store, main/index, main/ipc, preload files and settings tab. --- src/main/index.js | 657 +++++++++++++++----------- src/main/ipc.js | 104 +++++ src/main/pet-image.js | 135 ++++++ src/main/settings-store.js | 12 + src/preload/api.js | 10 + src/preload/dom/pet.js | 795 ++++++++++++++++++++++++++++++++ src/preload/dom/settings-tab.js | 296 +++++++++++- src/preload/i18n/i18n.js | 35 ++ src/preload/index.js | 224 +++++---- 9 files changed, 1916 insertions(+), 352 deletions(-) create mode 100644 src/main/pet-image.js create mode 100644 src/preload/dom/pet.js diff --git a/src/main/index.js b/src/main/index.js index eddb212..2430a85 100644 --- a/src/main/index.js +++ b/src/main/index.js @@ -2,44 +2,97 @@ * 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); +} + +// Если папка пуста — пробуем подкинуть дефолтный спрайт чубрика из +// известных мест (Downloads / проект). Пользователь может заменить вручную. +(function seedDefaultPet() { + try { + const target = path.join(CUSTOM_PETS_DIR, "chubrik.png"); + if (fs.existsSync(target)) return; + const candidates = [ + path.join( + process.env.USERPROFILE || process.env.HOME || "", + "Downloads", + "20693345_proof.png", + ), + path.join(__dirname, "..", "ui", "pets", "chubrik.png"), + ]; + for (const src of candidates) { + try { + if (src && fs.existsSync(src)) { + fs.copyFileSync(src, target); + console.log( + "[Cookie Code] Дефолтный спрайт пета скопирован:", + src, + "->", + target, + ); + return; + } + } catch (_) {} + } + } catch (_) {} +})(); + // Имя приложения в системных уведомлениях 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 +105,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 +124,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 +152,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 +164,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 +193,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 +205,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 +228,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 +239,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 +273,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 +400,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 +459,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 +600,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 +705,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 +719,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 +740,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..5c4e277 100644 --- a/src/main/ipc.js +++ b/src/main/ipc.js @@ -1190,6 +1190,110 @@ 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 }; + } + }); + + // Импорт пета из произвольного файла: диалог выбора → копирование в 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..7c33e44 100644 --- a/src/main/settings-store.js +++ b/src/main/settings-store.js @@ -70,6 +70,18 @@ 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: "", + // Debug-режим пета: разблокирует горячие клавиши F8/F9/F10/F11. + petDebugMode: false, }; let cachedPath = null; diff --git a/src/preload/api.js b/src/preload/api.js index 7f823c1..becb7ad 100644 --- a/src/preload/api.js +++ b/src/preload/api.js @@ -141,6 +141,16 @@ 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"); + }, // ========== Todo-задачи ========== getTodos: () => { return ipcRenderer.invoke("todo-get"); diff --git a/src/preload/dom/pet.js b/src/preload/dom/pet.js new file mode 100644 index 0000000..4dbd19f --- /dev/null +++ b/src/preload/dom/pet.js @@ -0,0 +1,795 @@ +/** + * Пет (чубрик) на экране. + * + * Два режима: + * - '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; + +// Позиция в режиме '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; + 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(); + 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. + */ +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, + ); + + // ВАЖНО: чтобы точно увидеть пета даже если 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, + 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..08de931 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; }" + @@ -462,6 +481,8 @@ function buildContentHTML() { " " + "
" + "" + + // ===== Чубрики (петы) ===== + buildPetsSection() + "
" + '
' + t("settings.section.background") + @@ -591,6 +612,62 @@ 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 +710,213 @@ 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"); + 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); + // Клик — выбрать + 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 (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); + } catch (_) {} + await loadPets(); + })(); +} + /** * Собрать HTML одного превью фона. */ diff --git a/src/preload/i18n/i18n.js b/src/preload/i18n/i18n.js index 9f41611..6bf60c7 100644 --- a/src/preload/i18n/i18n.js +++ b/src/preload/i18n/i18n.js @@ -321,6 +321,41 @@ const KEYS = { en: "Dangerous commands (regex, one per line)", }, "settings.section.background": { ru: "Фон страницы", en: "Page background" }, + // ---- Чубрики (петы) ---- + "settings.section.pets": { ru: "Чубрики", en: "Pets" }, + "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" }, + "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..a290475 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,161 @@ 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()); // Стилизация абсолютных путей к файлам как чипов с открытием в системе - 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 +239,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)); } From acea13b30bc822151a39fff773bad06bff28413a Mon Sep 17 00:00:00 2001 From: MerfiDEV Date: Thu, 17 Sep 2026 17:53:58 +0300 Subject: [PATCH 2/4] Add pets UI and configurable JS timeout Introduce pet ("chubrik") support and a configurable JS execution timeout. Added built-in pet assets (src/ui/pets) and seeded them into userData on first run. Extended bot settings with a Pets page, dynamic pet listing, enum helper, and related i18n keys. Added jsTimeoutSec default to settings-store and a settings-tab UI (validation + save) to control the JS timeout. JsRunner now reads the timeout from settings (getRunDeadlineMs, clamped to min/max) and exports related helpers. Minor logging/error handling added for robustness. --- botsrc/i18n.js | 13 +++++ botsrc/index.js | 81 +++++++++++++++++++++++++++++++- src/main/index.js | 59 +++++++++++++---------- src/main/settings-store.js | 4 ++ src/preload/dom/settings-tab.js | 63 +++++++++++++++++++++++++ src/preload/i18n/i18n.js | 19 ++++++++ src/ui/pets/20693346_proof.png | Bin 0 -> 84659 bytes src/ui/pets/chubrik.png | Bin 0 -> 30798 bytes tools/JsRunner.js | 31 ++++++++++-- 9 files changed, 239 insertions(+), 31 deletions(-) create mode 100644 src/ui/pets/20693346_proof.png create mode 100644 src/ui/pets/chubrik.png diff --git a/botsrc/i18n.js b/botsrc/i18n.js index 40139c7..9e265f7 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,11 @@ const STRINGS = { "settings.glass.text": "Прозрачность, размытие и цвета панели Cookie Code.", "settings.agent.title": "🤖 Агент и приватность", "settings.agent.text": "Подтверждения, служебные сообщения, форматтеры.", + "settings.pets.title": "🐦 Чубрики (петы)", + "settings.pets.text": "Выбор спрайта и debug-режим. Файлы — в папке pets.", + "label.petId": "Пет (спрайт)", + "label.petDebugMode": "Debug-режим пета", + "label.jsTimeoutSec": "Таймаут JS-скриптов", "settings.tg.title": "📡 Telegram-бот", "settings.tg.text": "Управление ботом и уведомлениями.", "settings.lang.title": "🌐 Язык бота", @@ -289,6 +295,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 +306,12 @@ 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.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..78454b5 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,39 @@ 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: "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 +804,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 +858,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 +923,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, })); diff --git a/src/main/index.js b/src/main/index.js index 2430a85..bbc1ef5 100644 --- a/src/main/index.js +++ b/src/main/index.js @@ -49,35 +49,44 @@ try { console.error("[Cookie Code] Не удалось нормализовать петов:", err.message); } -// Если папка пуста — пробуем подкинуть дефолтный спрайт чубрика из -// известных мест (Downloads / проект). Пользователь может заменить вручную. -(function seedDefaultPet() { +// Встроенные петы из src/ui/pets/ — копируем в userData при первом запуске, +// чтобы у новых юзеров "из коробки" было 2 чубрика. Существующие файлы не +// трогаем (пользователь мог заменить/сжать их вручную). +(function seedBuiltinPets() { try { - const target = path.join(CUSTOM_PETS_DIR, "chubrik.png"); - if (fs.existsSync(target)) return; - const candidates = [ - path.join( - process.env.USERPROFILE || process.env.HOME || "", - "Downloads", - "20693345_proof.png", - ), - path.join(__dirname, "..", "ui", "pets", "chubrik.png"), - ]; - for (const src of candidates) { + 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 (src && fs.existsSync(src)) { - fs.copyFileSync(src, target); - console.log( - "[Cookie Code] Дефолтный спрайт пета скопирован:", - src, - "->", - target, - ); - return; + if (!fs.existsSync(dst)) { + fs.copyFileSync(src, dst); + copied++; } - } catch (_) {} + } catch (err) { + console.error( + "[Cookie Code] Не удалось скопировать встроенного пета:", + f, + err.message, + ); + } + } + if (copied > 0) { + console.log( + "[Cookie Code] Встроенные петы скопированы в", + CUSTOM_PETS_DIR, + "(" + copied + " шт.)", + ); } - } catch (_) {} + } catch (err) { + console.error("[Cookie Code] seedBuiltinPets error:", err.message); + } })(); // Имя приложения в системных уведомлениях Windows (иначе показывается electron.app.Electron) diff --git a/src/main/settings-store.js b/src/main/settings-store.js index 7c33e44..3c238f1 100644 --- a/src/main/settings-store.js +++ b/src/main/settings-store.js @@ -82,6 +82,10 @@ const DEFAULTS = { petId: "", // 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/dom/settings-tab.js b/src/preload/dom/settings-tab.js index 08de931..7e6865a 100644 --- a/src/preload/dom/settings-tab.js +++ b/src/preload/dom/settings-tab.js @@ -423,6 +423,24 @@ function buildContentHTML() { "" + "
" + "
" + + // ===== Таймаут выполнения JS-скриптов ===== + '
' + + '
' + + t("settings.jsTimeout.title") + + "
" + + '
' + + t("settings.jsTimeout.hint") + + "
" + + '
' + + ' ' + + ' ' + + t("settings.jsTimeout.unit") + + "" + + ' " + + "
" + + "
" + '
' + '