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) => {