Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 15 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,18 @@
# Serenade Chrome Extension
# WebChime Chrome Extension
Available for download on the [chrome web store](https://chromewebstore.google.com/detail/pfcnjhdacgnclpladnjfpepigbbbbkfc?utm_source=item-share-cb)
![WebChime logo](./extension_promo_images//webchime.png)

Source for the [Serenade Chrome Extension](https://chrome.google.com/webstore/detail/serenade-for-chrome/bgfbijeikimjmdjldemlegooghdjinmj?hl=en)
## Changelog
- v 2.0.5, merged 9/9/2025, confirmed chrome version Version 140.0.7339.133 (Official Build) (arm64). Adds trusted types compatibility for google suite apps (and other that might use this feature) See [docs](https://developer.mozilla.org/en-US/docs/Web/API/Trusted_Types_API)

## Alscotty hotfix 8/21/2025
- refactored to make it more compliant with manifest_v3, extension what hitting issues with injecting the content scripts, thus it was not executing commands like clicks/typing, particularly "show links" or "show inputs" were loading overlay but unable to actually click
- now it is working again! tested on chrome
- the tests need some updating (as the show fails for content script still, inaccurate) but the playwright stuff is a good start to test for regressions etc.

## Installation

1. Download `build.zip` and unzip
1. Run `npm run build && npm run dist` to generate `build.zip`, then unzip
2. In Chrome, go to [chrome://extensions](chrome://extensions) and enable Developer Mode
3. Click "Load unpacked" and select the unzipped `build` folder

Expand All @@ -21,7 +29,7 @@ Each of these script types can communicate between each other with browser event

- Extension code
- `extension.ts`: Entry-point for the extension
- `ipc.ts`: Handles communication between the Serenade app and the Chrome extension. Also determines which command handler to send each incoming command to.
- `ipc.ts`: Handles communication between the Serenade app and WebChime Chrome extension. Also determines which command handler to send each incoming command to.
- `extension-command-handler.ts`: Handles commands that do not need page content/require access to the browser APIs (e.g. tab management)
- Content scripts
- `content-script.ts`: Adds the tag containing the injected scripts and sets up communication between `ipc.ts` and the injected code
Expand Down Expand Up @@ -69,3 +77,6 @@ The `Editor` class also contains some helper functions to determine a suitable f
1. Update the version number in `manifest.json`
2. Run `npm run dist`
3. Upload the new `build.zip` file to the Chrome Web Store

## Privacy Policy
[see link](https://webchime-privacy-fir-6d7s.bolt.host)
Binary file removed build.zip
Binary file not shown.
Binary file added extension_promo_images/example_img_1.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added extension_promo_images/webchime.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
8 changes: 4 additions & 4 deletions manifest.json
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
{
"manifest_version": 3,
"name": "Serenade",
"version": "2.0.3",
"description": "Code with voice. Learn more at https://serenade.ai.",
"name": "WebChime",
"version": "2.0.5",
"description": "Browse the web hands-free with only your voice. This extension pairs with Serenade AI https://serenade.ai.",
"permissions": ["background", "tabs", "storage", "alarms", "idle", "scripting"],
"host_permissions": ["*://*/*"],
"action": {
"default_title": "Serenade for Chrome",
"default_title": "WebChime for Chrome",
"default_popup": "build/popup.html"
},
"background": {
Expand Down
10 changes: 9 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,19 @@
"build": "webpack --mode development",
"dist": "webpack --mode production && zip -r build.zip build img manifest.json",
"watch": "webpack --mode development --watch",
"test": "open http://localhost:8001/src/test & python -m http.server 8001"
"test": "open http://localhost:8001/src/test & python3 -m http.server 8001",
"test:playwright": "npm run build && playwright test",
"test:watch": "node scripts/watch-and-test.js",
"test:ui": "npm run build && playwright test --ui",
"test:debug": "npm run build && playwright test --debug"
},
"devDependencies": {
"@playwright/test": "^1.40.0",
"@types/chrome": "^0.0.174",
"@types/uuid": "^8.3.3",
"chokidar": "^3.5.3",
"concurrently": "^8.2.2",
"lodash.debounce": "^4.0.8",
"copy-webpack-plugin": "^11.0.0",
"ts-loader": "^9.2.6",
"typescript": "^4.5.4",
Expand Down
76 changes: 76 additions & 0 deletions playwright-report/index.html

Large diffs are not rendered by default.

44 changes: 44 additions & 0 deletions playwright.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { defineConfig, devices } from '@playwright/test';
import path from 'path';

export default defineConfig({
testDir: './tests',
fullyParallel: false, // Extensions can't run in parallel
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: 1, // Only one worker for extension testing
reporter: 'html',
use: {
trace: 'on-first-retry',
screenshot: 'only-on-failure',
},

projects: [
{
name: 'chromium-extension',
use: {
...devices['Desktop Chrome'],
headless: true, // Run tests headless to avoid popup windows
channel: 'chrome',
launchOptions: {
args: [
`--disable-extensions-except=${path.join(__dirname, './build')}`,
`--load-extension=${path.join(__dirname, './build')}`,
'--no-first-run',
'--no-default-browser-check',
'--disable-background-timer-throttling',
'--disable-backgrounding-occluded-windows',
'--disable-renderer-backgrounding',
],
},
contextOptions: {
// Grant all permissions to the extension
permissions: ['background-sync', 'notifications'],
},
},
},
],

// Global setup and teardown
globalSetup: './tests/global-setup.ts',
});
101 changes: 101 additions & 0 deletions scripts/watch-and-test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
#!/usr/bin/env node

const { spawn } = require('child_process');
const chokidar = require('chokidar');
const path = require('path');
const debounce = require('lodash.debounce');

let testProcess = null;
let buildProcess = null;

// Function to kill running processes
function killProcesses() {
if (testProcess) {
testProcess.kill();
testProcess = null;
}
if (buildProcess) {
buildProcess.kill();
buildProcess = null;
}
}

// Function to build the extension
function build() {
return new Promise((resolve, reject) => {
console.log('🔨 Building extension...');
buildProcess = spawn('npm', ['run', 'build'], {
stdio: 'inherit',
cwd: process.cwd()
});

buildProcess.on('close', (code) => {
buildProcess = null;
if (code === 0) {
console.log('✅ Build completed successfully');
resolve();
} else {
console.log('❌ Build failed');
reject(new Error(`Build failed with code ${code}`));
}
});
});
}

// Function to run tests
function runTests() {
console.log('🧪 Running tests...');
testProcess = spawn('npx', ['playwright', 'test', '--reporter=dot'], {
stdio: 'inherit',
cwd: process.cwd()
});

testProcess.on('close', (code) => {
testProcess = null;
if (code === 0) {
console.log('✅ All tests passed');
} else {
console.log('❌ Some tests failed');
}
console.log('👀 Watching for changes...\n');
});
}

// Debounced function to rebuild and test
const rebuildAndTest = debounce(async () => {
killProcesses();
try {
await build();
runTests();
} catch (error) {
console.log('Build failed, skipping tests');
}
}, 1000);

// Initial build and test
console.log('🚀 Starting watch mode...');
rebuildAndTest();

// Watch for file changes
const watcher = chokidar.watch(['src/**/*', 'tests/**/*'], {
ignored: /node_modules/,
persistent: true
});

watcher.on('change', (filepath) => {
console.log(`📁 File changed: ${path.relative(process.cwd(), filepath)}`);
rebuildAndTest();
});

watcher.on('add', (filepath) => {
console.log(`📁 File added: ${path.relative(process.cwd(), filepath)}`);
rebuildAndTest();
});

// Graceful shutdown
process.on('SIGINT', () => {
console.log('\n👋 Shutting down...');
killProcesses();
watcher.close();
process.exit(0);
});
132 changes: 104 additions & 28 deletions src/content-script.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,59 +2,135 @@ function injectScript(path: string) {
const script = document.createElement("script");
script.setAttribute("type", "text/javascript");
script.setAttribute("src", path);
document.documentElement.appendChild(script);

script.onerror = (error) => console.error("Script failed to load:", path, error);

// Try head first, then body, then documentElement as fallback
const target = document.head || document.body || document.documentElement;
target.appendChild(script);
}

function injectCSS(path: string) {
const css = document.createElement("link");
css.setAttribute("rel", "stylesheet");
css.setAttribute("type", "text/css");
css.setAttribute("href", path);
document.documentElement.appendChild(css);

css.onerror = (error) => console.error("CSS failed to load:", path, error);

const target = document.head || document.documentElement;
target.appendChild(css);
}

function ensureInjection() {
const scriptUrl = chrome.runtime.getURL("build/injected.js");
const cssUrl = chrome.runtime.getURL("build/injected.css");

injectScript(scriptUrl);
injectCSS(cssUrl);
}

injectScript(chrome.runtime.getURL("build/injected.js"));
injectCSS(chrome.runtime.getURL("build/injected.css"));
// Wait for DOM to be ready before injecting
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', ensureInjection);
} else {
ensureInjection();
}

let resolvers: { [k: number]: any } = {};
let injectedScriptReady = false;

document.addEventListener('serenade-injected-script-ready', () => {
injectedScriptReady = true;
});

let resolvers: { [k: number]: any } = [];
document.addEventListener(`serenade-injected-script-command-response`, (e: any) => {
if (resolvers[e.detail.id]) {
resolvers[e.detail.id](e.detail);
delete resolvers[e.detail.id];
}
});

async function sendMessageToInjectedScript(data: any) {
const id = Math.random();
const response = await new Promise((resolve) => {
resolvers[id] = resolve;
async function waitForInjectedScript(timeout = 10000): Promise<void> {
if (injectedScriptReady) return;

return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
reject(new Error('Injected script ready timeout'));
}, timeout);

const handler = () => {
clearTimeout(timer);
document.removeEventListener('serenade-injected-script-ready', handler);
resolve();
};

document.addEventListener('serenade-injected-script-ready', handler);
});
}

async function sendMessageToInjectedScript(data: any): Promise<any> {
try {
await waitForInjectedScript();
} catch (error) {
return { error: 'Injected script not ready' };
}

return new Promise((resolve) => {
const id = Math.random();

const timeout = setTimeout(() => {
if (resolvers[id]) {
delete resolvers[id];
resolve({ error: 'Injected script timeout' });
}
}, 5000);

const originalResolve = resolve;
resolvers[id] = (responseData: any) => {
clearTimeout(timeout);
originalResolve(responseData);
};

document.dispatchEvent(
new CustomEvent(`serenade-injected-script-command-request`, {
detail: {
id,
data: data,
},
new CustomEvent('serenade-injected-script-command-request', {
detail: { id, data },
})
);
});
return response;
}

chrome.runtime.onMessage.addListener(async (request, _sender, sendResponse) => {
if (request.type == "injected-script-command-request") {
const response = await sendMessageToInjectedScript(request.data);
sendResponse(response);
chrome.runtime.onMessage.addListener((request, _sender, sendResponse) => {
if (request.type === "injected-script-command-request") {
// Return a promise to handle async operations properly in MV3
(async () => {
try {
const response = await sendMessageToInjectedScript(request.data);
sendResponse(response);
} catch (error) {
sendResponse({ error: "Injected script communication failed" });
}
})();
return true; // Indicate we will send response asynchronously
}
return true;
});

document.addEventListener("DOMContentLoaded", async () => {
const settings = await chrome.storage.sync.get(["alwaysShowClickables"]);
sendMessageToInjectedScript({
type: "clearOverlays",
});
sendMessageToInjectedScript({
type: "updateSettings",
...settings,
});
try {
const settings = await chrome.storage.sync.get(["alwaysShowClickables"]);

// Wait a bit more for injected script to fully initialize
await new Promise(resolve => setTimeout(resolve, 1000));

await sendMessageToInjectedScript({
type: "clearOverlays",
});

await sendMessageToInjectedScript({
type: "updateSettings",
...settings,
});
} catch (error) {
console.warn("Failed to initialize injected script:", error);
}
});
1 change: 0 additions & 1 deletion src/editors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -341,7 +341,6 @@ class NativeInput extends Editor {

private undoStack(): { source: string; cursor: number }[] {
const editor = document.activeElement as Element;
console.log(editor);
if (!this.undoStacks.has(editor)) {
this.undoStacks.set(editor, []);
}
Expand Down
Loading