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
14 changes: 9 additions & 5 deletions backend/apps/auth/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,15 +106,19 @@ async def signin_activate(body: SigninActivateRequest):
raise HTTPException(status_code=400, detail="Invalid token")

proxy = p_proxy_url()
payload = {
"token": body.token,
"signin_method": body.signin_method,
"email": body.email,
}
p_install_id = getattr(load_settings(), "installation_id", None)
if p_install_id:
payload["install_id"] = p_install_id
try:
async with httpx.AsyncClient(timeout=10.0) as client:
r = await client.post(
f"{proxy}/api/auth/signin-activate",
json={
"token": body.token,
"signin_method": body.signin_method,
"email": body.email,
},
json=payload,
)
except httpx.HTTPError as e:
raise HTTPException(
Expand Down
7 changes: 6 additions & 1 deletion backend/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,11 +65,16 @@

# Generate the per-install id (installation_id) at the same pre-bind moment as the auth token. It is otherwise created lazily on the first analytics submission, so on a clean install the sign-in window can render and build its Google/email OAuth URL (which embeds install_id) before that submission fires, producing an empty install_id that the cloud rejects. Generating here guarantees the very first GET /api/settings already carries it. Platform-agnostic; wrapped so a settings hiccup never blocks startup, and the lazy path stays as a fallback.
try:
import re as p_re
import uuid as p_uuid
from backend.apps.settings.store import load_settings as p_load_boot_settings, save_settings as p_save_boot_settings
p_boot_settings = p_load_boot_settings()
if not getattr(p_boot_settings, "installation_id", None):
p_boot_settings.installation_id = p_uuid.uuid4().hex
# Electron resolves this before startup so analytics and affiliate attribution share one install id.
p_env_iid = os.environ.get("OPENSWARM_INSTALLATION_ID", "")
p_boot_settings.installation_id = (
p_env_iid if p_re.fullmatch(r"[A-Za-z0-9_-]{8,128}", p_env_iid) else p_uuid.uuid4().hex
)
p_save_boot_settings(p_boot_settings)
except Exception:
pass
Expand Down
9 changes: 9 additions & 0 deletions backend/tests/test_auth_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,11 @@ def reset_settings():
# --------------------------------------------------------------------------- /api/auth/signin-activate ---------------------------------------------------------------------------

def test_signin_activate_persists_user_id(client, reset_settings):
from backend.apps.settings.settings import load_settings, save_settings
s = load_settings()
s.installation_id = "unified-install-id-123"
save_settings(s)

fake_response = AsyncMock()
fake_response.status_code = 200
fake_response.json = lambda: {
Expand All @@ -61,6 +66,10 @@ def test_signin_activate_persists_user_id(client, reset_settings):
},
)
assert r.status_code == 200
assert any(
call.kwargs.get("json", {}).get("install_id") == "unified-install-id-123"
for call in instance.post.call_args_list
)
body = r.json()
assert body["user_id"] == "u-1234"
assert body["email"] == "smoke@example.com"
Expand Down
216 changes: 173 additions & 43 deletions electron/affiliateTracking.js
Original file line number Diff line number Diff line change
@@ -1,40 +1,19 @@
// 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 os = require("os");

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;
const FILENAME_ATTRIBUTION_WINDOW_MS =
Number(process.env.OPENSWARM_AFFILIATE_FILENAME_WINDOW_MS) || 30 * 24 * 60 * 60 * 1000;
const INSTALLER_HASH_RE =
/^OpenSwarm(?:-Setup)?-(?:arm64|x64)-([A-Za-z0-9_-]{16,32})(?: \([0-9]+\))?\.(dmg|exe|AppImage)$/i;
// Allow clock skew so a fresh installer is not discarded after an NTP adjustment.
const FUTURE_MTIME_TOLERANCE_MS = 60 * 1000;

function getStateFilePath(userDataDir) {
return path.join(userDataDir, "install.json");
Expand All @@ -46,17 +25,15 @@ function readState(userDataDir) {
const raw = fs.readFileSync(p, "utf8");
const parsed = JSON.parse(raw);
if (parsed && typeof parsed === "object") return parsed;
} catch (_) {}
} 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).
// Rename a complete temp file so termination cannot leave invalid JSON.
const tmp = p + ".tmp";
fs.writeFileSync(tmp, JSON.stringify(state, null, 2), "utf8");
fs.renameSync(tmp, p);
Expand All @@ -65,6 +42,50 @@ function writeState(userDataDir, state) {
}
}

const INSTALL_ID_RE = /^[A-Za-z0-9_-]{8,128}$/;

function pythonSettingsFile({ isPackaged, projectRoot, platform, env, homeDir }) {
if (!isPackaged) {
return path.join(projectRoot, "backend", "data", "settings", "settings.json");
}
let appSupport;
if (platform === "darwin") {
appSupport = path.join(homeDir, "Library", "Application Support", "OpenSwarm");
} else if (platform === "win32") {
appSupport = path.join(env.APPDATA || homeDir, "OpenSwarm");
} else {
appSupport = path.join(env.XDG_DATA_HOME || path.join(homeDir, ".local", "share"), "OpenSwarm");
}
return path.join(appSupport, "data", "settings", "settings.json");
}

function resolveInstallId({
userDataDir,
isPackaged,
projectRoot,
platform = process.platform,
env = process.env,
homeDir = os.homedir(),
}) {
const state = readState(userDataDir);
if (typeof state.app_install_id === "string" && INSTALL_ID_RE.test(state.app_install_id)) {
return state.app_install_id;
}

try {
const settingsPath = pythonSettingsFile({ isPackaged, projectRoot, platform, env, homeDir });
const iid = JSON.parse(fs.readFileSync(settingsPath, "utf8")).installation_id;
if (typeof iid === "string" && INSTALL_ID_RE.test(iid)) {
writeState(userDataDir, { ...state, app_install_id: iid });
return iid;
}
} catch {}

const freshId = crypto.randomUUID();
writeState(userDataDir, { ...state, app_install_id: freshId });
return freshId;
}

