Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -428,13 +428,18 @@ tests/
console-crane-bridge.test.ts # window CustomEvent emit/listen contracts
console-crane-store.test.ts # toggleConsoleCrane / goBack / canGoBack / isOnMainPage
translate.service.test.ts # cache hit/miss + 24h TTL eviction (vi.useFakeTimers)
translate-analytics-args.test.ts # install id (deviceId) and trigger sent with translation calls (E-01, E-05)
settings-host.test.ts # nibbleDisabledDomains normalize / toggle
language-detection.test.ts # RTL detection, title lookup, supported codes
selection-popup.test.ts # @mousedown.prevent.stop regression
caption-translation-gate.test.ts # whole-caption translation: settle ~150 ms, skip the caption already sent (E-06); in-flight sharing is in translate.service.test.ts (E-04)
nibble-surface.test.ts # bridge-driven hide/show
translate-card.test.ts # popup translate input flow
login-password.test.ts # popup password form (ENABLE_PASSWORD_AUTH=true)
login-password-disabled.test.ts # popup password form hidden when flag is unset
login-analytics.test.ts # user_logged-in source, login_failed source + reason (E-03)
save-login-prompt.test.ts # "Login to save" prompt viewed once per word view, clicked (E-03)
install-tracking.test.ts # onInstalled: extension_installed / extension_updated + welcome page (E-02)
e2e/
extension-fixture.ts # chromium.launchPersistentContext + extension load
server.mjs # static fixtures HTTP server
Expand Down
16 changes: 15 additions & 1 deletion src/background.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
MESSAGE_TYPE,
} from "./common/types/messaging";
import { ensureStoredTargetLanguage } from "./common/helper/default-language";
import { handleInstalled } from "./common/helper/install-tracking";

export {};

Expand All @@ -20,6 +21,14 @@ chrome.runtime.onInstalled.addListener(() => {
);
});

// Track installs and updates on the install's analytics id, and open the
// welcome page on a first install (E-02, see install-tracking.ts).
chrome.runtime.onInstalled.addListener((details) => {
handleInstalled(details).catch((e) =>
log("background: install tracking failed", e)
);
});

