diff --git a/system/netlify/functions/oskiewar-pops.mjs b/system/netlify/functions/oskiewar-pops.mjs index a3a1e9cc21..e8de8b8661 100644 --- a/system/netlify/functions/oskiewar-pops.mjs +++ b/system/netlify/functions/oskiewar-pops.mjs @@ -12,7 +12,29 @@ import { connect } from "../../backend/database.mjs"; import { respond } from "../../backend/http.mjs"; const COLLECTION = "oskiewar-pops"; +// Individual pops are swept after two days, which is all the window logic below +// ever needs. But a total that resets every two days is not a total, so each pop +// also increments a per-day counter that is never swept. One extra small upsert +// on the hottest path buys a history that would otherwise be unrecoverable — +// and it has to be written now, because a day that has already been swept can +// never be counted again. +const DAYS_COLLECTION = "oskiewar-pop-days"; const HOUR = 60 * 60 * 1000; +// The wall, the site and the day counters must agree on where a day ends, so the +// boundary is defined once, here, and imported by anything that buckets by day. +export const WALL_TIMEZONE = process.env.OSKIEWAR_WALL_TIMEZONE || + "America/Los_Angeles"; + +// A calendar day in the room, not in UTC — asked of Intl so it matches the keys +// Mongo produces from the same timezone with `$dateToString`. +export function localDay(instant, timeZone = WALL_TIMEZONE) { + const parts = new Intl.DateTimeFormat("en-CA", { + timeZone, year: "numeric", month: "2-digit", day: "2-digit", + }).formatToParts(instant); + const value = {}; + for (const part of parts) value[part.type] = part.value; + return `${value.year}-${value.month}-${value.day}`; +} // The longest window the statistic will ever quote, and so the longest a row // is worth keeping. An hour of slack past it covers the sweep's own latency. export const MAX_HOURS = 48; @@ -66,7 +88,16 @@ export async function handler(event) { await database.disconnect(); return respond(429, { error: "Pop rate limit reached" }); } - await collection.insertOne({ at: new Date(now), sourceDigest: digest }); + const at = new Date(now); + await collection.insertOne({ at, sourceDigest: digest }); + // Counted after the pop is stored, so a failed rollup cannot invent a pop + // that never happened — the reverse, a stored pop missing from the day + // total, is the error worth preferring. + await database.db.collection(DAYS_COLLECTION).updateOne( + { _id: localDay(at) }, + { $inc: { pops: 1 }, $set: { updatedAt: at } }, + { upsert: true }, + ).catch(() => {}); await database.disconnect(); return respond(201, { ok: true }); } diff --git a/system/netlify/functions/oskiewar-stats.mjs b/system/netlify/functions/oskiewar-stats.mjs new file mode 100644 index 0000000000..a66456d48d --- /dev/null +++ b/system/netlify/functions/oskiewar-stats.mjs @@ -0,0 +1,332 @@ +// oskiewar-stats, 26.08.10 +// One JSON for the oskiewar wall: how much was played, by how many people, and +// how the reels did. +// +// Nothing here is new measurement. Rounds already land in `oskiewar-replays` +// with everything this needs on them, pops already land in `oskiewar-pops`, and +// the reel ledger already records what was published. This endpoint only asks +// those three the questions a dashboard asks, in one round trip, so the wall +// polls once a minute instead of three times. +// +// A day is a *local* day, because a wall in a room is read by someone standing +// in that room. The boundary is drawn by Mongo with `$dateToString` and a +// timezone rather than by subtracting hours in JS, so the day that spans a +// daylight-saving change is still one day long. + +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { connect } from "../../backend/database.mjs"; +import { respond } from "../../backend/http.mjs"; +import { chooseWindow, localDay, MAX_HOURS, WALL_TIMEZONE } + from "./oskiewar-pops.mjs"; + +// The pop counter owns the day boundary because it is the thing that has to +// bucket by day permanently; everything here agrees with it by importing it. +export { localDay }; + +const REPLAYS = "oskiewar-replays"; +const POPS = "oskiewar-pops"; +const POP_DAYS = "oskiewar-pop-days"; +const HOUR = 60 * 60 * 1000; +const DAY = 24 * HOUR; +// The wall shows a month of history and a day of detail. +const DAILY_DAYS = 31; +const HOURLY_HOURS = 24; +// A match is won at five rounds — the same threshold `oskiewarMatchCompleted` +// applies to a single demo, expressed here as an aggregation so the count can +// be taken without pulling every document. +const MATCH_WINS = 5; +// The ledger lives beside the reel tooling, not in the database. Resolved from +// this file rather than from cwd so it is found whether lith runs the function +// from `system/` or a bundler moves it. +const LEDGER = join(dirname(fileURLToPath(import.meta.url)), + "../../../xbox/live/marketing/ledger.json"); + +// `fighters` holds two names and the dummy is spelled DUMMY, but a name is +// allowed to arrive in any case, so both of these fold case before comparing. +const FIGHTERS_UPPER = { $map: { input: "$fighters", in: { $toUpper: "$$this" } } }; +const IS_DUMMY = { $in: ["DUMMY", FIGHTERS_UPPER] }; +const IS_COMPLETED = { $gte: [{ $max: "$finalRoundWins" }, MATCH_WINS] }; +const count = (condition) => ({ $sum: { $cond: [condition, 1, 0] } }); + +const dayKey = (field, timezone) => + ({ $dateToString: { format: "%Y-%m-%d", date: field, timezone } }); + +// A round is one document; a match is a series of them. Both are worth showing: +// rounds is the honest measure of play, matches of completion. +export function shapeDaily(rows, days, today) { + const byDay = new Map(rows.map((row) => [row._id, row])); + const out = []; + for (const day of days) { + const row = byDay.get(day); + out.push({ + day, + rounds: row?.rounds || 0, + matches: row?.matches || 0, + players: row?.players || 0, + dummy: row?.dummy || 0, + today: day === today, + }); + } + return out; +} + +// The last `HOURLY_HOURS` whole hours, oldest first, so the sparkline reads +// left to right the way time does. Mongo groups by hours-ago; this turns that +// into a dense series with the gaps filled by the zeroes that really happened. +export function shapeHourly(rows, hours = HOURLY_HOURS) { + const byAge = new Map(rows.map((row) => [Number(row._id), row.rounds])); + const out = []; + for (let age = hours - 1; age >= 0; age--) out.push(byAge.get(age) || 0); + return out; +} + +// Sum the reels the ledger says actually went out. `insights` is null until +// something pulls it, so a reel that has never been measured contributes its +// existence and nothing else — the wall says "2 live, 0 measured" rather than +// quietly reporting zero views as though that were a result. +export function shapeReels(ledger) { + const posts = Array.isArray(ledger?.posts) ? ledger.posts : []; + const live = posts.filter((post) => post.mode === "live"); + const measured = live.filter((post) => post.insights); + const totals = { views: 0, reach: 0, interactions: 0, likes: 0, comments: 0 }; + for (const post of measured) { + totals.views += post.insights.views || 0; + totals.reach += post.insights.reach || 0; + totals.interactions += post.insights.total_interactions || 0; + totals.likes += post.insights.likes || 0; + totals.comments += post.insights.comments || 0; + } + const watchTimes = measured + .map((post) => post.insights.ig_reels_avg_watch_time) + .filter((value) => Number.isFinite(value)); + return { + live: live.length, + measured: measured.length, + totals, + // Milliseconds, as the Graph API reports it. + avgWatchTime: watchTimes.length + ? watchTimes.reduce((sum, value) => sum + value, 0) / watchTimes.length + : null, + posts: live + .slice() + .sort((a, b) => String(b.publishedAt).localeCompare(String(a.publishedAt))) + .map((post) => ({ + id: post.id, + day: post.day, + segment: post.segment, + publishedAt: post.publishedAt, + cover: post.urls?.cover || null, + insights: post.insights || null, + })), + }; +} + +// Play numbers are public — the pop counter on the title screen already shares +// them, and a wall in a room is not a secret. Reel performance is different: it +// is marketing data about an account, so it travels only to a caller holding the +// wall key. With no key configured nothing is disclosed, because the failure a +// public endpoint must not have is the one where it quietly answers everybody. +function reelsPermitted(event) { + const expected = process.env.OSKIEWAR_WALL_KEY; + if (!expected) return false; + const offered = event.queryStringParameters?.key || + event.headers?.["x-oskiewar-wall-key"]; + return typeof offered === "string" && offered === expected; +} + +function readLedger() { + // A missing or half-written ledger must not take the wall down with it — the + // play numbers are the point and they come from somewhere else entirely. + try { + return JSON.parse(readFileSync(LEDGER, "utf8")); + } catch { + return null; + } +} + +export async function handler(event) { + if (event.httpMethod === "OPTIONS") return respond(204, ""); + if (event.httpMethod !== "GET") + return respond(405, { error: "Method not allowed" }); + + const timezone = event.queryStringParameters?.tz || WALL_TIMEZONE; + let database; + try { + database = await connect(); + const now = new Date(); + const today = localDay(now, timezone); + const key = dayKey("$recordedAt", timezone); + const replays = database.db.collection(REPLAYS); + + const [facets, popRows, popDays] = await Promise.all([ + replays.aggregate([{ + $facet: { + // Everything ever recorded, in one pass. + totals: [{ + $group: { + _id: null, + rounds: { $sum: 1 }, + matches: count(IS_COMPLETED), + dummy: count(IS_DUMMY), + latestAt: { $max: "$recordedAt" }, + series: { $addToSet: "$seriesId" }, + }, + }], + // A player is a fighter who is not the dummy. Counting distinct names + // is what answers "how many people have played" — the same person on + // two nights is one player, which is the honest reading. + players: [ + { $unwind: "$fighters" }, + { $match: { $expr: { $ne: [{ $toUpper: "$fighters" }, "DUMMY"] } } }, + { $group: { _id: { $toUpper: "$fighters" } } }, + { $count: "distinct" }, + ], + playersToday: [ + { $match: { $expr: { $eq: [key, today] } } }, + { $unwind: "$fighters" }, + { $match: { $expr: { $ne: [{ $toUpper: "$fighters" }, "DUMMY"] } } }, + { $group: { _id: { $toUpper: "$fighters" } } }, + { $count: "distinct" }, + ], + daily: [ + { $match: { recordedAt: { $gte: new Date(now - DAILY_DAYS * DAY) } } }, + { + $group: { + _id: key, + rounds: { $sum: 1 }, + matches: count(IS_COMPLETED), + dummy: count(IS_DUMMY), + fighters: { $addToSet: FIGHTERS_UPPER }, + }, + }, + { + $project: { + rounds: 1, matches: 1, dummy: 1, + players: { + $size: { + $filter: { + input: { $setDifference: [ + { $reduce: { input: "$fighters", initialValue: [], + in: { $setUnion: ["$$value", "$$this"] } } }, + ["DUMMY"], + ] }, + cond: { $ne: ["$$this", null] }, + }, + }, + }, + }, + }, + ], + hourly: [ + { $match: { recordedAt: { $gte: new Date(now - HOURLY_HOURS * HOUR) } } }, + { + $group: { + _id: { $floor: { $divide: [ + { $subtract: [now, "$recordedAt"] }, HOUR] } }, + rounds: { $sum: 1 }, + }, + }, + ], + }, + }]).toArray(), + // Pops carry only the hour they happened, so the same hour-bucket + // aggregation the pop counter runs is the only shape available here. + database.db.collection(POPS).aggregate([ + { $match: { at: { $gte: new Date(now - MAX_HOURS * HOUR) } } }, + { + $group: { + _id: { $floor: { $divide: [{ $subtract: [now, "$at"] }, HOUR] } }, + pops: { $sum: 1 }, + }, + }, + ]).toArray(), + // The day counters the pop endpoint keeps outside the TTL sweep. They only + // begin where that rollup was deployed, so `since` travels with the total + // and the wall labels it rather than passing it off as all-time. + database.db.collection(POP_DAYS).find({}) + .sort({ _id: 1 }).limit(400).toArray(), + ]); + + await database.disconnect(); + + const facet = facets[0] || {}; + const totals = facet.totals?.[0] || {}; + const days = []; + for (let back = DAILY_DAYS - 1; back >= 0; back--) { + days.push(localDay(new Date(now.getTime() - back * DAY), timezone)); + } + const daily = shapeDaily(facet.daily || [], days, today); + const todayRow = daily.find((row) => row.today) || + { rounds: 0, matches: 0, players: 0, dummy: 0 }; + + const byHour = []; + for (const row of popRows) byHour[Number(row._id)] = row.pops; + const window = chooseWindow(byHour); + + // The rollup is authoritative for a whole day; the raw rows are the only + // thing that can speak for a day still in progress. So today comes from the + // rows and every finished day comes from the counter. + const popsByDay = new Map(popDays.map((row) => [row._id, row.pops || 0])); + const popsToday = byHour + .slice(0, HOURLY_HOURS) + .reduce((sum, value) => sum + (value || 0), 0); + for (const row of daily) { + row.pops = row.today ? popsToday : (popsByDay.get(row.day) || 0); + } + const popsCounted = popDays.reduce((sum, row) => sum + (row.pops || 0), 0); + + const seriesEver = (totals.series || []).filter(Boolean).length; + const rounds = totals.rounds || 0; + + return respond(200, { + format: "ac.oskiewar.stats", + version: 1, + generatedAt: now.toISOString(), + timezone, + day: today, + today: { + rounds: todayRow.rounds, + matches: todayRow.matches, + players: todayRow.players, + // Split of today's rounds by who was on the other side. + dummy: todayRow.dummy, + localPlayer: todayRow.rounds - todayRow.dummy, + pops: popsToday, + }, + allTime: { + rounds, + matches: totals.matches || 0, + series: seriesEver, + players: facet.players?.[0]?.distinct || 0, + dummy: totals.dummy || 0, + localPlayer: rounds - (totals.dummy || 0), + // Of the rounds recorded, how many ended a match. + completionRatio: rounds ? (totals.matches || 0) / rounds : 0, + latestAt: totals.latestAt || null, + }, + // `window` is what the pop counter itself would quote, kept here so the + // wall cannot contradict the site. `counted` is the swept-proof total, and + // it carries the day it starts from because it is not all of history — + // only history since the rollup began. + pops: { + ...window, + maxHours: MAX_HOURS, + counted: popsCounted, + since: popDays[0]?._id || null, + days: popDays.length, + }, + hourly: shapeHourly(facet.hourly || []), + daily, + reels: reelsPermitted(event) ? shapeReels(readLedger()) : null, + }, { + // The wall polls this; a minute stale costs nothing and keeps a reload + // storm off the database. + "Cache-Control": "public, max-age=60", + }); + } catch (error) { + if (database) await database.disconnect(); + return respond(500, { error: error.message || "Stats failed" }); + } +} diff --git a/xbox/live/marketing/ledger.json b/xbox/live/marketing/ledger.json new file mode 100644 index 0000000000..c218e74a74 --- /dev/null +++ b/xbox/live/marketing/ledger.json @@ -0,0 +1,68 @@ +{ + "format": "ac.oskiewar.reel-ledger", + "version": 1, + "posts": [ + { + "mode": "live", + "id": "2026-08-09-s0-fgc", + "slot": 660, + "day": "2026-08-09", + "index": 0, + "segment": "fgc", + "seed": "2026-08-09#0", + "kind": "self-play", + "round": null, + "publishedAt": "2026-08-09T23:24:42.378Z", + "urls": { + "reel": "https://art-aesthetic-computer.sfo3.digitaloceanspaces.com/oskiewar/reels/2026-08-09-s0-fgc/reel.mp4", + "cover": "https://art-aesthetic-computer.sfo3.digitaloceanspaces.com/oskiewar/reels/2026-08-09-s0-fgc/cover.jpg" + }, + "containerId": "18079051220446357", + "mediaId": "18349296478171889", + "insights": { + "views": 1358, + "reach": 1072, + "likes": 6, + "comments": 0, + "saved": 1, + "shares": 0, + "total_interactions": 7, + "ig_reels_avg_watch_time": 7085, + "ig_reels_video_view_total_time": 7510253, + "reels_skip_rate": 48.1 + }, + "insightsAt": "2026-08-11T00:53:27.550Z" + }, + { + "mode": "live", + "id": "2026-08-10-s2-homebrew", + "slot": 665, + "day": "2026-08-10", + "index": 2, + "segment": "homebrew", + "seed": "2026-08-10#2", + "kind": "self-play", + "round": null, + "publishedAt": "2026-08-10T19:50:37.933Z", + "urls": { + "reel": "https://art-aesthetic-computer.sfo3.digitaloceanspaces.com/oskiewar/reels/2026-08-10-s2-homebrew/reel.mp4", + "cover": "https://art-aesthetic-computer.sfo3.digitaloceanspaces.com/oskiewar/reels/2026-08-10-s2-homebrew/cover.jpg" + }, + "containerId": "18079390346446357", + "mediaId": "18364064911240314", + "insights": { + "views": 146, + "reach": 126, + "likes": 0, + "comments": 0, + "saved": 0, + "shares": 0, + "total_interactions": 0, + "ig_reels_avg_watch_time": 2426, + "ig_reels_video_view_total_time": 303266, + "reels_skip_rate": 76 + }, + "insightsAt": "2026-08-11T00:53:28.073Z" + } + ] +} diff --git a/xbox/live/marketing/publish.mjs b/xbox/live/marketing/publish.mjs index f8e4c8ef71..e7da40dac3 100644 --- a/xbox/live/marketing/publish.mjs +++ b/xbox/live/marketing/publish.mjs @@ -172,13 +172,68 @@ export const reelMetrics = ["views", "reach", "likes", "comments", "saved", "shares", "total_interactions", "reposts", "ig_reels_avg_watch_time", "ig_reels_video_view_total_time", "reels_skip_rate"]; -export async function pullInsights(mediaId, token) { - const body = await call(`${host()}/${apiVersion()}/${mediaId}/insights` + - `?metric=${reelMetrics.join(",")}&access_token=${token}`); +// Meta rejects the *whole* request if any single metric is unsupported, and +// which metrics are supported drifts with the API version, the account type and +// the login path — `reposts` is documented but refused on Instagram Login as of +// 2026-08-10. It names the offenders in the error, so drop exactly those and ask +// again. Asking is cheaper than maintaining a list here that quietly rots, and +// it means a newly-supported metric starts appearing on its own. +const UNSUPPORTED = /does not support the metrics:\s*([a-z_,\s]+)/i; + +export async function pullInsights(mediaId, token, metrics = reelMetrics) { + let body; + try { + body = await call(`${host()}/${apiVersion()}/${mediaId}/insights` + + `?metric=${metrics.join(",")}&access_token=${token}`); + } catch (error) { + const named = UNSUPPORTED.exec(error.message || ""); + const dropped = (named?.[1] || "").split(",") + .map((name) => name.trim()).filter(Boolean); + const kept = metrics.filter((name) => !dropped.includes(name)); + // Only retry when the error actually narrowed the set — otherwise this is a + // different failure (a dead token, a deleted post) and it belongs upstream. + if (!kept.length || kept.length === metrics.length) throw error; + return pullInsights(mediaId, token, kept); + } return Object.fromEntries((body.data || []).map((row) => [row.name, row.values?.[0]?.value ?? row.total_value?.value ?? null])); } +// Insights are the one thing a reel cannot report about itself at publish time: +// they only exist once people have watched. So they are pulled on a pass of +// their own, over every live post, and hung on the ledger entry that was left +// with `insights: null` when it went out. +// +// A metric Meta has not computed yet comes back missing rather than zero, and +// `pullInsights` preserves that as null — which is why a fresh reel must read as +// "not measured" on any dashboard instead of as a reel that got no views. +export async function refreshInsights(token, { + log = console.log, + pull = pullInsights, +} = {}) { + if (!token) throw new Error("OSKIEWAR_IG_TOKEN required"); + const ledger = readLedger(); + const refreshed = []; + for (const post of ledger.posts) { + if (post.mode !== "live" || !post.mediaId) continue; + try { + post.insights = await pull(post.mediaId, token); + post.insightsAt = new Date().toISOString(); + refreshed.push(post.id); + log(`📈 ${post.id} · views ${post.insights.views ?? "—"} · ` + + `reach ${post.insights.reach ?? "—"} · ` + + `skip ${post.insights.reels_skip_rate ?? "—"}`); + } catch (error) { + // One reel Meta will not talk about must not cost the others their + // refresh, and must not blank the figure already on the ledger. + log(`⚠️ ${post.id} · ${error.message}`); + } + } + if (refreshed.length) + writeFileSync(ledgerPath, JSON.stringify(ledger, null, 2) + "\n"); + return refreshed; +} + export function segmentReport(ledger = readLedger()) { const rows = {}; for (const post of ledger.posts) { diff --git a/xbox/live/marketing/reel.mjs b/xbox/live/marketing/reel.mjs index f6722767f6..609c4c78ce 100644 --- a/xbox/live/marketing/reel.mjs +++ b/xbox/live/marketing/reel.mjs @@ -7,6 +7,7 @@ // node xbox/live/marketing/reel.mjs --queue # list what is staged // node xbox/live/marketing/reel.mjs --publish # dry run by default // node xbox/live/marketing/reel.mjs --publish --live # actually posts +// node xbox/live/marketing/reel.mjs --insights # pull Meta's numbers // // A reel is one whole match, recorded at 1080x1920 and never composed on: // no plate, no bands, no rescale. Recording waits out the round already in @@ -21,8 +22,8 @@ import { pickSource, seed32 } from "./source.mjs"; import { bakeReplay } from "./replay-oven.mjs"; import { cover, inspect, thumbnail, writeSidecar } from "./dress.mjs"; import { verifySync } from "./verify.mjs"; -import { appendLedger, dryRun, publishLive, queueDir, readLedger, reveal, - segmentReport, uploadPublic } from "./publish.mjs"; +import { appendLedger, dryRun, publishLive, queueDir, readLedger, + refreshInsights, reveal, segmentReport, uploadPublic } from "./publish.mjs"; import { repo } from "./shell.mjs"; import { dress, segments, share } from "./segments.mjs"; @@ -170,6 +171,14 @@ async function main() { log(JSON.stringify(segmentReport(), null, 2)); return; } + // Pulling every live post each time is cheap and keeps the ledger a snapshot + // of now rather than a patchwork of whenever each reel was last asked about. + if (flags.insights) { + const refreshed = await refreshInsights(process.env.OSKIEWAR_IG_TOKEN, { log }); + log(`📊 ${refreshed.length} reel(s) refreshed`); + log(JSON.stringify(segmentReport(), null, 2)); + return; + } if (flags.publish) { const dir = join(staging, flags.publish); const record = JSON.parse(readFileSync(join(dir, "reel.json"), "utf8")); diff --git a/xbox/live/wall.html b/xbox/live/wall.html new file mode 100644 index 0000000000..d50a591183 --- /dev/null +++ b/xbox/live/wall.html @@ -0,0 +1,339 @@ + + + + +oskiewar wall + + + + +
+
OSKIEWAR
+
+
+
+
+
+ + + + diff --git a/xbox/live/wall/install.sh b/xbox/live/wall/install.sh new file mode 100644 index 0000000000..021b389943 --- /dev/null +++ b/xbox/live/wall/install.sh @@ -0,0 +1,72 @@ +#!/usr/bin/env bash +# Installs the oskiewar wall on the machine it is run on, and stands the previous +# dashboards down. +# +# Run it from the directory this script lives in, with wall.html beside it: +# scp -r xbox/live/wall xbox/live/wall.html host:/tmp/oskiewar-wall-install/ +# ssh host 'bash /tmp/oskiewar-wall-install/wall/install.sh' +# +# Nothing is deleted. The cockpit autostarts are renamed aside, so putting them +# back is one `mv` each — see the summary this prints at the end. +set -euo pipefail + +HERE="$(cd -- "$(dirname "$0")" && pwd)" +ROOT="$HOME/.local/share/oskiewar-wall" +BIN="$HOME/.local/bin" +AUTOSTART="$HOME/.config/autostart" +UNITS="$HOME/.config/systemd/user" + +mkdir -p "$ROOT" "$BIN" "$AUTOSTART" "$UNITS" + +# wall.html may sit beside this script or one directory up, depending on how it +# was copied over. +for candidate in "$HERE/wall.html" "$HERE/../wall.html"; do + [ -f "$candidate" ] && cp "$candidate" "$ROOT/wall.html" && break +done +[ -f "$ROOT/wall.html" ] || { echo "install: wall.html not found next to $HERE" >&2; exit 1; } + +install -m 755 "$HERE/oskiewar-wall" "$BIN/oskiewar-wall" +install -m 644 "$HERE/oskiewar-wall-serve.service" "$UNITS/oskiewar-wall-serve.service" +# The autostart entry needs an absolute path, so the user's home is baked in here +# rather than left as a %h the desktop spec will not expand in Exec. +sed "s#^Exec=.*#Exec=$BIN/oskiewar-wall#" "$HERE/oskiewar-wall.desktop" \ + > "$AUTOSTART/oskiewar-wall.desktop" + +# --- stand the old dashboards down, reversibly --- +restored=() +for entry in fleet-cockpit btx-fleet-cockpit; do + if [ -f "$AUTOSTART/$entry.desktop" ]; then + mv "$AUTOSTART/$entry.desktop" "$AUTOSTART/$entry.desktop.disabled" + restored+=("mv $AUTOSTART/$entry.desktop.disabled $AUTOSTART/$entry.desktop") + fi +done +# Close what is on the monitors right now. The cockpit is two xterms plus a +# foot window; none of them own any state worth saving. +pkill -f "/opt/fleet-cockpit/fleet_tui.py" 2>/dev/null || true +pkill -f "app-id=btx-cockpit" 2>/dev/null || true +pkill -f "/usr/local/bin/fleet-cockpit" 2>/dev/null || true +pkill -f "python3 /home/$USER/fleet-cockpit.py" 2>/dev/null || true +# The collector is a system timer; stopping it needs root, and it is only writing +# a JSON file, so a machine without passwordless sudo just leaves it running. +if sudo -n true 2>/dev/null; then + sudo systemctl disable --now fleet-cockpit-collect.timer 2>/dev/null || true + restored+=("sudo systemctl enable --now fleet-cockpit-collect.timer") +fi + +systemctl --user daemon-reload +systemctl --user enable --now oskiewar-wall-serve.service + +echo +echo "oskiewar wall installed." +echo " page $ROOT/wall.html" +echo " launcher $BIN/oskiewar-wall" +echo " server systemctl --user status oskiewar-wall-serve" +echo " autostart $AUTOSTART/oskiewar-wall.desktop" +if [ ! -f "$ROOT/wall.key" ]; then + echo + echo " NOTE: no $ROOT/wall.key — the reel block will read as locked." + echo " echo '' > $ROOT/wall.key then rerun the launcher." +fi +echo +echo "to put the old dashboards back:" +for line in "${restored[@]}"; do echo " $line"; done diff --git a/xbox/live/wall/oskiewar-wall b/xbox/live/wall/oskiewar-wall new file mode 100644 index 0000000000..9ce4480de2 --- /dev/null +++ b/xbox/live/wall/oskiewar-wall @@ -0,0 +1,91 @@ +#!/usr/bin/env bash +# The oskiewar wall — one Chrome window per monitor, fullscreened onto it. +# +# Two windows rather than one tall one because Mutter clamps a normal window to a +# single monitor's work area: ask for 2560x2880 and it comes back about 1371 +# tall. The fleet cockpit learned this the same way and ended up as two xterms. +# +# Chrome is forced through XWayland (--ozone-platform=x11) because under Wayland +# it ignores --window-position, and position is the whole trick: Chrome +# fullscreens onto whichever output the window's position lands in. +# +# Outputs are read from xrandr, never from `xdotool getdisplaygeometry` — that +# reports the primary output's size, not the X screen's, which on this stacked +# pair means 2560x1440 for a 2560x2880 desktop. +# +# oskiewar-wall # start (restarts if already up) +# oskiewar-wall stop # close both panes +set -euo pipefail + +ROOT="${OSKIEWAR_WALL_ROOT:-$HOME/.local/share/oskiewar-wall}" +PORT="${OSKIEWAR_WALL_PORT:-7799}" +API="${OSKIEWAR_WALL_API:-https://oskiewar.com}" +CHROME="${OSKIEWAR_WALL_CHROME:-google-chrome}" +export DISPLAY="${DISPLAY:-:0}" + +# The launcher is started by GNOME autostart, which does not run inside the +# session's X authority by default. +if [ -z "${XAUTHORITY:-}" ]; then + for candidate in /run/user/$(id -u)/.mutter-Xwaylandauth.*; do + [ -e "$candidate" ] && export XAUTHORITY="$candidate" && break + done +fi + +stop() { + pkill -f "user-data-dir=$ROOT/chrome-" 2>/dev/null || true +} +if [ "${1:-}" = "stop" ]; then stop; echo "wall stopped"; exit 0; fi + +mkdir -p "$ROOT" +KEY="$(cat "$ROOT/wall.key" 2>/dev/null || true)" + +# A wall that goes to sleep is not a wall. +gsettings set org.gnome.desktop.session idle-delay 0 2>/dev/null || true +gsettings set org.gnome.desktop.screensaver lock-enabled false 2>/dev/null || true + +# "y x" for every connected output, sorted by y — so the first row is the top +# monitor and the last is the bottom, whichever DP-n they happen to be. +mapfile -t PLACES < <(xrandr --current | + grep -oE '[0-9]+x[0-9]+\+[0-9]+\+[0-9]+' | + awk -F'[x+]' '{print $4" "$3}' | sort -n -k1,1) + +if [ "${#PLACES[@]}" -lt 1 ]; then + echo "oskiewar-wall: no outputs found on DISPLAY=$DISPLAY" >&2 + exit 1 +fi + +open_pane() { # open_pane + local pane=$1 x=$2 y=$3 + local url="http://127.0.0.1:$PORT/wall.html?pane=$pane&api=$API" + [ -n "$KEY" ] && url="$url&key=$KEY" + "$CHROME" \ + --ozone-platform=x11 \ + --user-data-dir="$ROOT/chrome-$pane" \ + --app="$url" \ + --window-position="$x,$y" \ + --window-size=2560,1440 \ + --start-fullscreen \ + --no-first-run --no-default-browser-check \ + --disable-session-crashed-bubble --hide-crash-restore-bubble \ + --disable-infobars --noerrdialogs \ + >"$ROOT/$pane.log" 2>&1 & + echo " $pane pane → +$x+$y" +} + +stop +sleep 1 + +read -r top_y top_x <<<"${PLACES[0]}" +open_pane top "$top_x" "$top_y" + +# One monitor is a legitimate configuration — the top pane is the one that +# matters, so a single-output machine simply shows that and stops. +if [ "${#PLACES[@]}" -ge 2 ]; then + sleep 2 # let Chrome claim the first output before the second window appears + read -r bottom_y bottom_x <<<"${PLACES[-1]}" + open_pane bottom "$bottom_x" "$bottom_y" +else + echo " (only one output — bottom pane skipped)" +fi + +echo "oskiewar wall up · serving from 127.0.0.1:$PORT · stats from $API" diff --git a/xbox/live/wall/oskiewar-wall-serve.service b/xbox/live/wall/oskiewar-wall-serve.service new file mode 100644 index 0000000000..72add12349 --- /dev/null +++ b/xbox/live/wall/oskiewar-wall-serve.service @@ -0,0 +1,16 @@ +[Unit] +# Serves wall.html to the two Chrome panes over loopback. The page could be +# opened as file:// instead, but a file:// origin is opaque and Chrome's handling +# of cross-origin fetch from one is not something a wall should depend on. +Description=oskiewar wall static server +After=graphical-session.target + +[Service] +Type=simple +WorkingDirectory=%h/.local/share/oskiewar-wall +ExecStart=/usr/bin/python3 -m http.server 7799 --bind 127.0.0.1 +Restart=always +RestartSec=2 + +[Install] +WantedBy=default.target diff --git a/xbox/live/wall/oskiewar-wall.desktop b/xbox/live/wall/oskiewar-wall.desktop new file mode 100644 index 0000000000..74de14b20d --- /dev/null +++ b/xbox/live/wall/oskiewar-wall.desktop @@ -0,0 +1,10 @@ +[Desktop Entry] +Type=Application +Name=oskiewar Wall +Comment=oskiewar play and reel stats across both monitors +Exec=/usr/local/bin/oskiewar-wall +# The static server and the X session both need to be up first; the fleet cockpit +# used 8s for the same reason. +X-GNOME-Autostart-enabled=true +X-GNOME-Autostart-Delay=8 +Terminal=false