diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..28983f0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,42 @@ +# ************************************************************************** +# * (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 * +# ************************************************************************** + +# vscode +.vscode + +# Intellij +*.iml +.idea + +# dependencies +node_modules + +# Build output. The unpacked extension and the signing package are release +# artifacts, not sources. +build +logsdu-*.zip +*.xpi + +# Exclude sourcemaps +*.map + +# Exclude macOS Finder (System Explorer) View States +.DS_Store diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..78a2fec --- /dev/null +++ b/Makefile @@ -0,0 +1,66 @@ +# ************************************************************************** +# * (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 * +# ************************************************************************** + +# Makefile for logsdu - build the unpacked extension into build/. +# +# Usage: +# make # typecheck and bundle into build/ +# make dist # build, then zip build/ into logsdu-.zip +# make clean # remove build/ and the zip +# +# Dependencies are installed with pnpm, never npm: +# corepack pnpm install +# +# Load build/ in Firefox: about:debugging -> This Firefox -> +# Load Temporary Add-on -> pick build/manifest.json + +EXT_ID := logsdu +VERSION := $(shell node -p "require('./package.json').version") +DIST := $(EXT_ID)-$(VERSION).zip + +.PHONY: all build dist clean check-deps + +all: build + +# 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 + +# A plain zip of build/ is exactly what AMO wants for signing. +dist: build + rm -f $(DIST) + cd build && zip -qr ../$(DIST) . + @echo "Package: $(DIST)" + @echo "Upload it to addons.mozilla.org as an unlisted add-on to get a signed .xpi." + +clean: + rm -rf build + rm -f $(EXT_ID)-*.zip + +# Fail with a useful message rather than a confusing "tsc: not found". +check-deps: + @test -x node_modules/.bin/tsc || { \ + echo "ERROR: dependencies are not installed."; \ + echo "Run: corepack pnpm install (do NOT use npm install in this tree)"; \ + exit 1; \ + } diff --git a/icons/logsdu.svg b/icons/logsdu.svg new file mode 100644 index 0000000..db81f0a --- /dev/null +++ b/icons/logsdu.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..87a867f --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,285 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + esbuild: + specifier: 0.25.5 + version: 0.25.5 + typescript: + specifier: ^5.8.3 + version: 5.9.3 + +packages: + + '@esbuild/aix-ppc64@0.25.5': + resolution: {integrity: sha512-9o3TMmpmftaCMepOdA5k/yDw8SfInyzWWTjYTFCX3kPSDJMROQTb8jg+h9Cnwnmm1vOzvxN7gIfB5V2ewpjtGA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.25.5': + resolution: {integrity: sha512-VGzGhj4lJO+TVGV1v8ntCZWJktV7SGCs3Pn1GRWI1SBFtRALoomm8k5E9Pmwg3HOAal2VDc2F9+PM/rEY6oIDg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.25.5': + resolution: {integrity: sha512-AdJKSPeEHgi7/ZhuIPtcQKr5RQdo6OO2IL87JkianiMYMPbCtot9fxPbrMiBADOWWm3T2si9stAiVsGbTQFkbA==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.25.5': + resolution: {integrity: sha512-D2GyJT1kjvO//drbRT3Hib9XPwQeWd9vZoBJn+bu/lVsOZ13cqNdDeqIF/xQ5/VmWvMduP6AmXvylO/PIc2isw==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.25.5': + resolution: {integrity: sha512-GtaBgammVvdF7aPIgH2jxMDdivezgFu6iKpmT+48+F8Hhg5J/sfnDieg0aeG/jfSvkYQU2/pceFPDKlqZzwnfQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.25.5': + resolution: {integrity: sha512-1iT4FVL0dJ76/q1wd7XDsXrSW+oLoquptvh4CLR4kITDtqi2e/xwXwdCVH8hVHU43wgJdsq7Gxuzcs6Iq/7bxQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.25.5': + resolution: {integrity: sha512-nk4tGP3JThz4La38Uy/gzyXtpkPW8zSAmoUhK9xKKXdBCzKODMc2adkB2+8om9BDYugz+uGV7sLmpTYzvmz6Sw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.25.5': + resolution: {integrity: sha512-PrikaNjiXdR2laW6OIjlbeuCPrPaAl0IwPIaRv+SMV8CiM8i2LqVUHFC1+8eORgWyY7yhQY+2U2fA55mBzReaw==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.25.5': + resolution: {integrity: sha512-Z9kfb1v6ZlGbWj8EJk9T6czVEjjq2ntSYLY2cw6pAZl4oKtfgQuS4HOq41M/BcoLPzrUbNd+R4BXFyH//nHxVg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.25.5': + resolution: {integrity: sha512-cPzojwW2okgh7ZlRpcBEtsX7WBuqbLrNXqLU89GxWbNt6uIg78ET82qifUy3W6OVww6ZWobWub5oqZOVtwolfw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.25.5': + resolution: {integrity: sha512-sQ7l00M8bSv36GLV95BVAdhJ2QsIbCuCjh/uYrWiMQSUuV+LpXwIqhgJDcvMTj+VsQmqAHL2yYaasENvJ7CDKA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.25.5': + resolution: {integrity: sha512-0ur7ae16hDUC4OL5iEnDb0tZHDxYmuQyhKhsPBV8f99f6Z9KQM02g33f93rNH5A30agMS46u2HP6qTdEt6Q1kg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.25.5': + resolution: {integrity: sha512-kB/66P1OsHO5zLz0i6X0RxlQ+3cu0mkxS3TKFvkb5lin6uwZ/ttOkP3Z8lfR9mJOBk14ZwZ9182SIIWFGNmqmg==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.25.5': + resolution: {integrity: sha512-UZCmJ7r9X2fe2D6jBmkLBMQetXPXIsZjQJCjgwpVDz+YMcS6oFR27alkgGv3Oqkv07bxdvw7fyB71/olceJhkQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.25.5': + resolution: {integrity: sha512-kTxwu4mLyeOlsVIFPfQo+fQJAV9mh24xL+y+Bm6ej067sYANjyEw1dNHmvoqxJUCMnkBdKpvOn0Ahql6+4VyeA==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.25.5': + resolution: {integrity: sha512-K2dSKTKfmdh78uJ3NcWFiqyRrimfdinS5ErLSn3vluHNeHVnBAFWC8a4X5N+7FgVE1EjXS1QDZbpqZBjfrqMTQ==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.25.5': + resolution: {integrity: sha512-uhj8N2obKTE6pSZ+aMUbqq+1nXxNjZIIjCjGLfsWvVpy7gKCOL6rsY1MhRh9zLtUtAI7vpgLMK6DxjO8Qm9lJw==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.25.5': + resolution: {integrity: sha512-pwHtMP9viAy1oHPvgxtOv+OkduK5ugofNTVDilIzBLpoWAM16r7b/mxBvfpuQDpRQFMfuVr5aLcn4yveGvBZvw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.25.5': + resolution: {integrity: sha512-WOb5fKrvVTRMfWFNCroYWWklbnXH0Q5rZppjq0vQIdlsQKuw6mdSihwSo4RV/YdQ5UCKKvBy7/0ZZYLBZKIbwQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.25.5': + resolution: {integrity: sha512-7A208+uQKgTxHd0G0uqZO8UjK2R0DDb4fDmERtARjSHWxqMTye4Erz4zZafx7Di9Cv+lNHYuncAkiGFySoD+Mw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.25.5': + resolution: {integrity: sha512-G4hE405ErTWraiZ8UiSoesH8DaCsMm0Cay4fsFWOOUcz8b8rC6uCvnagr+gnioEjWn0wC+o1/TAHt+It+MpIMg==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/sunos-x64@0.25.5': + resolution: {integrity: sha512-l+azKShMy7FxzY0Rj4RCt5VD/q8mG/e+mDivgspo+yL8zW7qEwctQ6YqKX34DTEleFAvCIUviCFX1SDZRSyMQA==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.25.5': + resolution: {integrity: sha512-O2S7SNZzdcFG7eFKgvwUEZ2VG9D/sn/eIiz8XRZ1Q/DO5a3s76Xv0mdBzVM5j5R639lXQmPmSo0iRpHqUUrsxw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.25.5': + resolution: {integrity: sha512-onOJ02pqs9h1iMJ1PQphR+VZv8qBMQ77Klcsqv9CNW2w6yLqoURLcgERAIurY6QE63bbLuqgP9ATqajFLK5AMQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.25.5': + resolution: {integrity: sha512-TXv6YnJ8ZMVdX+SXWVBo/0p8LTcrUYngpWjvm91TMjjBQii7Oz11Lw5lbDV5Y0TzuhSJHwiH4hEtC1I42mMS0g==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + esbuild@0.25.5: + resolution: {integrity: sha512-P8OtKZRv/5J5hhz0cUAdu/cLuPIKXpQl1R9pZtvmHWQvrAUVd0UNIPT4IB4W3rNOqVO0rlqHmCIbSwxh/c9yUQ==} + engines: {node: '>=18'} + hasBin: true + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + +snapshots: + + '@esbuild/aix-ppc64@0.25.5': + optional: true + + '@esbuild/android-arm64@0.25.5': + optional: true + + '@esbuild/android-arm@0.25.5': + optional: true + + '@esbuild/android-x64@0.25.5': + optional: true + + '@esbuild/darwin-arm64@0.25.5': + optional: true + + '@esbuild/darwin-x64@0.25.5': + optional: true + + '@esbuild/freebsd-arm64@0.25.5': + optional: true + + '@esbuild/freebsd-x64@0.25.5': + optional: true + + '@esbuild/linux-arm64@0.25.5': + optional: true + + '@esbuild/linux-arm@0.25.5': + optional: true + + '@esbuild/linux-ia32@0.25.5': + optional: true + + '@esbuild/linux-loong64@0.25.5': + optional: true + + '@esbuild/linux-mips64el@0.25.5': + optional: true + + '@esbuild/linux-ppc64@0.25.5': + optional: true + + '@esbuild/linux-riscv64@0.25.5': + optional: true + + '@esbuild/linux-s390x@0.25.5': + optional: true + + '@esbuild/linux-x64@0.25.5': + optional: true + + '@esbuild/netbsd-arm64@0.25.5': + optional: true + + '@esbuild/netbsd-x64@0.25.5': + optional: true + + '@esbuild/openbsd-arm64@0.25.5': + optional: true + + '@esbuild/openbsd-x64@0.25.5': + optional: true + + '@esbuild/sunos-x64@0.25.5': + optional: true + + '@esbuild/win32-arm64@0.25.5': + optional: true + + '@esbuild/win32-ia32@0.25.5': + optional: true + + '@esbuild/win32-x64@0.25.5': + optional: true + + esbuild@0.25.5: + optionalDependencies: + '@esbuild/aix-ppc64': 0.25.5 + '@esbuild/android-arm': 0.25.5 + '@esbuild/android-arm64': 0.25.5 + '@esbuild/android-x64': 0.25.5 + '@esbuild/darwin-arm64': 0.25.5 + '@esbuild/darwin-x64': 0.25.5 + '@esbuild/freebsd-arm64': 0.25.5 + '@esbuild/freebsd-x64': 0.25.5 + '@esbuild/linux-arm': 0.25.5 + '@esbuild/linux-arm64': 0.25.5 + '@esbuild/linux-ia32': 0.25.5 + '@esbuild/linux-loong64': 0.25.5 + '@esbuild/linux-mips64el': 0.25.5 + '@esbuild/linux-ppc64': 0.25.5 + '@esbuild/linux-riscv64': 0.25.5 + '@esbuild/linux-s390x': 0.25.5 + '@esbuild/linux-x64': 0.25.5 + '@esbuild/netbsd-arm64': 0.25.5 + '@esbuild/netbsd-x64': 0.25.5 + '@esbuild/openbsd-arm64': 0.25.5 + '@esbuild/openbsd-x64': 0.25.5 + '@esbuild/sunos-x64': 0.25.5 + '@esbuild/win32-arm64': 0.25.5 + '@esbuild/win32-ia32': 0.25.5 + '@esbuild/win32-x64': 0.25.5 + + typescript@5.9.3: {} diff --git a/src/content.ts b/src/content.ts new file mode 100644 index 0000000..17a4ca3 --- /dev/null +++ b/src/content.ts @@ -0,0 +1,208 @@ +// ************************************************************************* +// * (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 * +// ************************************************************************* + +// The content script is declared against every URL, because the one address +// it cares about is configured at runtime and deliberately absent from the +// manifest. Everything below is therefore written to bail out as early and as +// cheaply as possible: one storage read and an origin comparison, then return. + +import { + decideSubmit, + isConfigured, + loadConfig, + loadState, + matchesSite, + saveState, +} from './config'; +import type { Config } from './config'; +import { EVENT_RESULT, FORM, LOGOUT } from './portal'; +import type { FillRequest, FillResult } from './portal'; + +const FORM_WAIT_MS = 10000; +const NOTICE_ID = 'logsdu-notice'; + +// User-facing strings follow the portal's language. +const NOTICE_TEXT: Record = { + disabled: 'logsdu: campos preenchidos. O login automático está desligado.', + 'rate-limited': + 'logsdu: campos preenchidos, sem envio automático (já houve uma tentativa na última hora). Confira os dados e clique em Entrar.', + logout: 'logsdu: campos preenchidos, sem envio automático depois do logout.', +}; + +/** + * Hand the values to the page-world filler. + * + * The payload travels on the script tag's dataset rather than in a CustomEvent + * detail: an object created in the isolated world is not reliably readable + * from the page, and a string on the element is both simple and synchronous. + * The injected script removes the element as its first act. + */ +function fill(request: FillRequest): Promise { + return new Promise((resolve) => { + const onResult = (event: Event): void => { + window.removeEventListener(EVENT_RESULT, onResult); + const detail = (event as CustomEvent).detail; + try { + resolve(JSON.parse(detail) as FillResult); + } catch { + resolve({ filled: false, submitted: false, error: 'bad result payload' }); + } + }; + window.addEventListener(EVENT_RESULT, onResult); + + const script = document.createElement('script'); + script.src = chrome.runtime.getURL('injected.js'); + script.dataset.logsdu = JSON.stringify(request); + (document.head ?? document.documentElement).appendChild(script); + }); +} + +/** Resolve once the login form exists, or null if it never shows up. */ +function awaitForm(): Promise { + const existing = document.querySelector(FORM); + if (existing) return Promise.resolve(existing); + + return new Promise((resolve) => { + const observer = new MutationObserver(() => { + const found = document.querySelector(FORM); + if (found) { + observer.disconnect(); + window.clearTimeout(timer); + resolve(found); + } + }); + const timer = window.setTimeout(() => { + observer.disconnect(); + resolve(null); + }, FORM_WAIT_MS); + observer.observe(document.documentElement, { childList: true, subtree: true }); + }); +} + +/** A small, self-removing note explaining why nothing was submitted. */ +function showNotice(text: string): void { + if (document.getElementById(NOTICE_ID)) return; + const notice = document.createElement('div'); + notice.id = NOTICE_ID; + notice.textContent = text; + notice.style.cssText = [ + 'position:fixed', + 'z-index:2147483647', + 'left:50%', + 'transform:translateX(-50%)', + 'bottom:16px', + 'max-width:min(90vw,520px)', + 'padding:10px 14px', + 'border-radius:8px', + 'background:#222', + 'color:#fff', + 'font:14px/1.4 system-ui,sans-serif', + 'box-shadow:0 2px 10px rgba(0,0,0,.35)', + ].join(';'); + notice.addEventListener('click', () => notice.remove()); + document.body?.appendChild(notice); + window.setTimeout(() => notice.remove(), 12000); +} + +/** + * Remember that the user asked to be logged out. + * + * Without this the logout redirect lands on the login page and the extension + * immediately logs them back in, which makes logging out impossible. + */ +function watchLogout(): void { + document.addEventListener( + 'click', + (event) => { + const target = event.target as Element | null; + if (!target?.closest?.(LOGOUT)) return; + void loadState().then((state) => + saveState({ ...state, logoutAt: Date.now() }), + ); + }, + true, + ); +} + +async function autoLogin(config: Config): Promise { + const form = await awaitForm(); + if (!form) return; + + const state = await loadState(); + const decision = decideSubmit(config, state, Date.now()); + + // Record the attempt before it happens, not after. A submit that navigates + // away, crashes, or is interrupted still has to count against the hourly + // limit, otherwise a failing login could retry on every page load. + if (decision.submit) { + await saveState({ ...state, lastSubmitAt: Date.now() }); + } + + const result = await fill({ + ra: config.ra, + dn: config.dn, + cpf: config.cpf, + submit: decision.submit, + }); + + if (result.error) { + console.warn('logsdu:', result.error); + return; + } + if (!decision.submit && result.filled) { + showNotice(NOTICE_TEXT[decision.reason] ?? ''); + } +} + +async function main(): Promise { + const config = await loadConfig(); + if (!isConfigured(config)) return; + if (!matchesSite(config, location.href)) return; + + watchLogout(); + + // The popup's "Preencher agora" button, for when the automatic submit is + // off or rate limited. + chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { + if ((message as { type?: string })?.type !== 'fill') return undefined; + void loadConfig() + .then((fresh) => + fill({ + ra: fresh.ra, + dn: fresh.dn, + cpf: fresh.cpf, + submit: (message as { submit?: boolean }).submit === true, + }), + ) + .then(sendResponse); + return true; + }); + + // Only chase the form when it is already here, or when the document is + // still loading and could still produce one. On the pages behind the login + // this returns immediately instead of holding an observer open for ten + // seconds on every navigation. + if (document.querySelector(FORM) || document.readyState !== 'complete') { + await autoLogin(config); + } +} + +void main(); diff --git a/src/manifest.json b/src/manifest.json index 60ea7e0..7860874 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -4,8 +4,8 @@ "version": "0.1.0", "description": "Fills and submits a three-field academic portal login.", "icons": { - "48": "icons/logsdu-48.png", - "96": "icons/logsdu-96.png" + "48": "icons/logsdu.svg", + "96": "icons/logsdu.svg" }, "browser_specific_settings": { "gecko": { @@ -38,6 +38,7 @@ }, "action": { "default_popup": "popup.html", - "default_title": "logsdu" + "default_title": "logsdu", + "default_icon": "icons/logsdu.svg" } } diff --git a/src/options.html b/src/options.html new file mode 100644 index 0000000..f768dd0 --- /dev/null +++ b/src/options.html @@ -0,0 +1,59 @@ + + + + + + logsdu + + + +

