diff --git a/src/argo-archive-list.ts b/src/argo-archive-list.ts index 9f66c25..c04f295 100644 --- a/src/argo-archive-list.ts +++ b/src/argo-archive-list.ts @@ -12,6 +12,7 @@ import filingDrawer from "assets/images/filing-drawer.avif"; import { getLocalOption } from "./localstorage"; import { Index as FlexIndex } from "flexsearch"; +import { onPageClicked } from "./events"; @customElement("argo-archive-list") export class ArgoArchiveList extends LitElement { @@ -434,6 +435,7 @@ export class ArgoArchiveList extends LitElement { } private async _openPage(page: { ts: string; url: string }) { + onPageClicked(page.url); const tsParam = new Date(Number(page.ts)) .toISOString() .replace(/[-:TZ.]/g, ""); diff --git a/src/events.ts b/src/events.ts new file mode 100644 index 0000000..fef2b57 --- /dev/null +++ b/src/events.ts @@ -0,0 +1,78 @@ +import { trackEvent } from "./matomo"; +import { getLocalOption } from "./localstorage"; + +// Track when a user clicks on an archived page to view it +export async function onPageClicked(pageUrl: string): Promise { + console.log("onPageClicked called with URL:", pageUrl); + await trackEvent("Packrat Chrome Extension: Archive", "ViewPage", pageUrl); +} + +// Track when a torrent is created for sharing +export async function onTorrentCreated(numPages: number): Promise { + console.log("onTorrentCreated called with pages:", numPages); + await trackEvent( + "Packrat Chrome Extension: Sharing", + "TorrentCreated", + `${numPages} pages`, + ); +} + +// Track when a page is successfully archived +export async function onPageArchived( + pageUrl: string, + pageSize?: number, +): Promise { + console.log("onPageArchived called:", pageUrl, pageSize); + await trackEvent( + "Packrat Chrome Extension: Archive", + "PageArchived", + pageUrl, + ); + + // If page size is provided, track it separately + if (pageSize !== undefined) { + await trackEvent( + "Packrat Chrome Extension: Archive", + "PageSize", + `${Math.round(pageSize / 1024)}KB`, + ); + } +} + +// Track settings changes +export async function onSettingsChanged( + settingName: string, + value: string | boolean | number, +): Promise { + console.log("onSettingsChanged:", settingName, value); + await trackEvent( + "Packrat Chrome Extension: Settings", + settingName, + String(value), + ); +} + +// Track total archive size +export async function trackArchiveSize(totalSizeBytes: number): Promise { + const sizeMB = Math.round(totalSizeBytes / (1024 * 1024)); + console.log("trackArchiveSize:", sizeMB, "MB"); + await trackEvent( + "Packrat Chrome Extension: Archive", + "TotalSize", + `${sizeMB}MB`, + ); +} + +// Track when archiving starts +export async function onArchivingStarted(pageUrl: string): Promise { + console.log("onArchivingStarted:", pageUrl); + await trackEvent("Packrat Chrome Extension: Archive", "Started", pageUrl); +} + +// Track when archiving stops +export async function onArchivingStopped( + reason: string = "manual", +): Promise { + console.log("onArchivingStopped:", reason); + await trackEvent("Packrat Chrome Extension: Archive", "Stopped", reason); +} diff --git a/src/ext/bg.ts b/src/ext/bg.ts index 8545d5d..abfc166 100644 --- a/src/ext/bg.ts +++ b/src/ext/bg.ts @@ -10,6 +10,12 @@ import { getSharedArchives, } from "../localstorage"; import { isValidUrl } from "../utils"; + +import { + trackArchiveSize, + onArchivingStarted, + onArchivingStopped, +} from "../events"; // =========================================================================== self.recorders = {}; self.newRecId = null; @@ -24,6 +30,7 @@ let defaultCollId = null; let autorun = false; let isRecordingEnabled = false; let skipDomains = [] as string[]; +let lastTrackedTotalSize = 0; const openWinMap = new Map(); @@ -39,6 +46,31 @@ let sidepanelPort = null; skipDomains = (await getLocalOption("skipDomains")) || []; })(); +async function checkAndTrackArchiveSize() { + try { + const collId = await getLocalOption("defaultCollId"); + if (!collId) return; + + const coll = await collLoader.loadColl(collId); + if (!coll?.store?.getAllPages) return; + + const pages = await coll.store.getAllPages(); + // @ts-expect-error any + const totalSize = pages.reduce((sum, page) => sum + (page.size || 0), 0); + + const sizeDiff = totalSize - lastTrackedTotalSize; + if ( + sizeDiff > 10 * 1024 * 1024 || + (totalSize > 0 && lastTrackedTotalSize === 0) + ) { + await trackArchiveSize(totalSize); + lastTrackedTotalSize = totalSize; + } + } catch (error) { + console.error("Error tracking archive size:", error); + } +} + // =========================================================================== function main() { @@ -110,6 +142,7 @@ function sidepanelHandler(port) { if (coll?.store?.getAllPages) { const pages = await coll.store.getAllPages(); port.postMessage({ type: "pages", pages }); + await checkAndTrackArchiveSize(); } else { port.postMessage({ type: "pages", pages: [] }); } @@ -127,6 +160,7 @@ function sidepanelHandler(port) { await coll.store.deletePage(id); } + await checkAndTrackArchiveSize(); // now re-send the new list of pages const pages = await coll.store.getAllPages(); port.postMessage({ type: "pages", pages }); @@ -156,6 +190,10 @@ function sidepanelHandler(port) { //@ts-expect-error - 2 parameters but 3 tab.url, ); + + if (tab.url) { + await onArchivingStarted(tab.url); + } } port.postMessage({ @@ -179,6 +217,9 @@ function sidepanelHandler(port) { stopRecorder(tabId); } + await checkAndTrackArchiveSize(); + await onArchivingStopped("manual"); + port.postMessage({ type: "status", recording: false, @@ -223,6 +264,21 @@ chrome.runtime.onMessage.addListener( // @ts-expect-error - TS7006 - Parameter 'message' implicitly has an 'any' type. (message /*sender, sendResponse*/) => { console.log("onMessage", message); + + if (message.type === "matomoTrack" && message.url) { + fetch(message.url, { + method: "GET", + mode: "no-cors", + }) + .then(() => { + console.log("Matomo tracking sent from background:", message.url); + }) + .catch((error) => { + console.error("Matomo tracking error in background:", error); + }); + return true; + } + switch (message.msg) { case "optionsChanged": for (const rec of Object.values(self.recorders)) { @@ -244,6 +300,11 @@ chrome.runtime.onMessage.addListener( case "disableCSP": disableCSPForTab(message.tabId); break; + + case "checkArchiveSize": + // Check and track archive size when requested + checkAndTrackArchiveSize(); + break; } return true; }, @@ -531,6 +592,19 @@ async function disableCSPForTab(tabId) { // =========================================================================== chrome.runtime.onInstalled.addListener(main); +chrome.runtime.onStartup.addListener(async () => { + await checkAndTrackArchiveSize(); +}); + +setInterval( + async () => { + if (Object.keys(self.recorders).length > 0) { + await checkAndTrackArchiveSize(); + } + }, + 5 * 60 * 1000, +); + if (self.importScripts) { self.importScripts("sw.js"); } diff --git a/src/matomo.ts b/src/matomo.ts new file mode 100644 index 0000000..0f27a36 --- /dev/null +++ b/src/matomo.ts @@ -0,0 +1,130 @@ +// matomo.ts - Matomo tracking with opt-out and persistent user ID + +import { getLocalOption, setLocalOption } from "./localstorage"; + +const MATOMO_URL = "https://analytics.vaporware.network/matomo.php"; +const SITE_ID = "1"; +const USER_ID_KEY = "matomoUserId"; + +/** + * Ensure there is a persistent user ID in local storage. + * If one doesn't exist, generate a random hex string, store it, and return it. + */ +async function getOrCreateUserId(): Promise { + let stored = await getLocalOption(USER_ID_KEY); + if (stored && typeof stored === "string") { + return stored; + } + + // Generate a 16-byte (128-bit) hex string + const randomId = Array.from({ length: 16 }) + .map(() => + Math.floor(Math.random() * 256) + .toString(16) + .padStart(2, "0"), + ) + .join(""); + + await setLocalOption(USER_ID_KEY, randomId); + return randomId; +} + +/** + * Reads the "analyticsEnabled" key via getLocalOption. + * We expect it to be stored as "1" or "0". + * Returns true only if the stored value is exactly "1". + */ +async function checkAnalyticsEnabled(): Promise { + const stored = await getLocalOption("analyticsEnabled"); + return stored === "1"; +} + +/** + * Check if we're in the background/service worker context + */ +function isBackgroundContext(): boolean { + // Check if we have access to chrome.tabs (only available in background) + return typeof chrome !== "undefined" && chrome.tabs !== undefined; +} + +/** + * Send a simple event to Matomo, but only if analyticsEnabled === "1". + * Includes a persistent user ID (uid) in every request. + */ +export async function trackEvent( + category: string, + action: string, + name?: string, +): Promise { + try { + const isEnabled = await checkAnalyticsEnabled(); + if (!isEnabled) { + console.log("Matomo tracking is disabled; skipping event:", { + category, + action, + name, + }); + return; + } + + const userId = await getOrCreateUserId(); + const params = new URLSearchParams({ + // Required + idsite: SITE_ID, + rec: "1", + + // Event parameters + e_c: category, + e_a: action, + e_n: name || "", + + // Basic info + url: "chrome-extension://" + chrome.runtime.id, + _id: Math.random().toString(16).substr(2, 16), + rand: Date.now().toString(), + apiv: "1", + + // Don't return image + send_image: "0", + + // Persistent user ID + uid: userId, + }); + + const url = `${MATOMO_URL}?${params.toString()}`; + console.log("Sending Matomo event:", { + category, + action, + name, + userId, + url, + }); + + // If we're in the background context, use fetch directly + if (isBackgroundContext()) { + await fetch(url, { + method: "GET", + mode: "no-cors", + }); + console.log("Matomo event sent directly from background"); + } else { + // Otherwise, try to send via message to background + try { + await chrome.runtime.sendMessage({ + type: "matomoTrack", + url: url, + }); + console.log("Matomo event sent via message"); + } catch (error) { + // Fallback to image beacon if messaging fails + const img = new Image(); + img.src = url; + console.log("Matomo event sent via image beacon"); + } + } + + console.log("Matomo event sent successfully"); + } catch (error) { + console.error("Matomo tracking error:", error); + } +} diff --git a/src/recorder.ts b/src/recorder.ts index 1955f3d..ed90724 100644 --- a/src/recorder.ts +++ b/src/recorder.ts @@ -22,6 +22,8 @@ import { } from "./consts"; import { getLocalOption } from "./localstorage"; +import { onPageArchived } from "./events"; + const encoder = new TextEncoder(); const MAX_CONCURRENT_FETCH = 6; @@ -1136,6 +1138,19 @@ class Recorder { // @ts-expect-error - TS2339 - Property '_cachePageInfo' does not exist on type 'Recorder'. this._cachePageInfo = null; } + + if (finished && currPage.url) { + try { + // Fire and forget, but with immediate error handling + onPageArchived(currPage.url, currPage.size).catch((err) => { + console.error("onPageArchived failed:", err); + }); + } catch (err) { + // Handle synchronous errors too + console.error("onPageArchived synchronous error:", err); + } + } + return res; } diff --git a/src/settings-page.ts b/src/settings-page.ts index c68f311..720b195 100644 --- a/src/settings-page.ts +++ b/src/settings-page.ts @@ -8,6 +8,7 @@ import "@material/web/iconbutton/icon-button.js"; import { styles as typescaleStyles } from "@material/web/typography/md-typescale-styles.js"; import { getLocalOption, setLocalOption } from "./localstorage"; import { state } from "lit/decorators.js"; +import { onSettingsChanged } from "./events"; @customElement("settings-page") export class SettingsPage extends LitElement { @@ -70,6 +71,8 @@ export class SettingsPage extends LitElement { @state() private archiveScreenshots = false; @state() + private analyticsEnabled = false; + @state() private skipDomains = ""; connectedCallback() { @@ -85,7 +88,10 @@ export class SettingsPage extends LitElement { this.archiveStorage = storage === "1"; const screenshots = await getLocalOption("archiveScreenshots"); this.archiveScreenshots = screenshots === "1"; + const analytics = await getLocalOption("analyticsEnabled"); + this.analyticsEnabled = analytics === "1"; const domains = await getLocalOption("skipDomains"); + this.skipDomains = Array.isArray(domains) ? domains.join("\n") : typeof domains === "string" @@ -110,6 +116,8 @@ export class SettingsPage extends LitElement { // persist and notify recorder await setLocalOption("skipDomains", list); chrome.runtime.sendMessage({ msg: "optionsChanged" }); + + await onSettingsChanged("SkippedDomains", list.length); } private async _onArchiveCookiesChange(e: Event) { @@ -118,6 +126,7 @@ export class SettingsPage extends LitElement { await setLocalOption("archiveCookies", checked ? "1" : "0"); chrome.runtime.sendMessage({ msg: "optionsChanged" }); + await onSettingsChanged("CookiesEnabled", checked); } private async _onArchiveLocalstorageChange(e: Event) { @@ -125,6 +134,7 @@ export class SettingsPage extends LitElement { const checked = (e.currentTarget as HTMLInputElement).selected; await setLocalOption("archiveStorage", checked ? "1" : "0"); chrome.runtime.sendMessage({ msg: "optionsChanged" }); + await onSettingsChanged("LocalstorageEnabled", checked); } private async _onArchiveScreenshotsChange(e: Event) { @@ -132,6 +142,15 @@ export class SettingsPage extends LitElement { const checked = (e.currentTarget as HTMLInputElement).selected; await setLocalOption("archiveScreenshots", checked ? "1" : "0"); chrome.runtime.sendMessage({ msg: "optionsChanged" }); + await onSettingsChanged("ScreenshotsEnabled", checked); + } + + private async _onAnalyticsChange(e: Event) { + // @ts-expect-error + const checked = (e.currentTarget as HTMLInputElement).selected; + await setLocalOption("analyticsEnabled", checked ? "1" : "0"); + chrome.runtime.sendMessage({ msg: "optionsChanged" }); + await onSettingsChanged("AnalyticsEnabled", checked); } private _onBack() { @@ -197,6 +216,22 @@ export class SettingsPage extends LitElement {

+
+ +

+ Allow anonymous usage tracking (e.g., page archives, settings changes). When enabled, basic events will be logged. You can disable this at any time to opt-out of data collection. +

+
+ +