function urlsFromEnv() {
return {
landingUrl: (process.env.OPENSWARM_AFFILIATE_LANDING_URL || DEFAULT_LANDING_URL).replace(/\/$/, ""),
Expand All @@ -74,8 +95,6 @@ function urlsFromEnv() {

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 {
Expand All @@ -84,13 +103,97 @@ async function pollLookupOnce(cloudUrl, appInstallId) {
const body = await res.json();
if (body && typeof body.ref === "string" && body.ref) return body.ref;
return null;
} catch (_) {
} catch {
return null;
} finally {
clearTimeout(t);
}
}

async function bindAffiliateHashOnce(cloudUrl, appInstallId, affiliateHash) {
const url = `${cloudUrl}/api/install/bind`;
const controller = new AbortController();
const t = setTimeout(() => controller.abort(), 5000);
try {
const res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
signal: controller.signal,
body: JSON.stringify({ affiliate_hash: affiliateHash, app_install_id: appInstallId }),
});
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 hashFromInstallerBasename(filePath) {
const base = path.basename(String(filePath || ""));
const m = INSTALLER_HASH_RE.exec(base);
return m ? m[1] : null;
}

function likelyDownloadDirs(homeDir) {
if (!homeDir) return [];
return [path.join(homeDir, "Downloads"), path.join(homeDir, "Desktop")];
}

function recentInstallerHashesInDir(dir, nowMs) {
const out = [];
let entries = [];
try {
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch {
return out;
}

for (const entry of entries) {
if (!entry.isFile()) continue;
const hash = hashFromInstallerBasename(entry.name);
if (!hash) continue;
const fullPath = path.join(dir, entry.name);
try {
const st = fs.statSync(fullPath);
const ageMs = nowMs - st.mtimeMs;
if (ageMs < -FUTURE_MTIME_TOLERANCE_MS || ageMs > FILENAME_ATTRIBUTION_WINDOW_MS) continue;
out.push({ hash, path: fullPath, mtimeMs: st.mtimeMs });
} catch {}
}
return out;
}

function findAffiliateHashFromInstaller({
platform = process.platform,
env = process.env,
homeDir = os.homedir(),
nowMs = Date.now(),
} = {}) {
if (platform === "linux") {
return hashFromInstallerBasename(env.APPIMAGE);
}

if (platform !== "darwin" && platform !== "win32") {
return null;
}

const matches = [];
for (const dir of likelyDownloadDirs(homeDir)) {
matches.push(...recentInstallerHashesInDir(dir, nowMs));
}
if (matches.length !== 1) {
if (matches.length > 1) {
console.log("[affiliate] multiple stamped installers found; falling back to welcome flow");
}
return null;
}
return matches[0].hash;
}

function delay(ms) {
return new Promise((r) => setTimeout(r, ms));
}
Expand Down Expand Up @@ -118,7 +221,15 @@ async function pollUntilBound({ cloudUrl, appInstallId, userDataDir }) {
// 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 }) {
async function maybeRunFirstLaunchHandshake({
shell,
userDataDir,
isDev,
isPackaged,
platform = process.platform,
env = process.env,
homeDir = os.homedir(),
}) {
// 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.
Expand Down Expand Up @@ -148,8 +259,10 @@ async function maybeRunFirstLaunchHandshake({ shell, userDataDir, isDev, isPacka
return;
}

// First launch.
const appInstallId = crypto.randomUUID();
const appInstallId =
typeof state.app_install_id === "string" && INSTALL_ID_RE.test(state.app_install_id)
? state.app_install_id
: crypto.randomUUID();
const now = Date.now();
const fresh = {
app_install_id: appInstallId,
Expand All @@ -161,6 +274,23 @@ async function maybeRunFirstLaunchHandshake({ shell, userDataDir, isDev, isPacka
writeState(userDataDir, fresh);

const { landingUrl, cloudUrl } = urlsFromEnv();
const affiliateHash = findAffiliateHashFromInstaller({ platform, env, homeDir });
if (affiliateHash) {
const ref = await bindAffiliateHashOnce(cloudUrl, appInstallId, affiliateHash);
if (ref) {
const bound = {
...fresh,
ref,
ref_bound_at: Date.now(),
ref_bind_method: "affiliate_filename_hash",
};
writeState(userDataDir, bound);
console.log(`[affiliate] bound filename hash ref=${ref}; skipping welcome URL`);
return;
}
console.log("[affiliate] filename hash bind failed; falling back to welcome flow");
}

const welcomeUrl = `${landingUrl}/welcome?app_install_id=${encodeURIComponent(appInstallId)}`;

console.log(`[affiliate] first launch: opening ${welcomeUrl}`);
Expand All @@ -181,9 +311,9 @@ async function maybeRunFirstLaunchHandshake({ shell, userDataDir, isDev, isPacka

module.exports = {
maybeRunFirstLaunchHandshake,
// Exported for tests + IPC handlers.
_readState: readState,
_writeState: writeState,
_getStateFilePath: getStateFilePath,
_pollLookupOnce: pollLookupOnce,
resolveInstallId,
p_readState: readState,
p_writeState: writeState,
p_hashFromInstallerBasename: hashFromInstallerBasename,
p_findAffiliateHashFromInstaller: findAffiliateHashFromInstaller,
};
Loading
Loading