diff --git a/system/netlify/functions/download.mjs b/system/netlify/functions/download.mjs new file mode 100644 index 0000000000..526d9204f6 --- /dev/null +++ b/system/netlify/functions/download.mjs @@ -0,0 +1,81 @@ +// Download, 2026.09.28 +// Counts a direct app download, then redirects to the file on the CDN. +// GET /api/download?app=slab&file=Slab-1.1.dmg +// GET /api/download?whoami=1 answers the caller's own hash, so reports can +// leave the fleet's own downloads out. +// +// The DMGs live behind Cloudflare with caching bypassed, so no server of ours +// ever saw a download. This is the one place that does. A row keeps the +// app, version, day, country and a coarse platform. The address is kept only +// as a keyed hash, so repeat downloads from one place can be told apart +// without storing where anyone is. +import { connect } from "../../backend/database.mjs"; +import { createHmac } from "node:crypto"; +import { respond } from "../../backend/http.mjs"; +import { automatedVisit } from "../../public/aesthetic.computer/lib/visit-model.mjs"; + +export const DOWNLOAD_COLLECTION = "downloads"; + +// Only these files redirect, so this can't be used as an open redirect. +const APPS = { + slab: { base: "https://assets.aesthetic.computer/slab/", file: /^Slab-(\d+(?:\.\d+){1,2})\.dmg$/ }, + aesel: { base: "https://releases.aesthetic.computer/aesel/mac/", file: /^aesel-(\d+(?:\.\d+){1,2})-arm64\.dmg$/ }, +}; + +// Keyed on a secret lith already holds, so the hash can't be reversed by +// hashing every address, and it stays stable across restarts. +const key = createHmac("sha256", "ac-download-v1") + .update(process.env.MONGODB_CONNECTION_STRING || "").digest(); +const addressHash = (event) => { + const address = event.headers?.["cf-connecting-ip"] || + event.headers?.["x-forwarded-for"]?.split(",")[0].trim() || "unknown"; + return createHmac("sha256", key).update(address).digest("hex").slice(0, 16); +}; + +function platform(agent = "") { + if (/iPhone|iPad/i.test(agent)) return "ios"; + if (/Macintosh|Mac OS X/i.test(agent)) return "mac"; + if (/Windows/i.test(agent)) return "windows"; + if (/Android/i.test(agent)) return "android"; + if (/Linux/i.test(agent)) return "linux"; + return "other"; +} + +let indexed; +async function record(row) { + const { db } = await connect(); + const collection = db.collection(DOWNLOAD_COLLECTION); + indexed ||= collection.createIndex({ app: 1, at: -1 }).catch((error) => { indexed = null; throw error; }); + await indexed; + await collection.insertOne(row); +} + +export async function handler(event) { + const headers = { "Cache-Control": "no-store" }; + const query = event.queryStringParameters || {}; + + if (query.whoami !== undefined) return respond(200, { hash: addressHash(event) }, headers); + + const app = APPS[query.app]; + const match = app && typeof query.file === "string" && query.file.match(app.file); + if (!match) return respond(404, { error: "Unknown download" }, headers); + + const location = app.base + query.file; + if (event.httpMethod === "GET") { + const agent = event.headers?.["user-agent"] || ""; + const at = new Date(); + const row = { + app: query.app, version: match[1], file: query.file, at, day: at.toISOString().slice(0, 10), + hash: addressHash(event), country: event.headers?.["cf-ipcountry"] || null, + platform: platform(agent), automated: automatedVisit({ userAgent: agent }), + from: typeof query.from === "string" ? query.from.slice(0, 32) : null, + }; + // The download never waits on the database for more than a moment. + await Promise.race([ + record(row).catch((error) => console.warn("⬇️ download not recorded:", error.message)), + new Promise((resolve) => setTimeout(resolve, 1500)), + ]); + console.log(`⬇️ download ${row.app} ${row.version} ${row.platform} ${row.country || "?"}`); + } + return respond(302, "", { ...headers, Location: location, "Content-Type": "text/plain" }); +} diff --git a/system/public/aesel/index.html b/system/public/aesel/index.html index c25860e419..b75dd372d1 100644 --- a/system/public/aesel/index.html +++ b/system/public/aesel/index.html @@ -20,7 +20,7 @@

