From 63d0169bfb5f806ba1c3b488057bef5e5a234033 Mon Sep 17 00:00:00 2001 From: Truong Trung Nghia <149591043+ttncode@users.noreply.github.com> Date: Sun, 11 Oct 2026 16:51:01 +0700 Subject: [PATCH] feat: package the app as a browser extension ## Summary Package the app as a Manifest V3 browser extension for Chromium browsers (Chrome, Edge, Brave, Opera). - `npm run build:extension` makes a static export (`BUILD_TARGET=extension`) and reshapes it for extension rules: renames `_next` (underscore names are reserved), externalizes every inline script (MV3 allows only same-origin scripts), drops unused `_`-prefixed routes, writes `manifest.json`, and zips the result to `dist/`. - `extension/background.js`: the toolbar button focuses the open timer tab or opens one, so two timers never race on the same data. A global shortcut can be assigned at `chrome://extensions/shortcuts`. - The shortcuts panel shows that hint when running inside the extension. - CI builds the extension and uploads the zip as an artifact. ## Testing - Lint, typecheck, unit tests and build pass. - `npm run build:extension` output served with the MV3 policy `script-src 'self'`: app loads with no CSP violations, all tabs and shortcuts work. --- .github/workflows/ci.yml | 20 +++++++ extension/background.js | 18 +++++++ next.config.ts | 6 ++- package.json | 1 + scripts/build-extension.mjs | 103 ++++++++++++++++++++++++++++++++++++ 5 files changed, 147 insertions(+), 1 deletion(-) create mode 100644 extension/background.js create mode 100644 scripts/build-extension.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bbcc505..91beb39 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/extension/background.js b/extension/background.js new file mode 100644 index 0000000..992d06b --- /dev/null +++ b/extension/background.js @@ -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 }); +}); diff --git a/next.config.ts b/next.config.ts index 273f7af..eaaa929 100644 --- a/next.config.ts +++ b/next.config.ts @@ -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 }, }; diff --git a/package.json b/package.json index 19061fc..52c9f15 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/scripts/build-extension.mjs b/scripts/build-extension.mjs new file mode 100644 index 0000000..640c62e --- /dev/null +++ b/scripts/build-extension.mjs @@ -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-.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 = /]*\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 ``; + }); + 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.");