diff --git a/backend/apps/auth/router.py b/backend/apps/auth/router.py index dd93e0c1..ee9b6b5b 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 8de2c5e0..74e7c6d0 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 f591258e..4c8a846f 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 8bcf6220..83b19191 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 d2cc88b1..b46bb628 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 034d0b83..870ae1e7 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 00000000..4b593695 --- /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 66814d76..e0771ba0 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 cc8254d6..0b786066 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 2f416e7b..61f473b6 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;