logsdu

+

+ Guarde os dados de acesso uma vez. Ao abrir a página de login, o + preenchimento e o envio acontecem sozinhos. +

+ +
+ + +

+ O endereço fica somente aqui, na memória local da extensão. Nada + dele aparece no código instalado. +

+ + + + + + + + + + + + +
+ + + +
+
+ +

Como isso é guardado

+

+ Os quatro valores ficam na memória local desta extensão, neste perfil + do navegador. Não são sincronizados nem enviados para lugar nenhum. A + proteção é a mesma de uma senha guardada no navegador: quem tiver a + sua sessão do sistema aberta consegue lê-los. +

+ + + + diff --git a/src/options.ts b/src/options.ts new file mode 100644 index 0000000..8ac910e --- /dev/null +++ b/src/options.ts @@ -0,0 +1,140 @@ +// ************************************************************************* +// * (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 * +// ************************************************************************* + +import { EMPTY_CONFIG, loadConfig, resetState, saveConfig } from './config'; +import { + formatCpf, + formatDate, + formatRa, + isCompleteCpf, + isCompleteDate, + isCompleteRa, + toOrigin, +} from './format'; + +function el(id: string): T { + const found = document.getElementById(id); + if (!found) throw new Error(`missing element #${id}`); + return found as T; +} + +const fields = { + url: el('url'), + ra: el('ra'), + dn: el('dn'), + cpf: el('cpf'), +}; +const autoSubmit = el('autoSubmit'); +const status = el('status'); +const form = el('form'); + +function setStatus(message: string, isError = false): void { + status.textContent = message; + status.classList.toggle('error', isError); +} + +/** + * Reformat as the user types, keeping the caret at the end. + * + * Anchoring the caret is only correct because these masks are append-only in + * practice: you type or paste a number left to right. It avoids the caret + * jumping to position zero after every keystroke. + */ +function liveFormat(input: HTMLInputElement, format: (v: string) => string): void { + input.addEventListener('input', () => { + const atEnd = input.selectionStart === input.value.length; + const formatted = format(input.value); + if (formatted === input.value) return; + input.value = formatted; + if (atEnd) input.setSelectionRange(formatted.length, formatted.length); + }); +} + +liveFormat(fields.ra, formatRa); +liveFormat(fields.dn, formatDate); +liveFormat(fields.cpf, formatCpf); + +async function load(): Promise { + const config = await loadConfig(); + fields.url.value = config.url; + fields.ra.value = config.ra; + fields.dn.value = config.dn; + fields.cpf.value = config.cpf; + autoSubmit.checked = config.autoSubmit; +} + +/** Mark the offending inputs and return the first complaint, if any. */ +function validate(): string | null { + for (const input of Object.values(fields)) input.classList.remove('invalid'); + + const origin = toOrigin(fields.url.value); + if (origin === null) { + fields.url.classList.add('invalid'); + return 'Endereço inválido.'; + } + if (!isCompleteRa(fields.ra.value)) { + fields.ra.classList.add('invalid'); + return 'Informe a matrícula.'; + } + if (!isCompleteDate(fields.dn.value)) { + fields.dn.classList.add('invalid'); + return 'Data de nascimento incompleta ou inexistente.'; + } + if (!isCompleteCpf(fields.cpf.value)) { + fields.cpf.classList.add('invalid'); + return 'CPF incompleto.'; + } + return null; +} + +form.addEventListener('submit', (event) => { + event.preventDefault(); + const complaint = validate(); + if (complaint) { + setStatus(complaint, true); + return; + } + + void (async () => { + await saveConfig({ + url: fields.url.value.trim(), + ra: fields.ra.value, + dn: fields.dn.value, + cpf: fields.cpf.value, + autoSubmit: autoSubmit.checked, + }); + // Saving is how you correct a typo, so it also clears the hourly limit + // and the logout cooldown: the next visit is allowed to try again. + await resetState(); + setStatus('Salvo.'); + })(); +}); + +el('clear').addEventListener('click', () => { + void (async () => { + await saveConfig({ ...EMPTY_CONFIG }); + await resetState(); + await load(); + setStatus('Dados apagados.'); + })(); +}); + +void load(); diff --git a/src/popup.html b/src/popup.html new file mode 100644 index 0000000..c670729 --- /dev/null +++ b/src/popup.html @@ -0,0 +1,22 @@ + + + + + + logsdu + + + +

