// The dev channel: a build of the extension you can install beside the store // one and still tell apart. The two installs already have different IDs (the // unpacked build keeps the manifest `key`, the store assigns its own), so // Chrome is happy to run both; what is missing is a way to see which toolbar // tile and which row in chrome://extensions is which. // // Three changes, all applied to dist/ after the build: // // - the icon PNGs are replaced with the inverted ones from brand/icons-dev/ // (rendered by scripts/render-icons.mjs). Overwriting the files rather than // adding new manifest entries means the popup header picture and anything // else pointing at /icons/... follows along. // - the manifest name gets a suffix, which is what chrome://extensions, the // extensions menu, and the default action tooltip show. // - `version_name` records the commit the build came from, which the popup // footer shows in place of the bare version. // // Nothing here touches public/, so a production build cannot pick it up. // Turn it on with SUBSTANDARD_CHANNEL=dev (npm run dev sets it by default); // vite.config.ts calls applyDevChannel from a writeBundle hook. import { execFileSync } from 'node:child_process' import { copyFileSync, readFileSync, writeFileSync } from 'node:fs' import { join, resolve } from 'node:path' /** Icon sizes the manifest declares, and that brand/icons-dev/ mirrors. */ export const DEV_ICON_SIZES = [16, 32, 48, 128] export const DEV_NAME_SUFFIX = ' (dev)' /** * The dev channel's manifest. The `key` stays, so the extension ID stays, so * the OAuth redirect URI registered for the dev ID keeps working. Applying it * twice is the same as applying it once. * * `build` (a short sha, or undefined outside a git checkout) goes in * `version_name`, not in `version`: Chrome only accepts up to four * dot-separated integers there and refuses to load anything else. Both are * built from `manifest.version`, so a rebuild at a new commit replaces the * stamp instead of appending to it. */ export function devManifest(manifest, build) { const name = manifest.name.endsWith(DEV_NAME_SUFFIX) ? manifest.name : manifest.name + DEV_NAME_SUFFIX const dev = { ...manifest, name } if (build) dev.version_name = `${manifest.version}+${build}` return dev } /** * The commit this build came from, `abc1234` or `abc1234-dirty`, or undefined * if git cannot say — a tarball, or a checkout with no commits yet. The dev * channel is a local build, so uncommitted work is the normal case and the * sha alone would claim more than the build can back up. */ export function buildStamp(root) { // stderr is dropped: "not a git repository" is an answer here, not a fault // worth printing on every rebuild. const git = (...args) => execFileSync('git', args, { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], }).trim() try { const sha = git('rev-parse', '--short', 'HEAD') return git('status', '--porcelain') ? `${sha}-dirty` : sha } catch { return undefined } } /** * Rewrite a built dist/ in place for the dev channel. Returns the new name * and version_name, for the build log. */ export function applyDevChannel(root, dist) { for (const size of DEV_ICON_SIZES) { copyFileSync( resolve(root, 'brand', 'icons-dev', `icon${size}.png`), join(dist, 'icons', `icon${size}.png`), ) } const file = join(dist, 'manifest.json') const manifest = devManifest(JSON.parse(readFileSync(file, 'utf8')), buildStamp(root)) writeFileSync(file, `${JSON.stringify(manifest, null, 2)}\n`) return manifest }