function getOAuthToken() {
return new Promise((resolve, reject) => {
chrome.identity.getAuthToken({ interactive: true }, function (token) {
Expand Down Expand Up @@ -106,8 +115,13 @@ chrome.runtime.onMessage.addListener(function (request, _sender, sendResponse) {
const left = screen.width / 2 - width / 2;
const top = screen.height / 2 - height / 2;

// The login source rides on the hash route's query (E-03).
const query = request.source
? `?source=${encodeURIComponent(request.source)}`
: "";

chrome.windows.create({
url: chrome.runtime.getURL("popup.html") + "#/login",
url: chrome.runtime.getURL("popup.html") + "#/login" + query,
type: "popup",
width: width,
height: height,
Expand Down
57 changes: 57 additions & 0 deletions src/common/helper/install-id.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
/**
* The install's analytics id (A3 in the analytics work list): one random id
* per install, kept in chrome.storage.local under `analyticsAnonymousId`.
*
* Mixpanel uses it as the anonymous `distinct_id` / `$device_id`
* (plugins/mixpanel.ts), and server calls that fire an analytics event send it
* as `deviceId` (E-01) so server events land on the same person.
*
* This module has no mixpanel import, so it is safe in every context: content
* scripts, the popup and the background service worker.
*/

export const ANON_ID_KEY = "analyticsAnonymousId";

/** crypto.randomUUID is missing on plain-http pages; getRandomValues is not. */
export function newAnonymousId(): string {
const b = crypto.getRandomValues(new Uint8Array(16));
b[6] = (b[6] & 0x0f) | 0x40;
b[8] = (b[8] & 0x3f) | 0x80;
const h = Array.from(b, (x) => x.toString(16).padStart(2, "0")).join("");
return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`;
}

// One read-or-create at a time per context, so two callers on the first page
// ever do not mint two different ids.
let pending: Promise<string | undefined> | null = null;

/**
* Read the stored install id, creating and storing it when there is none yet.
* Not cached after it settles: logout rotates the id (resetAnalyticsIdentity),
* and the next call must see the new one.
*
* Resolves `undefined` only when there is no extension context (e.g. the
* content script was orphaned by an update); callers then send no id.
*/
export function getInstallId(): Promise<string | undefined> {
if (pending) return pending;
pending = new Promise<string | undefined>((resolve) => {
try {
chrome.storage.local.get(ANON_ID_KEY, (data) => {
let id = data?.[ANON_ID_KEY] as string | undefined;
if (id) return resolve(id);
id = newAnonymousId();
try {
chrome.storage.local.set({ [ANON_ID_KEY]: id }, () => resolve(id));
} catch {
resolve(id);
}
});
} catch {
resolve(undefined);
}
}).finally(() => {
pending = null;
});
return pending;
}
125 changes: 125 additions & 0 deletions src/common/helper/install-tracking.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
/**
* Install and update tracking for the background service worker (E-02).
*
* mixpanel-browser is not loaded here: it expects `window` and `document`,
* which a MV3 service worker does not have. So the two events go straight to
* Mixpanel's /track endpoint with fetch, in the same shape mixpanel-browser
* sends (base64 JSON in a `data` form field), through the same
* MIXPANEL_API_HOST proxy, on the install's analytics id. The content scripts
* use that same id as their anonymous `distinct_id` (plugins/mixpanel.ts), so
* these events join the rest of the install's events.
*/
import { getInstallId } from "./install-id";
import { log } from "./log";

/** The welcome page opened on first install (landing D-01 reads install_id). */
export const WELCOME_URL = "https://subturtle.app/";

export function welcomeUrl(installId: string): string {
const params = new URLSearchParams({
install_id: installId,
utm_source: "extension",
utm_medium: "install",
});
return `${WELCOME_URL}?${params.toString()}`;
}

const DEFAULT_API_HOST = "https://api-js.mixpanel.com";

function insertId(): string {
const b = crypto.getRandomValues(new Uint8Array(12));
return Array.from(b, (x) => x.toString(16).padStart(2, "0")).join("");
}

/** UTF-8 safe base64, the encoding mixpanel-browser 2.45 uses for `data`. */
function base64Json(value: unknown): string {
const bytes = new TextEncoder().encode(JSON.stringify(value));
let bin = "";
bytes.forEach((b) => (bin += String.fromCharCode(b)));
return btoa(bin);
}

/**
* Send one event to Mixpanel from a context without mixpanel-browser.
* Never throws: analytics must not break install handling.
*/
export async function trackFromBackground(
event: string,
installId: string,
properties: Record<string, unknown> = {}
): Promise<void> {
const token = process.env.MIXPANEL_PROJECT_TOKEN;
if (!token) return;
const host = (process.env.MIXPANEL_API_HOST || DEFAULT_API_HOST).replace(
/\/+$/,
""
);
const manifest = chrome.runtime.getManifest();
const payload = {
event,
properties: {
token,
// Same identity the content scripts register for an anonymous install.
distinct_id: installId,
$device_id: installId,
time: Date.now() / 1000,
$insert_id: insertId(),
app: "chrome-extension",
version: manifest.version_name || manifest.version,
...properties,
},
};
try {
await fetch(`${host}/track/?ip=1`, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: `data=${encodeURIComponent(base64Json(payload))}`,
});
} catch (e) {
log("background: analytics request failed", e);
}
}

/**
* Web Store installs carry `update_url` in the manifest; an unpacked build
* (local dev, the CI e2e run, a dev zip loaded by hand) does not.
*/
function isStoreInstall(): boolean {
return "update_url" in chrome.runtime.getManifest();
}

/**
* chrome.runtime.onInstalled handler: `extension_installed` on a new install
* (and open the welcome page), `extension_updated` with `previous_version` on
* an update. Other reasons (a Chrome update) are ignored.
*/
export async function handleInstalled(
details: chrome.runtime.InstalledDetails
): Promise<void> {
const { reason, previousVersion } = details;
if (reason !== "install" && reason !== "update") return;

const installId = await getInstallId();
if (!installId) return;

if (reason === "install") {
// Open the page first: it must not wait on the analytics request.
// Unpacked builds skip it, so dev reloads and CI runs do not open
// subturtle.app or count fake installs on the landing page.
if (isStoreInstall()) {
try {
await chrome.tabs.create({ url: welcomeUrl(installId) });
} catch (e) {
log("background: failed to open the welcome page", e);
}
}
await trackFromBackground("extension_installed", installId, {
reason: "install",
});
} else {
await trackFromBackground("extension_updated", installId, {
reason: "update",
previous_version: previousVersion || null,
});
}
}
Loading
Loading