...

+

+
+ + +
+ + + diff --git a/src/popup.ts b/src/popup.ts new file mode 100644 index 0000000..5c61131 --- /dev/null +++ b/src/popup.ts @@ -0,0 +1,79 @@ +// ************************************************************************* +// * (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 * +// ************************************************************************* + +// The manual escape hatch: fill (and optionally submit) on demand, for when +// the automatic submit is switched off, rate limited, or suppressed after a +// logout. + +import { isConfigured, loadConfig, matchesSite } from './config'; + +const state = document.getElementById('state') as HTMLParagraphElement; +const detail = document.getElementById('detail') as HTMLParagraphElement; +const fillButton = document.getElementById('fill') as HTMLButtonElement; +const optionsButton = document.getElementById('options') as HTMLButtonElement; + +optionsButton.addEventListener('click', () => { + void chrome.runtime.openOptionsPage(); +}); + +async function activeTabOnSite(): Promise { + const config = await loadConfig(); + if (!isConfigured(config)) return null; + const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); + if (!tab?.id || !tab.url) return null; + return matchesSite(config, tab.url) ? tab.id : null; +} + +async function refresh(): Promise { + const config = await loadConfig(); + if (!isConfigured(config)) { + state.textContent = 'Não configurado'; + detail.textContent = 'Informe o endereço do portal e os três dados de acesso.'; + return; + } + + const tabId = await activeTabOnSite(); + if (tabId === null) { + state.textContent = 'Configurado'; + detail.textContent = 'Esta aba não é o portal configurado.'; + return; + } + + state.textContent = 'Pronto'; + detail.textContent = config.autoSubmit + ? 'Login automático ligado.' + : 'Login automático desligado: preencha e clique em Entrar.'; + fillButton.disabled = false; +} + +fillButton.addEventListener('click', () => { + void (async () => { + const tabId = await activeTabOnSite(); + if (tabId === null) return; + fillButton.disabled = true; + // Fill only. Pressing "Entrar" stays with the user here, which is the + // point of a manual button. + await chrome.tabs.sendMessage(tabId, { type: 'fill', submit: false }); + window.close(); + })(); +}); + +void refresh(); diff --git a/src/ui.css b/src/ui.css new file mode 100644 index 0000000..0d7f94a --- /dev/null +++ b/src/ui.css @@ -0,0 +1,149 @@ +/* + * (C)opyright 2026 by Ruben Carlo Benante + * Licensed under the GNU General Public License v3.0 or later. + * See https://www.gnu.org/licenses/ and the LICENSE file. + */ + +:root { + color-scheme: light dark; + --bg: #ffffff; + --fg: #1b1b1b; + --muted: #5c5c5c; + --line: #d6d6d6; + --accent: #2f6f4e; + --field: #ffffff; +} + +@media (prefers-color-scheme: dark) { + :root { + --bg: #1e1e1e; + --fg: #ededed; + --muted: #a8a8a8; + --line: #3d3d3d; + --accent: #6cc294; + --field: #2a2a2a; + } +} + +body { + margin: 0; + background: var(--bg); + color: var(--fg); + font: 15px/1.5 system-ui, sans-serif; +} + +.page { + max-width: 34rem; + margin: 0 auto; + padding: 1.5rem 1.25rem 3rem; +} + +.popup { + width: 20rem; + padding: 1rem; +} + +h1 { + margin: 0 0 0.25rem; + font-size: 1.4rem; +} + +h2 { + margin: 2rem 0 0.5rem; + font-size: 1rem; +} + +.lede { + margin: 0 0 1.5rem; + color: var(--muted); +} + +label { + display: block; + margin: 1rem 0 0.35rem; + font-weight: 600; +} + +input[type='text'] { + width: 100%; + box-sizing: border-box; + padding: 0.5rem 0.6rem; + border: 1px solid var(--line); + border-radius: 6px; + background: var(--field); + color: inherit; + font: inherit; +} + +input[type='text']:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 1px; +} + +input.invalid { + border-color: #c0392b; +} + +.hint { + margin: 0.35rem 0 0; + color: var(--muted); + font-size: 0.85rem; +} + +.check { + display: flex; + align-items: center; + gap: 0.5rem; + margin-top: 1.25rem; + font-weight: 400; +} + +.check input { + margin: 0; +} + +.row { + display: flex; + align-items: center; + gap: 0.6rem; + margin-top: 1.5rem; + flex-wrap: wrap; +} + +button { + padding: 0.5rem 1rem; + border: 1px solid transparent; + border-radius: 6px; + background: var(--accent); + color: #fff; + font: inherit; + cursor: pointer; +} + +button.ghost { + background: transparent; + border-color: var(--line); + color: var(--fg); +} + +button:disabled { + opacity: 0.5; + cursor: default; +} + +#status { + color: var(--muted); + font-size: 0.9rem; +} + +#status.error { + color: #c0392b; +} + +.popup p { + margin: 0 0 0.75rem; +} + +.popup .state { + font-weight: 600; +}