feat: add cross-browser extension with AI reading assistant

- Create Pi Reader browser extension for Chrome/Firefox
- Chrome uses Side Panel API, Firefox uses Sidebar Action API
- Supports both browsers with separate manifests and unified codebase
- Built with mini-lit components and Tailwind CSS v4
- Features model selection dialog with Ollama support
- Hot reload development server watches both browser builds
- Add useDefineForClassFields: false to fix LitElement reactivity
This commit is contained in:
Mario Zechner 2025-10-01 04:33:56 +02:00
parent c1185c7b95
commit b67c10dfb1
33 changed files with 4453 additions and 1202 deletions

View file

@ -0,0 +1,84 @@
import { build, context } from "esbuild";
import { copyFileSync, mkdirSync, rmSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const packageRoot = join(__dirname, "..");
const isWatch = process.argv.includes("--watch");
// Determine target browser from command line arguments
const targetBrowser = process.argv.includes("--firefox") ? "firefox" : "chrome";
const outDir = join(packageRoot, `dist-${targetBrowser}`);
const entryPoints = {
sidepanel: join(packageRoot, "src/sidepanel.ts"),
background: join(packageRoot, "src/background.ts")
};
rmSync(outDir, { recursive: true, force: true });
mkdirSync(outDir, { recursive: true });
const buildOptions = {
absWorkingDir: packageRoot,
entryPoints,
bundle: true,
outdir: outDir,
format: "esm",
target: targetBrowser === "firefox" ? ["firefox115"] : ["chrome120"],
platform: "browser",
sourcemap: isWatch ? "inline" : true,
entryNames: "[name]",
loader: {
".ts": "ts",
".tsx": "tsx"
},
define: {
"process.env.NODE_ENV": JSON.stringify(process.env.NODE_ENV ?? (isWatch ? "development" : "production")),
"process.env.TARGET_BROWSER": JSON.stringify(targetBrowser)
}
};
const copyStatic = () => {
// Use browser-specific manifest
const manifestSource = join(packageRoot, `manifest.${targetBrowser}.json`);
const manifestDest = join(outDir, "manifest.json");
copyFileSync(manifestSource, manifestDest);
// Copy other static files
const filesToCopy = [
"icon-16.png",
"icon-48.png",
"icon-128.png",
join("src", "sidepanel.html")
];
for (const relative of filesToCopy) {
const source = join(packageRoot, relative);
let destination = join(outDir, relative);
if (relative.startsWith("src/")) {
destination = join(outDir, relative.slice(4)); // Remove "src/" prefix
}
copyFileSync(source, destination);
}
console.log(`Built for ${targetBrowser} in ${outDir}`);
};
const run = async () => {
if (isWatch) {
const ctx = await context(buildOptions);
await ctx.watch();
copyStatic();
process.stdout.write("Watching for changes...\n");
} else {
await build(buildOptions);
copyStatic();
}
};
run().catch((error) => {
console.error(error);
process.exitCode = 1;
});

View file

@ -0,0 +1,83 @@
import { createServer } from "http";
import { WebSocketServer } from "ws";
import { watch } from "fs";
import { join, dirname } from "path";
import { fileURLToPath } from "url";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// Watch both browser directories
const distDirChrome = join(__dirname, "..", "dist-chrome");
const distDirFirefox = join(__dirname, "..", "dist-firefox");
const PORT = 8765; // Fixed port for WebSocket server
const server = createServer();
const wss = new WebSocketServer({ server });
const clients = new Set();
// WebSocket connection handling
wss.on("connection", (ws) => {
console.log("[DevServer] Client connected");
clients.add(ws);
ws.on("close", () => {
console.log("[DevServer] Client disconnected");
clients.delete(ws);
});
ws.on("error", (error) => {
console.error("[DevServer] WebSocket error:", error);
clients.delete(ws);
});
// Send initial connection confirmation
ws.send(JSON.stringify({ type: "connected" }));
});
// Watch for changes in both dist directories
const watcherChrome = watch(distDirChrome, { recursive: true }, (eventType, filename) => {
if (filename) {
console.log(`[DevServer] Chrome file changed: ${filename}`);
// Send reload message to all connected clients
const message = JSON.stringify({ type: "reload", browser: "chrome", file: filename });
clients.forEach((client) => {
if (client.readyState === 1) { // OPEN state
client.send(message);
}
});
}
});
const watcherFirefox = watch(distDirFirefox, { recursive: true }, (eventType, filename) => {
if (filename) {
console.log(`[DevServer] Firefox file changed: ${filename}`);
// Send reload message to all connected clients
const message = JSON.stringify({ type: "reload", browser: "firefox", file: filename });
clients.forEach((client) => {
if (client.readyState === 1) { // OPEN state
client.send(message);
}
});
}
});
// Start server
server.listen(PORT, () => {
console.log(`[DevServer] WebSocket server running on ws://localhost:${PORT}`);
console.log(`[DevServer] Watching for changes in ${distDirChrome} and ${distDirFirefox}`);
});
// Graceful shutdown
process.on("SIGINT", () => {
console.log("\n[DevServer] Shutting down...");
watcherChrome.close();
watcherFirefox.close();
clients.forEach((client) => client.close());
server.close(() => {
process.exit(0);
});
});