moving
This commit is contained in:
@@ -1,69 +1,66 @@
|
||||
# Waggle Companion · Chrome MV3 extension
|
||||
# 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.
|
||||
Browser-side capture for Waggle OS. It saves a page selection or the current
|
||||
page to personal Waggle memory 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
|
||||
|
||||
## What it does (v0.1.0)
|
||||
- The popup shows connection status and the active memory destination.
|
||||
- Save Selection and Save Page send personal imported-memory captures.
|
||||
- The context menu can save selected text.
|
||||
- Pairing is explicit: generate a one-time code in Waggle Settings, then enter
|
||||
it in the popup. The code is single-use and expires after ten minutes.
|
||||
|
||||
- **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.
|
||||
The popup never makes network requests. The MV3 background worker redeems the
|
||||
code and stores only the resulting scoped credential in
|
||||
`chrome.storage.local.companionToken`. Legacy `sessionToken` values are deleted
|
||||
and never trusted. A rejected credential is removed; captures are never replayed
|
||||
or automatically re-paired.
|
||||
|
||||
## How to load (developer mode, local install)
|
||||
## Load locally
|
||||
|
||||
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.
|
||||
1. Start Waggle with an extension allowlist:
|
||||
- Development only: `WAGGLE_DEV_ALLOW_ANY_EXTENSION=1`
|
||||
- Production-shaped: `WAGGLE_BROWSER_EXT_IDS=<extension-id>`
|
||||
2. Open `chrome://extensions`, enable Developer mode, and choose Load unpacked.
|
||||
3. Select this `apps/browser-ext` directory.
|
||||
4. If using the production-shaped allowlist, copy the installed extension ID
|
||||
into `WAGGLE_BROWSER_EXT_IDS` and restart Waggle.
|
||||
5. In Waggle Settings -> Advanced, generate a Browser Companion code.
|
||||
6. Enter that code in the extension popup.
|
||||
|
||||
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.
|
||||
Never enable `WAGGLE_DEV_ALLOW_ANY_EXTENSION` in a production build.
|
||||
|
||||
## 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 |
|
||||
| `manifest.json` | MV3 permissions, popup, content script, background worker |
|
||||
| `popup.html` | Popup UI |
|
||||
| `popup.js` | Pairing, health, and capture UI logic |
|
||||
| `content.js` | On-demand selection and page extraction |
|
||||
| `background.js` | Pairing and authenticated loopback requests |
|
||||
|
||||
## 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.
|
||||
- `POST /api/browser-ext/pair` redeems an allowlisted extension's valid code.
|
||||
- `GET /api/browser-ext/health` reports local connection and workspace state.
|
||||
- `POST /api/memory/frames` accepts only `source: "import"`, personal scope,
|
||||
and normal/low importance for the paired credential.
|
||||
|
||||
## Verification
|
||||
The global desktop session token is never exposed to the extension.
|
||||
|
||||
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`.
|
||||
## Manual smoke test
|
||||
|
||||
## Roadmap (post-MVP)
|
||||
1. Open the popup before pairing: it must show the code form and disable
|
||||
authenticated capture.
|
||||
2. Pair with a fresh Settings code: the popup must show Connected.
|
||||
3. Save a selection and confirm a personal frame with source `import` appears.
|
||||
4. Revoke in Settings: the next health/save request must require a new code and
|
||||
must not retry the previous capture.
|
||||
|
||||
- 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).
|
||||
## Deferred
|
||||
|
||||
- Side-panel chat about the current page.
|
||||
- Firefox-specific packaging.
|
||||
- Reader-mode extraction and branded icons.
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
// 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.
|
||||
// Waggle Companion background service worker — the only extension process that
|
||||
// talks to the loopback sidecar. The popup supplies a one-time code; only the
|
||||
// resulting scoped credential is persisted.
|
||||
|
||||
const SIDECAR = 'http://127.0.0.1:3333';
|
||||
const PAIRING_REQUIRED = 'Browser Companion not paired. Generate a one-time code in Waggle Settings.';
|
||||
|
||||
async function readJson(response) {
|
||||
try {
|
||||
@@ -18,56 +16,77 @@ async function readJson(response) {
|
||||
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.';
|
||||
return 'Browser Companion not allowlisted. Add the extension ID in Waggle, restart Waggle, and try again.';
|
||||
}
|
||||
if (status === 401 && code === 'INVALID_TOKEN') {
|
||||
return 'Browser Companion pairing expired. Reopen Waggle desktop, then try again.';
|
||||
if (code === 'PAIRING_CODE_INVALID') {
|
||||
return 'Invalid or expired pairing code. Generate a new one-time code in Waggle Settings.';
|
||||
}
|
||||
if (status === 401 && (code === 'MISSING_TOKEN' || !code)) {
|
||||
return 'Browser Companion is not paired. Start Waggle desktop, then try again.';
|
||||
if (status === 401 && (code === 'INVALID_TOKEN' || code === 'MISSING_TOKEN' || !code)) {
|
||||
return PAIRING_REQUIRED;
|
||||
}
|
||||
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 removeLegacyToken() {
|
||||
await chrome.storage.local.remove('sessionToken');
|
||||
}
|
||||
|
||||
async function getAuthHeaders(options = {}) {
|
||||
async function getAuthHeaders() {
|
||||
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}` } };
|
||||
const { companionToken, sessionToken } = await chrome.storage.local.get([
|
||||
'companionToken',
|
||||
'sessionToken',
|
||||
]);
|
||||
if (sessionToken) await removeLegacyToken();
|
||||
if (!companionToken) return { headers: {}, error: PAIRING_REQUIRED };
|
||||
return { headers: { Authorization: `Bearer ${companionToken}` } };
|
||||
} catch (err) {
|
||||
return { headers: {}, error: String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
async function pairWithCode(rawCode) {
|
||||
const code = typeof rawCode === 'string' ? rawCode.trim().toUpperCase() : '';
|
||||
if (!/^[A-HJ-NP-Z2-9]{8}$/.test(code)) {
|
||||
return { ok: false, error: 'Enter the 8-character code shown in Waggle Settings.' };
|
||||
}
|
||||
try {
|
||||
const response = await fetch(`${SIDECAR}/api/browser-ext/pair`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'X-Waggle-Extension-Id': chrome.runtime.id,
|
||||
},
|
||||
body: JSON.stringify({ code }),
|
||||
});
|
||||
const data = await readJson(response);
|
||||
if (!response.ok || typeof data?.token !== 'string') {
|
||||
return { ok: false, error: authErrorMessage(response.status, data) };
|
||||
}
|
||||
await chrome.storage.local.set({ companionToken: data.token });
|
||||
await removeLegacyToken();
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
return { ok: false, error: String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
async function health() {
|
||||
try {
|
||||
const auth = await getAuthHeaders({ pair: true });
|
||||
const auth = await getAuthHeaders();
|
||||
if (auth.error) return { ok: false, error: auth.error };
|
||||
const r = await fetch(`${SIDECAR}/api/browser-ext/health`, {
|
||||
const response = 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();
|
||||
const data = await readJson(response);
|
||||
if (response.status === 401) {
|
||||
await chrome.storage.local.remove('companionToken');
|
||||
return { ok: false, error: PAIRING_REQUIRED };
|
||||
}
|
||||
if (!response.ok) return { ok: false, error: authErrorMessage(response.status, data) };
|
||||
return data;
|
||||
} catch (err) {
|
||||
return { ok: false, error: String(err) };
|
||||
}
|
||||
@@ -75,30 +94,23 @@ async function health() {
|
||||
|
||||
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 });
|
||||
const auth = await getAuthHeaders();
|
||||
if (auth.error) return { saved: false, error: auth.error };
|
||||
let r = await fetch(`${SIDECAR}/api/memory/frames`, {
|
||||
const response = await fetch(`${SIDECAR}/api/memory/frames`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...auth.headers },
|
||||
body,
|
||||
body: JSON.stringify({
|
||||
content: payload.content,
|
||||
source: payload.source || 'import',
|
||||
importance: payload.importance || 'normal',
|
||||
}),
|
||||
});
|
||||
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,
|
||||
});
|
||||
}
|
||||
const data = await readJson(response);
|
||||
if (response.status === 401) {
|
||||
await chrome.storage.local.remove('companionToken');
|
||||
return { saved: false, error: PAIRING_REQUIRED };
|
||||
}
|
||||
if (!r.ok) return { saved: false, error: authErrorMessage(r.status, await readJson(r)) };
|
||||
const data = await r.json();
|
||||
if (!response.ok) return { saved: false, error: authErrorMessage(response.status, data) };
|
||||
return {
|
||||
saved: data?.saved ?? true,
|
||||
duplicate: data?.duplicate ?? false,
|
||||
@@ -109,16 +121,16 @@ async function saveMemory(payload) {
|
||||
}
|
||||
}
|
||||
|
||||
chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
|
||||
chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
|
||||
(async () => {
|
||||
if (msg?.type === 'health') sendResponse(await health());
|
||||
else if (msg?.type === 'save-memory') sendResponse(await saveMemory(msg));
|
||||
if (message?.type === 'health') sendResponse(await health());
|
||||
else if (message?.type === 'pair') sendResponse(await pairWithCode(message.code));
|
||||
else if (message?.type === 'save-memory') sendResponse(await saveMemory(message));
|
||||
else sendResponse({ error: 'unknown message type' });
|
||||
})();
|
||||
return true; // keep channel open for async sendResponse
|
||||
return true;
|
||||
});
|
||||
|
||||
// Context menu: right-click selection → "Save to Waggle memory"
|
||||
chrome.runtime.onInstalled.addListener(() => {
|
||||
chrome.contextMenus.create({
|
||||
id: 'waggle-save-selection',
|
||||
@@ -134,7 +146,6 @@ chrome.contextMenus.onClicked.addListener(async (info, tab) => {
|
||||
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);
|
||||
|
||||
@@ -74,6 +74,19 @@
|
||||
}
|
||||
#toast.ok { color: var(--success); border-color: var(--success); }
|
||||
#toast.err { color: var(--danger); border-color: var(--danger); }
|
||||
#pair-form {
|
||||
margin-bottom: 10px; padding: 8px; border: 1px solid var(--border);
|
||||
border-radius: 8px; background: #101014;
|
||||
}
|
||||
#pair-form label { display: block; margin-bottom: 6px; color: var(--fg); }
|
||||
.pair-row { display: flex; gap: 6px; }
|
||||
#pair-code {
|
||||
width: 100%; min-width: 0; padding: 7px 8px; border-radius: 6px;
|
||||
border: 1px solid var(--border); background: var(--bg); color: var(--fg);
|
||||
font: 600 14px/1 monospace; letter-spacing: .12em; text-transform: uppercase;
|
||||
}
|
||||
#pair-code:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; }
|
||||
#pair-submit { width: auto; margin: 0; }
|
||||
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>
|
||||
@@ -87,6 +100,15 @@
|
||||
|
||||
<div class="workspace">Memory destination: <strong id="workspace-name">—</strong></div>
|
||||
|
||||
<form id="pair-form" hidden>
|
||||
<label for="pair-code">One-time code from Waggle Settings</label>
|
||||
<div class="pair-row">
|
||||
<input id="pair-code" name="pair-code" maxlength="8" minlength="8"
|
||||
pattern="[A-HJ-NP-Za-hj-np-z2-9]{8}" autocomplete="off" spellcheck="false" required>
|
||||
<button class="primary" id="pair-submit" type="submit">Pair</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<button class="primary" id="save-selection" disabled>
|
||||
<span class="icon">💾</span><span>Save selection to memory</span>
|
||||
</button>
|
||||
|
||||
@@ -12,6 +12,9 @@ const btnSelection = $('save-selection');
|
||||
const btnPage = $('save-page');
|
||||
const btnOpen = $('open-waggle');
|
||||
const toast = $('toast');
|
||||
const pairForm = $('pair-form');
|
||||
const pairCode = $('pair-code');
|
||||
const pairSubmit = $('pair-submit');
|
||||
|
||||
let cachedSelection = '';
|
||||
let cachedPageMeta = null;
|
||||
@@ -60,6 +63,7 @@ async function refreshHealth() {
|
||||
// textContent (not innerHTML) — workspace names are user-controlled
|
||||
// and could otherwise be XSS sinks in the extension context.
|
||||
workspaceNameEl.textContent = formatMemoryDestination(reply);
|
||||
pairForm.hidden = true;
|
||||
} else {
|
||||
throw new Error(reply?.error || 'No response');
|
||||
}
|
||||
@@ -67,11 +71,32 @@ async function refreshHealth() {
|
||||
dot.className = 'dot disconnected';
|
||||
statusText.textContent = 'Not connected';
|
||||
workspaceNameEl.textContent = 'Unavailable';
|
||||
pairForm.hidden = false;
|
||||
const msg = err?.message || 'Start Waggle desktop on this machine, then re-open this popup.';
|
||||
showToast(msg, 'err', { sticky: true });
|
||||
}
|
||||
}
|
||||
|
||||
async function pair(event) {
|
||||
event.preventDefault();
|
||||
const code = pairCode.value.trim().toUpperCase();
|
||||
pairSubmit.disabled = true;
|
||||
try {
|
||||
const reply = await chrome.runtime.sendMessage({ type: 'pair', code });
|
||||
if (!reply?.ok) {
|
||||
showToast(reply?.error || 'Pairing failed.', 'err', { sticky: true });
|
||||
return;
|
||||
}
|
||||
pairCode.value = '';
|
||||
showToast('Browser Companion paired.', 'ok');
|
||||
await refreshHealth();
|
||||
} catch (err) {
|
||||
showToast(err?.message || 'Pairing failed.', 'err', { sticky: true });
|
||||
} finally {
|
||||
pairSubmit.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function readActiveTab() {
|
||||
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
|
||||
if (!tab?.id) return null;
|
||||
@@ -108,6 +133,7 @@ async function save(kind) {
|
||||
showToast(reply.duplicate ? 'Already in memory.' : 'Saved to Waggle memory ✓', 'ok');
|
||||
} else {
|
||||
const msg = reply?.error || 'Save failed.';
|
||||
if (isSetupError(msg)) pairForm.hidden = false;
|
||||
showToast(msg, 'err', { sticky: isSetupError(msg) });
|
||||
}
|
||||
}
|
||||
@@ -115,6 +141,7 @@ async function save(kind) {
|
||||
btnSelection.addEventListener('click', () => save('selection'));
|
||||
btnPage.addEventListener('click', () => save('page'));
|
||||
btnOpen.addEventListener('click', () => chrome.tabs.create({ url: 'http://127.0.0.1:3333' }));
|
||||
pairForm.addEventListener('submit', pair);
|
||||
|
||||
refreshHealth();
|
||||
readActiveTab();
|
||||
|
||||
Reference in New Issue
Block a user