1 Commits
0.2.0 ... 0.3.0

Author SHA1 Message Date
a7531914f9 chrome+firefox v0.3 2026-08-10 15:49:01 -03:00
14 changed files with 242 additions and 43 deletions

12
.gitignore vendored
View File

@@ -32,14 +32,10 @@ node_modules
# Unpacked build output. Regenerated by "make", never edited by hand.
build
# Intermediate packages.
logsdu-*.zip
# Packaged .xpi files are NOT ignored. They are the published artifact: the
# signed add-on is what people download and install, so it belongs in the
# repository (or attached to a release) rather than being rebuilt by everyone
# who wants to install it. An unsigned local build can be removed with
# "make clean".
# dist/ holds the publishable packages and is NOT ignored: the signed add-on
# is what people download and install, so it belongs in the repository (or
# attached to a release) rather than being rebuilt by everyone who wants it.
# Local unsigned builds land there too; "make clean" removes the directory.
# Exclude sourcemaps
*.map

View File

@@ -19,33 +19,41 @@
# * rcb@beco.cc *
# **************************************************************************
# Makefile for logsdu - build the unpacked extension into build/.
# Makefile for logsdu - build the extension for Firefox and Chrome.
#
# Usage:
# make # typecheck and bundle into build/
# make # typecheck and bundle for Firefox into build/
# make chrome # same, but with Chrome's manifest, into build/
# make test # run the unit tests
# make xpi # build, then package build/ as logsdu-<version>.xpi
# make clean # remove build/ and the package
# make smoke # check the background bundle works as a service worker
# make xpi # package the Firefox build -> dist/logsdu-<version>.xpi
# make crx # package the Chrome build -> dist/logsdu-<version>-chrome.zip
# make packages # both of the above
# make clean # remove build/ and dist/
#
# build/ holds the unpacked extension and is regenerated from scratch; dist/
# holds the packages meant to be published, and is kept out of build/ so that
# packaging never tries to include its own output.
#
# Dependencies are installed with pnpm, never npm:
# corepack pnpm install
#
# Permanent install (Firefox ESR, Developer Edition or Nightly):
# set xpinstall.signatures.required=false in about:config, then
# about:addons -> gear -> Install Add-on From File -> pick the .xpi
# Load the unpacked build while developing:
# Firefox about:debugging -> This Firefox -> Load Temporary Add-on ->
# build/manifest.json (dropped when Firefox restarts)
# Chrome chrome://extensions -> Developer mode -> Load unpacked -> build/
#
# Release Firefox refuses unsigned add-ons whatever that pref says. There the
# same .xpi has to go through addons.mozilla.org as an unlisted add-on first,
# which signs it automatically without publishing or reviewing it.
#
# Throwaway install for development: about:debugging -> This Firefox ->
# Load Temporary Add-on -> pick build/manifest.json (dropped on restart).
# Publishing:
# Firefox upload dist/*.xpi at addons.mozilla.org
# Chrome upload dist/*-chrome.zip at chrome.google.com/webstore/devconsole
EXT_ID := logsdu
VERSION := $(shell node -p "require('./package.json').version")
XPI := $(EXT_ID)-$(VERSION).xpi
DIST := dist
XPI := $(DIST)/$(EXT_ID)-$(VERSION).xpi
CRX := $(DIST)/$(EXT_ID)-$(VERSION)-chrome.zip
.PHONY: all build chrome test xpi clean check-deps
.PHONY: all build chrome test smoke xpi crx packages clean check-deps
all: build
@@ -55,23 +63,29 @@ all: build
test:
node --test "src/**/*.test.ts"
# Runs the built background bundle in a service-worker-shaped sandbox, which
# is where a Chrome-only breakage would otherwise hide until runtime.
smoke: build
node tools/sw-smoke.mjs
# Call the local toolchain directly, so this works regardless of how pnpm is
# provided (corepack vs standalone). Run "corepack pnpm install" first.
build: check-deps
node_modules/.bin/tsc -noEmit -skipLibCheck
node esbuild.config.mjs production
# The same sources with Chrome's background key. Untested against Chrome; it
# exists so the port is a build flag rather than a fork.
# The same sources with Chrome's manifest. Firefox and Chrome disagree on the
# background key and on the gecko block, so the manifest is generated per
# target rather than forked.
chrome: check-deps
node_modules/.bin/tsc -noEmit -skipLibCheck
TARGET=chrome node esbuild.config.mjs production
@echo "Chrome build in build/ -- load it via chrome://extensions (Developer mode)."
@echo "Chrome build in build/ -- chrome://extensions -> Developer mode -> Load unpacked."
# An .xpi is just a zip of the extension directory, with the manifest at the
# top level rather than inside a wrapper folder. The same file installs
# directly on ESR and uploads to AMO for signing.
# top level rather than inside a wrapper folder.
xpi: build
@mkdir -p $(DIST)
rm -f $(XPI)
cd build && zip -qr ../$(XPI) .
@echo
@@ -80,7 +94,8 @@ xpi: build
@echo "This file is UNSIGNED. Two ways to use it:"
@echo
@echo " Publish -- upload it at addons.mozilla.org/developers/addon/submit/"
@echo " Mozilla signs it; the signed file installs on any Firefox."
@echo " Listed add-ons are signed once review approves them; unlisted"
@echo " ones are signed straight away."
@echo
@echo " Install locally -- only on ESR, Developer Edition or Nightly:"
@echo " 1. about:config -> xpinstall.signatures.required = false"
@@ -89,9 +104,22 @@ xpi: build
@echo " $(CURDIR)/$(XPI)"
@echo
# The Chrome Web Store takes a plain zip, and does the packing into .crx itself.
crx: chrome
@mkdir -p $(DIST)
rm -f $(CRX)
cd build && zip -qr ../$(CRX) .
@echo
@echo "Built: $(CURDIR)/$(CRX)"
@echo "Upload it at chrome.google.com/webstore/devconsole"
@echo
# Both packages. The Firefox build runs last so build/ is left in the state
# the development instructions assume.
packages: crx xpi
clean:
rm -rf build
rm -f $(EXT_ID)-*.xpi $(EXT_ID)-*.zip
rm -rf build $(DIST)
# Fail with a useful message rather than a confusing "tsc: not found".
check-deps:

View File

@@ -19,7 +19,7 @@ Once it is published, from addons.mozilla.org. Until then, build it yourself:
```
corepack pnpm install # first time, or after a dependency change
make xpi # typecheck, bundle, and package logsdu-<version>.xpi
make xpi # typecheck, bundle, and package dist/logsdu-<version>.xpi
```
`make xpi` prints the full path of the file and how to install it. The package
@@ -118,23 +118,56 @@ check.
## Chrome
`make chrome` builds it. The only difference is the background key: Firefox MV3
uses an event page, Chrome MV3 requires a service worker, so the manifest is
generated per target rather than duplicated. Everything else -- `chrome.*`
namespace, MV3, no Firefox-only APIs -- is already shared.
```
make chrome # unpacked Chrome build in build/
make crx # package it as dist/logsdu-<version>-chrome.zip
```
It has not been tested against Chrome. Do not assume it works.
Load `build/` via `chrome://extensions` -> Developer mode -> **Load unpacked**,
or upload the zip at
[the Web Store dashboard](https://chrome.google.com/webstore/devconsole).
One codebase, two manifests. Chrome MV3 requires a background *service worker*
and rejects Firefox's event-page `background.scripts`; Firefox needs the gecko
block that Chrome has no use for. `esbuild.config.mjs` writes the right manifest
per target, so the port is a build flag rather than a fork. Everything else --
the `chrome.*` namespace, MV3, the permission model -- is shared.
Two things that differ in practice, both handled:
- **Icons must be raster.** Chrome does not accept SVG in `icons`, so the PNGs
in `icons/` are generated from `logsdu.svg` and both browsers use those.
- **Service workers have no `window` or `document`.** A stray reference through
a shared import would break Chrome only, silently, at runtime. `make smoke`
runs the built background bundle in a worker-shaped sandbox to catch that.
What has been verified: Chrome 151 loads the build without errors, and the
background bundle registers exactly one content script for exactly the
configured origin. What has **not** been verified is a real login against a live
portal in Chrome.
A caveat that applies to both browsers: the page-world filler is injected as a
`<script src>` tag, which a site's Content-Security-Policy can refuse. Portals
that send no CSP -- the common case for this kind of form -- are unaffected. A
portal that does would need the filler registered as a `MAIN` world content
script instead.
## Development
```
corepack pnpm install
make test # unit tests
make smoke # background bundle under a service worker
make # typecheck and bundle into build/
make packages # both publishable packages into dist/
corepack pnpm run dev # rebuild on change
make clean
```
`build/` is the unpacked extension and is regenerated from scratch every time.
`dist/` holds the packages meant to be published, and is kept separate so that
packaging never tries to include its own output.
While iterating, load the unpacked directory rather than reinstalling an `.xpi`
each time: `about:debugging` -> **This Firefox** -> **Load Temporary Add-on** ->
`build/manifest.json`, then press **Reload** there after each rebuild. That copy
@@ -156,6 +189,7 @@ still needs the Reload click to pick anything up.
| `src/config.ts` | Stored values, the rate limit and the logout cooldown |
| `src/format.ts` | Input normalisers for the three masked fields |
| `src/options.*`, `src/popup.*` | The two bits of UI |
| `tools/sw-smoke.mjs` | Checks the background bundle survives a service worker |
### Why two scripts instead of one

View File

@@ -1 +1 @@
0.2.0
0.3.0

Binary file not shown.

BIN
dist/logsdu-0.3.0.xpi vendored Normal file

Binary file not shown.

BIN
icons/logsdu-128.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

BIN
icons/logsdu-16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 442 B

BIN
icons/logsdu-32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 729 B

BIN
icons/logsdu-48.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

BIN
icons/logsdu-96.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

@@ -1,6 +1,6 @@
{
"name": "logsdu",
"version": "0.2.0",
"version": "0.3.0",
"description": "Browser extension that fills and submits a three-field academic portal login.",
"author": "Ruben Carlo Benante <rcb@beco.cc>",
"type": "module",

View File

@@ -1,13 +1,16 @@
{
"manifest_version": 3,
"name": "logsdu",
"version": "0.2.0",
"version": "0.3.0",
"description": "Saves and fills logins that password managers cannot: registration number, date of birth and document number.",
"author": "Ruben Carlo Benante (Dr. Beco)",
"homepage_url": "https://code.beco.cc/beco/logsdu",
"icons": {
"48": "icons/logsdu.svg",
"96": "icons/logsdu.svg"
"16": "icons/logsdu-16.png",
"32": "icons/logsdu-32.png",
"48": "icons/logsdu-48.png",
"96": "icons/logsdu-96.png",
"128": "icons/logsdu-128.png"
},
"browser_specific_settings": {
"gecko": {
@@ -36,6 +39,9 @@
"action": {
"default_popup": "popup.html",
"default_title": "logsdu",
"default_icon": "icons/logsdu.svg"
"default_icon": {
"16": "icons/logsdu-16.png",
"32": "icons/logsdu-32.png"
}
}
}

135
tools/sw-smoke.mjs Normal file
View File

@@ -0,0 +1,135 @@
// *************************************************************************
// * (C)opyright 2026 by Ruben Carlo Benante *
// * *
// * This program is free software; you can redistribute it and/or modify *
// * it under the terms of the GNU General Public License as published by *
// * the Free Software Foundation, either version 3 of the License, or *
// * (at your option) any later version. *
// * *
// * This program is distributed in the hope that it will be useful, *
// * but WITHOUT ANY WARRANTY; without even the implied warranty of *
// * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
// * GNU General Public License for more details. *
// * *
// * You should have received a copy of the GNU General Public License *
// * along with this program. If not, see http://www.gnu.org/licenses/. *
// * *
// * Contact author at: *
// * Ruben Carlo Benante *
// * rcb@beco.cc *
// *************************************************************************
// Smoke test for the background bundle under Chrome's execution model.
//
// Chrome MV3 runs the background as a service worker, where there is no
// `window` and no `document`, and the global object is `self`. Firefox runs
// the same file as an event page, where those do exist -- so a reference that
// creeps in through a shared import breaks Chrome only, and breaks it silently
// at runtime rather than at build time.
//
// This evaluates the built bundle in a worker-shaped sandbox with a stubbed
// extension API, and asserts that it registers exactly one content script, for
// exactly the configured origin. Run it against build/ after a build:
//
// make smoke
import { readFileSync } from 'node:fs';
import vm from 'node:vm';
const BUNDLE = 'build/background.js';
const CONFIGURED_ORIGIN = 'https://portal.example.br';
const permissionChecks = [];
const registered = [];
const listeners = {
onInstalled: 0,
onStartup: 0,
onRemoved: 0,
onChanged: 0,
onMessage: 0,
};
const chrome = {
runtime: {
onInstalled: { addListener: () => listeners.onInstalled++ },
onStartup: { addListener: () => listeners.onStartup++ },
onMessage: { addListener: () => listeners.onMessage++ },
},
permissions: {
onRemoved: { addListener: () => listeners.onRemoved++ },
contains: async (p) => {
permissionChecks.push(p.origins);
return true;
},
},
storage: {
local: {
get: async () => ({
config: {
url: `${CONFIGURED_ORIGIN}/`,
ra: '2000101010',
dn: '01/02/1999',
cpf: '123.456.789-01',
autoSubmit: true,
},
}),
set: async () => {},
},
onChanged: { addListener: () => listeners.onChanged++ },
},
scripting: {
getRegisteredContentScripts: async () => [],
unregisterContentScripts: async () => {},
registerContentScripts: async (scripts) => registered.push(...scripts),
},
};
// Deliberately no window and no document: referencing either must fail here
// exactly as it would inside a service worker.
const sandbox = { chrome, console, setTimeout, clearTimeout, queueMicrotask, URL };
sandbox.self = sandbox;
vm.createContext(sandbox);
const failures = [];
try {
vm.runInContext(readFileSync(BUNDLE, 'utf8'), sandbox, { filename: BUNDLE });
} catch (error) {
console.error(`FAIL: ${BUNDLE} threw on evaluation: ${error.message}`);
process.exit(1);
}
// Give the top-level resync() a turn of the event loop to settle.
await new Promise((resolve) => setTimeout(resolve, 50));
for (const [name, count] of Object.entries(listeners)) {
if (count !== 1) failures.push(`${name} registered ${count} times, expected 1`);
}
if (registered.length !== 1) {
failures.push(`registered ${registered.length} content scripts, expected 1`);
} else {
const script = registered[0];
const expected = `${CONFIGURED_ORIGIN}/*`;
if (script.matches?.length !== 1 || script.matches[0] !== expected) {
failures.push(`matches ${JSON.stringify(script.matches)}, expected ["${expected}"]`);
}
if (script.js?.[0] !== 'content.js') {
failures.push(`js ${JSON.stringify(script.js)}, expected ["content.js"]`);
}
if (script.persistAcrossSessions !== false) {
failures.push('persistAcrossSessions must be false, or startup registers twice');
}
}
if (permissionChecks.length !== 1) {
failures.push(`checked permissions ${permissionChecks.length} times, expected 1`);
}
if (failures.length > 0) {
for (const failure of failures) console.error(`FAIL: ${failure}`);
process.exit(1);
}
console.log('ok - background bundle runs as a service worker');
console.log(`ok - registers content.js for ${CONFIGURED_ORIGIN}/* and nothing else`);