mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-11 12:17:45 +02:00
[aidan] feat/affiliate-filenames: bind installs from stamped filenames
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
const APP_INSTALL_ID_RE = /^[A-Za-z0-9_-]{8,128}$/;
|
||||
|
||||
export async function getAffiliateAppInstallId(): Promise<string | null> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
|
||||
@@ -87,6 +87,7 @@ export interface ActivateSigninPayload {
|
||||
token: string;
|
||||
signin_method: 'google' | 'email';
|
||||
email?: string | null;
|
||||
app_install_id?: string | null;
|
||||
}
|
||||
|
||||
export interface BrowseResult {
|
||||
|
||||
Vendored
+1
@@ -48,6 +48,7 @@ declare global {
|
||||
onUpdateError: (cb: (message: string) => void) => () => void;
|
||||
onWebviewNewWindow: (cb: (url: string, webContentsId: number) => void) => () => void;
|
||||
openExternal: (url: string) => Promise<void>;
|
||||
getInstallState?: () => Promise<{ app_install_id?: string; ref?: string | null; ref_bind_method?: string | null }>;
|
||||
hardReset?: () => Promise<void>;
|
||||
onAuthUrl?: (cb: (url: string) => void) => () => void;
|
||||
onOauthClaim?: (cb: (url: string) => void) => () => void;
|
||||
|
||||
Reference in New Issue
Block a user