From 71462d83de29cb4eec174c324e917cb71351deaf Mon Sep 17 00:00:00 2001 From: Andrew Zambazos Date: Thu, 11 Sep 2025 20:42:43 +1200 Subject: [PATCH] Replace Nebot plugin with Return YouTube Dislike Removed the Nebot chat plugin and its files, and added the Return YouTube Dislike plugin with main process logic, renderer preload, and manifest. Updated plugin manager and main process to support internal plugin pages and improved plugin event handling. Minor updates to renderer and documentation. --- TYPING_ANIMATION_DEMO.md | 133 +++++ main.js | 8 + plugin-manager.js | 19 + plugins/nebot/main.js | 291 ----------- plugins/nebot/plugin.json | 14 - plugins/nebot/renderer-preload.js | 488 ------------------ plugins/return-youtube-dislike/main.js | 85 +++ plugins/return-youtube-dislike/plugin.json | 17 + .../renderer-preload.js | 286 ++++++++++ renderer/nebot.html | 59 +++ renderer/script.js | 176 +++++-- 11 files changed, 731 insertions(+), 845 deletions(-) create mode 100644 TYPING_ANIMATION_DEMO.md delete mode 100644 plugins/nebot/main.js delete mode 100644 plugins/nebot/plugin.json delete mode 100644 plugins/nebot/renderer-preload.js create mode 100644 plugins/return-youtube-dislike/main.js create mode 100644 plugins/return-youtube-dislike/plugin.json create mode 100644 plugins/return-youtube-dislike/renderer-preload.js create mode 100644 renderer/nebot.html diff --git a/TYPING_ANIMATION_DEMO.md b/TYPING_ANIMATION_DEMO.md new file mode 100644 index 0000000..4dde636 --- /dev/null +++ b/TYPING_ANIMATION_DEMO.md @@ -0,0 +1,133 @@ +# Nebot Typing Animation Feature + +## Overview +Added a realistic typing animation to the Nebot chat interface that makes AI responses appear character by character, similar to ChatGPT and other modern AI chat interfaces. + +## Features Added + +### 1. **Typing Animation** +- Characters appear one by one instead of instantly +- Smooth, natural typing rhythm +- Configurable typing speed +- Blinking cursor indicator during typing + +### 2. **Settings Integration** +- **Enable/Disable Toggle**: Users can turn typing animation on/off +- **Speed Control**: Adjustable from 10-200 characters per second +- **Live Preview**: Speed indicator updates in real-time +- **Persistent Settings**: Preferences are saved and restored + +### 3. **Smart Behavior** +- **Queue Management**: Handles fast token streams efficiently +- **Graceful Fallback**: Falls back to instant display if disabled +- **Markdown Rendering**: Waits for typing to complete before rendering markdown +- **Auto-scroll**: Maintains scroll position during animation + +## Technical Implementation + +### Code Changes Made: + +#### 1. **page.js** - Main Logic +```javascript +// Typing animation state +let typingQueue = []; +let isTyping = false; +let typingSpeed = 25; // milliseconds per character +let typingEnabled = true; // can be toggled in settings + +function startTypingAnimation(element) { + if (isTyping || typingQueue.length === 0) return; + + isTyping = true; + element.classList.add('typing'); + + function typeNext() { + if (typingQueue.length === 0) { + isTyping = false; + element.classList.remove('typing'); + return; + } + + const char = typingQueue.shift(); + element.textContent += char; + els.messages.scrollTop = els.messages.scrollHeight; + + setTimeout(typeNext, typingSpeed); + } + + typeNext(); +} +``` + +#### 2. **page.css** - Visual Effects +```css +/* Typing animation cursor */ +.markdown.typing:after { + content: "β–‹"; + color: var(--accent); + animation: blink 1s infinite; + margin-left: 1px; +} + +@keyframes blink { + 0%, 50% { opacity: 1; } + 51%, 100% { opacity: 0; } +} +``` + +#### 3. **Settings UI** - User Controls +- Checkbox to enable/disable typing animation +- Range slider for speed control (10-200 chars/sec) +- Real-time speed display +- Proper styling for form elements + +## User Experience + +### Before: +- Text appeared instantly when AI responded +- No visual feedback during response generation +- Less engaging interaction + +### After: +- Smooth character-by-character reveal +- Blinking cursor shows active typing +- Configurable speed for user preference +- More engaging, human-like interaction + +## Usage Instructions + +1. **Open Nebot**: Navigate to the Nebot page in Nebula Browser +2. **Start a Chat**: Send a message to begin conversation +3. **Watch the Animation**: AI responses will type out naturally +4. **Customize Settings**: + - Click the βš™ Settings button + - Toggle "Enable typing animation" + - Adjust typing speed with the slider + - Save changes + +## Performance Considerations + +- **Efficient Queuing**: Uses character queue to handle fast token streams +- **Memory Friendly**: Minimal memory overhead +- **Responsive**: Maintains smooth UI during animation +- **Interruptible**: Can be disabled without restart + +## Future Enhancements + +Potential improvements could include: +- Variable speed based on punctuation (pause at periods) +- Sound effects for typing +- Different animation styles +- Per-conversation speed settings +- Typing speed based on message length + +## Testing + +To test the feature: +1. Start Nebula Browser (`npm start`) +2. Navigate to Nebot page +3. Send a message and observe the typing animation +4. Try different speed settings in the settings panel +5. Toggle the feature on/off to compare experiences + +The typing animation enhances the user experience by making AI interactions feel more natural and engaging, similar to popular chat interfaces like ChatGPT. diff --git a/main.js b/main.js index e7eef3f..7822f95 100644 --- a/main.js +++ b/main.js @@ -787,6 +787,11 @@ ipcMain.handle('plugins-get-renderer-preloads', () => { try { return pluginManager.getRendererPreloads(); } catch { return []; } }); +// Plugins: expose registered internal pages (browser://) +ipcMain.handle('plugins-get-pages', () => { + try { return pluginManager.getRendererPages(); } catch { return []; } +}); + // Plugins: management IPC for settings UI ipcMain.handle('plugins-list', () => pluginManager.discoverPlugins()); ipcMain.handle('plugins-set-enabled', async (_e, { id, enabled }) => { @@ -806,6 +811,9 @@ app.on('web-contents-created', (event, contents) => { buildAndShowContextMenu(contents, params); }); + // Emit to plugins + try { pluginManager.emit('web-contents-created', contents); } catch {} + // On macOS, when a page (or a ) enters HTML fullscreen (e.g., YouTube video), // also toggle the BrowserWindow into simple fullscreen so the content uses the whole // screen and macOS traffic lights/titlebar are hidden. Revert when HTML fullscreen exits. diff --git a/plugin-manager.js b/plugin-manager.js index 2ceb9d3..2249c35 100644 --- a/plugin-manager.js +++ b/plugin-manager.js @@ -1,11 +1,13 @@ const fs = require('fs'); const path = require('path'); +const { pathToFileURL } = require('url'); const { app, session, Menu, ipcMain, BrowserWindow, dialog, shell } = require('electron'); class PluginManager { constructor() { this.plugins = []; // { id, dir, manifest, mod, enabled } this.rendererPreloads = []; // absolute file paths + this.rendererPages = []; // { id, file, pluginId } this._listeners = { 'app-ready': [], 'window-created': [], @@ -33,6 +35,7 @@ class PluginManager { loadAll() { this.plugins = []; this.rendererPreloads = []; + this.rendererPages = []; const dirs = this.getPluginDirs(); for (const root of dirs) { let entries = []; @@ -127,6 +130,17 @@ class PluginManager { contributeContextMenu: (contribFn) => { try { manager._contextMenuContribs.push(contribFn); } catch (e) { console.error(logPrefix, 'contributeContextMenu failed', e); } }, + // Register a dedicated internal page (shown via browser://) + registerRendererPage: ({ id, html }) => { + try { + if (!id || !html) return; + let fileUrl = null; + try { fileUrl = pathToFileURL(html).href; } catch {} + manager.rendererPages.push({ id, file: html, fileUrl, pluginId: plugin.id }); + console.log('[Plugins] Registered page:', id, '->', html, 'fileUrl:', fileUrl); + manager.log('registered page:', id, '->', html); + } catch (e) { manager.error('registerRendererPage failed', e); } + } }; } @@ -134,6 +148,11 @@ class PluginManager { return Array.from(new Set(this.rendererPreloads)); } + getRendererPages() { + // Return a shallow copy so callers can't mutate internal array + return this.rendererPages.map(p => ({ ...p })); + } + on(evt, cb) { if (!this._listeners[evt]) this._listeners[evt] = []; this._listeners[evt].push(cb); diff --git a/plugins/nebot/main.js b/plugins/nebot/main.js deleted file mode 100644 index dfc0347..0000000 --- a/plugins/nebot/main.js +++ /dev/null @@ -1,291 +0,0 @@ -// Nebot plugin - main process side -// Responsibilities: -// - Persist chat sessions under the plugin directory (JSON files) -// - IPC handlers for CRUD + streaming chat completions via Ollama HTTP API -// - Add a Help menu item to toggle the chat panel in the renderer - -const fs = require('fs'); -const path = require('path'); - -/** - * A tiny JSON store stored in pluginDir/chats - */ -function ensureDirSync(p) { - try { fs.mkdirSync(p, { recursive: true }); } catch {} -} - -function readJSONSafe(p, fallback) { - try { return JSON.parse(fs.readFileSync(p, 'utf-8')); } catch { return fallback; } -} - -function writeJSONSafe(p, data) { - fs.writeFileSync(p, JSON.stringify(data, null, 2), 'utf-8'); -} - -module.exports.activate = function(ctx) { - const pluginId = 'ollama-chat'; - const pluginDir = ctx.paths?.pluginDir || ctx.paths?.appPath || process.cwd(); - const userPlugins = path.join(ctx.paths?.userData || pluginDir, 'plugins'); - // Prefer saving under userData/plugins/ to ensure write access - const dataRoot = pluginDir.startsWith(userPlugins) - ? pluginDir - : path.join(userPlugins, pluginId); - ensureDirSync(dataRoot); - const chatsDir = path.join(dataRoot, 'chats'); - ensureDirSync(chatsDir); - - // Simple settings (host/model) stored alongside chats - const settingsPath = path.join(dataRoot, 'settings.json'); - ensureDirSync(path.dirname(settingsPath)); - const defaultSettings = { - ollamaBaseUrl: 'http://localhost:11434', - model: 'gpt-oss:20b', - systemPrompt: 'You are Nebot, the embedded chat assistant inside the Nebula browser. Be friendly, confident, and a bit playful. Prefer clear, descriptive answers with brief reasoning when helpful, and include short examples when it aids understanding. Keep responses concise by default; expand only if asked. Stay safe and do not claim capabilities you lack.' - }; - const loadSettings = () => readJSONSafe(settingsPath, defaultSettings); - const saveSettings = (s) => writeJSONSafe(settingsPath, { ...defaultSettings, ...s }); - - async function generateTitleIfNeeded(senderWebContents, chatPath) { - try { - const chat = readJSONSafe(chatPath, null); - if (!chat) return; - const needsTitle = !chat.title || /^new chat/i.test(chat.title) || /^chat \d|^chat \d{1,2}:\d{2}/i.test(chat.title); - if (!needsTitle) return; - if (!Array.isArray(chat.messages) || chat.messages.length < 2) return; // need at least user+assistant - const firstUser = chat.messages.find(m => m.role === 'user'); - const firstAssistant = chat.messages.find(m => m.role === 'assistant'); - if (!firstUser || !firstAssistant) return; - - const userText = String(firstUser.content || '').slice(0, 400); - const asstText = String(firstAssistant.content || '').slice(0, 400); - const { ollamaBaseUrl } = loadSettings(); - const model = 'gpt-oss:20b'; - const prompt = `Create a concise, descriptive chat title (4-8 words) for this conversation. Use Title Case. No quotes. No trailing punctuation.\n\nUser: ${userText}\nAssistant: ${asstText}\n\nTitle:`; - const url = `${ollamaBaseUrl.replace(/\/$/, '')}/api/generate`; - let resp; - try { - resp = await fetch(url, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ model, prompt, stream: false }) - }); - } catch { - return; // no network/title - } - if (!resp.ok) return; - let data; - try { data = await resp.json(); } catch { return; } - let title = (data && typeof data.response === 'string') ? data.response.trim() : ''; - if (!title) return; - // Sanitize: single line, strip quotes - title = title.split('\n')[0].replace(/^"|"$/g, '').replace(/^'|'$/g, '').trim(); - // Clamp length - if (title.length > 80) title = title.slice(0, 77) + '…'; - if (!title) return; - - const latest = readJSONSafe(chatPath, null); - if (!latest) return; - latest.title = title; - latest.updatedAt = Date.now(); - writeJSONSafe(chatPath, latest); - try { senderWebContents?.send('ollama-chat:chat-updated', { id: latest.id, title }); } catch {} - } catch {} - } - - // IPC: list chats - ctx.registerIPC(`${pluginId}:list-chats`, async () => { - const files = fs.readdirSync(chatsDir).filter(f => f.endsWith('.json')); - const chats = files.map(f => { - const p = path.join(chatsDir, f); - const j = readJSONSafe(p, null); - return j ? { id: j.id, title: j.title, updatedAt: j.updatedAt } : null; - }).filter(Boolean).sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0)); - return { chats }; - }); - - // IPC: get chat - ctx.registerIPC(`${pluginId}:get-chat`, async (_e, { id }) => { - const p = path.join(chatsDir, `${id}.json`); - const chat = readJSONSafe(p, null); - if (!chat) return { error: 'not_found' }; - return { chat }; - }); - - // IPC: create chat - ctx.registerIPC(`${pluginId}:create-chat`, async (_e, { title }) => { - const id = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; - const now = Date.now(); - const chat = { id, title: title || 'New chat', createdAt: now, updatedAt: now, messages: [] }; - writeJSONSafe(path.join(chatsDir, `${id}.json`), chat); - return { chat }; - }); - - // IPC: delete chat - ctx.registerIPC(`${pluginId}:delete-chat`, async (_e, { id }) => { - try { fs.unlinkSync(path.join(chatsDir, `${id}.json`)); return { ok: true }; } catch (e) { return { ok: false, error: String(e) }; } - }); - - // IPC: update settings - ctx.registerIPC(`${pluginId}:get-settings`, async () => ({ settings: loadSettings() })); - ctx.registerIPC(`${pluginId}:set-settings`, async (_e, s) => { - // Enforce fixed model regardless of input - const next = { ...s, model: 'gpt-oss:20b' }; - saveSettings(next); - return { settings: loadSettings() }; - }); - - // IPC: append user message and request model completion (streamed) - // Renderer will send: { id, content } - // We append the user message to the chat file, then call Ollama chat API with full history. - ctx.registerIPC(`${pluginId}:send`, async (event, { id, content }) => { - const p = path.join(chatsDir, `${id}.json`); - const chat = readJSONSafe(p, null); - if (!chat) return { error: 'not_found' }; - - chat.messages.push({ role: 'user', content, timestamp: Date.now() }); - chat.updatedAt = Date.now(); - writeJSONSafe(p, chat); - - // Build payload for Ollama - const { ollamaBaseUrl, systemPrompt } = loadSettings(); - const model = 'gpt-oss:20b'; - const fixedIdentity = 'System: You are Nebot, a plugin running inside the Nebula browser. Adopt a helpful, engaging tone. Describe your answers clearly and briefly explain your reasoning when useful. Use concise formatting and small examples. Avoid unsafe content and be honest about limitations.'; - const messages = [ { role: 'system', content: fixedIdentity } ]; - if (systemPrompt) messages.push({ role: 'system', content: systemPrompt }); - for (const m of chat.messages) messages.push({ role: m.role, content: m.content }); - - // Stream back tokens to the same renderer that invoked this call - const senderWebContents = (event && (event.sender?.hostWebContents || event.sender)) || ctx.BrowserWindow.getFocusedWindow()?.webContents; - const channel = `${pluginId}:stream:${id}`; - - // Use global fetch available in recent Electron or node:http as fallback - const url = `${ollamaBaseUrl.replace(/\/$/, '')}/api/chat`; - let resp; - try { - resp = await fetch(url, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ model, messages, stream: true }) - }); - } catch (e) { - ctx.error('Failed to reach Ollama', e); - try { senderWebContents?.send(channel, { type: 'error', message: 'Failed to reach Ollama server' }); } catch {} - return { error: 'network' }; - } - - if (!resp.ok || !resp.body) { - try { senderWebContents?.send(channel, { type: 'error', message: `Bad response: ${resp.status}` }); } catch {} - return { error: `bad_response:${resp.status}` }; - } - - // Stream NDJSON lines with proper boundary handling; treat message.content/response as delta tokens - const reader = resp.body.getReader(); - let assistant = ''; - let buf = ''; - try { - while (true) { - const { done, value } = await reader.read(); - if (done) break; - buf += Buffer.from(value).toString('utf8'); - while (true) { - const idx = buf.indexOf('\n'); - if (idx === -1) break; - const line = buf.slice(0, idx).trim(); - buf = buf.slice(idx + 1); - if (!line) continue; - try { - const j = JSON.parse(line); - if (j.done) { - // Some servers send a final done object - try { senderWebContents?.send(channel, { type: 'done' }); } catch {} - continue; - } - let delta = ''; - if (j && j.message && typeof j.message.content === 'string') { - delta = j.message.content; // chat endpoint streams deltas - assistant += delta; - } else if (typeof j.response === 'string') { - delta = j.response; // generate endpoint style - assistant += delta; - } - if (delta) { - try { senderWebContents?.send(channel, { type: 'token', token: delta }); } catch {} - } - } catch (e) { - // ignore malformed partials - } - } - } - // flush leftover (may be a final JSON object without trailing newline) - const line = buf.trim(); - if (line) { - try { - const j = JSON.parse(line); - if (!j.done) { - let delta = ''; - if (j && j.message && typeof j.message.content === 'string') { - delta = j.message.content; assistant += delta; - } else if (typeof j.response === 'string') { - delta = j.response; assistant += delta; - } - if (delta) { - try { senderWebContents?.send(channel, { type: 'token', token: delta }); } catch {} - } - } - } catch {} - } - } catch (e) { - ctx.warn('stream interrupted', e); - } - - // Persist assistant message - const persisted = readJSONSafe(p, chat); - persisted.messages.push({ role: 'assistant', content: assistant, timestamp: Date.now() }); - persisted.updatedAt = Date.now(); - writeJSONSafe(p, persisted); - - try { senderWebContents?.send(channel, { type: 'done' }); } catch {} - // Fire-and-forget title generation if this is the first assistant response - try { generateTitleIfNeeded(senderWebContents, p); } catch {} - return { ok: true }; - }); - - // Add Help menu toggle - try { - const template = ctx.Menu.getApplicationMenu()?.items?.map(mi => mi); - if (template) { - const help = template.find(i => /help/i.test(i.label || '')); - const insertInto = help || template[template.length - 1]; - if (insertInto && insertInto.submenu) { - insertInto.submenu.append(new ctx.Menu.MenuItem({ - label: 'Toggle Nebot', - click: () => { - const win = ctx.BrowserWindow.getFocusedWindow(); - if (win) win.webContents.send(`${pluginId}:toggle`); - } - })); - ctx.Menu.setApplicationMenu(ctx.Menu.getApplicationMenu()); - } - } - } catch (e) { ctx.warn('menu injection skipped', e); } - - // Bounce renderer-triggered toggles back to the same sender - try { - ctx.ipcMain.on(`${pluginId}:toggle`, (e) => { - try { (e.sender.hostWebContents || e.sender).send(`${pluginId}:toggle`); } catch {} - }); - } catch {} - - // Contribute to right-click context menu - try { - ctx.contributeContextMenu?.((template, params, sender) => { - try { template.push({ type: 'separator' }); } catch {} - template.push({ - label: 'Toggle Nebot', - click: () => { - try { (sender.hostWebContents || sender).send(`${pluginId}:toggle`); } catch {} - } - }); - }); - } catch (e) { ctx.warn('context menu contrib failed', e); } -}; diff --git a/plugins/nebot/plugin.json b/plugins/nebot/plugin.json deleted file mode 100644 index 4c3f7d4..0000000 --- a/plugins/nebot/plugin.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "id": "nebot-chat", - "name": "Nebot", - "version": "0.1.0", - "description": "Nebot: a floating chat panel that talks to a local/remote Ollama server and saves chats in the plugin folder.", - "main": "main.js", - "rendererPreload": "renderer-preload.js", - "categories": ["AI", "Chat", "Utilities"], - "authors": [ - { "name": "Nebula Team", "email": "andrewzambazos@gmail.com" }, - "Bobbybear007" - ], - "enabled": true -} \ No newline at end of file diff --git a/plugins/nebot/renderer-preload.js b/plugins/nebot/renderer-preload.js deleted file mode 100644 index b032ead..0000000 --- a/plugins/nebot/renderer-preload.js +++ /dev/null @@ -1,488 +0,0 @@ -// Renderer preload for Nebot plugin -const { contextBridge, ipcRenderer } = require('electron'); -// Markdown rendering & sanitization -let marked, hljs, createDOMPurify, DOMPurify; -try { - // These will be available after adding dependencies to package.json - marked = require('marked'); - hljs = require('highlight.js'); - createDOMPurify = require('dompurify'); - DOMPurify = createDOMPurify(window); - marked.setOptions({ - breaks: true, - highlight(code, lang) { - try { - if (lang && hljs.getLanguage(lang)) { - return hljs.highlight(code, { language: lang }).value; - } - } catch {} - try { - return hljs.highlightAuto(code).value; - } catch { return code; } - } - }); -} catch (e) { - // If libs aren't available yet, we'll gracefully render as plain text. -} - -const pluginId = 'ollama-chat'; - -// Expose minimal API for page scripts (optional) -contextBridge.exposeInMainWorld('ollamaChat', { - toggle: () => ipcRenderer.send(`${pluginId}:toggle`), - listChats: () => ipcRenderer.invoke(`${pluginId}:list-chats`), - getChat: (id) => ipcRenderer.invoke(`${pluginId}:get-chat`, { id }), - createChat: (title) => ipcRenderer.invoke(`${pluginId}:create-chat`, { title }), - deleteChat: (id) => ipcRenderer.invoke(`${pluginId}:delete-chat`, { id }), - getSettings: () => ipcRenderer.invoke(`${pluginId}:get-settings`), - setSettings: (s) => ipcRenderer.invoke(`${pluginId}:set-settings`, s), - send: (id, content) => ipcRenderer.invoke(`${pluginId}:send`, { id, content }), -}); - -// UI Injection: floating panel -function ensureStyles() { - if (document.getElementById(`${pluginId}-styles`)) return; - const style = document.createElement('style'); - style.id = `${pluginId}-styles`; - style.textContent = ` - .${pluginId}-panel { position: fixed; background: - linear-gradient(180deg, rgba(22,25,37,0.8), rgba(16,18,26,0.82)) padding-box, - linear-gradient(135deg, rgba(140,86,255,0.22), rgba(62,149,255,0.18)) border-box; - color: var(--text, #e8e8f0); border: 1px solid transparent; display: flex; flex-direction: column; overflow: hidden; z-index: 999999; position: fixed; overscroll-behavior: contain; - -webkit-backdrop-filter: blur(12px); backdrop-filter: blur(12px); box-shadow: var(--shadow-1, 0 6px 20px rgba(0,0,0,.35)); } - .${pluginId}-panel.floating { right: 16px; bottom: 16px; width: var(--ollama-chat-width, 460px); height: 70vh; max-height: 92vh; border-radius: var(--radius-lg, 16px); } - .${pluginId}-panel.docked { right: 0; top: var(--nebula-header-height, 0px); bottom: 0; width: var(--ollama-chat-width, 460px); height: calc(100vh - var(--nebula-header-height, 0px)); border-left: 1px solid rgba(255,255,255,0.06); border-radius: 0; box-shadow: none; } - .${pluginId}-resizer { position: absolute; left: 0; top: 0; bottom: 0; width: 8px; cursor: ew-resize; background: linear-gradient(90deg, rgba(255,255,255,0.06), rgba(255,255,255,0)); opacity: 0.25; } - .${pluginId}-resizer:hover { opacity: 0.5; } - .${pluginId}-header { display: flex; align-items: center; justify-content: space-between; padding: 10px 12px; background: - linear-gradient(180deg, rgba(24,26,36,0.7), rgba(24,26,36,0.62)); border-bottom: 1px solid rgba(255,255,255,0.06); font-weight: 600; } - .${pluginId}-btn { background: var(--accent, #7b61ff); color: #fff; border: 1px solid transparent; padding: 6px 10px; border-radius: 8px; cursor: pointer; box-shadow: inset 0 1px 0 rgba(255,255,255,0.05); } - .${pluginId}-btn:hover { filter: brightness(1.05); } - .${pluginId}-btn:active { transform: translateY(1px); } - .${pluginId}-btn.secondary { background: rgba(255,255,255,0.12); border-color: rgba(255,255,255,0.14); color: var(--text, #e8e8f0); } - .${pluginId}-body { display: grid; grid-template-columns: 260px 1fr; flex: 1 1 auto; min-height: 0; height: auto; } - .${pluginId}-sidebar { border-right: 1px solid rgba(255,255,255,0.06); overflow: auto; background: rgba(0,0,0,0.08); min-height: 0; } - .${pluginId}-chatlist { list-style: none; margin: 0; padding: 8px; } - .${pluginId}-chatlist li { display: flex; align-items: center; gap: 8px; padding: 10px 10px; cursor: pointer; border: 1px solid rgba(255,255,255,0.06); border-radius: 12px; margin-bottom: 8px; background: rgba(255,255,255,0.03); } - .${pluginId}-chatlist li:hover { background: rgba(255,255,255,0.06); } - .${pluginId}-chatlist li.active { background: rgba(123,97,255,0.16); border-color: rgba(123,97,255,0.38); } - .${pluginId}-chat-item-main { display: flex; flex-direction: column; gap: 2px; flex: 1 1 auto; min-width: 0; } - .${pluginId}-chat-title { font-size: 13px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } - .${pluginId}-chat-meta { font-size: 11px; color: var(--muted, #a4a7b3); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } - .${pluginId}-chat-actions { display: flex; align-items: center; gap: 4px; } - .${pluginId}-icon-btn { background: transparent; color: var(--text, #e8e8f0); border: 1px solid rgba(255,255,255,0.14); width: 28px; height: 28px; border-radius: 8px; display: inline-flex; align-items: center; justify-content: center; cursor: pointer; } - .${pluginId}-icon-btn:hover { background: rgba(255,255,255,0.12); } - .${pluginId}-main { display: flex; flex-direction: column; flex: 1 1 auto; min-height: 0; } - .${pluginId}-msgs { flex: 1 1 auto; overflow: auto; padding: 14px 12px; scrollbar-width: thin; scrollbar-color: rgba(255,255,255,0.25) transparent; min-height: 0; overscroll-behavior: contain; -webkit-overflow-scrolling: touch; touch-action: pan-y; } - .${pluginId}-msgs::-webkit-scrollbar { width: 10px; } - .${pluginId}-msgs::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.22); border-radius: 10px; } - .${pluginId}-msgs::-webkit-scrollbar-track { background: transparent; } - .${pluginId}-msg { margin: 8px 0; padding: 10px 12px; border-radius: 12px; max-width: 88%; line-height: 1.5; } - .${pluginId}-msg.user { background: - linear-gradient(180deg, rgba(36,40,66,0.8), rgba(28,32,52,0.78)); border: 1px solid rgba(123,97,255,0.28); align-self: flex-end; } - .${pluginId}-msg.assistant { background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.08); align-self: flex-start; } - /* Rich content styles */ - .${pluginId}-msg * { color: inherit; } - .${pluginId}-msg p { margin: 0.4em 0; } - .${pluginId}-msg h1, .${pluginId}-msg h2, .${pluginId}-msg h3 { margin: 0.6em 0 0.3em; font-weight: 700; } - .${pluginId}-msg ul, .${pluginId}-msg ol { padding-left: 1.2em; margin: 0.4em 0; } - .${pluginId}-msg blockquote { margin: 0.6em 0; padding: 0.4em 0.8em; border-left: 3px solid rgba(255,255,255,0.25); background: rgba(255,255,255,0.05); border-radius: 8px; } - .${pluginId}-msg code { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace; background: rgba(0,0,0,0.35); padding: 0.15em 0.35em; border-radius: 6px; } - .${pluginId}-msg pre { background: rgba(0,0,0,0.4); padding: 10px; border-radius: 10px; overflow: auto; border: 1px solid rgba(255,255,255,0.08); } - .${pluginId}-msg pre code { background: transparent; padding: 0; } - /* Minimal highlight colors aligned to theme */ - .${pluginId}-msg .hljs { color: var(--text, #e8e8f0); } - .${pluginId}-msg .hljs-keyword, .${pluginId}-msg .hljs-selector-tag { color: #c792ea; } - .${pluginId}-msg .hljs-string, .${pluginId}-msg .hljs-attr { color: #ecc48d; } - .${pluginId}-msg .hljs-number, .${pluginId}-msg .hljs-literal { color: #f78c6c; } - .${pluginId}-msg .hljs-comment { color: #7f848e; } - .${pluginId}-composer { display: flex; gap: 8px; padding: 10px; border-top: 1px solid rgba(255,255,255,0.06); background: rgba(0,0,0,0.06); } - .${pluginId}-composer textarea { flex: 1; resize: vertical; min-height: 44px; max-height: 140px; background: rgba(0,0,0,0.28); color: var(--text, #e8e8f0); border: 1px solid rgba(255,255,255,0.12); border-radius: 10px; padding: 10px 12px; outline: none; } - .${pluginId}-composer textarea:focus { border-color: rgba(123,97,255,0.45); box-shadow: 0 0 0 3px rgba(123,97,255,0.18); } - .${pluginId}-footer { display: flex; gap: 6px; align-items: center; padding: 8px 10px; background: rgba(0,0,0,0.08); border-top: 1px solid rgba(255,255,255,0.06); color: var(--muted, #a4a7b3); font-size: 12px; } - /* Shrink main page content when docked panel is open */ - #webviews { width: calc(100% - var(--ollama-right-offset, 0px)) !important; } - #home-container { width: calc(100% - var(--ollama-right-offset, 0px)) !important; } - `; - document.head.appendChild(style); -} - -function h(tag, attrs = {}, ...children) { - const el = document.createElement(tag); - for (const [k, v] of Object.entries(attrs)) { - if (k === 'class') el.className = v; - else if (k === 'onclick') el.addEventListener('click', v); - else el.setAttribute(k, v); - } - for (const c of children) { - if (c == null) continue; - el.appendChild(typeof c === 'string' ? document.createTextNode(c) : c); - } - return el; -} - -let state = { chats: [], currentId: null, streaming: false, docked: true, width: 0 }; -let els = {}; - -function getSavedWidth() { - const v = Number(localStorage.getItem(`${pluginId}:width`) || '0'); - return Number.isFinite(v) && v >= 300 ? v : 460; -} - -function saveWidth(w) { - try { localStorage.setItem(`${pluginId}:width`, String(w)); } catch {} -} - -function applyWidth(root, w) { - const min = 320, max = 1024; - const clamped = Math.max(min, Math.min(max, Math.round(w))); - state.width = clamped; - root.style.setProperty('--ollama-chat-width', `${clamped}px`); - setPageOffset(root); -} - -function initResizer(root) { - const handle = h('div', { class: `${pluginId}-resizer` }); - root.appendChild(handle); - let startX = 0, startW = 0, moving = false; - const onMove = (e) => { - if (!moving) return; - const clientX = e.touches ? e.touches[0].clientX : e.clientX; - const deltaX = clientX - startX; - const next = startW - deltaX; // anchored to right, dragging left increases width - applyWidth(root, next); - }; - const onUp = () => { - if (!moving) return; - moving = false; - document.body.style.userSelect = ''; - saveWidth(state.width); - window.removeEventListener('mousemove', onMove); - window.removeEventListener('mouseup', onUp); - window.removeEventListener('touchmove', onMove); - window.removeEventListener('touchend', onUp); - }; - const onDown = (e) => { - e.preventDefault(); - const rect = root.getBoundingClientRect(); - startW = rect.width; - startX = e.touches ? e.touches[0].clientX : e.clientX; - moving = true; - document.body.style.userSelect = 'none'; - window.addEventListener('mousemove', onMove); - window.addEventListener('mouseup', onUp); - window.addEventListener('touchmove', onMove, { passive: false }); - window.addEventListener('touchend', onUp); - }; - handle.addEventListener('mousedown', onDown); - handle.addEventListener('touchstart', onDown, { passive: false }); -} - -function setPageOffset(root) { - try { - // Only offset when docked so the page remains fully visible behind the panel - const px = (state.docked && root && document.body.contains(root)) ? state.width : 0; - document.documentElement.style.setProperty('--ollama-right-offset', `${px}px`); - // Force a reflow so and layout pick up the width change immediately - // by reading offsetWidth of an affected element. - const target = document.getElementById('webviews') || document.getElementById('home-container'); - if (target) void target.offsetWidth; // reflow hint - } catch {} -} - -function closePanel(root) { - setTimeout(() => { - try { document.documentElement.style.setProperty('--ollama-right-offset', '0px'); } catch {} - }, 0); - root.remove(); -} - -function mdToHtml(md) { - // Fall back to simple escape if libs not present - if (!marked || !DOMPurify) { - const div = document.createElement('div'); - div.textContent = md; - return div.innerHTML; - } - const raw = marked.parse(md || ''); - const clean = DOMPurify.sanitize(raw, { ADD_ATTR: ['target', 'rel', 'class'] }); - return clean; -} - -function setRichContent(el, md) { - el.innerHTML = mdToHtml(md); - // Enhance links to open in new tab and be safe - el.querySelectorAll('a[href]').forEach(a => { - a.setAttribute('target', '_blank'); - a.setAttribute('rel', 'noopener noreferrer'); - }); -} - -async function refreshList() { - const { chats } = await ipcRenderer.invoke(`${pluginId}:list-chats`); - state.chats = chats || []; - renderList(); -} - -async function openChat(id) { - state.currentId = id; - const { chat, error } = await ipcRenderer.invoke(`${pluginId}:get-chat`, { id }); - if (error) return; - renderMessages(chat); - renderList(); - subscribeStream(id); -} - -async function newChat() { - const { chat } = await ipcRenderer.invoke(`${pluginId}:create-chat`, { title: 'Chat ' + new Date().toLocaleTimeString() }); - await refreshList(); - await openChat(chat.id); -} - -async function deleteChat(id) { - await ipcRenderer.invoke(`${pluginId}:delete-chat`, { id }); - await refreshList(); - if (state.currentId === id) { - state.currentId = state.chats[0]?.id || null; - if (state.currentId) openChat(state.currentId); else renderMessages(null); - } -} - -function subscribeStream(id) { - // Remove previous - ipcRenderer.removeAllListeners(`${pluginId}:stream:${id}`); - let buffer = ''; - let scheduled = null; - const scheduleRender = () => { - if (scheduled) return; - scheduled = requestAnimationFrame(() => { - const last = els.msgs && els.msgs.querySelector('.streaming'); - if (last) setRichContent(last, buffer); - scheduled = null; - }); - }; - ipcRenderer.on(`${pluginId}:stream:${id}`, (_e, payload) => { - if (!els.msgs) return; - if (payload.type === 'token') { - let last = els.msgs.querySelector('.streaming'); - if (!last) { - last = h('div', { class: `${pluginId}-msg assistant streaming` }); - els.msgs.appendChild(last); - buffer = ''; - } - buffer += payload.token || ''; - scheduleRender(); - els.msgs.scrollTop = els.msgs.scrollHeight; - } else if (payload.type === 'done') { - const last = els.msgs.querySelector('.streaming'); - if (last) { - setRichContent(last, buffer); - last.classList.remove('streaming'); - } - buffer = ''; - } - }); -} - -function renderList() { - if (!els.chatlist) return; - els.chatlist.innerHTML = ''; - for (const c of state.chats) { - const li = h('li', { class: state.currentId === c.id ? 'active' : '', onclick: () => openChat(c.id) }); - const updated = new Date(c.updatedAt || Date.now()).toLocaleString(); - const main = h('div', { class: `${pluginId}-chat-item-main` }, - h('div', { class: `${pluginId}-chat-title` }, c.title || 'Untitled Chat'), - h('div', { class: `${pluginId}-chat-meta` }, updated) - ); - const actions = h('div', { class: `${pluginId}-chat-actions` }); - const del = h('button', { class: `${pluginId}-icon-btn`, title: 'Delete chat', onclick: (e) => { e.stopPropagation(); deleteChat(c.id); } }, 'πŸ—‘'); - actions.appendChild(del); - li.appendChild(main); - li.appendChild(actions); - els.chatlist.appendChild(li); - } -} - -function renderMessages(chat) { - if (!els.msgs) return; - els.msgs.innerHTML = ''; - if (!chat) return; - for (const m of chat.messages) { - const div = h('div', { class: `${pluginId}-msg ${m.role}` }); - setRichContent(div, m.content); - els.msgs.appendChild(div); - } - els.msgs.scrollTop = els.msgs.scrollHeight; -} - -async function sendCurrent() { - const content = els.input.value.trim(); - if (!content) return; - // If no chat selected, create one on first send - if (!state.currentId) { - const { chat } = await ipcRenderer.invoke(`${pluginId}:create-chat`, { title: 'New chat' }); - await refreshList(); - state.currentId = chat.id; - await openChat(state.currentId); - } - els.input.value = ''; - // echo user message into UI immediately - const userDiv = h('div', { class: `${pluginId}-msg user` }); - // Render user content as plain text to avoid accidental HTML - userDiv.textContent = content; - els.msgs.appendChild(userDiv); - els.msgs.scrollTop = els.msgs.scrollHeight; - await ipcRenderer.invoke(`${pluginId}:send`, { id: state.currentId, content }); -} - -function setDockClass(root) { - root.classList.remove('floating', 'docked'); - root.classList.add(state.docked ? 'docked' : 'floating'); -} - -function toggleDock(root) { - state.docked = !state.docked; - setDockClass(root); - if (els.dockBtn) els.dockBtn.textContent = state.docked ? 'Undock' : 'Dock'; - setPageOffset(root); - applyHeaderOffset(); -} - -function panelEl() { - ensureStyles(); - applyHeaderOffset(); - let root = document.getElementById(`${pluginId}-panel`); - if (root) return root; - state.width = getSavedWidth(); - root = h('div', { id: `${pluginId}-panel`, class: `${pluginId}-panel ${state.docked ? 'docked' : 'floating'}` }, - h('div', { class: `${pluginId}-header` }, - h('span', {}, 'Nebot'), - h('div', {}, - h('button', { class: `${pluginId}-btn secondary`, onclick: () => closePanel(root) }, 'Close') - ) - ), - h('div', { class: `${pluginId}-body` }, - h('div', { class: `${pluginId}-sidebar` }, - h('div', { style: 'padding:6px;' }, - h('button', { class: `${pluginId}-btn`, onclick: newChat }, 'New chat') - ), - els.chatlist = h('ul', { class: `${pluginId}-chatlist` }) - ), - h('div', { class: `${pluginId}-main` }, - els.msgs = h('div', { class: `${pluginId}-msgs` }), - h('div', { class: `${pluginId}-composer` }, - els.input = h('textarea', { placeholder: 'Type a message to start a new chat…' }), - h('button', { class: `${pluginId}-btn`, onclick: sendCurrent }, 'Send') - ), - h('div', { class: `${pluginId}-footer` }, - h('small', {}, 'Messages are stored locally in the plugin folder.') - ) - ) - ) - ); - document.body.appendChild(root); - // Route assistant links to open in a new browser tab via host - const routeToNewTab = (url) => { - try { - // Prefer direct sendToHost when available - ipcRenderer.sendToHost('navigate', url, { newTab: true }); - } catch { - try { - if (window.parent && typeof window.parent.postMessage === 'function') { - window.parent.postMessage({ type: 'navigate', url, newTab: true }, '*'); - } else { - window.open(url, '_blank', 'noopener'); - } - } catch { - window.open(url, '_blank', 'noopener'); - } - } - }; - // Delegate clicks from within messages area - els.msgs.addEventListener('click', (e) => { - const a = e.target && e.target.closest ? e.target.closest('a[href]') : null; - if (!a) return; - const href = a.href || a.getAttribute('href'); - if (!href) return; - // Only intercept http(s) links for in-browser tabs - if (/^https?:\/\//i.test(href)) { - e.preventDefault(); - routeToNewTab(href); - } - }); - // Middle-click support (auxclick) - els.msgs.addEventListener('auxclick', (e) => { - if (e.button !== 1) return; - const a = e.target && e.target.closest ? e.target.closest('a[href]') : null; - if (!a) return; - const href = a.href || a.getAttribute('href'); - if (!href) return; - if (/^https?:\/\//i.test(href)) { - e.preventDefault(); - routeToNewTab(href); - } - }); - applyWidth(root, state.width); - initResizer(root); - refreshList().then(() => state.chats[0] && openChat(state.chats[0].id)); - return root; -} - -async function openSettings() { - const { settings } = await ipcRenderer.invoke(`${pluginId}:get-settings`); - const base = prompt('Ollama base URL', settings.ollamaBaseUrl || 'http://homelab.andrewzambazos.com:11434'); - if (base == null) return; - // Model is fixed; show message for clarity - alert('Model is fixed to gpt-oss:20b'); - const systemPrompt = prompt('System prompt', settings.systemPrompt || 'You are a helpful assistant inside the Nebula browser.'); - await ipcRenderer.invoke(`${pluginId}:set-settings`, { ollamaBaseUrl: base, systemPrompt }); -} - -// Listen for toggle from main menu -ipcRenderer.on(`${pluginId}:toggle`, () => { - const existing = document.getElementById(`${pluginId}-panel`); - if (existing) closePanel(existing); else panelEl(); -}); - -// When main updates a chat (e.g., after auto-title), refresh the list and keep selection -ipcRenderer.on('ollama-chat:chat-updated', (_e, { id, title }) => { - if (!state.chats.length) return; - const item = state.chats.find(c => c.id === id); - if (item) item.title = title; - renderList(); -}); - -// Also expose a global keyboard shortcut inside renderer (optional, light) -window.addEventListener('keydown', (e) => { - if (e.ctrlKey && e.shiftKey && e.key.toLowerCase() === 'o') { - e.preventDefault(); - const existing = document.getElementById(`${pluginId}-panel`); - if (existing) existing.remove(); else panelEl(); - } -}); - -// Compute header offset so docked panel doesn't overlap top UI -function applyHeaderOffset() { - try { - const tab = document.getElementById('tab-bar'); - const nav = document.getElementById('nav'); - let h = 0; - if (tab) h += Math.max(0, tab.getBoundingClientRect().height || 0); - if (nav) h += Math.max(0, nav.getBoundingClientRect().height || 0); - document.documentElement.style.setProperty('--nebula-header-height', `${Math.round(h)}px`); - } catch {} -} - -window.addEventListener('resize', applyHeaderOffset); -window.addEventListener('resize', () => setPageOffset(document.getElementById(`${pluginId}-panel`))); -document.addEventListener('DOMContentLoaded', applyHeaderOffset); -// Watch for dynamic header size changes -(() => { - try { - const ro = new ResizeObserver(() => applyHeaderOffset()); - const tab = document.getElementById('tab-bar'); - const nav = document.getElementById('nav'); - if (tab) ro.observe(tab); - if (nav) ro.observe(nav); - } catch {} -})(); diff --git a/plugins/return-youtube-dislike/main.js b/plugins/return-youtube-dislike/main.js new file mode 100644 index 0000000..2f41003 --- /dev/null +++ b/plugins/return-youtube-dislike/main.js @@ -0,0 +1,85 @@ +// Return YouTube Dislike - main process side +// Provides an IPC endpoint to fetch dislike data, bypassing page CSP. +// Also injects the renderer script into YouTube pages in webviews. + +const fs = require('fs'); +const path = require('path'); + +module.exports.activate = function(ctx) { + const CACHE_TTL_MS = 3 * 60 * 1000; // 3 minutes + const cache = new Map(); // key: videoId -> { t, data } + + async function fetchVotes(videoId) { + const url = `https://returnyoutubedislikeapi.com/votes?videoId=${encodeURIComponent(videoId)}`; + let resp; + try { + resp = await fetch(url, { method: 'GET', headers: { 'Accept': 'application/json' } }); + } catch (e) { + ctx.warn('[RYD] fetch failed', e); + return null; + } + if (!resp.ok) return null; + try { return await resp.json(); } catch { return null; } + } + + ctx.registerIPC('return-youtube-dislike:get', async (_e, { videoId }) => { + if (!videoId || typeof videoId !== 'string') return { ok: false, error: 'bad_args' }; + const now = Date.now(); + const ent = cache.get(videoId); + if (ent && (now - ent.t) < CACHE_TTL_MS) { + return { ok: true, data: ent.data, cached: true }; + } + const data = await fetchVotes(videoId); + if (!data) return { ok: false, error: 'fetch_failed' }; + cache.set(videoId, { t: now, data }); + return { ok: true, data }; + }); + + // Load the renderer script + const rendererScriptPath = path.join(ctx.paths.pluginDir, 'renderer-preload.js'); + let rendererScript = ''; + try { + rendererScript = fs.readFileSync(rendererScriptPath, 'utf8'); + } catch (e) { + ctx.error('[RYD] Failed to load renderer script:', e); + return; + } + + // Listen for web contents creation to inject into YouTube pages + ctx.on('web-contents-created', (contents) => { + // Only inject into webviews (guest pages), not the main window + if (!contents.hostWebContents) return; + + // Handle IPC messages from the injected script + contents.on('ipc-message', async (event, message) => { + if (message && message.data && message.data.channel === 'return-youtube-dislike:get') { + const { videoId, id } = message.data.args[0]; + try { + const data = await fetchVotes(videoId); + if (data) { + event.reply('return-youtube-dislike:get', { ok: true, data, id }); + } else { + event.reply('return-youtube-dislike:get', { ok: false, error: 'fetch_failed', id }); + } + } catch (e) { + event.reply('return-youtube-dislike:get', { ok: false, error: e.message, id }); + } + } + }); + + contents.on('dom-ready', () => { + const url = contents.getURL(); + if (!url || !/^(?:.*\.)?youtube\.com$/.test(new URL(url).hostname)) return; + + // Inject the script into the guest page + try { + contents.executeJavaScript(rendererScript); + ctx.log('[RYD] Injected script into YouTube page'); + } catch (e) { + ctx.warn('[RYD] Failed to inject script:', e); + } + }); + }); + + ctx.log('Return YouTube Dislike plugin activated'); +}; diff --git a/plugins/return-youtube-dislike/plugin.json b/plugins/return-youtube-dislike/plugin.json new file mode 100644 index 0000000..87a8738 --- /dev/null +++ b/plugins/return-youtube-dislike/plugin.json @@ -0,0 +1,17 @@ +{ + "id": "return-youtube-dislike", + "name": "Return YouTube Dislike", + "version": "0.1.0", + "description": "Shows estimated dislike counts on YouTube using the Return YouTube Dislike API.", + "main": "main.js", + "categories": [ + "Media", + "Utilities" + ], + "authors": [ + { + "name": "Nebula Team" + } + ], + "enabled": true +} \ No newline at end of file diff --git a/plugins/return-youtube-dislike/renderer-preload.js b/plugins/return-youtube-dislike/renderer-preload.js new file mode 100644 index 0000000..4d067f4 --- /dev/null +++ b/plugins/return-youtube-dislike/renderer-preload.js @@ -0,0 +1,286 @@ +// Return YouTube Dislike - injected into YouTube pages +// Injects a compact dislike counter into YouTube watch/shorts pages. +try { console.info('[RYD] script injected into', location.hostname, 'url=', location.href); } catch {} + +// Minimal CSS injected once +function injectStyles() { + if (document.getElementById('ryd-styles')) return; + const style = document.createElement('style'); + style.id = 'ryd-styles'; + style.textContent = ` + .ryd-badge { display:inline-flex; align-items:center; gap:6px; padding:4px 8px; border-radius:999px; font: 12px/1.2 -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Helvetica, Arial, sans-serif; color:#e8e8f0; background: rgba(255,255,255,0.08); border:1px solid rgba(255,255,255,0.14); } + .ryd-badge .icon { width:14px; height:14px; display:inline-block; } + .ryd-badge .count { font-weight:600; } + .ryd-muted { opacity: .65 } + .ryd-floating-wrap { pointer-events: none; } + .ryd-floating-wrap .ryd-badge { pointer-events: auto; backdrop-filter: blur(6px); background: rgba(0,0,0,0.45); border-color: rgba(255,255,255,0.18); } + .ryd-fixed-wrap { position: fixed; left: 12px; bottom: 12px; z-index: 2147483647; pointer-events: none; } + .ryd-fixed-wrap .ryd-badge { pointer-events: auto; backdrop-filter: blur(6px); background: rgba(0,0,0,0.45); border-color: rgba(255,255,255,0.18); } + `; + if (document.head) document.head.appendChild(style); else document.documentElement.appendChild(style); +} + +function nfmt(n) { + try { return new Intl.NumberFormat(undefined, { notation: 'compact' }).format(n); } catch { return String(n); } +} + +function invokeIPC(channel, args) { + return new Promise((resolve, reject) => { + const id = Math.random().toString(36).substr(2, 9); + const message = { type: 'message', data: { channel, args: [args], id } }; + const handler = (event) => { + if (event.data && event.data.type === 'message' && event.data.data && event.data.data.id === id) { + window.removeEventListener('message', handler); + const response = event.data.data.args[0]; + if (response && response.ok) { + resolve(response); + } else { + reject(new Error(response ? response.error : 'IPC failed')); + } + } + }; + window.addEventListener('message', handler); + window.postMessage(message, '*'); + // Timeout after 10 seconds + setTimeout(() => { + window.removeEventListener('message', handler); + reject(new Error('IPC timeout')); + }, 10000); + }); +} + +async function fetchRyd(videoId) { + // Try IPC first to bypass CSP + try { + const res = await invokeIPC('return-youtube-dislike:get', { videoId }); + if (res && res.ok) return res.data; + } catch (e) { + console.debug('[RYD] IPC failed, falling back to fetch:', e.message); + } + // Fallback to direct fetch + try { + const url = `https://returnyoutubedislikeapi.com/votes?videoId=${encodeURIComponent(videoId)}`; + const r = await fetch(url, { cache: 'no-store' }); + if (!r.ok) return null; + return await r.json(); + } catch (e) { + console.debug('[RYD] Fetch failed:', e); + return null; + } +} + +function getVideoIdFromUrl(u) { + try { + const url = new URL(u); + if (url.hostname.includes('youtube.com') || url.hostname === 'youtu.be') { + // watch?v=ID + if (url.pathname === '/watch') return url.searchParams.get('v'); + // shorts/ID + if (url.pathname.startsWith('/shorts/')) return url.pathname.split('/')[2] || null; + // youtu.be/ID + if (url.hostname === 'youtu.be') return url.pathname.slice(1) || null; + } + } catch {} + return null; +} + +function findBadgeHost() { + // Primary: watch page actions container that holds the like/share buttons + const primarySelectors = [ + 'ytd-watch-metadata ytd-menu-renderer #top-level-buttons-computed', + 'ytd-video-primary-info-renderer #top-level-buttons-computed', + 'ytd-watch-metadata #top-row #actions', + 'ytd-watch-metadata #actions', + '#actions-inner' + ]; + for (const sel of primarySelectors) { + const n = document.querySelector(sel); + if (n) return n; + } + // Fallback: if we can find the segmented like/dislike component, place next to it + const seg = document.querySelector('ytd-segmented-like-dislike-button-renderer'); + if (seg && seg.parentElement) return seg.parentElement; + // Shorts: different overlay structure + const shortsSelectors = [ + 'ytd-reel-player-overlay-renderer #actions', + 'ytd-reel-video-renderer #actions' + ]; + for (const sel of shortsSelectors) { + const n = document.querySelector(sel); + if (n) return n; + } + // Shadow DOM targeted probes (open shadow roots only) + const probeShadow = (tag, innerSel) => { + try { + const nodes = document.querySelectorAll(tag); + for (const el of nodes) { + if (el && el.shadowRoot) { + const found = el.shadowRoot.querySelector(innerSel); + if (found) return found; + } + } + } catch {} + return null; + }; + // Actions under menu renderer + let deep = probeShadow('ytd-menu-renderer', '#top-level-buttons-computed'); + if (deep) return deep; + // Watch metadata containers + deep = probeShadow('ytd-watch-metadata', '#top-row #actions'); + if (deep) return deep; + deep = probeShadow('ytd-watch-metadata', '#actions'); + if (deep) return deep; + // Shorts overlay + deep = probeShadow('ytd-reel-player-overlay-renderer', '#actions'); + if (deep) return deep; + return null; +} + +function ensureBadge(host) { + if (!host) return null; + let slot = host.querySelector('.ryd-badge'); + if (!slot) { + slot = document.createElement('span'); + slot.className = 'ryd-badge ryd-muted'; + slot.innerHTML = `β€”`; + try { host.appendChild(slot); } catch {} + } + return slot; +} + +function findPlayerOverlayHost() { + // As a fallback, attach to the player container and absolutely position the badge. + const containers = [ + '#player', + '#movie_player', + 'ytd-player', + 'ytd-watch-flexy #player-container', + 'ytd-watch-flexy #player' + ]; + for (const sel of containers) { + const n = document.querySelector(sel); + if (n) return n; + } + return null; +} + +let lastVideoId = null; +let pending = 0; +let hostRetryTimer = null; + +async function updateForCurrentUrl() { + const vid = getVideoIdFromUrl(location.href); + if (!vid) return; + if (vid !== lastVideoId) { + lastVideoId = vid; + pending++; // invalidate prior fetches + } + injectStyles(); + + const tryAttach = async () => { + let host = findBadgeHost(); + if (!host) { + // Fallback: player overlay attachment + const player = findPlayerOverlayHost(); + if (player) { + // Ensure the player can position children over video + try { + const st = player.style; + if (getComputedStyle(player).position === 'static') st.position = 'relative'; + } catch {} + // Create a container to hold the floating badge in the player + let wrap = player.querySelector('.ryd-floating-wrap'); + if (!wrap) { + wrap = document.createElement('div'); + wrap.className = 'ryd-floating-wrap'; + wrap.style.position = 'absolute'; + wrap.style.left = '12px'; + wrap.style.bottom = '12px'; + wrap.style.zIndex = '2147483647'; + player.appendChild(wrap); + } + host = wrap; + } + } + if (!host) { return false; } + try { console.debug('[RYD] attaching badge to', host.tagName || host.className || host.id || host); } catch {} + const badge = ensureBadge(host); + if (!badge) return false; + badge.classList.add('ryd-muted'); + const ticket = ++pending; + const data = await fetchRyd(vid); + if (ticket !== pending || lastVideoId !== vid) return true; // outdated + if (!data) { const cnt = badge.querySelector('.count'); if (cnt) cnt.textContent = 'n/a'; return true; } + const dislikes = Number(data.dislikes || data.dislikeCount || 0); + const likes = Number(data.likes || data.likeCount || 0); + const ratio = likes + dislikes > 0 ? Math.round((dislikes / (likes + dislikes)) * 100) : 0; + const cnt = badge.querySelector('.count'); + if (cnt) cnt.textContent = `${nfmt(dislikes)} πŸ‘Ž (${ratio}%)`; + badge.title = `${dislikes.toLocaleString()} dislikes\n${likes.toLocaleString()} likes`; + badge.classList.remove('ryd-muted'); + return true; + }; + + // Immediate attempt, then retry a few seconds while YouTube lays out + const okNow = await tryAttach(); + if (okNow) return; + let tries = 0; + clearInterval(hostRetryTimer); + hostRetryTimer = setInterval(async () => { + tries++; + const done = await tryAttach(); + if (done || tries > 60) { // up to ~30s for very slow layouts + clearInterval(hostRetryTimer); + hostRetryTimer = null; + if (!done) { + // Final fallback: fixed overlay attached to body + try { + let fixed = document.querySelector('.ryd-fixed-wrap'); + if (!fixed) { + fixed = document.createElement('div'); + fixed.className = 'ryd-fixed-wrap'; + document.body.appendChild(fixed); + } + const badge = ensureBadge(fixed); + if (badge) { + badge.classList.add('ryd-muted'); + const ticket = ++pending; + const data = await fetchRyd(vid); + if (ticket === pending && lastVideoId === vid) { + const dislikes = Number((data && (data.dislikes || data.dislikeCount)) || 0); + const likes = Number((data && (data.likes || data.likeCount)) || 0); + const ratio = likes + dislikes > 0 ? Math.round((dislikes / (likes + dislikes)) * 100) : 0; + const cnt = badge.querySelector('.count'); + if (cnt) cnt.textContent = `${nfmt(dislikes)} πŸ‘Ž (${ratio}%)`; + badge.title = `${dislikes.toLocaleString()} dislikes\n${likes.toLocaleString()} likes`; + badge.classList.remove('ryd-muted'); + } + } + } catch {} + } + } + }, 500); +} + +function observeUrlChanges() { + // Single-page app navigations + let last = location.href; + const mo = new MutationObserver(() => { + if (location.href !== last) { last = location.href; updateForCurrentUrl(); } + }); + mo.observe(document, { subtree: true, childList: true }); + window.addEventListener('yt-navigate-finish', updateForCurrentUrl, true); + window.addEventListener('popstate', updateForCurrentUrl, true); + window.addEventListener('yt-page-data-updated', updateForCurrentUrl, true); +} + +document.addEventListener('readystatechange', () => { if (document.readyState === 'interactive') updateForCurrentUrl(); }); +document.addEventListener('DOMContentLoaded', () => { + // Only act on YouTube + if (!/^(?:.*\.)?youtube\.com$/.test(location.hostname) && location.hostname !== 'youtu.be') return; + updateForCurrentUrl(); + observeUrlChanges(); + // Also schedule a couple of follow-up attempts after page scripts settle + setTimeout(updateForCurrentUrl, 1500); + setTimeout(updateForCurrentUrl, 3500); +}); diff --git a/renderer/nebot.html b/renderer/nebot.html new file mode 100644 index 0000000..58af12b --- /dev/null +++ b/renderer/nebot.html @@ -0,0 +1,59 @@ + + + + + Nebot + + + + +
+ + + diff --git a/renderer/script.js b/renderer/script.js index b8b8bf0..071dc25 100644 --- a/renderer/script.js +++ b/renderer/script.js @@ -52,7 +52,52 @@ urlBox.addEventListener('keydown', (e) => { let tabs = []; let activeTabId = null; -const allowedInternalPages = ['settings', 'home', 'downloads']; +const allowedInternalPages = ['settings', 'home', 'downloads', 'nebot']; +let pluginPages = []; // { id, file, fileUrl, pluginId } +let pluginPagesReady = false; +const pendingInternalNavigations = []; + +// Allow isolated worlds / plugin preloads (contextIsolation) to request opening an internal page +window.addEventListener('message', (e) => { + try { + const data = e.data; + if (!data || typeof data !== 'object') return; + if (data.type === 'open-internal-page' && typeof data.url === 'string') { + console.log('[DEBUG] Message request to open internal page:', data.url); + createTab(data.url); + } + } catch (err) { + console.warn('[DEBUG] open-internal-page handler error', err); + } +}); + +// Fetch plugin-provided pages (browser://) once on startup +(async () => { + try { + console.log('[DEBUG] About to request plugin pages from main process...'); + pluginPages = await ipcRenderer.invoke('plugins-get-pages'); + console.log('[DEBUG] Loaded pluginPages:', pluginPages); + console.log('[DEBUG] allowedInternalPages before:', allowedInternalPages); + for (const p of pluginPages) { + if (p && p.id && !allowedInternalPages.includes(p.id)) { + console.log('[DEBUG] Adding plugin page to allowed list:', p.id); + allowedInternalPages.push(p.id); + } + } + console.log('[DEBUG] allowedInternalPages after:', allowedInternalPages); + } catch (e) { + console.warn('Failed to load plugin pages', e); + } + finally { + pluginPagesReady = true; + console.log('[DEBUG] Plugin pages ready, flushing', pendingInternalNavigations.length, 'pending navigations'); + // Flush any queued internal navigations that occurred before readiness + while (pendingInternalNavigations.length) { + const fn = pendingInternalNavigations.shift(); + try { fn(); } catch {} + } + } +})(); let bookmarks = []; // Efficient render scheduling to avoid redundant DOM work @@ -134,8 +179,14 @@ ipcRenderer.on('record-site-history', (event, url) => { function createTab(inputUrl) { inputUrl = inputUrl || 'browser://home'; - debug('[DEBUG] createTab() inputUrl =', inputUrl); + console.log('[DEBUG] createTab() inputUrl =', inputUrl); const id = crypto.randomUUID(); + if (inputUrl.startsWith('browser://') && !pluginPagesReady) { + // Defer creation until plugin pages known to avoid 404 race + console.log('[DEBUG] Deferring createTab until pluginPagesReady'); + pendingInternalNavigations.push(() => createTab(inputUrl)); + return id; + } // Handle home page specially if (inputUrl === 'browser://home') { @@ -162,6 +213,7 @@ function createTab(inputUrl) { // For all other URLs, use webview let resolvedUrl = resolveInternalUrl(inputUrl); + console.log('[DEBUG] createTab resolvedUrl:', resolvedUrl, 'from inputUrl:', inputUrl); // If it's a raw data: URL (image) keep as is; blob: will only resolve within its origin context (may fail) // For very long data URLs we could embed them in a minimal viewer page for cleaner rendering. if (resolvedUrl.startsWith('data:') && resolvedUrl.length > 4096) { @@ -278,13 +330,45 @@ function createTab(inputUrl) { scheduleRenderTabs(); } +// Expose for plugin usage (e.g., Nebot panel "Open Page") +try { window.createTab = createTab; } catch {} + function resolveInternalUrl(url) { + console.log('[DEBUG] resolveInternalUrl called with:', url); if (url.startsWith('browser://')) { const page = url.replace('browser://', ''); - if (allowedInternalPages.includes(page)) return `${page}.html`; - else return '404.html'; + console.log('[DEBUG] Extracted page:', page); + // Fast path: if user typed browser://nebot and plugin page exists, return immediately + if (page === 'nebot') { + const nebotPage = pluginPages.find(p => p.id === 'nebot'); + console.log('[DEBUG] Fast path for nebot, pluginPages:', pluginPages, 'nebotPage:', nebotPage); + if (nebotPage && (nebotPage.fileUrl || nebotPage.file)) { + const resolvedFast = nebotPage.fileUrl || (nebotPage.file.startsWith('file://') ? nebotPage.file : 'file://' + nebotPage.file.replace(/\\/g,'/')); + console.log('[DEBUG] Fast path nebot resolve ->', resolvedFast); + return resolvedFast; + } + console.log('[DEBUG] No plugin page found for nebot, falling back to nebot.html'); + } + console.log('[DEBUG] Checking if page in allowedInternalPages:', page, 'list:', allowedInternalPages); + if (allowedInternalPages.includes(page)) { + // Check if this page is provided by a plugin (absolute file path) + const plug = pluginPages.find(p => p.id === page); + console.log('[DEBUG] Resolving browser://' + page, 'plug:', plug); + if (plug && (plug.fileUrl || plug.file)) { + // Prefer pre-built fileUrl for correctness across platforms + const resolved = plug.fileUrl ? plug.fileUrl : (plug.file.startsWith('file://') ? plug.file : 'file://' + plug.file.replace(/\\/g,'/')); + console.log('[DEBUG] Resolved plugin page', page, '->', resolved); + return resolved; + } + // Fallback: built-in renderer copy (e.g., renderer/nebot.html) + console.log('[DEBUG] Using fallback for page:', page); + if (page === 'nebot') return 'nebot.html'; + return `${page}.html`; + } + console.log('[DEBUG] Page not in allowedInternalPages, returning 404'); + return '404.html'; } // Allow direct loading of common schemes without forcing https:// if (/^(https?:|file:|data:|blob:)/i.test(url)) return url; @@ -309,21 +393,9 @@ function updateTabMetadata(id, key, value) { } } -function navigate() { - const rawInput = urlBox.value.trim(); - // Strip surrounding single or double quotes (common when copying paths) - let input = rawInput; - if ((input.startsWith('"') && input.endsWith('"')) || (input.startsWith("'") && input.endsWith("'"))) { - input = input.slice(1, -1); - } - // If we modified input (removed quotes), reflect it back in the UI for clarity - if (input !== rawInput) { - urlBox.value = input; - } +function performNavigation(input, originalInputForHistory) { const tab = tabs.find(t => t.id === activeTabId); if (!tab) return; - - // decide if this is a search query or a URL/internal page const hasProtocol = /^https?:\/\//i.test(input); const isFileProtocol = /^file:\/\//i.test(input); const looksLikeLocalPath = /^(?:[A-Za-z]:\\|\\\\|\/?)[^?]*\.(?:x?html?)$/i.test(input); @@ -331,51 +403,58 @@ function navigate() { const isLikelyUrl = hasProtocol || input.includes('.'); let resolved; if (isFileProtocol) { - resolved = input; // Electron will load file:// directly in + resolved = input; } else if (looksLikeLocalPath) { - // Convert Windows or *nix style path to file:// URL - let p = input; - // Expand backslashes - p = p.replace(/\\/g, '/'); - // If it starts with a drive letter like C:/ ensure single leading slash - if (/^[A-Za-z]:\//.test(p)) { - resolved = 'file:///' + encodeURI(p); - } else if (p.startsWith('/')) { - resolved = 'file://' + encodeURI(p); // already absolute - } else { - // relative path relative to app root (renderer directory) - resolved = 'file://' + encodeURI(p); // fallback; treat as relative from working dir - } + let p = input.replace(/\\/g,'/'); + if (/^[A-Za-z]:\//.test(p)) resolved = 'file:///' + encodeURI(p); else if (p.startsWith('/')) resolved = 'file://' + encodeURI(p); else resolved = 'file://' + encodeURI(p); } else if (!isInternal && !isLikelyUrl) { resolved = `https://www.google.com/search?q=${encodeURIComponent(input)}`; } else { resolved = resolveInternalUrl(input); } - // If current tab is a home tab and we're navigating to a website, - // we need to convert it to a webview tab or create a new one - if (tab.isHome && !input.startsWith('browser://')) { - // Convert home tab to webview tab - convertHomeTabToWebview(tab.id, input, resolved); + console.log('[DEBUG] performNavigation input:', input, 'resolved:', resolved, 'tab.isHome:', tab.isHome, 'isInternal:', isInternal); + + if (tab.isHome && !isInternal) { + convertHomeTabToWebview(tab.id, originalInputForHistory, resolved); + return; + } + + // If this is a home tab and we're navigating to an internal page, convert to webview + if (tab.isHome && isInternal) { + convertHomeTabToWebview(tab.id, originalInputForHistory, resolved); return; } - // For regular webview tabs, just navigate const webview = document.getElementById(`tab-${activeTabId}`); - if (!webview) return; - - // Push to history using the original input + if (!webview) { + console.log('[DEBUG] No webview found for tab', activeTabId, 'creating new tab instead'); + createTab(input); + return; + } tab.history = tab.history.slice(0, tab.historyIndex + 1); - tab.history.push(input); + tab.history.push(originalInputForHistory); tab.historyIndex++; - - tab.url = input; + tab.url = originalInputForHistory; webview.src = resolved; - scheduleRenderTabs(); scheduleUpdateNavButtons(); } +function navigate() { + const rawInput = urlBox.value.trim(); + let input = rawInput; + if ((input.startsWith('"') && input.endsWith('"')) || (input.startsWith("'") && input.endsWith("'"))) input = input.slice(1, -1); + if (input !== rawInput) urlBox.value = input; + const isInternal = input.startsWith('browser://'); + if (isInternal && !pluginPagesReady) { + const captured = input; // preserve original + pendingInternalNavigations.push(() => performNavigation(captured, captured)); + return; + } + performNavigation(input, input); +} + // Keyboard shortcut: Ctrl+O (Cmd+O on mac) to open a local file document.addEventListener('keydown', async (e) => { const isAccel = (navigator.platform.includes('Mac') ? e.metaKey : e.ctrlKey); @@ -1126,14 +1205,7 @@ function attachCloseMenuOnInteract(el) { el.addEventListener('focus', closeIfOpen, true); } -// Attempt to load Node modules if available for context-menu actions -let fs, remote; -try { - fs = require('fs'); - remote = require('electron').remote; -} catch (err) { - console.warn('fs or remote modules unavailable in renderer:', err); -} +// Use electronAPI from preload - already defined at top of file // Native context menu: delegate to main via preload API document.addEventListener('contextmenu', (e) => {