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.
+