Skip to content
Merged
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
20 changes: 20 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,23 @@ jobs:
- run: npm test
- run: npm run build

extension:
runs-on: ubuntu-latest
timeout-minutes: 15
needs: check
steps:
- uses: actions/checkout@v7

- uses: actions/setup-node@v7
with:
node-version-file: .nvmrc
cache: npm

- run: npm ci
- run: npm run build:extension

- uses: actions/upload-artifact@v7
with:
name: boost-focus-timer-extension
path: dist/*.zip
if-no-files-found: error
18 changes: 18 additions & 0 deletions extension/background.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
// Toolbar button (and its optional keyboard shortcut): focus the open timer tab, or open one.
// A single tab matters because two running timers would race on the same local data.
const APP_URL = chrome.runtime.getURL("index.html");

async function findAppTab() {
const contexts = await chrome.runtime.getContexts({ contextTypes: ["TAB"], documentUrls: [APP_URL] });
return contexts.find((context) => context.tabId !== -1) ?? null;
}

chrome.action.onClicked.addListener(async () => {
const existing = await findAppTab();
if (!existing) {
await chrome.tabs.create({ url: APP_URL });
return;
}
await chrome.tabs.update(existing.tabId, { active: true });
await chrome.windows.update(existing.windowId, { focused: true });
});
6 changes: 5 additions & 1 deletion next.config.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
import type { NextConfig } from "next";
import packageJson from "./package.json" with { type: "json" };

const isExtensionBuild = process.env.BUILD_TARGET === "extension";

const nextConfig: NextConfig = {
output: "standalone",
// The extension is a static bundle; the web app ships as a standalone server.
output: isExtensionBuild ? "export" : "standalone",
devIndicators: false,
images: { unoptimized: isExtensionBuild },
env: { NEXT_PUBLIC_APP_VERSION: packageJson.version },
};

Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
"typecheck": "tsc --noEmit",
"test": "node --test \"tests/**/*.test.ts\"",
"icons": "node scripts/generate-icons.mjs",
"build:extension": "node scripts/build-extension.mjs",
"check": "npm run lint && npm run typecheck && npm test && npm run build"
},
"dependencies": {
Expand Down
103 changes: 103 additions & 0 deletions scripts/build-extension.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
#!/usr/bin/env node
// Builds the app as a Manifest V3 browser extension (Chrome, Edge, Brave, Opera):
// a static Next export, reshaped to fit extension rules, plus a manifest and a zip.
//
// Usage: npm run build:extension -> dist/extension/ and dist/boost-focus-timer-extension-<version>.zip
import { execFileSync } from "node:child_process";
import { createHash } from "node:crypto";
import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
import { dirname, join, relative } from "node:path";
import { fileURLToPath } from "node:url";

const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
const EXPORT_DIR = join(ROOT, "out");
const DIST_DIR = join(ROOT, "dist");
const EXTENSION_DIR = join(DIST_DIR, "extension");
// Chrome refuses to load extensions containing files or folders whose names start with "_".
const ASSET_DIR = "next";
const INLINE_DIR = `${ASSET_DIR}/inline`;
const TEXT_FILE = /\.(html|js|css|txt|json|webmanifest)$/;

const { name, version, description } = JSON.parse(readFileSync(join(ROOT, "package.json"), "utf8"));

function walk(dir) {
return readdirSync(dir).flatMap((entry) => {
const path = join(dir, entry);
return statSync(path).isDirectory() ? walk(path) : [path];
});
}

function exportStaticSite() {
rmSync(EXPORT_DIR, { recursive: true, force: true });
execFileSync("npx", ["next", "build"], { cwd: ROOT, stdio: "inherit", env: { ...process.env, BUILD_TARGET: "extension" } });
rmSync(EXTENSION_DIR, { recursive: true, force: true });
mkdirSync(DIST_DIR, { recursive: true });
cpSync(EXPORT_DIR, EXTENSION_DIR, { recursive: true });
}

function renameReservedPaths() {
renameSync(join(EXTENSION_DIR, "_next"), join(EXTENSION_DIR, ASSET_DIR));
for (const file of walk(EXTENSION_DIR).filter((path) => TEXT_FILE.test(path))) {
const text = readFileSync(file, "utf8");
if (text.includes("/_next/")) writeFileSync(file, text.replaceAll("/_next/", `/${ASSET_DIR}/`));
}
// Leftover "_"-prefixed routes (e.g. the not-found page) are unused inside the extension.
for (const entry of readdirSync(EXTENSION_DIR, { recursive: true })) {
const isReserved = String(entry).split("/").some((part) => part.startsWith("_"));
if (isReserved) rmSync(join(EXTENSION_DIR, String(entry)), { recursive: true, force: true });
}
}

/** MV3 pages may only run scripts from the extension itself, so inline scripts become files. */
function externalizeInlineScripts() {
mkdirSync(join(EXTENSION_DIR, INLINE_DIR), { recursive: true });
const inlineScript = /<script(?![^>]*\bsrc=)([^>]*)>([\s\S]*?)<\/script>/g;
for (const page of walk(EXTENSION_DIR).filter((path) => path.endsWith(".html"))) {
const html = readFileSync(page, "utf8").replace(inlineScript, (_match, attributes, code) => {
if (!code.trim()) return "";
const fileName = `${createHash("sha256").update(code).digest("hex").slice(0, 16)}.js`;
writeFileSync(join(EXTENSION_DIR, INLINE_DIR, fileName), code);
return `<script${attributes} src="/${INLINE_DIR}/${fileName}"></script>`;
});
writeFileSync(page, html);
}
}

function writeManifest() {
const icons = Object.fromEntries([16, 32, 48, 128].map((size) => [String(size), `icons/icon-${size}.png`]));
const manifest = {
manifest_version: 3,
name: "Boost Focus Timer",
short_name: "Boost",
version,
description,
icons,
action: { default_title: "Open Boost Focus Timer", default_icon: icons },
background: { service_worker: "background.js" },
// Unassigned by default; users pick a key at chrome://extensions/shortcuts.
commands: { _execute_action: { description: "Open Boost Focus Timer" } },
};
writeFileSync(join(EXTENSION_DIR, "manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`);
cpSync(join(ROOT, "extension", "background.js"), join(EXTENSION_DIR, "background.js"));
}

function zipExtension() {
const zipPath = join(DIST_DIR, `${name}-extension-${version}.zip`);
rmSync(zipPath, { force: true });
try {
execFileSync("zip", ["-qr", zipPath, "."], { cwd: EXTENSION_DIR });
return zipPath;
} catch (error) {
console.error("Skipped the zip (is the `zip` command installed?):", error instanceof Error ? error.message : error);
return null;
}
}

exportStaticSite();
renameReservedPaths();
externalizeInlineScripts();
writeManifest();
const zipPath = zipExtension();
if (!existsSync(join(EXTENSION_DIR, "index.html"))) throw new Error("Static export produced no index.html");
console.log(`\nExtension ready: ${relative(ROOT, EXTENSION_DIR)}${zipPath ? ` and ${relative(ROOT, zipPath)}` : ""}`);
console.log("Load it via chrome://extensions -> Developer mode -> Load unpacked.");
Loading