From ac2f8e31b6f1defde84b2e37a1b8333045687ae3 Mon Sep 17 00:00:00 2001 From: abccodes Date: Fri, 10 Jul 2026 02:37:48 -0700 Subject: [PATCH 1/4] [aidan] feat/affiliate-filenames: bind installs from stamped filenames --- backend/apps/auth/router.py | 2 + backend/tests/test_auth_router.py | 5 + electron/affiliateTracking.js | 120 +++++++++++++++++- electron/affiliateTracking.test.js | 80 +++++++++++- .../app/components/overlays/SignInDialog.tsx | 8 +- .../sections/subscription/AccountCard.tsx | 5 +- frontend/src/shared/affiliateInstall.ts | 14 ++ frontend/src/shared/hooks/useDeepLink.ts | 16 ++- frontend/src/shared/state/settingsSlice.ts | 1 + frontend/src/types/electron.d.ts | 1 + 10 files changed, 242 insertions(+), 10 deletions(-) create mode 100644 frontend/src/shared/affiliateInstall.ts diff --git a/backend/apps/auth/router.py b/backend/apps/auth/router.py index dd93e0c1f..ee9b6b5bf 100644 --- a/backend/apps/auth/router.py +++ b/backend/apps/auth/router.py @@ -90,6 +90,7 @@ class SigninActivateRequest(BaseModel): token: str signin_method: Literal["google", "email"] email: Optional[str] = None + app_install_id: Optional[str] = None @auth.router.post("/signin-activate") @@ -114,6 +115,7 @@ async def signin_activate(body: SigninActivateRequest): "token": body.token, "signin_method": body.signin_method, "email": body.email, + "app_install_id": body.app_install_id, }, ) except httpx.HTTPError as e: diff --git a/backend/tests/test_auth_router.py b/backend/tests/test_auth_router.py index 8de2c5e0f..74e7c6d02 100644 --- a/backend/tests/test_auth_router.py +++ b/backend/tests/test_auth_router.py @@ -58,9 +58,14 @@ def test_signin_activate_persists_user_id(client, reset_settings): "token": "fake-bearer-1234567890abcdef", "signin_method": "google", "email": "smoke@example.com", + "app_install_id": "app-install-affiliate-123", }, ) assert r.status_code == 200 + assert any( + call.kwargs.get("json", {}).get("app_install_id") == "app-install-affiliate-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..4c8a846f9 100644 --- a/electron/affiliateTracking.js +++ b/electron/affiliateTracking.js @@ -13,6 +13,7 @@ // first_launch_at: 1700000000000, // unix ms; presence = "this isn't first launch" // ref: "haik" | null, // populated once lookup succeeds // ref_bound_at: 1700000000000 | null, +// ref_bind_method: "affiliate_filename_hash" | null, // attempts: 0 // last polling attempt count, for debugging // } // @@ -22,6 +23,7 @@ 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"; @@ -35,6 +37,10 @@ const DEFAULT_CLOUD_URL = "https://api.openswarm.com"; // 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; function getStateFilePath(userDataDir) { return path.join(userDataDir, "install.json"); @@ -91,6 +97,90 @@ async function pollLookupOnce(cloudUrl, appInstallId) { } } +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 < 0 || 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 +208,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. @@ -161,6 +259,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}`); @@ -186,4 +301,7 @@ module.exports = { _writeState: writeState, _getStateFilePath: getStateFilePath, _pollLookupOnce: pollLookupOnce, + _bindAffiliateHashOnce: bindAffiliateHashOnce, + _hashFromInstallerBasename: hashFromInstallerBasename, + _findAffiliateHashFromInstaller: findAffiliateHashFromInstaller, }; diff --git a/electron/affiliateTracking.test.js b/electron/affiliateTracking.test.js index 8bcf6220f..83b191910 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,65 @@ 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._hashFromInstallerBasename("OpenSwarm-arm64-abcDEF1234567890_hash (1).dmg"), + "abcDEF1234567890_hash", + ); +}); + +test("filename parser keeps hyphens inside base64url affiliate hash", () => { + assert.equal( + affiliateTracking._hashFromInstallerBasename("OpenSwarm-arm64-abcDEF1234567890-hash.dmg"), + "abcDEF1234567890-hash", + ); +}); + +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._findAffiliateHashFromInstaller({ + platform: "darwin", + homeDir: userDataDir, + nowMs: Date.now(), + }); + assert.equal(hash, null); +}); + test("returning launch: no-op when ref already bound", async () => { const cloud = await makeMockCloud(); try { diff --git a/frontend/src/app/components/overlays/SignInDialog.tsx b/frontend/src/app/components/overlays/SignInDialog.tsx index d2cc88b16..b46bb628c 100644 --- a/frontend/src/app/components/overlays/SignInDialog.tsx +++ b/frontend/src/app/components/overlays/SignInDialog.tsx @@ -19,6 +19,7 @@ import { activateSignin, fetchSettings } from '@/shared/state/settingsSlice'; import { useClaudeTokens } from '@/shared/styles/ThemeContext'; import { OPENSWARM_DEFAULT_PROXY_URL } from '@/shared/config'; import { report } from '@/shared/serviceClient'; +import { getAffiliateAppInstallId } from '@/shared/affiliateInstall'; type Stage = 'choose' | 'email_form' | 'code_form'; @@ -46,13 +47,15 @@ export default function SignInDialog({ onClose }: { onClose: () => void }): JSX. const cloudBase = proxyUrl.replace(/\/$/, ''); - const onGoogle = () => { + const onGoogle = async () => { report('signin', 'google_clicked'); const localPort = (window as any).__OPENSWARM_PORT__ || 8324; const params = new URLSearchParams({ install_id: installId, local_port: String(localPort), }); + const appInstallId = await getAffiliateAppInstallId(); + if (appInstallId) params.set('app_install_id', appInstallId); const startUrl = `${cloudBase}/api/auth/google/start?${params.toString()}`; const api = (window as any).openswarm; if (api?.openExternal) { @@ -115,6 +118,7 @@ export default function SignInDialog({ onClose }: { onClose: () => void }): JSX. try { report('signin', 'email_verify_submitted'); const localPort = (window as any).__OPENSWARM_PORT__ || 8324; + const appInstallId = await getAffiliateAppInstallId(); const res = await fetch(`${cloudBase}/api/auth/email/verify`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -122,6 +126,7 @@ export default function SignInDialog({ onClose }: { onClose: () => void }): JSX. email: email.trim(), code, install_id: installId, + ...(appInstallId ? { app_install_id: appInstallId } : {}), local_port: localPort, }), }); @@ -137,6 +142,7 @@ export default function SignInDialog({ onClose }: { onClose: () => void }): JSX. token: data.bearer, email: data.user_email, signin_method: 'email', + ...(appInstallId ? { app_install_id: appInstallId } : {}), }), ).unwrap(); } catch (err) { diff --git a/frontend/src/app/pages/Settings/sections/subscription/AccountCard.tsx b/frontend/src/app/pages/Settings/sections/subscription/AccountCard.tsx index 034d0b83c..870ae1e7c 100644 --- a/frontend/src/app/pages/Settings/sections/subscription/AccountCard.tsx +++ b/frontend/src/app/pages/Settings/sections/subscription/AccountCard.tsx @@ -8,6 +8,7 @@ import { signOut } from '@/shared/state/settingsSlice'; import { OPENSWARM_DEFAULT_PROXY_URL } from '@/shared/config'; import { useClaudeTokens } from '@/shared/styles/ThemeContext'; import SignInDialog from '@/app/components/overlays/SignInDialog'; +import { getAffiliateAppInstallId } from '@/shared/affiliateInstall'; /** Account card at top of General tab; three states: signed in, paid-but-unlinked, or not signed in. */ const AccountCard: React.FC = () => { @@ -43,13 +44,15 @@ const AccountCard: React.FC = () => { } }; - const onSignIn = () => { + const onSignIn = async () => { // Pass local_port so the bearer-handoff page POSTs to the right backend (Electron binds in 8324..8424). const localPort = (window as any).__OPENSWARM_PORT__ || 8324; const params = new URLSearchParams({ install_id: installId, local_port: String(localPort), }); + const appInstallId = await getAffiliateAppInstallId(); + if (appInstallId) params.set('app_install_id', appInstallId); const startUrl = proxyUrl.replace(/\/$/, '') + '/api/auth/google/start?' + params.toString(); const api = (window as any).openswarm; if (api?.openExternal) api.openExternal(startUrl); diff --git a/frontend/src/shared/affiliateInstall.ts b/frontend/src/shared/affiliateInstall.ts new file mode 100644 index 000000000..4b5936951 --- /dev/null +++ b/frontend/src/shared/affiliateInstall.ts @@ -0,0 +1,14 @@ +const APP_INSTALL_ID_RE = /^[A-Za-z0-9_-]{8,128}$/; + +export async function getAffiliateAppInstallId(): Promise { + try { + const api = (window as any).openswarm; + const state = await api?.getInstallState?.(); + const appInstallId = state && typeof state.app_install_id === 'string' + ? state.app_install_id + : ''; + return APP_INSTALL_ID_RE.test(appInstallId) ? appInstallId : null; + } catch { + return null; + } +} diff --git a/frontend/src/shared/hooks/useDeepLink.ts b/frontend/src/shared/hooks/useDeepLink.ts index 66814d765..e0771ba09 100644 --- a/frontend/src/shared/hooks/useDeepLink.ts +++ b/frontend/src/shared/hooks/useDeepLink.ts @@ -5,6 +5,7 @@ import { fetchModels } from '@/shared/state/modelsSlice'; import { fetchTools } from '@/shared/state/toolsSlice'; import { API_BASE } from '@/shared/config'; import { report } from '@/shared/serviceClient'; +import { getAffiliateAppInstallId } from '@/shared/affiliateInstall'; /** Subscribe to openswarm:// auth/oauth deep-links from Electron main; no-op in browser. */ export function useDeepLink(): void { @@ -14,7 +15,7 @@ export function useDeepLink(): void { const api = (window as any).openswarm as OpenSwarmAPI | undefined; if (!api) return; - const unsubscribe = api.onAuthUrl?.((rawUrl: string) => { + const unsubscribe = api.onAuthUrl?.(async (rawUrl: string) => { try { // openswarm://auth?token=...; signin=true => free sign-in, else Stripe activation. const url = new URL(rawUrl); @@ -34,11 +35,16 @@ export function useDeepLink(): void { const expires = url.searchParams.get('expires'); if (isSignin) { - // 1.0.29 only ships Google sign-in; read for forward compat. - void signinMethodRaw; - report('signin', 'deep_link_received', { method: 'google' }); + const signinMethod = signinMethodRaw === 'email' ? 'email' : 'google'; + const appInstallId = url.searchParams.get('app_install_id') || (await getAffiliateAppInstallId()); + report('signin', 'deep_link_received', { method: signinMethod }); - dispatch(activateSignin({ token, signin_method: 'google', email })) + dispatch(activateSignin({ + token, + signin_method: signinMethod, + email, + ...(appInstallId ? { app_install_id: appInstallId } : {}), + })) .unwrap() .then((res) => { report('signin', 'activated', { method: res.signin_method, plan: res.plan }); diff --git a/frontend/src/shared/state/settingsSlice.ts b/frontend/src/shared/state/settingsSlice.ts index cc8254d6d..0b7860662 100644 --- a/frontend/src/shared/state/settingsSlice.ts +++ b/frontend/src/shared/state/settingsSlice.ts @@ -87,6 +87,7 @@ export interface ActivateSigninPayload { token: string; signin_method: 'google' | 'email'; email?: string | null; + app_install_id?: string | null; } export interface BrowseResult { diff --git a/frontend/src/types/electron.d.ts b/frontend/src/types/electron.d.ts index 2f416e7b0..61f473b69 100644 --- a/frontend/src/types/electron.d.ts +++ b/frontend/src/types/electron.d.ts @@ -48,6 +48,7 @@ declare global { onUpdateError: (cb: (message: string) => void) => () => void; onWebviewNewWindow: (cb: (url: string, webContentsId: number) => void) => () => void; openExternal: (url: string) => Promise; + getInstallState?: () => Promise<{ app_install_id?: string; ref?: string | null; ref_bind_method?: string | null }>; hardReset?: () => Promise; onAuthUrl?: (cb: (url: string) => void) => () => void; onOauthClaim?: (cb: (url: string) => void) => () => void; From c7004c98789f84f6e16ede79e496c8bdfb95d1c3 Mon Sep 17 00:00:00 2001 From: abccodes Date: Fri, 10 Jul 2026 18:06:11 -0700 Subject: [PATCH 2/4] [aidan] feat/affiliate-ids: unify install identity across electron and python backend Electron resolves ONE install id before spawning the backend (install.json app_install_id -> python settings installation_id -> fresh uuid) and exports it as OPENSWARM_INSTALLATION_ID; the backend adopts it when settings has no installation_id yet. The affiliate app_install_id and the analytics install_id are now the same value, so affiliate refs join directly to telemetry with no sign-in required. Drops the frontend app_install_id forwarding through sign-in flows; the desktop auth router forwards settings.installation_id as install_id instead. --- backend/apps/auth/router.py | 20 +++-- backend/main.py | 11 ++- backend/tests/test_auth_router.py | 11 ++- electron/affiliateTracking.js | 79 ++++++++++++++++++- electron/affiliateTracking.test.js | 79 +++++++++++++++++++ electron/main.js | 16 ++++ .../app/components/overlays/SignInDialog.tsx | 8 +- .../sections/subscription/AccountCard.tsx | 5 +- frontend/src/shared/affiliateInstall.ts | 14 ---- frontend/src/shared/hooks/useDeepLink.ts | 16 ++-- frontend/src/shared/state/settingsSlice.ts | 1 - frontend/src/types/electron.d.ts | 1 - 12 files changed, 210 insertions(+), 51 deletions(-) delete mode 100644 frontend/src/shared/affiliateInstall.ts diff --git a/backend/apps/auth/router.py b/backend/apps/auth/router.py index ee9b6b5bf..706995ffc 100644 --- a/backend/apps/auth/router.py +++ b/backend/apps/auth/router.py @@ -90,7 +90,6 @@ class SigninActivateRequest(BaseModel): token: str signin_method: Literal["google", "email"] email: Optional[str] = None - app_install_id: Optional[str] = None @auth.router.post("/signin-activate") @@ -107,16 +106,23 @@ async def signin_activate(body: SigninActivateRequest): raise HTTPException(status_code=400, detail="Invalid token") proxy = p_proxy_url() + # settings.installation_id doubles as the affiliate app_install_id (one + # unified id, resolved by Electron before the backend spawns). Forwarding + # it lets the cloud record affiliate attribution the moment sign-in + # identifies the user, instead of waiting for a Stripe checkout. + 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, - "app_install_id": body.app_install_id, - }, + json=payload, ) except httpx.HTTPError as e: raise HTTPException( diff --git a/backend/main.py b/backend/main.py index dfc1817e1..cfac7651e 100644 --- a/backend/main.py +++ b/backend/main.py @@ -65,11 +65,20 @@ # 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 + # Prefer the unified install id Electron resolved before spawning us + # (shared with the affiliate install.json app_install_id), so the + # analytics install_id and the affiliate id are the same value and + # affiliate refs join directly to telemetry. Falls back to a fresh + # uuid for bare `uvicorn backend.main:app` runs. + 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 74e7c6d02..ec3516194 100644 --- a/backend/tests/test_auth_router.py +++ b/backend/tests/test_auth_router.py @@ -39,6 +39,14 @@ def reset_settings(): # --------------------------------------------------------------------------- /api/auth/signin-activate --------------------------------------------------------------------------- def test_signin_activate_persists_user_id(client, reset_settings): + # Give settings a known installation_id so we can assert the router + # forwards it as install_id (the unified id the cloud uses for both + # identity stitching and affiliate attribution). + 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: { @@ -58,12 +66,11 @@ def test_signin_activate_persists_user_id(client, reset_settings): "token": "fake-bearer-1234567890abcdef", "signin_method": "google", "email": "smoke@example.com", - "app_install_id": "app-install-affiliate-123", }, ) assert r.status_code == 200 assert any( - call.kwargs.get("json", {}).get("app_install_id") == "app-install-affiliate-123" + call.kwargs.get("json", {}).get("install_id") == "unified-install-id-123" for call in instance.post.call_args_list ) body = r.json() diff --git a/electron/affiliateTracking.js b/electron/affiliateTracking.js index 4c8a846f9..6f060dd03 100644 --- a/electron/affiliateTracking.js +++ b/electron/affiliateTracking.js @@ -9,7 +9,7 @@ // // State lives in `/install.json`. The shape: // { -// app_install_id: "uuid", // generated once per install +// app_install_id: "uuid", // unified install id, see resolveInstallId() // first_launch_at: 1700000000000, // unix ms; presence = "this isn't first launch" // ref: "haik" | null, // populated once lookup succeeds // ref_bound_at: 1700000000000 | null, @@ -71,6 +71,73 @@ function writeState(userDataDir, state) { } } +// -------------------------------------------------------------------------- +// Unified install identity. +// +// The desktop historically had TWO per-install ids that never met: this +// module's app_install_id (install.json, affiliate handshake) and the Python +// backend's settings.installation_id (analytics envelope). Affiliate data +// could therefore only join to analytics through a signed-in user — and +// sign-in is optional. resolveInstallId collapses them into one value: +// main.js calls it BEFORE spawning the backend and exports the result as +// OPENSWARM_INSTALLATION_ID, so install_tokens.app_install_id in the cloud +// and the analytics install_id carry the same id and affiliate refs join +// directly to telemetry with no sign-in required. +// +// Resolution order (first hit wins): +// 1. install.json app_install_id — continuity for installs that already +// ran the affiliate handshake +// 2. python settings.json installation_id — upgrades adopt the existing +// analytics identity instead of minting a second one +// 3. fresh crypto.randomUUID(), persisted to install.json immediately so +// every later reader (handshake, renderer, next boot) agrees on it + +const INSTALL_ID_RE = /^[A-Za-z0-9_-]{8,128}$/; + +// Mirrors backend/config/paths.py: packaged data root is per-OS app support; +// dev is /backend/data. +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(/\/$/, ""), @@ -246,8 +313,13 @@ async function maybeRunFirstLaunchHandshake({ return; } - // First launch. - const appInstallId = crypto.randomUUID(); + // First launch. Reuse the id resolveInstallId persisted before the backend + // spawned (the unified install id); only generate here if main.js never + // resolved one (e.g. direct module use in tests). + 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, @@ -296,6 +368,7 @@ async function maybeRunFirstLaunchHandshake({ module.exports = { maybeRunFirstLaunchHandshake, + resolveInstallId, // Exported for tests + IPC handlers. _readState: readState, _writeState: writeState, diff --git a/electron/affiliateTracking.test.js b/electron/affiliateTracking.test.js index 83b191910..8ae987051 100644 --- a/electron/affiliateTracking.test.js +++ b/electron/affiliateTracking.test.js @@ -312,6 +312,85 @@ test("download scan refuses ambiguous stamped installers", () => { assert.equal(hash, null); }); +test("resolveInstallId: reuses install.json app_install_id", () => { + const userDataDir = makeTempUserDataDir(); + affiliateTracking._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 { diff --git a/electron/main.js b/electron/main.js index ac4470a73..fa6c63bb3 100644 --- a/electron/main.js +++ b/electron/main.js @@ -944,6 +944,22 @@ async function startBackend() { PYTHONUTF8: '1', }; + // Unified install identity: resolve the affiliate app_install_id (adopting + // the backend's existing settings.installation_id on upgrades) BEFORE the + // backend spawns, and hand it down so the analytics install_id and the + // affiliate id are the same value. One id means affiliate refs join + // directly to telemetry without requiring a sign-in. See + // affiliateTracking.resolveInstallId(). + 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 diff --git a/frontend/src/app/components/overlays/SignInDialog.tsx b/frontend/src/app/components/overlays/SignInDialog.tsx index b46bb628c..d2cc88b16 100644 --- a/frontend/src/app/components/overlays/SignInDialog.tsx +++ b/frontend/src/app/components/overlays/SignInDialog.tsx @@ -19,7 +19,6 @@ import { activateSignin, fetchSettings } from '@/shared/state/settingsSlice'; import { useClaudeTokens } from '@/shared/styles/ThemeContext'; import { OPENSWARM_DEFAULT_PROXY_URL } from '@/shared/config'; import { report } from '@/shared/serviceClient'; -import { getAffiliateAppInstallId } from '@/shared/affiliateInstall'; type Stage = 'choose' | 'email_form' | 'code_form'; @@ -47,15 +46,13 @@ export default function SignInDialog({ onClose }: { onClose: () => void }): JSX. const cloudBase = proxyUrl.replace(/\/$/, ''); - const onGoogle = async () => { + const onGoogle = () => { report('signin', 'google_clicked'); const localPort = (window as any).__OPENSWARM_PORT__ || 8324; const params = new URLSearchParams({ install_id: installId, local_port: String(localPort), }); - const appInstallId = await getAffiliateAppInstallId(); - if (appInstallId) params.set('app_install_id', appInstallId); const startUrl = `${cloudBase}/api/auth/google/start?${params.toString()}`; const api = (window as any).openswarm; if (api?.openExternal) { @@ -118,7 +115,6 @@ export default function SignInDialog({ onClose }: { onClose: () => void }): JSX. try { report('signin', 'email_verify_submitted'); const localPort = (window as any).__OPENSWARM_PORT__ || 8324; - const appInstallId = await getAffiliateAppInstallId(); const res = await fetch(`${cloudBase}/api/auth/email/verify`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -126,7 +122,6 @@ export default function SignInDialog({ onClose }: { onClose: () => void }): JSX. email: email.trim(), code, install_id: installId, - ...(appInstallId ? { app_install_id: appInstallId } : {}), local_port: localPort, }), }); @@ -142,7 +137,6 @@ export default function SignInDialog({ onClose }: { onClose: () => void }): JSX. token: data.bearer, email: data.user_email, signin_method: 'email', - ...(appInstallId ? { app_install_id: appInstallId } : {}), }), ).unwrap(); } catch (err) { diff --git a/frontend/src/app/pages/Settings/sections/subscription/AccountCard.tsx b/frontend/src/app/pages/Settings/sections/subscription/AccountCard.tsx index 870ae1e7c..034d0b83c 100644 --- a/frontend/src/app/pages/Settings/sections/subscription/AccountCard.tsx +++ b/frontend/src/app/pages/Settings/sections/subscription/AccountCard.tsx @@ -8,7 +8,6 @@ import { signOut } from '@/shared/state/settingsSlice'; import { OPENSWARM_DEFAULT_PROXY_URL } from '@/shared/config'; import { useClaudeTokens } from '@/shared/styles/ThemeContext'; import SignInDialog from '@/app/components/overlays/SignInDialog'; -import { getAffiliateAppInstallId } from '@/shared/affiliateInstall'; /** Account card at top of General tab; three states: signed in, paid-but-unlinked, or not signed in. */ const AccountCard: React.FC = () => { @@ -44,15 +43,13 @@ const AccountCard: React.FC = () => { } }; - const onSignIn = async () => { + const onSignIn = () => { // Pass local_port so the bearer-handoff page POSTs to the right backend (Electron binds in 8324..8424). const localPort = (window as any).__OPENSWARM_PORT__ || 8324; const params = new URLSearchParams({ install_id: installId, local_port: String(localPort), }); - const appInstallId = await getAffiliateAppInstallId(); - if (appInstallId) params.set('app_install_id', appInstallId); const startUrl = proxyUrl.replace(/\/$/, '') + '/api/auth/google/start?' + params.toString(); const api = (window as any).openswarm; if (api?.openExternal) api.openExternal(startUrl); diff --git a/frontend/src/shared/affiliateInstall.ts b/frontend/src/shared/affiliateInstall.ts deleted file mode 100644 index 4b5936951..000000000 --- a/frontend/src/shared/affiliateInstall.ts +++ /dev/null @@ -1,14 +0,0 @@ -const APP_INSTALL_ID_RE = /^[A-Za-z0-9_-]{8,128}$/; - -export async function getAffiliateAppInstallId(): Promise { - try { - const api = (window as any).openswarm; - const state = await api?.getInstallState?.(); - const appInstallId = state && typeof state.app_install_id === 'string' - ? state.app_install_id - : ''; - return APP_INSTALL_ID_RE.test(appInstallId) ? appInstallId : null; - } catch { - return null; - } -} diff --git a/frontend/src/shared/hooks/useDeepLink.ts b/frontend/src/shared/hooks/useDeepLink.ts index e0771ba09..66814d765 100644 --- a/frontend/src/shared/hooks/useDeepLink.ts +++ b/frontend/src/shared/hooks/useDeepLink.ts @@ -5,7 +5,6 @@ import { fetchModels } from '@/shared/state/modelsSlice'; import { fetchTools } from '@/shared/state/toolsSlice'; import { API_BASE } from '@/shared/config'; import { report } from '@/shared/serviceClient'; -import { getAffiliateAppInstallId } from '@/shared/affiliateInstall'; /** Subscribe to openswarm:// auth/oauth deep-links from Electron main; no-op in browser. */ export function useDeepLink(): void { @@ -15,7 +14,7 @@ export function useDeepLink(): void { const api = (window as any).openswarm as OpenSwarmAPI | undefined; if (!api) return; - const unsubscribe = api.onAuthUrl?.(async (rawUrl: string) => { + const unsubscribe = api.onAuthUrl?.((rawUrl: string) => { try { // openswarm://auth?token=...; signin=true => free sign-in, else Stripe activation. const url = new URL(rawUrl); @@ -35,16 +34,11 @@ export function useDeepLink(): void { const expires = url.searchParams.get('expires'); if (isSignin) { - const signinMethod = signinMethodRaw === 'email' ? 'email' : 'google'; - const appInstallId = url.searchParams.get('app_install_id') || (await getAffiliateAppInstallId()); - report('signin', 'deep_link_received', { method: signinMethod }); + // 1.0.29 only ships Google sign-in; read for forward compat. + void signinMethodRaw; + report('signin', 'deep_link_received', { method: 'google' }); - dispatch(activateSignin({ - token, - signin_method: signinMethod, - email, - ...(appInstallId ? { app_install_id: appInstallId } : {}), - })) + dispatch(activateSignin({ token, signin_method: 'google', email })) .unwrap() .then((res) => { report('signin', 'activated', { method: res.signin_method, plan: res.plan }); diff --git a/frontend/src/shared/state/settingsSlice.ts b/frontend/src/shared/state/settingsSlice.ts index 0b7860662..cc8254d6d 100644 --- a/frontend/src/shared/state/settingsSlice.ts +++ b/frontend/src/shared/state/settingsSlice.ts @@ -87,7 +87,6 @@ export interface ActivateSigninPayload { token: string; signin_method: 'google' | 'email'; email?: string | null; - app_install_id?: string | null; } export interface BrowseResult { diff --git a/frontend/src/types/electron.d.ts b/frontend/src/types/electron.d.ts index 61f473b69..2f416e7b0 100644 --- a/frontend/src/types/electron.d.ts +++ b/frontend/src/types/electron.d.ts @@ -48,7 +48,6 @@ declare global { onUpdateError: (cb: (message: string) => void) => () => void; onWebviewNewWindow: (cb: (url: string, webContentsId: number) => void) => () => void; openExternal: (url: string) => Promise; - getInstallState?: () => Promise<{ app_install_id?: string; ref?: string | null; ref_bind_method?: string | null }>; hardReset?: () => Promise; onAuthUrl?: (cb: (url: string) => void) => () => void; onOauthClaim?: (cb: (url: string) => void) => () => void; From 1e1d0d3c605bcb9cffc6de5cb60899360e4861d7 Mon Sep 17 00:00:00 2001 From: abccodes Date: Fri, 10 Jul 2026 18:51:04 -0700 Subject: [PATCH 3/4] [aidan] fix/affiliate-scan: tolerate future mtimes + cover win32 and all artifact shapes The Downloads scan rejected any file whose mtime was newer than the scan's Date.now() snapshot (sub-ms fs timestamps, NTP stepping the clock back), and the ambiguity test was green only because both fixtures were skipped by that guard. Allow 60s of future skew, add a win32 end-to-end bind test, and cover every stamped artifact name (mac arm64/x64 dmg, windows setup exe, linux AppImages) plus unstamped negatives in the parser test. --- electron/affiliateTracking.js | 6 +++- electron/affiliateTracking.test.js | 50 ++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/electron/affiliateTracking.js b/electron/affiliateTracking.js index 6f060dd03..cb97c4dc9 100644 --- a/electron/affiliateTracking.js +++ b/electron/affiliateTracking.js @@ -41,6 +41,10 @@ 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; +// A file whose mtime is slightly in the future is NOT suspicious: APFS/NTFS +// keep sub-ms timestamps, and NTP can step the clock backwards between the +// download and first launch. Only skip files more than a minute ahead. +const FUTURE_MTIME_TOLERANCE_MS = 60 * 1000; function getStateFilePath(userDataDir) { return path.join(userDataDir, "install.json"); @@ -214,7 +218,7 @@ function recentInstallerHashesInDir(dir, nowMs) { try { const st = fs.statSync(fullPath); const ageMs = nowMs - st.mtimeMs; - if (ageMs < 0 || ageMs > FILENAME_ATTRIBUTION_WINDOW_MS) continue; + if (ageMs < -FUTURE_MTIME_TOLERANCE_MS || ageMs > FILENAME_ATTRIBUTION_WINDOW_MS) continue; out.push({ hash, path: fullPath, mtimeMs: st.mtimeMs }); } catch (_) {} } diff --git a/electron/affiliateTracking.test.js b/electron/affiliateTracking.test.js index 8ae987051..c81b89494 100644 --- a/electron/affiliateTracking.test.js +++ b/electron/affiliateTracking.test.js @@ -297,6 +297,56 @@ test("filename parser keeps hyphens inside base64url affiliate hash", () => { ); }); +test("filename parser covers every stamped artifact shape (mac/win/linux)", () => { + const h = "abcDEF1234567890_hash"; + for (const name of [ + `OpenSwarm-arm64-${h}.dmg`, // mac Apple Silicon + `OpenSwarm-x64-${h}.dmg`, // mac Intel + `OpenSwarm-Setup-x64-${h}.exe`, // windows squirrel setup + `OpenSwarm-x64-${h}.AppImage`, // linux x64 + `OpenSwarm-arm64-${h}.AppImage`, // linux arm64 + ]) { + assert.equal(affiliateTracking._hashFromInstallerBasename(name), h, name); + } + // Unstamped artifacts must NOT parse as carrying a hash. + for (const name of ["OpenSwarm-arm64.dmg", "OpenSwarm-Setup-x64.exe", "OpenSwarm-x64.AppImage"]) { + assert.equal(affiliateTracking._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"); From fb89324462c7e94e349910047e4500c9e9ea977a Mon Sep 17 00:00:00 2001 From: abccodes Date: Fri, 10 Jul 2026 19:31:35 -0700 Subject: [PATCH 4/4] [aidan] chore/affiliate-attribution: align private names and trim comments --- backend/apps/auth/router.py | 4 -- backend/main.py | 6 +- backend/tests/test_auth_router.py | 3 - electron/affiliateTracking.js | 89 ++++-------------------------- electron/affiliateTracking.test.js | 29 +++++----- electron/main.js | 12 +--- 6 files changed, 30 insertions(+), 113 deletions(-) diff --git a/backend/apps/auth/router.py b/backend/apps/auth/router.py index 706995ffc..dd813efdf 100644 --- a/backend/apps/auth/router.py +++ b/backend/apps/auth/router.py @@ -106,10 +106,6 @@ async def signin_activate(body: SigninActivateRequest): raise HTTPException(status_code=400, detail="Invalid token") proxy = p_proxy_url() - # settings.installation_id doubles as the affiliate app_install_id (one - # unified id, resolved by Electron before the backend spawns). Forwarding - # it lets the cloud record affiliate attribution the moment sign-in - # identifies the user, instead of waiting for a Stripe checkout. payload = { "token": body.token, "signin_method": body.signin_method, diff --git a/backend/main.py b/backend/main.py index cfac7651e..5827f78e2 100644 --- a/backend/main.py +++ b/backend/main.py @@ -70,11 +70,7 @@ 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): - # Prefer the unified install id Electron resolved before spawning us - # (shared with the affiliate install.json app_install_id), so the - # analytics install_id and the affiliate id are the same value and - # affiliate refs join directly to telemetry. Falls back to a fresh - # uuid for bare `uvicorn backend.main:app` runs. + # 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 diff --git a/backend/tests/test_auth_router.py b/backend/tests/test_auth_router.py index ec3516194..8690f21dc 100644 --- a/backend/tests/test_auth_router.py +++ b/backend/tests/test_auth_router.py @@ -39,9 +39,6 @@ def reset_settings(): # --------------------------------------------------------------------------- /api/auth/signin-activate --------------------------------------------------------------------------- def test_signin_activate_persists_user_id(client, reset_settings): - # Give settings a known installation_id so we can assert the router - # forwards it as install_id (the unified id the cloud uses for both - # identity stitching and affiliate attribution). from backend.apps.settings.settings import load_settings, save_settings s = load_settings() s.installation_id = "unified-install-id-123" diff --git a/electron/affiliateTracking.js b/electron/affiliateTracking.js index cb97c4dc9..650e6736e 100644 --- a/electron/affiliateTracking.js +++ b/electron/affiliateTracking.js @@ -1,25 +1,3 @@ -// 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", // unified install id, see resolveInstallId() -// first_launch_at: 1700000000000, // unix ms; presence = "this isn't first launch" -// ref: "haik" | null, // populated once lookup succeeds -// ref_bound_at: 1700000000000 | null, -// ref_bind_method: "affiliate_filename_hash" | 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"); @@ -28,22 +6,13 @@ 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; -// A file whose mtime is slightly in the future is NOT suspicious: APFS/NTFS -// keep sub-ms timestamps, and NTP can step the clock backwards between the -// download and first launch. Only skip files more than a minute ahead. +// Allow clock skew so a fresh installer is not discarded after an NTP adjustment. const FUTURE_MTIME_TOLERANCE_MS = 60 * 1000; function getStateFilePath(userDataDir) { @@ -56,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 {}; } @@ -64,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); @@ -75,31 +42,8 @@ function writeState(userDataDir, state) { } } -// -------------------------------------------------------------------------- -// Unified install identity. -// -// The desktop historically had TWO per-install ids that never met: this -// module's app_install_id (install.json, affiliate handshake) and the Python -// backend's settings.installation_id (analytics envelope). Affiliate data -// could therefore only join to analytics through a signed-in user — and -// sign-in is optional. resolveInstallId collapses them into one value: -// main.js calls it BEFORE spawning the backend and exports the result as -// OPENSWARM_INSTALLATION_ID, so install_tokens.app_install_id in the cloud -// and the analytics install_id carry the same id and affiliate refs join -// directly to telemetry with no sign-in required. -// -// Resolution order (first hit wins): -// 1. install.json app_install_id — continuity for installs that already -// ran the affiliate handshake -// 2. python settings.json installation_id — upgrades adopt the existing -// analytics identity instead of minting a second one -// 3. fresh crypto.randomUUID(), persisted to install.json immediately so -// every later reader (handshake, renderer, next boot) agrees on it - const INSTALL_ID_RE = /^[A-Za-z0-9_-]{8,128}$/; -// Mirrors backend/config/paths.py: packaged data root is per-OS app support; -// dev is /backend/data. function pythonSettingsFile({ isPackaged, projectRoot, platform, env, homeDir }) { if (!isPackaged) { return path.join(projectRoot, "backend", "data", "settings", "settings.json"); @@ -135,7 +79,7 @@ function resolveInstallId({ writeState(userDataDir, { ...state, app_install_id: iid }); return iid; } - } catch (_) {} + } catch {} const freshId = crypto.randomUUID(); writeState(userDataDir, { ...state, app_install_id: freshId }); @@ -151,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 { @@ -161,7 +103,7 @@ 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); @@ -183,7 +125,7 @@ async function bindAffiliateHashOnce(cloudUrl, appInstallId, affiliateHash) { 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); @@ -206,7 +148,7 @@ function recentInstallerHashesInDir(dir, nowMs) { let entries = []; try { entries = fs.readdirSync(dir, { withFileTypes: true }); - } catch (_) { + } catch { return out; } @@ -220,7 +162,7 @@ function recentInstallerHashesInDir(dir, nowMs) { 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 (_) {} + } catch {} } return out; } @@ -317,9 +259,6 @@ async function maybeRunFirstLaunchHandshake({ return; } - // First launch. Reuse the id resolveInstallId persisted before the backend - // spawned (the unified install id); only generate here if main.js never - // resolved one (e.g. direct module use in tests). const appInstallId = typeof state.app_install_id === "string" && INSTALL_ID_RE.test(state.app_install_id) ? state.app_install_id @@ -373,12 +312,8 @@ async function maybeRunFirstLaunchHandshake({ module.exports = { maybeRunFirstLaunchHandshake, resolveInstallId, - // Exported for tests + IPC handlers. - _readState: readState, - _writeState: writeState, - _getStateFilePath: getStateFilePath, - _pollLookupOnce: pollLookupOnce, - _bindAffiliateHashOnce: bindAffiliateHashOnce, - _hashFromInstallerBasename: hashFromInstallerBasename, - _findAffiliateHashFromInstaller: findAffiliateHashFromInstaller, + 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 c81b89494..3a6fb6b63 100644 --- a/electron/affiliateTracking.test.js +++ b/electron/affiliateTracking.test.js @@ -285,14 +285,14 @@ test("first launch: stamped AppImage hash binds before opening welcome URL", asy test("filename parser accepts browser duplicate suffix", () => { assert.equal( - affiliateTracking._hashFromInstallerBasename("OpenSwarm-arm64-abcDEF1234567890_hash (1).dmg"), + affiliateTracking.p_hashFromInstallerBasename("OpenSwarm-arm64-abcDEF1234567890_hash (1).dmg"), "abcDEF1234567890_hash", ); }); test("filename parser keeps hyphens inside base64url affiliate hash", () => { assert.equal( - affiliateTracking._hashFromInstallerBasename("OpenSwarm-arm64-abcDEF1234567890-hash.dmg"), + affiliateTracking.p_hashFromInstallerBasename("OpenSwarm-arm64-abcDEF1234567890-hash.dmg"), "abcDEF1234567890-hash", ); }); @@ -300,17 +300,16 @@ test("filename parser keeps hyphens inside base64url affiliate hash", () => { test("filename parser covers every stamped artifact shape (mac/win/linux)", () => { const h = "abcDEF1234567890_hash"; for (const name of [ - `OpenSwarm-arm64-${h}.dmg`, // mac Apple Silicon - `OpenSwarm-x64-${h}.dmg`, // mac Intel - `OpenSwarm-Setup-x64-${h}.exe`, // windows squirrel setup - `OpenSwarm-x64-${h}.AppImage`, // linux x64 - `OpenSwarm-arm64-${h}.AppImage`, // linux arm64 + `OpenSwarm-arm64-${h}.dmg`, + `OpenSwarm-x64-${h}.dmg`, + `OpenSwarm-Setup-x64-${h}.exe`, + `OpenSwarm-x64-${h}.AppImage`, + `OpenSwarm-arm64-${h}.AppImage`, ]) { - assert.equal(affiliateTracking._hashFromInstallerBasename(name), h, name); + assert.equal(affiliateTracking.p_hashFromInstallerBasename(name), h, name); } - // Unstamped artifacts must NOT parse as carrying a hash. for (const name of ["OpenSwarm-arm64.dmg", "OpenSwarm-Setup-x64.exe", "OpenSwarm-x64.AppImage"]) { - assert.equal(affiliateTracking._hashFromInstallerBasename(name), null, name); + assert.equal(affiliateTracking.p_hashFromInstallerBasename(name), null, name); } }); @@ -354,7 +353,7 @@ test("download scan refuses ambiguous stamped installers", () => { fs.writeFileSync(path.join(downloads, "OpenSwarm-arm64-abcDEF1234567890_a.dmg"), ""); fs.writeFileSync(path.join(downloads, "OpenSwarm-arm64-abcDEF1234567890_b.dmg"), ""); - const hash = affiliateTracking._findAffiliateHashFromInstaller({ + const hash = affiliateTracking.p_findAffiliateHashFromInstaller({ platform: "darwin", homeDir: userDataDir, nowMs: Date.now(), @@ -364,7 +363,7 @@ test("download scan refuses ambiguous stamped installers", () => { test("resolveInstallId: reuses install.json app_install_id", () => { const userDataDir = makeTempUserDataDir(); - affiliateTracking._writeState(userDataDir, { app_install_id: "existing-id-12345" }); + affiliateTracking.p_writeState(userDataDir, { app_install_id: "existing-id-12345" }); const id = affiliateTracking.resolveInstallId({ userDataDir, isPackaged: true, projectRoot: userDataDir, homeDir: userDataDir, }); @@ -596,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")); @@ -605,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 fa6c63bb3..03a5cfac7 100644 --- a/electron/main.js +++ b/electron/main.js @@ -944,12 +944,6 @@ async function startBackend() { PYTHONUTF8: '1', }; - // Unified install identity: resolve the affiliate app_install_id (adopting - // the backend's existing settings.installation_id on upgrades) BEFORE the - // backend spawns, and hand it down so the analytics install_id and the - // affiliate id are the same value. One id means affiliate refs join - // directly to telemetry without requiring a sign-in. See - // affiliateTracking.resolveInstallId(). try { env.OPENSWARM_INSTALLATION_ID = affiliateTracking.resolveInstallId({ userDataDir: app.getPath('userData'), @@ -990,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 @@ -2603,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 {}; }