version 0.2 for public listing

This commit is contained in:
2026-08-10 14:58:43 -03:00
parent 8bf20af4bb
commit 9826dc1263
6 changed files with 237 additions and 37 deletions

View File

@@ -67,9 +67,17 @@ build: check-deps
xpi: build
rm -f $(XPI)
cd build && zip -qr ../$(XPI) .
@echo "Package: $(XPI)"
@echo "Install: about:addons -> gear -> Install Add-on From File"
@echo "Needs xpinstall.signatures.required=false on ESR/Developer/Nightly."
@echo
@echo "Built: $(CURDIR)/$(XPI)"
@echo
@echo "To install, in Firefox:"
@echo " 1. about:config -> set xpinstall.signatures.required = false"
@echo " (only ESR, Developer Edition and Nightly honour this)"
@echo " 2. about:addons -> gear icon -> Install Add-on From File"
@echo " 3. paste this path into the file picker:"
@echo
@echo " $(CURDIR)/$(XPI)"
@echo
clean:
rm -rf build

107
src/background.ts Normal file
View File

@@ -0,0 +1,107 @@
// *************************************************************************
// * (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 *
// *************************************************************************
// Decides where the content script is allowed to run, at runtime.
//
// The manifest declares no content script and no host permission at all, so a
// fresh install can read nothing. The single site the user configures is
// granted through permissions.request() from the options page, and only then
// does this register the content script against that one origin. Removing the
// permission, or clearing the address, unregisters it again.
//
// The alternative was a manifest matching every URL with the script bailing
// out on the wrong origin. That works, but it means holding read access to
// every page the user visits in order to act on one of them.
import { isConfigured, loadConfig, sitePattern } from './config.ts';
const SCRIPT_ID = 'logsdu-portal';
async function unregister(): Promise<void> {
const existing = await chrome.scripting.getRegisteredContentScripts({
ids: [SCRIPT_ID],
});
if (existing.length > 0) {
await chrome.scripting.unregisterContentScripts({ ids: [SCRIPT_ID] });
}
}
/**
* Bring the registered script in line with the stored config.
*
* Called on install, on startup, whenever the config changes, and whenever a
* host permission is revoked. It always tears down first and rebuilds, which
* is cheap and avoids reasoning about the previous state.
*/
async function sync(): Promise<void> {
await unregister();
const config = await loadConfig();
const pattern = sitePattern(config);
if (!isConfigured(config) || pattern === null) return;
// Registering without the host permission throws, and the user is free to
// revoke it from about:addons at any time.
const granted = await chrome.permissions.contains({ origins: [pattern] });
if (!granted) return;
await chrome.scripting.registerContentScripts([
{
id: SCRIPT_ID,
matches: [pattern],
js: ['content.js'],
runAt: 'document_idle',
allFrames: false,
// Firefox keeps registrations across restarts, so without this the
// script would be registered twice on the next startup.
persistAcrossSessions: false,
},
]);
}
function resync(): void {
void sync().catch((error: unknown) => {
console.warn('logsdu: could not register content script:', error);
});
}
chrome.runtime.onInstalled.addListener(resync);
chrome.runtime.onStartup.addListener(resync);
chrome.permissions.onRemoved.addListener(resync);
chrome.storage.onChanged.addListener((changes, area) => {
// Only the address matters here. Ignore the state key, which is written on
// every login attempt and would otherwise re-register constantly.
if (area === 'local' && 'config' in changes) resync();
});
// The event page is also woken by the options page after a successful
// permission request, so that the script starts working without a restart.
chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
if ((message as { type?: string })?.type !== 'resync') return undefined;
void sync().then(
() => sendResponse({ ok: true }),
(error: unknown) => sendResponse({ ok: false, error: String(error) }),
);
return true;
});
resync();

View File

@@ -20,6 +20,8 @@
// *************************************************************************
import { toOrigin } from './format.ts';
import { DEFAULT_SELECTORS } from './portal.ts';
import type { Selectors } from './portal.ts';
const CONFIG_KEY = 'config';
const STATE_KEY = 'state';
@@ -112,6 +114,18 @@ export function matchesSite(config: Config, href: string): boolean {
}
}
/**
* The configured site as a match pattern, for permissions.request() and for
* scripting.registerContentScripts(). Null when the address is unusable.
*
* The pattern is pinned to one origin -- scheme, host and port -- so granting
* it never widens beyond the single site the user typed.
*/
export function sitePattern(config: Config): string | null {
const origin = toOrigin(config.url);
return origin === null ? null : `${origin}/*`;
}
export type SubmitDecision =
| { submit: true }
| { submit: false; reason: 'disabled' | 'rate-limited' | 'logout' };

View File

