Files
openswarm/electron/affiliateTracking.js

190 lines
7.1 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Affiliate / referral install tracking on the desktop side.
//
// On first launch the app opens https://openswarm.com/welcome?app_install_id=…
// in the user's default browser and polls the cloud's /api/install/lookup
// endpoint until a referral binding shows up (or we time out). The browser
// page is what actually performs the bind: it reads the install_token that
// the landing page stashed in localStorage / cookie when the user clicked
// Download, and POSTs it to the cloud paired with our app_install_id.
//
// State lives in `<userData>/install.json`. The shape:
// {
// app_install_id: "uuid", // generated once per install
// first_launch_at: 1700000000000, // unix ms; presence = "this isn't first launch"
// ref: "haik" | null, // populated once lookup succeeds
// ref_bound_at: 1700000000000 | null,
// attempts: 0 // last polling attempt count, for debugging
// }
//
// Skipped entirely in dev unless OPENSWARM_AFFILIATE_FORCE=1 is set, so
// `bash run.sh` doesn't pop a browser tab on every restart.
const fs = require("fs");
const path = require("path");
const crypto = require("crypto");
const DEFAULT_LANDING_URL = "https://openswarm.com";
const DEFAULT_CLOUD_URL = "https://api.openswarm.com";
// Polling: 12 attempts, 5s apart = 60s window. Generous enough for the user
// to actually click through the welcome page; small enough that a stuck
// poll doesn't sit around all day. The page itself is fast (single POST)
// so most binds land in the first one or two ticks.
//
// Both knobs are overridable via env so tests can drive a 200ms × 5
// poll window instead of 60s.
const POLL_INTERVAL_MS = Number(process.env.OPENSWARM_AFFILIATE_POLL_INTERVAL_MS) || 5000;
const POLL_MAX_ATTEMPTS = Number(process.env.OPENSWARM_AFFILIATE_POLL_MAX_ATTEMPTS) || 12;
function getStateFilePath(userDataDir) {
return path.join(userDataDir, "install.json");
}
function readState(userDataDir) {
const p = getStateFilePath(userDataDir);
try {
const raw = fs.readFileSync(p, "utf8");
const parsed = JSON.parse(raw);
if (parsed && typeof parsed === "object") return parsed;
} catch (_) {}
return {};
}
function writeState(userDataDir, state) {
const p = getStateFilePath(userDataDir);
try {
fs.mkdirSync(path.dirname(p), { recursive: true });
// Atomic-ish write: temp file + rename. Avoids leaving a half-written
// install.json if the process is killed mid-write (which would brick
// first-launch detection on the next start).
const tmp = p + ".tmp";
fs.writeFileSync(tmp, JSON.stringify(state, null, 2), "utf8");
fs.renameSync(tmp, p);
} catch (err) {
console.warn("[affiliate] failed to write install.json:", err && err.message);
}
}
function urlsFromEnv() {
return {
landingUrl: (process.env.OPENSWARM_AFFILIATE_LANDING_URL || DEFAULT_LANDING_URL).replace(/\/$/, ""),
cloudUrl: (process.env.OPENSWARM_AFFILIATE_CLOUD_URL || DEFAULT_CLOUD_URL).replace(/\/$/, ""),
};
}
async function pollLookupOnce(cloudUrl, appInstallId) {
const url = `${cloudUrl}/api/install/lookup?app_install_id=${encodeURIComponent(appInstallId)}`;
// Node 18+ ships global fetch; Electron 40 is on a Chromium that has it.
// Defensive timeout via AbortSignal.timeout (Node 17+).
const controller = new AbortController();
const t = setTimeout(() => controller.abort(), 5000);
try {
const res = await fetch(url, { method: "GET", signal: controller.signal });
if (!res.ok) return null;
const body = await res.json();
if (body && typeof body.ref === "string" && body.ref) return body.ref;
return null;
} catch (_) {
return null;
} finally {
clearTimeout(t);
}
}
function delay(ms) {
return new Promise((r) => setTimeout(r, ms));
}
async function pollUntilBound({ cloudUrl, appInstallId, userDataDir }) {
for (let i = 0; i < POLL_MAX_ATTEMPTS; i++) {
await delay(POLL_INTERVAL_MS);
const ref = await pollLookupOnce(cloudUrl, appInstallId);
const state = readState(userDataDir);
state.attempts = i + 1;
if (ref) {
state.ref = ref;
state.ref_bound_at = Date.now();
writeState(userDataDir, state);
console.log(`[affiliate] bound ref=${ref} after ${i + 1} attempt(s)`);
return ref;
}
writeState(userDataDir, state);
}
console.log("[affiliate] no bind after polling window; giving up silently");
return null;
}
// Public entry: call once from app.whenReady() after backend is up. Safe to
// call on every launch — internal first-launch check makes subsequent calls
// a no-op. `shell` is electron's shell module, passed in to avoid this
// module needing to require electron at the top (keeps it test-friendly).
async function maybeRunFirstLaunchHandshake({ shell, userDataDir, isDev, isPackaged }) {
// Skip in dev to avoid spawning a browser tab on every `bash run.sh`.
// OPENSWARM_AFFILIATE_FORCE=1 lets us actually exercise the flow against
// a local landing page + local cloud during integration testing.
if (isDev && process.env.OPENSWARM_AFFILIATE_FORCE !== "1") {
return;
}
const state = readState(userDataDir);
if (state.first_launch_at) {
// Returning launch. If we never managed to bind a ref, optionally try
// again — but only for a short grace window after the original launch
// (24h) so we don't pop a browser tab on someone who's been using the
// app for a month.
const ageMs = Date.now() - Number(state.first_launch_at || 0);
const stillInGracePeriod = Number.isFinite(ageMs) && ageMs >= 0 && ageMs < 24 * 60 * 60 * 1000;
if (state.ref || !stillInGracePeriod || !state.app_install_id) {
return;
}
// Within grace window and still no ref — silently re-poll (no second
// browser pop-up) in case the user hasn't completed the welcome page
// handshake yet.
pollUntilBound({
cloudUrl: urlsFromEnv().cloudUrl,
appInstallId: state.app_install_id,
userDataDir,
}).catch(() => {});
return;
}
// First launch.
const appInstallId = crypto.randomUUID();
const now = Date.now();
const fresh = {
app_install_id: appInstallId,
first_launch_at: now,
ref: null,
ref_bound_at: null,
attempts: 0,
};
writeState(userDataDir, fresh);
const { landingUrl, cloudUrl } = urlsFromEnv();
const welcomeUrl = `${landingUrl}/welcome?app_install_id=${encodeURIComponent(appInstallId)}`;
console.log(`[affiliate] first launch: opening ${welcomeUrl}`);
try {
if (shell && typeof shell.openExternal === "function") {
await shell.openExternal(welcomeUrl);
}
} catch (err) {
console.warn("[affiliate] failed to open welcome URL:", err && err.message);
}
// Fire-and-forget the polling loop. We intentionally don't await it from
// app.whenReady() so backend / window startup stays unblocked.
pollUntilBound({ cloudUrl, appInstallId, userDataDir }).catch((err) => {
console.warn("[affiliate] poll loop crashed:", err && err.message);
});
}
module.exports = {
maybeRunFirstLaunchHandshake,
// Exported for tests + IPC handlers.
_readState: readState,
_writeState: writeState,
_getStateFilePath: getStateFilePath,
_pollLookupOnce: pollLookupOnce,
};