2 Commits
0.2.0 ... 0.3.1

14 changed files with 308 additions and 58 deletions

12
.gitignore vendored
View File

@@ -32,14 +32,10 @@ node_modules
# Unpacked build output. Regenerated by "make", never edited by hand. # Unpacked build output. Regenerated by "make", never edited by hand.
build build
# Intermediate packages. # dist/ holds the publishable packages and is NOT ignored: the signed add-on
logsdu-*.zip # 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.
# Packaged .xpi files are NOT ignored. They are the published artifact: the # Local unsigned builds land there too; "make clean" removes the directory.
# 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".
# Exclude sourcemaps # Exclude sourcemaps
*.map *.map

124
Makefile
View File

@@ -19,35 +19,57 @@
# * rcb@beco.cc * # * rcb@beco.cc *
# ************************************************************************** # **************************************************************************
# Makefile for logsdu - build the unpacked extension into build/. # Makefile for logsdu - build the extension for Firefox and Chrome.
# #
# Usage: # Usage:
# make # typecheck and bundle into build/ # make # everything: build and package both browsers
# make firefox # build Firefox only -> build/firefox/
# make chrome # build Chrome only -> build/chrome/
# make test # run the unit tests # make test # run the unit tests
# make xpi # build, then package build/ as logsdu-<version>.xpi # make smoke # check the background bundle works as a service worker
# make clean # remove build/ and the package # make xpi # package Firefox -> dist/logsdu-<version>-firefox.xpi
# make crx # package Chrome -> dist/logsdu-<version>-chrome.zip
# make packages # both of the above (same as plain "make")
# make clean # remove build/, dist/ and stray packages
# make distclean # clean, plus node_modules/
#
# build/<target>/ holds the unpacked extension for one browser; the two targets
# never share a directory, so neither can be left stale by the other. 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: # Dependencies are installed with pnpm, never npm:
# corepack pnpm install # corepack pnpm install
# #
# Permanent install (Firefox ESR, Developer Edition or Nightly): # Load the unpacked build while developing:
# set xpinstall.signatures.required=false in about:config, then # Firefox about:debugging -> This Firefox -> Load Temporary Add-on ->
# about:addons -> gear -> Install Add-on From File -> pick the .xpi # build/firefox/manifest.json (dropped when Firefox restarts)
# Chrome chrome://extensions -> Developer mode -> Load unpacked ->
# build/chrome/
# #
# Release Firefox refuses unsigned add-ons whatever that pref says. There the # Publishing:
# same .xpi has to go through addons.mozilla.org as an unlisted add-on first, # Firefox upload dist/*-firefox.xpi at addons.mozilla.org
# which signs it automatically without publishing or reviewing it. # Chrome upload dist/*-chrome.zip at chrome.google.com/webstore/devconsole
# #
# Throwaway install for development: about:debugging -> This Firefox -> # Every package filename names its browser. The two are not interchangeable --
# Load Temporary Add-on -> pick build/manifest.json (dropped on restart). # they differ in the manifest's background key -- and uploading the wrong one
# fails in ways that are not obvious from the error.
EXT_ID := logsdu EXT_ID := logsdu
VERSION := $(shell node -p "require('./package.json').version") VERSION := $(shell node -p "require('./package.json').version")
XPI := $(EXT_ID)-$(VERSION).xpi FIREFOX_DIR := build/firefox
CHROME_DIR := build/chrome
DIST := dist
XPI := $(DIST)/$(EXT_ID)-$(VERSION)-firefox.xpi
CRX := $(DIST)/$(EXT_ID)-$(VERSION)-chrome.zip
.PHONY: all build chrome test xpi clean check-deps .PHONY: all firefox chrome typecheck test smoke xpi crx packages clean \
distclean check-deps
all: build # The default does the lot: build both browsers and package both. Packaging is
# only a zip of a directory that was going to be built anyway, so making it the
# default costs nothing and means dist/ is never quietly out of date with src/.
all: packages
# Unit tests for the pure logic: the input formatters and the decision that # Unit tests for the pure logic: the input formatters and the decision that
# says whether a page load may press "Entrar". Run straight through Node's # says whether a page load may press "Entrar". Run straight through Node's
@@ -55,32 +77,49 @@ all: build
test: test:
node --test "src/**/*.test.ts" node --test "src/**/*.test.ts"
# Call the local toolchain directly, so this works regardless of how pnpm is # Runs the built background bundle in a service-worker-shaped sandbox, which
# provided (corepack vs standalone). Run "corepack pnpm install" first. # is where a Chrome-only breakage would otherwise hide until runtime. The
build: check-deps # bundle is identical for both targets, so checking one covers both.
node_modules/.bin/tsc -noEmit -skipLibCheck smoke: chrome
node esbuild.config.mjs production node tools/sw-smoke.mjs $(CHROME_DIR)/background.js
# The same sources with Chrome's background key. Untested against Chrome; it # Typecheck once. Both build targets depend on it rather than each running tsc,
# exists so the port is a build flag rather than a fork. # which halves the work when building both.
chrome: check-deps #
# Calls the local toolchain directly, so this works regardless of how pnpm is
# provided (corepack vs standalone). Run "corepack pnpm install" first.
typecheck: check-deps
node_modules/.bin/tsc -noEmit -skipLibCheck node_modules/.bin/tsc -noEmit -skipLibCheck
firefox: typecheck
node esbuild.config.mjs production
@echo "Firefox build: $(CURDIR)/$(FIREFOX_DIR)"
# 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: typecheck
TARGET=chrome node esbuild.config.mjs production TARGET=chrome node esbuild.config.mjs production
@echo "Chrome build in build/ -- load it via chrome://extensions (Developer mode)." @echo
@echo "Chrome build: $(CURDIR)/$(CHROME_DIR)"
@echo "Load it with chrome://extensions -> Developer mode -> Load unpacked."
@echo "Select the folder itself; Chrome wants the directory holding manifest.json."
@echo
# An .xpi is just a zip of the extension directory, with the manifest at the # 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 # top level rather than inside a wrapper folder.
# directly on ESR and uploads to AMO for signing. xpi: firefox
xpi: build @mkdir -p $(DIST)
rm -f $(XPI) rm -f $(XPI)
cd build && zip -qr ../$(XPI) . cd $(FIREFOX_DIR) && zip -qr $(CURDIR)/$(XPI) .
@echo @echo
@echo "Built: $(CURDIR)/$(XPI)" @echo "Built: $(CURDIR)/$(XPI)"
@echo @echo
@echo "This file is UNSIGNED. Two ways to use it:" @echo "This file is UNSIGNED. Two ways to use it:"
@echo @echo
@echo " Publish -- upload it at addons.mozilla.org/developers/addon/submit/" @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
@echo " Install locally -- only on ESR, Developer Edition or Nightly:" @echo " Install locally -- only on ESR, Developer Edition or Nightly:"
@echo " 1. about:config -> xpinstall.signatures.required = false" @echo " 1. about:config -> xpinstall.signatures.required = false"
@@ -89,9 +128,34 @@ xpi: build
@echo " $(CURDIR)/$(XPI)" @echo " $(CURDIR)/$(XPI)"
@echo @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 $(CHROME_DIR) && zip -qr $(CURDIR)/$(CRX) .
@echo
@echo "Built: $(CURDIR)/$(CRX)"
@echo "Upload it at chrome.google.com/webstore/devconsole"
@echo
# Both packages. Order no longer matters: each target has its own directory.
packages: xpi crx
# Removes everything the build produces, including packages from earlier
# versions, whose filenames carry their own version number and so are never
# overwritten by a later build. dist/ is tracked in git, so a clean shows the
# packages as deleted until the next "make packages" puts them back.
clean: clean:
rm -rf build rm -rf build $(DIST)
rm -f $(EXT_ID)-*.xpi $(EXT_ID)-*.zip rm -f $(EXT_ID)-*.xpi $(EXT_ID)-*.zip
find . -name '*.map' -not -path './node_modules/*' -delete
@echo "Removed build/, $(DIST)/ and any stray packages."
# Everything clean removes, plus the installed dependencies. Recover with
# "corepack pnpm install" -- never with npm, see the note in README.md.
distclean: clean
rm -rf node_modules
@echo "Removed node_modules/. Run: corepack pnpm install"
# Fail with a useful message rather than a confusing "tsc: not found". # Fail with a useful message rather than a confusing "tsc: not found".
check-deps: 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 corepack pnpm install # first time, or after a dependency change
make xpi # typecheck, bundle, and package logsdu-<version>.xpi make xpi # bundle and package dist/logsdu-<version>-firefox.xpi
``` ```
`make xpi` prints the full path of the file and how to install it. The package `make xpi` prints the full path of the file and how to install it. The package
@@ -118,26 +118,67 @@ check.
## Chrome ## 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 make chrome # unpacked Chrome build in build/chrome/
generated per target rather than duplicated. Everything else -- `chrome.*` make crx # package it as dist/logsdu-<version>-chrome.zip
namespace, MV3, no Firefox-only APIs -- is already shared. ```
It has not been tested against Chrome. Do not assume it works. Load `build/chrome/` via `chrome://extensions` -> Developer mode -> **Load
unpacked** (select the folder itself), 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 ## Development
``` ```
corepack pnpm install corepack pnpm install
make # everything: build and package both browsers
make firefox # build Firefox only -> build/firefox/
make chrome # build Chrome only -> build/chrome/
make test # unit tests make test # unit tests
make # typecheck and bundle into build/ make smoke # background bundle under a service worker
corepack pnpm run dev # rebuild on change corepack pnpm run dev # rebuild Firefox on change
make clean make clean # remove build/, dist/ and stray packages
make distclean # clean, plus node_modules/
``` ```
A bare `make` typechecks once, bundles for both browsers into `build/`, and
packages both into `dist/`. Packaging is only a zip of a directory that was
going to be built anyway, so it costs nothing and keeps `dist/` from drifting
out of step with the sources.
Each browser gets its own directory under `build/`, so the two can coexist and
neither is ever left stale by the other. `dist/` holds the packages meant to be
published, and is kept out of `build/` so that packaging never tries to include
its own output.
While iterating, load the unpacked directory rather than reinstalling an `.xpi` While iterating, load the unpacked directory rather than reinstalling an `.xpi`
each time: `about:debugging` -> **This Firefox** -> **Load Temporary Add-on** -> each time: `about:debugging` -> **This Firefox** -> **Load Temporary Add-on** ->
`build/manifest.json`, then press **Reload** there after each rebuild. That copy `build/firefox/manifest.json`, then press **Reload** there after each rebuild. That copy
disappears on restart, which is the point -- it is for development, not daily disappears on restart, which is the point -- it is for development, not daily
use. use.
@@ -156,6 +197,7 @@ still needs the Reload click to pick anything up.
| `src/config.ts` | Stored values, the rate limit and the logout cooldown | | `src/config.ts` | Stored values, the rate limit and the logout cooldown |
| `src/format.ts` | Input normalisers for the three masked fields | | `src/format.ts` | Input normalisers for the three masked fields |
| `src/options.*`, `src/popup.*` | The two bits of UI | | `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 ### Why two scripts instead of one

View File

@@ -1 +1 @@
0.2.0 0.3.1

View File

@@ -35,12 +35,17 @@ const banner = `/*
`; `;
const prod = process.argv[2] === 'production'; const prod = process.argv[2] === 'production';
const outdir = 'build';
// Target browser. Firefox and Chrome disagree on exactly one manifest key, so // Target browser. Firefox and Chrome disagree on exactly one manifest key, so
// the manifest is written per target rather than duplicated in the tree. // the manifest is written per target rather than duplicated in the tree.
const target = process.env.TARGET === 'chrome' ? 'chrome' : 'firefox'; const target = process.env.TARGET === 'chrome' ? 'chrome' : 'firefox';
// Each target gets its own directory. They used to share one, and the result
// was that whichever build ran last silently won: loading the other browser's
// output then failed with a confusing manifest error. Separate directories
// mean both can exist at once and neither can be stale by accident.
const outdir = `build/${target}`;
/** /**
* Write the manifest for the target browser. * Write the manifest for the target browser.
* *
@@ -60,8 +65,9 @@ async function writeManifest() {
); );
} }
// Everything that is not TypeScript is copied verbatim into build/, so that // Everything that is not TypeScript is copied verbatim into the output, so
// the directory can be handed straight to about:debugging. // that the directory can be handed straight to about:debugging or to Chrome's
// "Load unpacked".
async function copyStatic() { async function copyStatic() {
await mkdir(outdir, { recursive: true }); await mkdir(outdir, { recursive: true });
for (const name of await readdir('src')) { for (const name of await readdir('src')) {
@@ -76,7 +82,7 @@ async function copyStatic() {
await copyStatic(); await copyStatic();
// In watch mode the static files must follow every rebuild, otherwise editing // In watch mode the static files must follow every rebuild, otherwise editing
// manifest.json or a .html file silently changes nothing in build/. // manifest.json or a .html file silently changes nothing in the output.
const staticPlugin = { const staticPlugin = {
name: 'copy-static', name: 'copy-static',
setup(build) { setup(build) {

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

Binary file not shown.

View File

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

View File

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

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

@@ -0,0 +1,136 @@
// *************************************************************************
// * (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.
//
// make smoke
// node tools/sw-smoke.mjs [path/to/background.js]
import { readFileSync } from 'node:fs';
import vm from 'node:vm';
const BUNDLE = process.argv[2] ?? 'build/chrome/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`);