chromium support
Release Firefox and Chromium extensions / Package and attach browser extensions (release) Successful in 54s

This commit is contained in:
2026-08-30 21:00:05 +02:00
parent 0b1e567781
commit df16075421
14 changed files with 350 additions and 57 deletions
+5 -5
View File
@@ -1,4 +1,4 @@
name: Release unsigned Firefox extension
name: Release Firefox and Chromium extensions
on:
release:
@@ -11,7 +11,7 @@ permissions:
jobs:
package-and-attach:
name: Package and attach unsigned XPI
name: Package and attach browser extensions
runs-on: ubuntu-latest
timeout-minutes: 10
@@ -30,14 +30,14 @@ jobs:
- name: Run automated checks
run: npm test
- name: Build unsigned release archive
- name: Build unsigned release archives
env:
GITEA_RELEASE_TAG: ${{ gitea.event.release.tag_name }}
run: npm run package -- --version "${GITEA_RELEASE_TAG}" --output-dir dist
- name: Verify release checksum
working-directory: dist
run: sha256sum --check *.xpi.sha256
run: sha256sum --check *.sha256
- name: Attach release assets
env:
@@ -47,7 +47,7 @@ jobs:
GITEA_RELEASE_ID: ${{ gitea.event.release.id }}
run: |
set -eu
for asset in dist/*.xpi dist/*.xpi.sha256; do
for asset in dist/*.xpi dist/*.xpi.sha256 dist/*-chromium.zip dist/*-chromium.zip.sha256; do
name="$(basename "${asset}")"
curl --fail-with-body --silent --show-error \
--request POST \
+29 -2
View File
@@ -1,11 +1,13 @@
# Glagolify
Glagolify is a small Firefox extension that turns Russian Cyrillic text on webpages into Glagolitic. Switch it off and the original text comes back—no reload required.
Glagolify is a small Firefox and Chromium extension that turns Russian Cyrillic text on webpages into Glagolitic. Switch it off and the original text comes back—no reload required.
Choose from four letter styles: **Round**, **Angular Sans**, **Historical Angular**, and **Missal Angular**. Glagolify also handles text added after a page loads while leaving text fields and editors alone.
## Install
### Firefox
Glagolify requires Firefox 142 or newer.
1. Download the latest `.xpi` from [Releases](https://git.krtss.de/StanPonomarev/glagolify/releases).
@@ -15,8 +17,33 @@ Glagolify requires Firefox 142 or newer.
Release builds are currently unsigned, so regular Firefox installs them temporarily and removes them when Firefox restarts.
### Chromium
Glagolify requires Chromium 102 or newer.
1. Download and extract the latest `-chromium.zip` from [Releases](https://git.krtss.de/StanPonomarev/glagolify/releases).
2. Open `chrome://extensions` in Chromium and enable **Developer mode**.
3. Select **Load unpacked** and choose the extracted directory.
## Use
Open a webpage, click the Glagolify toolbar icon, choose a style, and select **Enable Glagolitic**. Select **Restore Cyrillic** when you want the original text back.
Glagolify transliterates letters; it does not translate language. Firefox settings pages, browser UI, and other protected pages cannot be changed.
Glagolify transliterates letters; it does not translate language. Browser settings pages, browser UI, and other protected pages cannot be changed.
## Build
Node.js 22 or newer is required. The build has no third-party dependencies.
```sh
npm test
npm run package
```
`npm run package` creates both `dist/glagolify-<version>.xpi` for Firefox and `dist/glagolify-<version>-chromium.zip`, with a `.sha256` file beside each archive. Use `npm run package:firefox` or `npm run package:chromium` to build only one target. A release version and output directory can be supplied to any command, for example:
```sh
npm run package:chromium -- --version v1.2.3 --output-dir dist
```
The checked-in manifest remains the Firefox Manifest V2 source. The Chromium package command converts it to Manifest V3 and uses the shared runtime files.
+59 -14
View File
@@ -2,6 +2,7 @@
"use strict";
const FONT_STYLE_KEY = "glagolifyFontStyle";
const TAB_STATES_KEY = "glagolifyTabStates";
const FONT_STYLES = new Set(["round", "angular-sans", "angular", "missal-angular"]);
const DEFAULT_FONT_STYLE = "round";
@@ -13,14 +14,35 @@
if (stored && FONT_STYLES.has(stored[FONT_STYLE_KEY])) {
fontStyle = stored[FONT_STYLE_KEY];
}
return fontStyle;
})
.catch(() => fontStyle);
.catch(() => undefined);
const tabStatesReady = Promise.resolve(api.storage.session.get(TAB_STATES_KEY))
.then((stored) => {
const states = stored && stored[TAB_STATES_KEY];
if (!states || typeof states !== "object" || Array.isArray(states)) {
return;
}
for (const [storedTabId, enabled] of Object.entries(states)) {
const tabId = Number(storedTabId);
if (validTabId(tabId) && typeof enabled === "boolean") {
tabStates.set(tabId, enabled);
}
}
})
.catch(() => undefined);
const controllerReady = Promise.all([fontStyleReady, tabStatesReady]);
function validTabId(tabId) {
return Number.isInteger(tabId) && tabId >= 0;
}
async function persistTabStates() {
await api.storage.session.set({
[TAB_STATES_KEY]: Object.fromEntries(tabStates)
});
}
async function broadcast(tabId, enabled) {
await fontStyleReady;
try {
@@ -35,14 +57,14 @@
}
}
api.runtime.onMessage.addListener((message, sender) => {
function handleMessage(message, sender) {
if (!message || typeof message.type !== "string") {
return undefined;
}
if (message.type === "glagolify:document-ready") {
const tabId = sender && sender.tab && sender.tab.id;
return fontStyleReady.then(() => ({
return controllerReady.then(() => ({
enabled: validTabId(tabId) && tabStates.get(tabId) === true,
fontStyle
}));
@@ -50,7 +72,7 @@
if (message.type === "glagolify:get-tab-state") {
const tabId = message.tabId;
return fontStyleReady.then(async () => {
return controllerReady.then(async () => {
if (!validTabId(tabId)) {
return { enabled: false, available: false, fontStyle };
}
@@ -64,15 +86,16 @@
if (message.type === "glagolify:set-tab-enabled") {
const tabId = message.tabId;
if (!validTabId(tabId) || typeof message.enabled !== "boolean") {
return fontStyleReady.then(() => ({
return controllerReady.then(() => ({
enabled: false,
available: false,
fontStyle
}));
}
return fontStyleReady.then(async () => {
return controllerReady.then(async () => {
tabStates.set(tabId, message.enabled);
await persistTabStates();
const available = await broadcast(tabId, message.enabled);
return {
enabled: message.enabled,
@@ -84,10 +107,10 @@
if (message.type === "glagolify:set-font-style") {
if (!FONT_STYLES.has(message.fontStyle)) {
return fontStyleReady.then(() => ({ fontStyle, accepted: false }));
return controllerReady.then(() => ({ fontStyle, accepted: false }));
}
return fontStyleReady.then(async () => {
return controllerReady.then(async () => {
await api.storage.local.set({ [FONT_STYLE_KEY]: message.fontStyle });
fontStyle = message.fontStyle;
await Promise.all(
@@ -100,24 +123,46 @@
}
return undefined;
}
api.runtime.onMessage.addListener((message, sender, sendResponse) => {
const response = handleMessage(message, sender);
if (response === undefined) {
return false;
}
Promise.resolve(response).then(
(value) => sendResponse(value),
() => sendResponse(undefined)
);
return true;
});
api.tabs.onRemoved.addListener((tabId) => {
tabStates.delete(tabId);
api.tabs.onRemoved.addListener(async (tabId) => {
await tabStatesReady;
if (tabStates.delete(tabId)) {
await persistTabStates();
}
});
return {
tabStates,
broadcast,
fontStyleReady,
controllerReady,
getFontStyle: () => fontStyle
};
}
const exported = Object.freeze({ createBackgroundController });
if (typeof browser === "object" && browser.runtime && browser.tabs) {
createBackgroundController(browser);
const extensionApi =
typeof browser === "object"
? browser
: typeof chrome === "object"
? chrome
: null;
if (extensionApi && extensionApi.runtime && extensionApi.tabs) {
createBackgroundController(extensionApi);
}
if (typeof module === "object" && module.exports) {
+7 -4
View File
@@ -1,6 +1,8 @@
(function () {
"use strict";
const api = typeof browser === "object" ? browser : chrome;
const EXCLUDED_ANCESTORS = "head, script, style, noscript, template, textarea, select, option";
const FONT_FAMILIES = Object.freeze({
round: '"Glagolify Shafarik Round"',
@@ -330,14 +332,15 @@
return { enabled };
}
browser.runtime.onMessage.addListener((message) => {
api.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (!message || message.type !== "glagolify:set-enabled" || typeof message.enabled !== "boolean") {
return undefined;
return false;
}
return Promise.resolve(setEnabled(message.enabled, message.fontStyle));
sendResponse(setEnabled(message.enabled, message.fontStyle));
return false;
});
browser.runtime.sendMessage({ type: "glagolify:document-ready" })
api.runtime.sendMessage({ type: "glagolify:document-ready" })
.then((response) => {
setEnabled(Boolean(response && response.enabled), response && response.fontStyle);
})
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 623 B

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

+4 -2
View File
@@ -1,10 +1,12 @@
{
"name": "glagolify-firefox-extension",
"name": "glagolify-extension",
"version": "1.1.0",
"private": true,
"description": "A dependency-free Firefox extension for toggling modern Russian text to Glagolitic.",
"description": "A dependency-free Firefox and Chromium extension for toggling modern Russian text to Glagolitic.",
"scripts": {
"package": "node scripts/package-extension.js",
"package:firefox": "node scripts/package-extension.js --target firefox",
"package:chromium": "node scripts/package-extension.js --target chromium",
"test": "node --test"
}
}
+5 -3
View File
@@ -1,6 +1,8 @@
(function () {
"use strict";
const api = typeof browser === "object" ? browser : chrome;
const status = document.getElementById("status");
const toggle = document.getElementById("toggle");
const fontStyleInputs = [...document.querySelectorAll('input[name="font-style"]')];
@@ -39,11 +41,11 @@
toggle.disabled = true;
toggle.setAttribute("aria-pressed", "false");
toggle.textContent = "Unavailable on this page";
status.textContent = "Firefox protects this page from extensions.";
status.textContent = "The browser protects this page from extensions.";
}
async function askBackground(type, extra = {}) {
return browser.runtime.sendMessage({ type, ...extra });
return api.runtime.sendMessage({ type, ...extra });
}
toggle.addEventListener("click", async () => {
@@ -104,7 +106,7 @@
async function initialize() {
try {
const tabs = await browser.tabs.query({ active: true, currentWindow: true });
const tabs = await api.tabs.query({ active: true, currentWindow: true });
if (!tabs[0] || typeof tabs[0].id !== "number") {
showUnavailable();
return;
+103 -20
View File
@@ -5,7 +5,7 @@ const { mkdir, readFile, writeFile } = require("node:fs/promises");
const path = require("node:path");
const { deflateRawSync } = require("node:zlib");
const ARCHIVE_FILES = Object.freeze([
const FIREFOX_ARCHIVE_FILES = Object.freeze([
"background.js",
"content.css",
"content.js",
@@ -23,6 +23,14 @@ const ARCHIVE_FILES = Object.freeze([
"popup.js",
"transliterate.js"
]);
const CHROMIUM_ARCHIVE_FILES = Object.freeze([
...FIREFOX_ARCHIVE_FILES.map((relativePath) =>
relativePath.endsWith(".svg") ? relativePath.replace(/\.svg$/, ".png") : relativePath
),
"icons/icon-128.png",
"icons/icon-48.svg"
]);
const TARGETS = new Set(["firefox", "chromium"]);
const VERSION_PATTERN = /^(0|[1-9][0-9]{0,8})(\.(0|[1-9][0-9]{0,8})){0,3}$/;
const UTF8_FLAG = 0x0800;
@@ -46,6 +54,47 @@ function normalizeVersion(value) {
return version;
}
function createManifest(contents, version, target) {
const manifest = JSON.parse(contents.toString("utf8"));
manifest.version = version;
if (target === "firefox") {
return manifest;
}
if (version.split(".").some((part) => Number(part) > 65535)) {
throw new Error(`Chromium extension version parts cannot exceed 65535: ${version}`);
}
manifest.manifest_version = 3;
manifest.minimum_chrome_version = "102";
delete manifest.browser_specific_settings;
manifest.host_permissions = manifest.permissions.filter(
(permission) => permission === "<all_urls>" || permission.includes("://")
);
manifest.permissions = manifest.permissions.filter(
(permission) => !manifest.host_permissions.includes(permission)
);
manifest.background = { service_worker: "background.js" };
manifest.action = manifest.browser_action;
delete manifest.browser_action;
manifest.web_accessible_resources = [
{
resources: manifest.web_accessible_resources,
matches: ["<all_urls>"]
}
];
for (const icons of [manifest.icons, manifest.action.default_icon]) {
for (const size of Object.keys(icons)) {
icons[size] = icons[size].replace(/\.svg$/, ".png");
}
}
manifest.icons["128"] = "icons/icon-128.png";
return manifest;
}
function crc32(buffer) {
let crc = 0xffffffff;
@@ -122,19 +171,29 @@ function createZip(entries) {
return Buffer.concat([...localRecords, centralDirectory, endRecord]);
}
async function packageExtension({ rootDirectory, outputDirectory, version: requestedVersion }) {
async function packageExtension({
rootDirectory,
outputDirectory,
target = "firefox",
version: requestedVersion
}) {
if (!TARGETS.has(target)) {
throw new Error(`Unknown extension target: ${target}`);
}
const packageMetadata = JSON.parse(
await readFile(path.join(rootDirectory, "package.json"), "utf8")
);
const version = normalizeVersion(requestedVersion ?? packageMetadata.version);
const archiveFiles =
target === "firefox" ? FIREFOX_ARCHIVE_FILES : CHROMIUM_ARCHIVE_FILES;
const entries = [];
for (const relativePath of ARCHIVE_FILES) {
for (const relativePath of archiveFiles) {
let contents = await readFile(path.join(rootDirectory, relativePath));
if (relativePath === "manifest.json") {
const manifest = JSON.parse(contents.toString("utf8"));
manifest.version = version;
const manifest = createManifest(contents, version, target);
contents = Buffer.from(`${JSON.stringify(manifest, null, 2)}\n`);
}
@@ -142,7 +201,10 @@ async function packageExtension({ rootDirectory, outputDirectory, version: reque
}
const archive = createZip(entries);
const archiveName = `glagolify-${version}.xpi`;
const archiveName =
target === "firefox"
? `glagolify-${version}.xpi`
: `glagolify-${version}-chromium.zip`;
const archivePath = path.join(outputDirectory, archiveName);
const checksumPath = `${archivePath}.sha256`;
const digest = createHash("sha256").update(archive).digest("hex");
@@ -151,17 +213,22 @@ async function packageExtension({ rootDirectory, outputDirectory, version: reque
await writeFile(archivePath, archive);
await writeFile(checksumPath, `${digest} ${archiveName}\n`);
return { archiveName, archivePath, checksumPath, digest, version };
return { archiveName, archivePath, checksumPath, digest, target, version };
}
function parseArguments(arguments_) {
let outputDirectory = "dist";
let target = "all";
let version;
for (let index = 0; index < arguments_.length; index += 1) {
const argument = arguments_[index];
if (argument === "--version" || argument === "--output-dir") {
if (
argument === "--version" ||
argument === "--output-dir" ||
argument === "--target"
) {
const value = arguments_[index + 1];
if (!value || value.startsWith("--")) {
throw new Error(`${argument} requires a value.`);
@@ -169,8 +236,10 @@ function parseArguments(arguments_) {
if (argument === "--version") {
version = value;
} else {
} else if (argument === "--output-dir") {
outputDirectory = value;
} else {
target = value;
}
index += 1;
} else {
@@ -178,22 +247,31 @@ function parseArguments(arguments_) {
}
}
return { outputDirectory, version };
if (target !== "all" && !TARGETS.has(target)) {
throw new Error(`Unknown extension target: ${target}`);
}
return { outputDirectory, target, version };
}
async function main() {
const rootDirectory = path.resolve(__dirname, "..");
const options = parseArguments(process.argv.slice(2));
const result = await packageExtension({
rootDirectory,
outputDirectory: path.resolve(rootDirectory, options.outputDirectory),
version: options.version
});
const targets = options.target === "all" ? [...TARGETS] : [options.target];
process.stdout.write(
`Created ${path.relative(rootDirectory, result.archivePath)} for version ${result.version}\n` +
`SHA-256 ${result.digest}\n`
);
for (const target of targets) {
const result = await packageExtension({
rootDirectory,
outputDirectory: path.resolve(rootDirectory, options.outputDirectory),
target,
version: options.version
});
process.stdout.write(
`Created ${path.relative(rootDirectory, result.archivePath)} for ${target} ` +
`version ${result.version}\nSHA-256 ${result.digest}\n`
);
}
}
if (require.main === module) {
@@ -203,4 +281,9 @@ if (require.main === module) {
});
}
module.exports = { ARCHIVE_FILES, normalizeVersion, packageExtension };
module.exports = {
CHROMIUM_ARCHIVE_FILES,
FIREFOX_ARCHIVE_FILES,
normalizeVersion,
packageExtension
};
+57 -4
View File
@@ -4,12 +4,14 @@ const test = require("node:test");
const assert = require("node:assert/strict");
const { createBackgroundController } = require("../background.js");
function makeBrowser(initialStorage = {}) {
function makeBrowser(initialStorage = {}, initialSessionStorage = {}) {
let messageListener;
let removedListener;
const broadcasts = [];
const stored = { ...initialStorage };
const sessionStored = { ...initialSessionStorage };
const storageWrites = [];
const sessionWrites = [];
const api = {
runtime: {
onMessage: {
@@ -27,6 +29,15 @@ function makeBrowser(initialStorage = {}) {
storageWrites.push({ ...values });
Object.assign(stored, values);
}
},
session: {
async get(key) {
return { [key]: sessionStored[key] };
},
async set(values) {
sessionWrites.push(structuredClone(values));
Object.assign(sessionStored, structuredClone(values));
}
}
},
tabs: {
@@ -45,13 +56,24 @@ function makeBrowser(initialStorage = {}) {
return {
api,
broadcasts,
sessionStored,
sessionWrites,
stored,
storageWrites,
send(message, sender = {}) {
return messageListener(message, sender);
return new Promise((resolve) => {
let responded = false;
const keepChannelOpen = messageListener(message, sender, (response) => {
responded = true;
resolve(response);
});
if (!keepChannelOpen && !responded) {
resolve(undefined);
}
});
},
remove(tabId) {
removedListener(tabId);
return removedListener(tabId);
}
};
}
@@ -91,10 +113,41 @@ test("owns absolute state per tab and supplies it to new frames", async () => {
{ enabled: false, fontStyle: "round" }
);
fake.remove(17);
await fake.remove(17);
assert.equal(controller.tabStates.has(17), false);
});
test("restores tab state after an MV3 background worker restart", async () => {
const firstWorker = makeBrowser();
createBackgroundController(firstWorker.api);
await firstWorker.send({
type: "glagolify:set-tab-enabled",
tabId: 23,
enabled: true
});
assert.deepEqual(firstWorker.sessionStored, {
glagolifyTabStates: { 23: true }
});
const restartedWorker = makeBrowser({}, firstWorker.sessionStored);
createBackgroundController(restartedWorker.api);
assert.deepEqual(
await restartedWorker.send(
{ type: "glagolify:document-ready" },
{ tab: { id: 23 } }
),
{ enabled: true, fontStyle: "round" }
);
assert.deepEqual(
await restartedWorker.send({
type: "glagolify:get-tab-state",
tabId: 23
}),
{ enabled: true, available: true, fontStyle: "round" }
);
});
test("keeps states isolated between tabs and reports protected tabs", async () => {
const fake = makeBrowser();
createBackgroundController(fake.api);
+81 -3
View File
@@ -8,7 +8,8 @@ const os = require("node:os");
const path = require("node:path");
const { inflateRawSync } = require("node:zlib");
const {
ARCHIVE_FILES,
CHROMIUM_ARCHIVE_FILES,
FIREFOX_ARCHIVE_FILES,
normalizeVersion,
packageExtension
} = require("../scripts/package-extension.js");
@@ -72,8 +73,12 @@ test("builds a deterministic unsigned XPI with the release version and runtime f
const entries = readZipEntries(firstArchive);
assert.equal(first.archiveName, "glagolify-9.8.7.xpi");
assert.deepEqual([...entries.keys()], [...ARCHIVE_FILES]);
assert.equal(JSON.parse(entries.get("manifest.json")).version, "9.8.7");
assert.deepEqual([...entries.keys()], [...FIREFOX_ARCHIVE_FILES]);
const manifest = JSON.parse(entries.get("manifest.json"));
assert.equal(manifest.version, "9.8.7");
assert.equal(manifest.manifest_version, 2);
assert.equal(manifest.background.persistent, true);
assert.equal(manifest.browser_action.default_popup, "popup.html");
assert.equal(entries.has("package.json"), false);
assert.equal(entries.has("tests/package-extension.test.js"), false);
assert.deepEqual(firstArchive, secondArchive);
@@ -84,3 +89,76 @@ test("builds a deterministic unsigned XPI with the release version and runtime f
`${digest} glagolify-9.8.7.xpi\n`
);
});
test("builds a Chromium MV3 ZIP with Chromium manifest fields and raster icons", async (context) => {
const rootDirectory = path.resolve(__dirname, "..");
const outputDirectory = await mkdtemp(path.join(os.tmpdir(), "glagolify-chromium-"));
context.after(() => rm(outputDirectory, { recursive: true, force: true }));
const result = await packageExtension({
rootDirectory,
outputDirectory,
target: "chromium",
version: "v4.5.6"
});
const archive = await readFile(result.archivePath);
const entries = readZipEntries(archive);
const manifest = JSON.parse(entries.get("manifest.json"));
assert.equal(result.archiveName, "glagolify-4.5.6-chromium.zip");
assert.deepEqual([...entries.keys()], [...CHROMIUM_ARCHIVE_FILES]);
assert.equal(manifest.manifest_version, 3);
assert.equal(manifest.minimum_chrome_version, "102");
assert.equal(manifest.version, "4.5.6");
assert.deepEqual(manifest.permissions, ["activeTab", "storage"]);
assert.deepEqual(manifest.host_permissions, ["<all_urls>"]);
assert.deepEqual(manifest.background, { service_worker: "background.js" });
assert.equal(manifest.action.default_popup, "popup.html");
assert.equal("browser_action" in manifest, false);
assert.equal("browser_specific_settings" in manifest, false);
assert.deepEqual(manifest.web_accessible_resources, [
{
resources: [
"fonts/NotoSansGlagolitic-Regular.ttf",
"fonts/Shafarik-Regular.ttf"
],
matches: ["<all_urls>"]
}
]);
assert.equal(manifest.icons["128"], "icons/icon-128.png");
for (const relativePath of CHROMIUM_ARCHIVE_FILES.filter((name) =>
name.endsWith(".png")
)) {
assert.deepEqual(
entries.get(relativePath).subarray(0, 8),
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
);
}
const popup = entries.get("popup.html").toString("utf8");
for (const [, relativePath] of popup.matchAll(/\b(?:href|src)="([^"]+)"/g)) {
assert.equal(
entries.has(relativePath),
true,
`${relativePath} referenced by popup.html should be packaged`
);
}
const digest = createHash("sha256").update(archive).digest("hex");
assert.equal(
await readFile(result.checksumPath, "utf8"),
`${digest} glagolify-4.5.6-chromium.zip\n`
);
});
test("rejects Chromium version components outside the MV3 manifest range", async () => {
await assert.rejects(
packageExtension({
rootDirectory: path.resolve(__dirname, ".."),
outputDirectory: os.tmpdir(),
target: "chromium",
version: "65536"
}),
/cannot exceed 65535/
);
});