aesel

-Download the Mac beta +Download the Mac beta

0.8.3 beta · Apple silicon · macOS 14+

diff --git a/system/public/slab/index.html b/system/public/slab/index.html index 19fa5bb8e3..e9024c0901 100644 --- a/system/public/slab/index.html +++ b/system/public/slab/index.html @@ -32,6 +32,7 @@ :root{color-scheme:light;--ink:#393047;--soft:#65546e;--purple:#79568b;--line:#ddcedc;--mono:"Berkeley Mono Variable",monospace} *{box-sizing:border-box}body{margin:0;color:var(--ink);background:radial-gradient(ellipse 650px 520px at 95% 0%,#e4d4f780,transparent),radial-gradient(ellipse 600px 600px at 0% 8%,#d4eedb80,transparent),radial-gradient(circle,#ae8cb32b 1px,transparent 1px) 0 0/24px 24px,#faf3e8;font:16px/1.6 -apple-system,BlinkMacSystemFont,sans-serif}a{color:inherit;text-underline-offset:4px}a:hover{color:var(--purple)}a:focus-visible,summary:focus-visible{outline:3px solid var(--purple);outline-offset:5px}.masthead,main,footer{max-width:1008px;margin:auto;padding-left:24px;padding-right:24px}.masthead{padding-top:24px;display:flex;justify-content:space-between;font-size:14px}.masthead a{text-decoration:none}.hero{text-align:center;padding:64px 0 42px}.icon{display:block;width:144px;height:144px;border-radius:32px;margin:0 auto 20px;transform:rotate(-6deg);filter:drop-shadow(0 8px 0 #d9c6de)}h1{font:normal 96px/1 Pixel,monospace;color:var(--purple);text-shadow:3px 4px #e2cdea;margin:0 0 26px}.download{display:inline-flex;align-items:center;gap:20px;padding:13px 24px;background:var(--purple);color:white;border:2px solid #684478;border-radius:999px;box-shadow:0 5px #d2b6df;text-decoration:none;font-weight:600;transition:transform 120ms ease}.download:hover{color:white;transform:translateY(-2px)}.requirements{font-size:13px;color:var(--soft);margin:18px 0 0}.shot{margin:0}.shot img{display:block;width:100%;height:auto;border-radius:18px;border:1px solid var(--line);box-shadow:0 6px #ddcfdf}.setup{max-width:680px;margin:42px auto 0}details{border-top:1px solid var(--line);padding:16px 0}summary{cursor:pointer;font-weight:600}details p{color:var(--soft);margin:14px 0}code{font-family:var(--mono);font-size:.88em}.hash{overflow-wrap:anywhere;font-size:11px}footer{display:flex;flex-wrap:wrap;gap:20px;font-size:13px;color:var(--soft);padding-top:32px;padding-bottom:36px}footer a:first-child{margin-right:auto}@media(max-width:600px){.hero{padding:48px 0 32px}.icon{width:112px;height:112px;border-radius:26px}h1{font-size:80px}.masthead,main,footer{padding-left:20px;padding-right:20px}.requirements{font-size:12px}.setup{margin-top:30px}.shot img{border-radius:10px}}@media(prefers-reduced-motion:reduce){.download{transition:none}} +
Aesthetic ComputerAesel ↗
@@ -39,7 +40,7 @@

slab

- Download for Mac + Download for Mac

1.1 · 7.0 MB · macOS 12+ · Intel & Apple silicon

Slab tiling four agent terminals and an Aesel notebook, with pastel session colors and named prompt rocks.
@@ -95,7 +96,9 @@ .then((data) => { const latest = (data.versions || []).find((v) => v.version === data.latest); if (!latest || !latest.dmg) return; - button.href = latest.dmg; + // Through lith's counter, which redirects on to the CDN. + const file = latest.dmg.split('/').pop(); + button.href = `/api/download?app=slab&file=${encodeURIComponent(file)}`; const mb = (latest.size / (1024 * 1024)).toFixed(1); if (meta) meta.textContent = `${latest.version} · ${mb} MB`; if (hash && latest.sha256) hash.textContent = `sha256 ${latest.sha256}`; diff --git a/toolchain/analytics/downloads-report.mjs b/toolchain/analytics/downloads-report.mjs new file mode 100644 index 0000000000..fe6bb603bd --- /dev/null +++ b/toolchain/analytics/downloads-report.mjs @@ -0,0 +1,29 @@ +#!/usr/bin/env node +// Run on Lith: cd /opt/ac/system && node --env-file=.env ../toolchain/analytics/downloads-report.mjs --days 30 --exclude , +// Direct DMG downloads counted by /api/download (system/netlify/functions/download.mjs). +import { connect, closePool } from "../../system/backend/database.mjs"; +const args = process.argv.slice(2); +const value = name => args[args.indexOf(name) + 1]; +const days = args.includes("--days") ? Number(value("--days")) : 30; +if (!Number.isFinite(days) || days <= 0 || days > 3650) throw new Error("Use --days 1..3650"); +const exclude = args.includes("--exclude") ? value("--exclude").split(",").filter(Boolean) : []; +const start = new Date(Date.now() - days * 86400000); +const { db } = await connect(); +try { + const rows = await db.collection("downloads").aggregate([ + { $match: { at: { $gte: start } } }, + { $group: { + _id: { app: "$app", version: "$version", automated: "$automated", self: { $in: ["$hash", exclude] } }, + downloads: { $sum: 1 }, places: { $addToSet: "$hash" }, + countries: { $push: "$country" }, platforms: { $push: "$platform" }, + first: { $min: "$at" }, last: { $max: "$at" }, + } }, + ], { maxTimeMS: 20000 }).toArray(); + const first = await db.collection("downloads").find({}, { projection: { at: 1 } }).sort({ at: 1 }).limit(1).next(); + const tally = list => list.reduce((out, key) => ({ ...out, [key ?? "?"]: (out[key ?? "?"] || 0) + 1 }), {}); + console.log(JSON.stringify({ format: "ac.downloads.v1", start, end: new Date(), + earliestDownload: first?.at || null, excluded: exclude.length, + rows: rows.map(({ places, countries, platforms, ...row }) => ({ ...row, + places: places.length, countries: tally(countries), platforms: tally(platforms) })), + }, null, 2)); +} finally { await closePool(); } diff --git a/toolchain/mcp/analytics-mcp.mjs b/toolchain/mcp/analytics-mcp.mjs index 50d55df0ca..4fcca6eae4 100644 --- a/toolchain/mcp/analytics-mcp.mjs +++ b/toolchain/mcp/analytics-mcp.mjs @@ -1,5 +1,5 @@ #!/usr/bin/env node -// analytics-mcp.mjs — network visits and App Store downloads as tools. +// analytics-mcp.mjs — network visits, direct DMG downloads and App Store downloads as tools. // // visits_report runs toolchain/analytics/visits-report.mjs on lith over ssh // (see toolchain/analytics/VISITS.md for what a "visit" can and cannot mean) @@ -33,6 +33,7 @@ const APPS = { fingerquilt: "1153451161", softwallpaper: "1390237091", aestheticcomputer: "6450940883", + aesel: "6812823093", }; const ACTIONS = ["download_clicked", "media_started", "link_followed", "canvas_interacted", "round_started", "round_completed", "match_completed", "mime_interact", "mime_scroll_feed", "mime_original_open"]; @@ -80,6 +81,36 @@ async function visitsReport({ hours = 48, scope = "all", end } = {}) { }; } +// ⬇️ Direct downloads + +// The Slab and Aesel DMGs go through lith's /api/download counter. Rows keep +// only a keyed hash of the address, so leaving the fleet out means asking +// lith for this machine's own hash and passing it (and any others) along. +async function directDownloads({ days = 30, exclude = [], excludeSelf = true } = {}) { + days = Number(days); + if (!Number.isFinite(days) || days <= 0 || days > 3650) throw new Error("days must be 1..3650"); + const hashes = [...exclude]; + if (excludeSelf) hashes.push((await (await fetch("https://aesthetic.computer/api/download?whoami=1")).json()).hash); + if (hashes.some((h) => !/^[a-f0-9]{16}$/.test(h))) throw new Error("exclude takes 16-hex hashes from /api/download?whoami=1"); + const remote = `cd /opt/ac/system && node --env-file=.env ../toolchain/analytics/downloads-report.mjs --days ${days}${hashes.length ? ` --exclude ${hashes.join(",")}` : ""}`; + const { stdout } = await pexec("ssh", ["-i", SSH_KEY, "-o", "ConnectTimeout=10", LITH, remote], + { timeout: 90_000, maxBuffer: 32 * 1024 * 1024 }); + const report = JSON.parse(stdout.slice(stdout.indexOf("{"))); + const apps = {}; + for (const row of report.rows) { + const app = apps[row._id.app] ??= { downloads: 0, places: 0, self: 0, automated: 0, byVersion: {}, countries: {}, platforms: {} }; + if (row._id.automated) { app.automated += row.downloads; continue; } + if (row._id.self) { app.self += row.downloads; continue; } + app.downloads += row.downloads; + app.places += row.places; + app.byVersion[row._id.version] = (app.byVersion[row._id.version] || 0) + row.downloads; + for (const [k, v] of Object.entries(row.countries)) app.countries[k] = (app.countries[k] || 0) + v; + for (const [k, v] of Object.entries(row.platforms)) app.platforms[k] = (app.platforms[k] || 0) + v; + } + return { window: { start: report.start, end: report.end, earliestDownload: report.earliestDownload }, + note: "downloads/places leave out automated and excluded (self) traffic; places = distinct address hashes per version, summed", apps }; +} + // 📱 App Store let ascKey; @@ -163,6 +194,18 @@ const TOOLS = [ }, }, }, + { + name: "direct_downloads", + description: "Direct DMG downloads of Slab and Aesel counted by lith's /api/download redirect (counting began 2026-09-28): per app downloads, distinct places, versions, countries, platforms. Leaves out bots and, by default, this machine's own network.", + inputSchema: { + type: "object", + properties: { + days: { type: "number", description: "How many days back (default 30)" }, + exclude: { type: "array", items: { type: "string" }, description: "More address hashes to leave out (from /api/download?whoami=1 on other fleet machines)" }, + excludeSelf: { type: "boolean", description: "Leave out this machine's own network (default true)" }, + }, + }, + }, { name: "app_downloads", description: "App Store downloads per app from Apple's App Downloads Standard analytics report: first-time downloads plus a daily breakdown by download type and device.", @@ -178,6 +221,7 @@ const TOOLS = [ async function callTool(name, args = {}) { const result = name === "visits_report" ? await visitsReport(args) + : name === "direct_downloads" ? await directDownloads(args) : name === "app_downloads" ? await appDownloads(args) : (() => { throw new Error(`unknown tool ${name}`); })(); return [{ type: "text", text: JSON.stringify(result, null, 2) }]; @@ -215,4 +259,4 @@ async function handleMessage(message) { const port = httpPort(process.argv, 0); if (port) serveHttp({ handleMessage, port, banner: "📈 analytics-mcp shared daemon" }); -else serveStdio({ handleMessage, banner: "📈 analytics-mcp started (visits_report, app_downloads)" }); +else serveStdio({ handleMessage, banner: "📈 analytics-mcp started (visits_report, direct_downloads, app_downloads)" });