@@ -16,16 +16,11 @@
}
}
},
"permissions": ["storage"],
"host_permissions": ["*://*/*"],
"content_scripts": [
{
"matches": ["*://*/*"],
"js": ["content.js"],
"run_at": "document_idle",
"all_frames": false
}
],
"permissions": ["storage", "scripting"],
"optional_host_permissions": ["*://*/*"],
"background": {
"scripts": ["background.js"]
},
"web_accessible_resources": [
{
"resources": ["injected.js"],

View File

@@ -19,41 +19,71 @@
// * rcb@beco.cc *
// *************************************************************************
// Every selector the extension knows about the target page, in one place.
// How to find the login form on a page.
//
// These describe a form shape, not a site: the address itself is configured
// at runtime and lives only in local storage, so nothing here names the
// institution.
// These are defaults, not constants. They describe the shape of one common
// portal, and every one of them can be overridden per installation from the
// options page, so the extension is not silently tied to a single institution
// it never names.
export interface Selectors {
/**
* The login form. Every field lookup is scoped to it on purpose.
*
* The same page carries a "forgot your registration number" modal whose
* inputs are id="CPF" and id="DTNASC". The three login inputs have no id at
* all, so an unscoped lookup for a CPF field finds the modal's copy and
* writes the value into the wrong form.
* Portals of this kind routinely carry a second "forgot your registration
* number" form in a modal on the same page, using the same field names or
* ids. An unscoped lookup finds that copy and writes into the wrong form.
*/
export const FORM = 'form[name="LoginAP"]';
form: string;
/** Registration number or code, relative to the form. */
ra: string;
/** Birth date, relative to the form. */
dn: string;
/** Document number, relative to the form. */
cpf: string;
/** The button that submits the login, relative to the form. */
submit: string;
/**
* The logout control on the pages behind the login. Clicking it is the
* signal that the user wants to stay out, which suppresses the auto-submit
* that would otherwise fire when the logout redirect lands back here.
*/
logout: string;
}
/** The three credential inputs, relative to FORM. */
export const FIELDS = {
export const DEFAULT_SELECTORS: Selectors = {
form: 'form[name="LoginAP"]',
ra: 'input[name="RA"]',
dn: 'input[name="DN"]',
cpf: 'input[name="CPF"]',
} as const;
submit: 'button[type="submit"]',
logout: '.js_logout',
};
/** The "Entrar" button, relative to FORM. */
export const SUBMIT = 'button[type="submit"]';
export const SELECTOR_KEYS = [
'form',
'ra',
'dn',
'cpf',
'submit',
'logout',
] as const;
/**
* The logout control, present on the pages behind the login. Clicking it is
* the signal that the user wants to stay logged out, which suppresses the
* auto-submit that would otherwise fire the moment the logout redirect lands
* back on the login page.
* Reject anything the browser cannot parse as a selector, so a typo in the
* options page fails there instead of silently never matching a page.
*/
export const LOGOUT = '.js_logout';
export function isValidSelector(value: string): boolean {
if (value.trim() === '') return false;
try {
document.querySelector(value);
return true;
} catch {
return false;
}
}
/** Event names used to talk to the page-world filler. */
/** Event name used to talk back from the page-world filler. */
export const EVENT_RESULT = 'logsdu:result';
/** Payload handed to the page-world script through its own dataset. */
@@ -62,6 +92,7 @@ export interface FillRequest {
dn: string;
cpf: string;
submit: boolean;
selectors: Selectors;
}
export interface FillResult {

45
src/webext.d.ts vendored
View File

@@ -29,10 +29,16 @@
// rather than a source concern.
declare namespace chrome {
interface Event0 {
addListener(callback: () => void): void;
}
namespace runtime {
const lastError: { message?: string } | undefined;
function getURL(path: string): string;
function openOptionsPage(): Promise<void>;
const onInstalled: Event0;
const onStartup: Event0;
const onMessage: {
addListener(
callback: (
@@ -51,6 +57,45 @@ declare namespace chrome {
remove(keys: string | string[]): Promise<void>;
}
const local: StorageArea;
const onChanged: {
addListener(
callback: (
changes: Record<string, { oldValue?: unknown; newValue?: unknown }>,
areaName: string,
) => void,
): void;
};
}
namespace permissions {
interface Permissions {
origins?: string[];
permissions?: string[];
}
function request(permissions: Permissions): Promise<boolean>;
function contains(permissions: Permissions): Promise<boolean>;
function remove(permissions: Permissions): Promise<boolean>;
const onRemoved: {
addListener(callback: (permissions: Permissions) => void): void;
};
}
namespace scripting {
interface RegisteredContentScript {
id: string;
matches?: string[];
js?: string[];
runAt?: 'document_start' | 'document_end' | 'document_idle';
allFrames?: boolean;
persistAcrossSessions?: boolean;
}
function registerContentScripts(
scripts: RegisteredContentScript[],
): Promise<void>;
function getRegisteredContentScripts(filter?: {
ids?: string[];
}): Promise<RegisteredContentScript[]>;
function unregisterContentScripts(filter?: { ids?: string[] }): Promise<void>;
}
namespace tabs {