This commit is contained in:
69
apps/browser-ext/README.md
Normal file
69
apps/browser-ext/README.md
Normal file
@@ -0,0 +1,69 @@
|
||||
# Waggle Companion · Chrome MV3 extension
|
||||
|
||||
The browser-side hook for Waggle OS. Lets the user save any page or selection
|
||||
to their workspace memory from anywhere on the web, without leaving the tab.
|
||||
|
||||
This implements **FR-1** from the 2026-05-28 addictiveness audit — closes the
|
||||
"external trigger surface" rubric gap (dim 1) for the non-coder personas
|
||||
whose real workflow lives in browser tabs (researcher, journalist, marketer,
|
||||
writer, retired teacher).
|
||||
|
||||
## What it does (v0.1.0)
|
||||
|
||||
- **Popup** — shows connection status + the active workspace memory is saving to + two buttons (save selection / save page).
|
||||
- **Right-click context menu** — "Save to Waggle memory" appears on any text selection.
|
||||
- **Reuses existing sidecar endpoints** — `/api/browser-ext/session-token` for local token bootstrap, `/api/browser-ext/health` for status, and `/api/memory/frames` for ingest. No new ingest logic.
|
||||
|
||||
## How to load (developer mode, local install)
|
||||
|
||||
1. Start the Waggle sidecar with one of these env vars set so its CORS layer accepts the dev extension origin:
|
||||
- **Quickest (dev only):** `WAGGLE_DEV_ALLOW_ANY_EXTENSION=1` — accepts any `chrome-extension://*` origin. Never set this in production.
|
||||
- **Production-shaped:** `WAGGLE_BROWSER_EXT_IDS=<your-extension-id>` (comma-separated for multiple IDs). Pin once you have the loaded extension's ID from `chrome://extensions`.
|
||||
2. Open `chrome://extensions` in Chrome (or Edge, or any Chromium browser).
|
||||
3. Toggle **Developer mode** on (top right).
|
||||
4. Click **Load unpacked** and pick this folder (`apps/browser-ext`).
|
||||
5. Copy the extension ID shown on the card.
|
||||
6. Restart the sidecar with `WAGGLE_BROWSER_EXT_IDS=<that-id>` for the production-shaped path, or skip this if you used the dev escape hatch in step 1.
|
||||
7. Pin the extension to the toolbar.
|
||||
8. Open the popup — you should see a green dot + "Connected" + the memory destination.
|
||||
|
||||
Without either env var set, the sidecar rejects Browser Companion token bootstrap and the popup shows setup recovery copy. The extension stores the sidecar session token in `chrome.storage.local.sessionToken` after a successful bootstrap and sends it as a bearer token on save/status calls.
|
||||
|
||||
## What's deliberately NOT in v0.1.0
|
||||
|
||||
- **Side panel chat** — the Chrome side panel for asking questions about the current page. Designed for v0.2; would call `/api/chat`.
|
||||
- **One-time-code pairing UX** — v0.1.0 bootstraps the local session token for an env-allowlisted extension ID. A more explicit desktop Settings pairing flow with a one-time code is future hardening.
|
||||
- **Cross-browser packaging** — manifest is MV3, works on Chrome/Edge/Brave. Firefox needs a parallel manifest shape.
|
||||
- **Article extraction** — page text capture is `document.body.innerText` capped at 12k chars. Reader-mode style extraction belongs server-side.
|
||||
- **Icons** — using browser default. Wire in real icons when we have the brand asset.
|
||||
- **Build step** — vanilla JS, no bundler. Simpler MVP; if we add typescript/react for the side panel later, add Vite then.
|
||||
|
||||
## Files
|
||||
|
||||
| File | Role |
|
||||
|---|---|
|
||||
| `manifest.json` | MV3 manifest — permissions, action, content script, background |
|
||||
| `popup.html` | Popup UI shell (dark Hive theme inline) |
|
||||
| `popup.js` | Popup logic — health refresh, selection read, save dispatch |
|
||||
| `content.js` | Per-page content script — extracts selection + body text on demand |
|
||||
| `background.js` | Service worker — fetch wrapper to the Waggle sidecar |
|
||||
|
||||
## Sidecar contract
|
||||
|
||||
- `GET /api/browser-ext/session-token` -> `{ token }` for allowlisted extension origins / MV3 service-worker requests.
|
||||
- `GET /api/browser-ext/health` -> `{ ok: true, version, activeWorkspaceId, activeWorkspace }` (defined in `packages/server/src/local/routes/browser-ext.ts`; `activeWorkspace` is legacy compatibility)
|
||||
- `POST /api/memory/frames` — existing endpoint, body `{ content, source: 'import', importance: 'normal' | 'low' }`. Dedup runs server-side.
|
||||
|
||||
## Verification
|
||||
|
||||
After loading the unpacked extension:
|
||||
1. Click the extension icon on any web page → status should read "Connected" with a green dot.
|
||||
2. Select some text → "Save selection to memory" enables → click it → toast reads "Saved to Waggle memory ✓".
|
||||
3. Open the Waggle desktop → Memory app → confirm the new frame appears with source `import`.
|
||||
|
||||
## Roadmap (post-MVP)
|
||||
|
||||
- v0.2 — side panel with chat about the current page (calls `/api/chat`).
|
||||
- v0.3 — pre-load Waggle's "Ask about this page" agent on important pages (configurable).
|
||||
- v0.4 — Firefox MV2 parallel manifest.
|
||||
- v0.5 — explicit auth pairing UX (one-time code from desktop Settings).
|
||||
141
apps/browser-ext/background.js
Normal file
141
apps/browser-ext/background.js
Normal file
@@ -0,0 +1,141 @@
|
||||
// Waggle Companion background service worker — routes messages from
|
||||
// popup.js to the local Waggle sidecar at 127.0.0.1:3333.
|
||||
//
|
||||
// MV3 service workers are short-lived; we don't keep any state here
|
||||
// beyond per-message handlers. The sidecar's session token (if any) is
|
||||
// pulled from chrome.storage.local on every request.
|
||||
|
||||
const SIDECAR = 'http://127.0.0.1:3333';
|
||||
|
||||
async function readJson(response) {
|
||||
try {
|
||||
return await response.json();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function authErrorMessage(status, body) {
|
||||
const code = body?.code;
|
||||
if (code === 'EXTENSION_NOT_ALLOWLISTED') {
|
||||
return 'Browser Companion is not allowlisted. Add this extension ID to Waggle, restart Waggle, then try again.';
|
||||
}
|
||||
if (status === 401 && code === 'INVALID_TOKEN') {
|
||||
return 'Browser Companion pairing expired. Reopen Waggle desktop, then try again.';
|
||||
}
|
||||
if (status === 401 && (code === 'MISSING_TOKEN' || !code)) {
|
||||
return 'Browser Companion is not paired. Start Waggle desktop, then try again.';
|
||||
}
|
||||
return body?.error || `HTTP ${status}`;
|
||||
}
|
||||
|
||||
async function requestSessionToken() {
|
||||
const r = await fetch(`${SIDECAR}/api/browser-ext/session-token`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'X-Waggle-Extension-Id': chrome.runtime.id,
|
||||
},
|
||||
});
|
||||
const data = await readJson(r);
|
||||
if (!r.ok || !data?.token) {
|
||||
return { ok: false, error: authErrorMessage(r.status, data) };
|
||||
}
|
||||
await chrome.storage.local.set({ sessionToken: data.token });
|
||||
return { ok: true, token: data.token };
|
||||
}
|
||||
|
||||
async function getAuthHeaders(options = {}) {
|
||||
try {
|
||||
const { sessionToken } = await chrome.storage.local.get(['sessionToken']);
|
||||
if (sessionToken) return { headers: { Authorization: `Bearer ${sessionToken}` } };
|
||||
if (!options.pair) return { headers: {} };
|
||||
const paired = await requestSessionToken();
|
||||
if (!paired.ok) return { headers: {}, error: paired.error };
|
||||
return { headers: { Authorization: `Bearer ${paired.token}` } };
|
||||
} catch (err) {
|
||||
return { headers: {}, error: String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
async function health() {
|
||||
try {
|
||||
const auth = await getAuthHeaders({ pair: true });
|
||||
if (auth.error) return { ok: false, error: auth.error };
|
||||
const r = await fetch(`${SIDECAR}/api/browser-ext/health`, {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json', ...auth.headers },
|
||||
});
|
||||
if (!r.ok) return { ok: false, error: authErrorMessage(r.status, await readJson(r)) };
|
||||
return await r.json();
|
||||
} catch (err) {
|
||||
return { ok: false, error: String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
async function saveMemory(payload) {
|
||||
try {
|
||||
const body = JSON.stringify({
|
||||
content: payload.content,
|
||||
source: payload.source || 'import',
|
||||
importance: payload.importance || 'normal',
|
||||
});
|
||||
const auth = await getAuthHeaders({ pair: true });
|
||||
if (auth.error) return { saved: false, error: auth.error };
|
||||
let r = await fetch(`${SIDECAR}/api/memory/frames`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...auth.headers },
|
||||
body,
|
||||
});
|
||||
if (r.status === 401) {
|
||||
const paired = await requestSessionToken();
|
||||
if (paired.ok) {
|
||||
r = await fetch(`${SIDECAR}/api/memory/frames`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${paired.token}` },
|
||||
body,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (!r.ok) return { saved: false, error: authErrorMessage(r.status, await readJson(r)) };
|
||||
const data = await r.json();
|
||||
return {
|
||||
saved: data?.saved ?? true,
|
||||
duplicate: data?.duplicate ?? false,
|
||||
frameId: data?.frameId,
|
||||
};
|
||||
} catch (err) {
|
||||
return { saved: false, error: String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
|
||||
(async () => {
|
||||
if (msg?.type === 'health') sendResponse(await health());
|
||||
else if (msg?.type === 'save-memory') sendResponse(await saveMemory(msg));
|
||||
else sendResponse({ error: 'unknown message type' });
|
||||
})();
|
||||
return true; // keep channel open for async sendResponse
|
||||
});
|
||||
|
||||
// Context menu: right-click selection → "Save to Waggle memory"
|
||||
chrome.runtime.onInstalled.addListener(() => {
|
||||
chrome.contextMenus.create({
|
||||
id: 'waggle-save-selection',
|
||||
title: 'Save to Waggle memory',
|
||||
contexts: ['selection'],
|
||||
});
|
||||
});
|
||||
|
||||
chrome.contextMenus.onClicked.addListener(async (info, tab) => {
|
||||
if (info.menuItemId !== 'waggle-save-selection' || !info.selectionText) return;
|
||||
const result = await saveMemory({
|
||||
content: `Selection from ${tab?.title || tab?.url}\n\n${info.selectionText}`,
|
||||
source: 'import',
|
||||
importance: 'normal',
|
||||
});
|
||||
// Best-effort badge feedback (MV3 has no toast API in background).
|
||||
await chrome.action.setBadgeText({ text: result.saved ? '✓' : '!' });
|
||||
await chrome.action.setBadgeBackgroundColor({ color: result.saved ? '#10b981' : '#ef4444' });
|
||||
setTimeout(() => chrome.action.setBadgeText({ text: '' }), 2500);
|
||||
});
|
||||
27
apps/browser-ext/content.js
Normal file
27
apps/browser-ext/content.js
Normal file
@@ -0,0 +1,27 @@
|
||||
// Waggle Companion content script — runs on every page, responds to
|
||||
// "extract" messages from the popup with the current selection + the
|
||||
// page's main text. Page-text extraction is intentionally simple
|
||||
// (innerText of the body, trimmed) — the right place to do article
|
||||
// extraction is server-side once the user opts in.
|
||||
|
||||
chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
|
||||
if (msg?.type !== 'extract') return;
|
||||
try {
|
||||
const selection = String(window.getSelection?.() || '').trim();
|
||||
const bodyText = (document.body?.innerText || '').replace(/\s+\n/g, '\n').trim();
|
||||
sendResponse({
|
||||
selection,
|
||||
page: {
|
||||
url: location.href,
|
||||
title: document.title || location.href,
|
||||
// Cap at 12k chars so popup → background message-passing stays snappy.
|
||||
// Server-side ingest pipeline can re-fetch the URL for the full body.
|
||||
text: bodyText.slice(0, 12000),
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
sendResponse({ selection: '', page: null, error: String(err) });
|
||||
}
|
||||
// Returning true keeps the message channel open for the async sendResponse.
|
||||
return true;
|
||||
});
|
||||
30
apps/browser-ext/manifest.json
Normal file
30
apps/browser-ext/manifest.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "Waggle Companion",
|
||||
"version": "0.1.0",
|
||||
"description": "Save pages, selections, and questions to your Waggle workspace memory — Waggle gets smarter the more you use it.",
|
||||
"permissions": [
|
||||
"activeTab",
|
||||
"storage",
|
||||
"contextMenus"
|
||||
],
|
||||
"host_permissions": [
|
||||
"http://127.0.0.1:3333/*",
|
||||
"http://localhost:3333/*"
|
||||
],
|
||||
"action": {
|
||||
"default_popup": "popup.html",
|
||||
"default_title": "Waggle Companion"
|
||||
},
|
||||
"background": {
|
||||
"service_worker": "background.js",
|
||||
"type": "module"
|
||||
},
|
||||
"content_scripts": [
|
||||
{
|
||||
"matches": ["<all_urls>"],
|
||||
"js": ["content.js"],
|
||||
"run_at": "document_idle"
|
||||
}
|
||||
]
|
||||
}
|
||||
106
apps/browser-ext/popup.html
Normal file
106
apps/browser-ext/popup.html
Normal file
@@ -0,0 +1,106 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Waggle Companion</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--bg: #08090c;
|
||||
--fg: #f4f4f5;
|
||||
--muted: #71717a;
|
||||
--primary: #e5a000;
|
||||
--primary-hover: #d18f00;
|
||||
--border: #27272a;
|
||||
--success: #10b981;
|
||||
--danger: #ef4444;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html, body { margin: 0; padding: 0; }
|
||||
body {
|
||||
width: 320px;
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
font: 13px/1.4 -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
|
||||
padding: 12px;
|
||||
}
|
||||
header {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding-bottom: 10px; border-bottom: 1px solid var(--border); margin-bottom: 10px;
|
||||
}
|
||||
.logo { width: 18px; height: 18px; background: var(--primary); border-radius: 4px; display: grid; place-items: center; font-weight: 700; color: var(--bg); font-size: 12px; }
|
||||
h1 { font-size: 13px; font-weight: 600; margin: 0; flex: 1; }
|
||||
.status {
|
||||
display: inline-flex; align-items: center; gap: 5px;
|
||||
font-size: 11px; color: var(--muted);
|
||||
}
|
||||
.dot { width: 8px; height: 8px; border-radius: 50%; background: var(--muted); }
|
||||
.dot.connected { background: var(--success); }
|
||||
.dot.disconnected { background: var(--danger); }
|
||||
.workspace { font-size: 11px; color: var(--muted); margin-bottom: 10px; }
|
||||
.workspace strong { color: var(--fg); }
|
||||
button {
|
||||
width: 100%; padding: 8px 10px; margin-bottom: 6px;
|
||||
background: #1a1a1f; color: var(--fg); border: 1px solid var(--border);
|
||||
border-radius: 8px; font: inherit; cursor: pointer; text-align: left;
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
transition: background 120ms, border-color 120ms, outline-color 120ms;
|
||||
}
|
||||
button:hover:not(:disabled) { background: #25252b; border-color: var(--primary); }
|
||||
button:focus-visible {
|
||||
outline: 2px solid var(--primary);
|
||||
outline-offset: 2px;
|
||||
border-color: var(--primary);
|
||||
}
|
||||
button:disabled {
|
||||
background: #101014;
|
||||
color: var(--muted);
|
||||
border-color: #1f1f26;
|
||||
opacity: 1;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
button.primary { background: var(--primary); color: var(--bg); border-color: var(--primary); font-weight: 600; }
|
||||
button.primary:hover:not(:disabled) { background: var(--primary-hover); border-color: var(--primary-hover); }
|
||||
button.primary:disabled {
|
||||
background: #15120a;
|
||||
color: #9a8253;
|
||||
border-color: #3a2a0a;
|
||||
}
|
||||
.icon { font-size: 14px; line-height: 1; }
|
||||
#toast {
|
||||
margin-top: 8px; padding: 6px 8px; border-radius: 6px; font-size: 11px;
|
||||
background: #1a1a1f; border: 1px solid var(--border); color: var(--muted);
|
||||
min-height: 16px;
|
||||
}
|
||||
#toast.ok { color: var(--success); border-color: var(--success); }
|
||||
#toast.err { color: var(--danger); border-color: var(--danger); }
|
||||
footer { margin-top: 10px; padding-top: 8px; border-top: 1px solid var(--border); font-size: 10px; color: var(--muted); text-align: center; }
|
||||
a { color: var(--primary); text-decoration: none; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<span class="logo">W</span>
|
||||
<h1>Waggle Companion</h1>
|
||||
<span class="status"><span class="dot" id="dot"></span><span id="status-text">…</span></span>
|
||||
</header>
|
||||
|
||||
<div class="workspace">Memory destination: <strong id="workspace-name">—</strong></div>
|
||||
|
||||
<button class="primary" id="save-selection" disabled>
|
||||
<span class="icon">💾</span><span>Save selection to memory</span>
|
||||
</button>
|
||||
<button id="save-page">
|
||||
<span class="icon">📄</span><span>Save whole page</span>
|
||||
</button>
|
||||
<button id="open-waggle">
|
||||
<span class="icon">🐝</span><span>Open Waggle desktop</span>
|
||||
</button>
|
||||
|
||||
<div id="toast" role="status" aria-live="polite"></div>
|
||||
|
||||
<footer>v0.1.0 · Requires Waggle running on 127.0.0.1:3333</footer>
|
||||
|
||||
<script src="popup.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
120
apps/browser-ext/popup.js
Normal file
120
apps/browser-ext/popup.js
Normal file
@@ -0,0 +1,120 @@
|
||||
// Waggle Companion popup — talks to background.js via chrome.runtime.sendMessage,
|
||||
// background talks to the local Waggle sidecar at 127.0.0.1:3333. The popup
|
||||
// itself never makes network requests so we don't pay the CORS preflight tax
|
||||
// from an extension origin.
|
||||
|
||||
const $ = (id) => document.getElementById(id);
|
||||
|
||||
const dot = $('dot');
|
||||
const statusText = $('status-text');
|
||||
const workspaceNameEl = $('workspace-name');
|
||||
const btnSelection = $('save-selection');
|
||||
const btnPage = $('save-page');
|
||||
const btnOpen = $('open-waggle');
|
||||
const toast = $('toast');
|
||||
|
||||
let cachedSelection = '';
|
||||
let cachedPageMeta = null;
|
||||
let toastTimer = null;
|
||||
|
||||
function showToast(msg, kind = '', options = {}) {
|
||||
if (toastTimer) clearTimeout(toastTimer);
|
||||
toast.textContent = msg;
|
||||
toast.className = kind;
|
||||
if (!options.sticky && (kind === 'ok' || kind === 'err')) {
|
||||
toastTimer = setTimeout(() => {
|
||||
if (toast.textContent === msg) {
|
||||
toast.textContent = '';
|
||||
toast.className = '';
|
||||
}
|
||||
}, 3500);
|
||||
}
|
||||
}
|
||||
|
||||
function isSetupError(msg) {
|
||||
return /allowlisted|paired|pairing/i.test(msg);
|
||||
}
|
||||
|
||||
function formatMemoryDestination(reply) {
|
||||
const workspaceName = typeof reply?.activeWorkspaceName === 'string'
|
||||
? reply.activeWorkspaceName.trim()
|
||||
: '';
|
||||
const workspaceId = typeof reply?.activeWorkspaceId === 'string'
|
||||
? reply.activeWorkspaceId.trim()
|
||||
: typeof reply?.activeWorkspace === 'string'
|
||||
? reply.activeWorkspace.trim()
|
||||
: '';
|
||||
if (workspaceName) return workspaceName;
|
||||
if (workspaceId && workspaceId !== 'local-default' && workspaceId !== 'default-workspace') {
|
||||
return `Workspace id: ${workspaceId}`;
|
||||
}
|
||||
return 'Personal memory';
|
||||
}
|
||||
|
||||
async function refreshHealth() {
|
||||
try {
|
||||
const reply = await chrome.runtime.sendMessage({ type: 'health' });
|
||||
if (reply?.ok) {
|
||||
dot.className = 'dot connected';
|
||||
statusText.textContent = 'Connected';
|
||||
// textContent (not innerHTML) — workspace names are user-controlled
|
||||
// and could otherwise be XSS sinks in the extension context.
|
||||
workspaceNameEl.textContent = formatMemoryDestination(reply);
|
||||
} else {
|
||||
throw new Error(reply?.error || 'No response');
|
||||
}
|
||||
} catch (err) {
|
||||
dot.className = 'dot disconnected';
|
||||
statusText.textContent = 'Not connected';
|
||||
workspaceNameEl.textContent = 'Unavailable';
|
||||
const msg = err?.message || 'Start Waggle desktop on this machine, then re-open this popup.';
|
||||
showToast(msg, 'err', { sticky: true });
|
||||
}
|
||||
}
|
||||
|
||||
async function readActiveTab() {
|
||||
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
|
||||
if (!tab?.id) return null;
|
||||
try {
|
||||
const res = await chrome.tabs.sendMessage(tab.id, { type: 'extract' });
|
||||
cachedSelection = res?.selection ?? '';
|
||||
cachedPageMeta = res?.page ?? null;
|
||||
btnSelection.disabled = !cachedSelection;
|
||||
} catch {
|
||||
// Content script unavailable (e.g. on chrome:// pages) — disable buttons gracefully.
|
||||
btnSelection.disabled = true;
|
||||
btnPage.disabled = true;
|
||||
showToast('Waggle cannot read this browser page. Open a normal webpage, then try again.', 'err', { sticky: true });
|
||||
}
|
||||
}
|
||||
|
||||
async function save(kind) {
|
||||
const isSelection = kind === 'selection';
|
||||
const text = isSelection ? cachedSelection : (cachedPageMeta?.text || '');
|
||||
if (!text) { showToast('Nothing to save.', 'err'); return; }
|
||||
const url = cachedPageMeta?.url || '';
|
||||
const title = cachedPageMeta?.title || '';
|
||||
const prefix = isSelection ? 'Selection from' : 'Saved page';
|
||||
const content = `${prefix} ${title || url}\n\n${text}`.slice(0, 16000);
|
||||
showToast(`Saving ${isSelection ? 'selection' : 'page'}…`);
|
||||
const reply = await chrome.runtime.sendMessage({
|
||||
type: 'save-memory',
|
||||
content,
|
||||
source: 'import',
|
||||
importance: isSelection ? 'normal' : 'low',
|
||||
url, title,
|
||||
});
|
||||
if (reply?.saved) {
|
||||
showToast(reply.duplicate ? 'Already in memory.' : 'Saved to Waggle memory ✓', 'ok');
|
||||
} else {
|
||||
const msg = reply?.error || 'Save failed.';
|
||||
showToast(msg, 'err', { sticky: isSetupError(msg) });
|
||||
}
|
||||
}
|
||||
|
||||
btnSelection.addEventListener('click', () => save('selection'));
|
||||
btnPage.addEventListener('click', () => save('page'));
|
||||
btnOpen.addEventListener('click', () => chrome.tabs.create({ url: 'http://127.0.0.1:3333' }));
|
||||
|
||||
refreshHealth();
|
||||
readActiveTab();
|
||||
Reference in New Issue
Block a user