diff --git a/backend/apps/auth/router.py b/backend/apps/auth/router.py index dd93e0c1f..dd813efdf 100644 --- a/backend/apps/auth/router.py +++ b/backend/apps/auth/router.py @@ -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( diff --git a/backend/main.py b/backend/main.py index dfc1817e1..5827f78e2 100644 --- a/backend/main.py +++ b/backend/main.py @@ -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 diff --git a/backend/tests/test_auth_router.py b/backend/tests/test_auth_router.py index 8de2c5e0f..8690f21dc 100644 --- a/backend/tests/test_auth_router.py +++ b/backend/tests/test_auth_router.py @@ -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: { @@ -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" diff --git a/electron/affiliateTracking.js b/electron/affiliateTracking.js index f591258e7..650e6736e 100644 --- a/electron/affiliateTracking.js +++ b/electron/affiliateTracking.js @@ -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 `/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"); @@ -46,7 +25,7 @@ function readState(userDataDir) { const raw = fs.readFileSync(p, "utf8"); const parsed = JSON.parse(raw); if (parsed && typeof parsed === "object") return parsed; - } catch (_) {} + } catch {} return {}; } @@ -54,9 +33,7 @@ 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); @@ -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(/\/$/, ""), @@ -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 { @@ -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)); } @@ -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. @@ -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, @@ -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}`); @@ -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, }; diff --git a/electron/affiliateTracking.test.js b/electron/affiliateTracking.test.js index 8bcf6220f..3a6fb6b63 100644 --- a/electron/affiliateTracking.test.js +++ b/electron/affiliateTracking.test.js @@ -38,6 +38,7 @@ const affiliateTracking = require("./affiliateTracking"); function makeMockCloud() { // Mirrors the install_tokens table. const tokens = new Map(); + const filenameHashes = new Map(); const server = http.createServer((req, res) => { const url = new URL(req.url, `http://${req.headers.host}`); @@ -77,8 +78,23 @@ function makeMockCloud() { if (req.method === "POST" && url.pathname === "/api/install/bind") { const body = await readBody(); - if (!body || typeof body.install_token !== "string" || typeof body.app_install_id !== "string") { - return send(400, { error: "install_token + app_install_id required" }); + if (!body || typeof body.app_install_id !== "string") { + return send(400, { error: "app_install_id required" }); + } + if (typeof body.affiliate_hash === "string" && !body.install_token) { + const ref = filenameHashes.get(body.affiliate_hash); + if (!ref) return send(404, { error: "affiliate_hash not found" }); + const token = `filename_${crypto.randomBytes(24).toString("base64url")}`; + tokens.set(token, { + ref, + app_install_id: body.app_install_id, + bound_at: Date.now(), + expires_at: Date.now() + 24 * 60 * 60 * 1000, + }); + return send(200, { ok: true, ref }); + } + if (typeof body.install_token !== "string") { + return send(400, { error: "install_token required" }); } const row = tokens.get(body.install_token); if (!row) return send(404, { error: "not found" }); @@ -114,6 +130,7 @@ function makeMockCloud() { resolve({ url: `http://127.0.0.1:${addr.port}`, tokens, + filenameHashes, close: () => new Promise((r) => server.close(r)), }); }); @@ -236,6 +253,193 @@ test("first launch: opens welcome URL and binds ref via poll loop", async () => } }); +test("first launch: stamped AppImage hash binds before opening welcome URL", async () => { + const cloud = await makeMockCloud(); + try { + const userDataDir = makeTempUserDataDir(); + const shell = makeFakeShell(); + const hash = "abcDEF1234567890_hash"; + cloud.filenameHashes.set(hash, "filename-affiliate"); + + process.env.OPENSWARM_AFFILIATE_LANDING_URL = "https://landing.test"; + process.env.OPENSWARM_AFFILIATE_CLOUD_URL = cloud.url; + + await affiliateTracking.maybeRunFirstLaunchHandshake({ + shell, + userDataDir, + isDev: false, + isPackaged: true, + platform: "linux", + env: { APPIMAGE: `/tmp/OpenSwarm-x64-${hash}.AppImage` }, + }); + + assert.equal(shell.opened.length, 0, "filename hash bind skips welcome URL"); + const state = readJson(path.join(userDataDir, "install.json")); + assert.equal(state.ref, "filename-affiliate"); + assert.equal(state.ref_bind_method, "affiliate_filename_hash"); + assert.ok(state.ref_bound_at > 0); + } finally { + await cloud.close(); + } +}); + +test("filename parser accepts browser duplicate suffix", () => { + assert.equal( + affiliateTracking.p_hashFromInstallerBasename("OpenSwarm-arm64-abcDEF1234567890_hash (1).dmg"), + "abcDEF1234567890_hash", + ); +}); + +test("filename parser keeps hyphens inside base64url affiliate hash", () => { + assert.equal( + affiliateTracking.p_hashFromInstallerBasename("OpenSwarm-arm64-abcDEF1234567890-hash.dmg"), + "abcDEF1234567890-hash", + ); +}); + +test("filename parser covers every stamped artifact shape (mac/win/linux)", () => { + const h = "abcDEF1234567890_hash"; + for (const name of [ + `OpenSwarm-arm64-${h}.dmg`, + `OpenSwarm-x64-${h}.dmg`, + `OpenSwarm-Setup-x64-${h}.exe`, + `OpenSwarm-x64-${h}.AppImage`, + `OpenSwarm-arm64-${h}.AppImage`, + ]) { + assert.equal(affiliateTracking.p_hashFromInstallerBasename(name), h, name); + } + for (const name of ["OpenSwarm-arm64.dmg", "OpenSwarm-Setup-x64.exe", "OpenSwarm-x64.AppImage"]) { + assert.equal(affiliateTracking.p_hashFromInstallerBasename(name), null, name); + } +}); + +test("first launch (win32): stamped setup exe in Downloads binds before welcome URL", async () => { + const cloud = await makeMockCloud(); + try { + const userDataDir = makeTempUserDataDir(); + const shell = makeFakeShell(); + const hash = "abcDEF1234567890_hash"; + cloud.filenameHashes.set(hash, "windows-affiliate"); + + const downloads = path.join(userDataDir, "Downloads"); + fs.mkdirSync(downloads, { recursive: true }); + fs.writeFileSync(path.join(downloads, `OpenSwarm-Setup-x64-${hash}.exe`), ""); + + process.env.OPENSWARM_AFFILIATE_LANDING_URL = "https://landing.test"; + process.env.OPENSWARM_AFFILIATE_CLOUD_URL = cloud.url; + + await affiliateTracking.maybeRunFirstLaunchHandshake({ + shell, + userDataDir, + isDev: false, + isPackaged: true, + platform: "win32", + homeDir: userDataDir, + }); + + assert.equal(shell.opened.length, 0, "filename hash bind skips welcome URL"); + const state = readJson(path.join(userDataDir, "install.json")); + assert.equal(state.ref, "windows-affiliate"); + assert.equal(state.ref_bind_method, "affiliate_filename_hash"); + } finally { + await cloud.close(); + } +}); + +test("download scan refuses ambiguous stamped installers", () => { + const userDataDir = makeTempUserDataDir(); + const downloads = path.join(userDataDir, "Downloads"); + fs.mkdirSync(downloads, { recursive: true }); + fs.writeFileSync(path.join(downloads, "OpenSwarm-arm64-abcDEF1234567890_a.dmg"), ""); + fs.writeFileSync(path.join(downloads, "OpenSwarm-arm64-abcDEF1234567890_b.dmg"), ""); + + const hash = affiliateTracking.p_findAffiliateHashFromInstaller({ + platform: "darwin", + homeDir: userDataDir, + nowMs: Date.now(), + }); + assert.equal(hash, null); +}); + +test("resolveInstallId: reuses install.json app_install_id", () => { + const userDataDir = makeTempUserDataDir(); + affiliateTracking.p_writeState(userDataDir, { app_install_id: "existing-id-12345" }); + const id = affiliateTracking.resolveInstallId({ + userDataDir, isPackaged: true, projectRoot: userDataDir, homeDir: userDataDir, + }); + assert.equal(id, "existing-id-12345"); +}); + +test("resolveInstallId: adopts python settings installation_id and persists it", () => { + const userDataDir = makeTempUserDataDir(); + const settingsDir = path.join(userDataDir, "backend", "data", "settings"); + fs.mkdirSync(settingsDir, { recursive: true }); + fs.writeFileSync( + path.join(settingsDir, "settings.json"), + JSON.stringify({ installation_id: "python-analytics-id-1" }), + ); + + const id = affiliateTracking.resolveInstallId({ + userDataDir, + isPackaged: false, + projectRoot: userDataDir, + }); + assert.equal(id, "python-analytics-id-1"); + const state = readJson(path.join(userDataDir, "install.json")); + assert.equal(state.app_install_id, "python-analytics-id-1"); +}); + +test("resolveInstallId: generates once and returns the same id on repeat calls", () => { + const userDataDir = makeTempUserDataDir(); + const first = affiliateTracking.resolveInstallId({ + userDataDir, isPackaged: true, projectRoot: userDataDir, homeDir: userDataDir, + }); + const second = affiliateTracking.resolveInstallId({ + userDataDir, isPackaged: true, projectRoot: userDataDir, homeDir: userDataDir, + }); + assert.ok(first && first.length >= 8); + assert.equal(second, first); + const state = readJson(path.join(userDataDir, "install.json")); + assert.equal(state.app_install_id, first); +}); + +test("first launch handshake reuses the pre-resolved install id", async () => { + const cloud = await makeMockCloud(); + try { + const userDataDir = makeTempUserDataDir(); + const shell = makeFakeShell(); + const hash = "abcDEF1234567890_hash"; + cloud.filenameHashes.set(hash, "unified-affiliate"); + + process.env.OPENSWARM_AFFILIATE_LANDING_URL = "https://landing.test"; + process.env.OPENSWARM_AFFILIATE_CLOUD_URL = cloud.url; + + const resolved = affiliateTracking.resolveInstallId({ + userDataDir, + isPackaged: true, + projectRoot: userDataDir, + homeDir: userDataDir, + }); + + await affiliateTracking.maybeRunFirstLaunchHandshake({ + shell, + userDataDir, + isDev: false, + isPackaged: true, + platform: "linux", + env: { APPIMAGE: `/tmp/OpenSwarm-x64-${hash}.AppImage` }, + }); + + const state = readJson(path.join(userDataDir, "install.json")); + assert.equal(state.app_install_id, resolved, "handshake must keep the unified id"); + assert.equal(state.ref, "unified-affiliate"); + const bound = [...cloud.tokens.values()].find((t) => t.ref === "unified-affiliate"); + assert.equal(bound.app_install_id, resolved, "cloud bind must carry the unified id"); + } finally { + await cloud.close(); + } +}); + test("returning launch: no-op when ref already bound", async () => { const cloud = await makeMockCloud(); try { @@ -391,7 +595,7 @@ test("dev mode: skipped unless OPENSWARM_AFFILIATE_FORCE=1", async () => { test("install.json write is atomic-ish (temp + rename)", async () => { const userDataDir = makeTempUserDataDir(); - affiliateTracking._writeState(userDataDir, { app_install_id: "atomic-test-1234567890", ref: "x" }); + affiliateTracking.p_writeState(userDataDir, { app_install_id: "atomic-test-1234567890", ref: "x" }); // After write, the temp file shouldn't be left behind. const files = fs.readdirSync(userDataDir); assert.ok(files.includes("install.json")); @@ -400,14 +604,14 @@ test("install.json write is atomic-ish (temp + rename)", async () => { test("readState returns {} when no install.json exists", () => { const userDataDir = makeTempUserDataDir(); - const state = affiliateTracking._readState(userDataDir); + const state = affiliateTracking.p_readState(userDataDir); assert.deepEqual(state, {}); }); test("readState returns {} when install.json is corrupt", () => { const userDataDir = makeTempUserDataDir(); fs.writeFileSync(path.join(userDataDir, "install.json"), "{ not json"); - const state = affiliateTracking._readState(userDataDir); + const state = affiliateTracking.p_readState(userDataDir); assert.deepEqual(state, {}); }); diff --git a/electron/main.js b/electron/main.js index ac4470a73..03a5cfac7 100644 --- a/electron/main.js +++ b/electron/main.js @@ -944,6 +944,16 @@ async function startBackend() { PYTHONUTF8: '1', }; + try { + env.OPENSWARM_INSTALLATION_ID = affiliateTracking.resolveInstallId({ + userDataDir: app.getPath('userData'), + isPackaged, + projectRoot, + }); + } catch (err) { + console.warn('[affiliate] resolveInstallId failed:', err && err.message); + } + // Tell the backend where to find a real Node binary for 9Router and // bundled MCP servers. Preferring this over ELECTRON_RUN_AS_NODE avoids // (a) the second OpenSwarm-as-Node process briefly registering in the @@ -974,8 +984,8 @@ async function startBackend() { // so a user-submitted backend.log instantly says what shipped. Emitted here // (not in whenReady) because openBackendLog() above just installed the console // tee; logging earlier would miss the persistent file. - const _bi = getBuildInfo(); - console.log(`[provenance] OpenSwarm ${app.getVersion()} sha=${_bi.shortSha} channel=${_bi.channel} builtAt=${_bi.builtAt || 'n/a'}`); + const p_buildInfo = getBuildInfo(); + console.log(`[provenance] OpenSwarm ${app.getVersion()} sha=${p_buildInfo.shortSha} channel=${p_buildInfo.channel} builtAt=${p_buildInfo.builtAt || 'n/a'}`); logPreflight(backendPort); runComprehensivePreflight(); // Record what we're about to launch and whether the interpreter is even @@ -2587,7 +2597,7 @@ ipcMain.handle('open-external', (_event, url) => { // (Stripe checkout, sign-in events) for downstream attribution. ipcMain.handle('get-install-state', () => { try { - return affiliateTracking._readState(app.getPath('userData')); + return affiliateTracking.p_readState(app.getPath('userData')); } catch (_) { return {}; }