forked from tas/major_tom
Set the Horchposten backend URL in the shell data attribute and replace the plaintext key lookup with XOR-encoded byte arrays decoded at runtime, so the API key never appears as a readable string in source, HTML, or the compiled WASM binary.
195 lines
7.3 KiB
C
195 lines
7.3 KiB
C
#include "game/analytics.h"
|
|
#include <stdio.h>
|
|
|
|
#ifdef __EMSCRIPTEN__
|
|
#include <emscripten.h>
|
|
|
|
/* ── EM_JS bridge: JavaScript functions callable from C ─────────── */
|
|
|
|
/* Initialize client_id in localStorage and store the analytics
|
|
* API URL + key. Called once at startup. */
|
|
EM_JS(void, js_analytics_init, (), {
|
|
/* Generate or retrieve a persistent client UUID */
|
|
if (!localStorage.getItem('jnr_client_id')) {
|
|
localStorage.setItem('jnr_client_id', crypto.randomUUID());
|
|
}
|
|
/* Store config on the Module for later use by other EM_JS calls.
|
|
* ANALYTICS_URL and ANALYTICS_KEY are replaced at build time via
|
|
* -D defines, falling back to sensible defaults. */
|
|
Module._analyticsClientId = localStorage.getItem('jnr_client_id');
|
|
Module._analyticsSessionId = null;
|
|
Module._analyticsStartPending = null; /* Promise while start is in-flight */
|
|
Module._analyticsLastStats = null; /* stashed for beforeunload fallback */
|
|
|
|
/* Runtime config: URL from data attribute, key decoded at runtime.
|
|
* The key is XOR-encoded across two byte arrays so it never appears
|
|
* as a plain string in the WASM binary, emitted JS, or HTML. */
|
|
var container = document.getElementById('canvas-container');
|
|
Module._analyticsUrl = (container && container.dataset.analyticsUrl)
|
|
? container.dataset.analyticsUrl
|
|
: (typeof ANALYTICS_URL !== 'undefined' ? ANALYTICS_URL : '');
|
|
var _a = [53,75,96,19,114,122,112,34,28,62,24,5,57,34,126,14,
|
|
112,73,105,121,122,79,50,0,77,33,82,58,61,19,44,0];
|
|
var _b = [82,15,4,95,36,32,29,18,95,14,87,95,115,70,12,76,
|
|
55,5,4,12,28,30,65,78,4,72,26,92,84,90,70,54];
|
|
var _k = '';
|
|
for (var i = 0; i < _a.length; i++) _k += String.fromCharCode(_a[i] ^ _b[i]);
|
|
Module._analyticsKey = _k;
|
|
|
|
if (!Module._analyticsUrl) {
|
|
console.log('[analytics] No analytics URL configured — analytics disabled');
|
|
} else {
|
|
console.log('[analytics] Initialized, client_id=' + Module._analyticsClientId);
|
|
}
|
|
});
|
|
|
|
/* Start a new session. Sends POST /api/analytics/session/start/.
|
|
* Stores the in-flight promise so session_end can wait for it. */
|
|
EM_JS(void, js_analytics_session_start, (), {
|
|
if (!Module._analyticsUrl) return;
|
|
|
|
var body = JSON.stringify({
|
|
client_id: Module._analyticsClientId,
|
|
device: {
|
|
platform: navigator.platform || '',
|
|
language: navigator.language || '',
|
|
screen_width: screen.width,
|
|
screen_height: screen.height,
|
|
device_pixel_ratio: window.devicePixelRatio || 1,
|
|
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || '',
|
|
webgl_renderer: (function() {
|
|
try {
|
|
var c = document.createElement('canvas');
|
|
var gl = c.getContext('webgl') || c.getContext('experimental-webgl');
|
|
if (gl) {
|
|
var ext = gl.getExtension('WEBGL_debug_renderer_info');
|
|
return ext ? gl.getParameter(ext.UNMASKED_RENDERER_WEBGL) : '';
|
|
}
|
|
} catch(e) {}
|
|
return '';
|
|
})(),
|
|
touch_support: ('ontouchstart' in window)
|
|
}
|
|
});
|
|
|
|
Module._analyticsStartPending = fetch(Module._analyticsUrl + '/api/analytics/session/start/', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-API-Key': Module._analyticsKey
|
|
},
|
|
body: body
|
|
})
|
|
.then(function(r) { return r.json(); })
|
|
.then(function(data) {
|
|
Module._analyticsSessionId = data.session_id || null;
|
|
Module._analyticsStartPending = null;
|
|
console.log('[analytics] Session started: ' + Module._analyticsSessionId);
|
|
})
|
|
.catch(function(err) {
|
|
Module._analyticsStartPending = null;
|
|
console.error('[analytics] Session start failed:', err);
|
|
});
|
|
});
|
|
|
|
/* Internal helper: send the session-end POST (used by both the C wrapper
|
|
* and the beforeunload fallback). */
|
|
EM_JS(void, js_analytics_send_end, (int score, int level_reached,
|
|
int lives_used, int duration_secs,
|
|
const char *end_reason_ptr), {
|
|
/* Helper that performs the actual end request given a session id. */
|
|
function doEnd(sid, endReason, score, levelReached, livesUsed, durationSecs) {
|
|
var body = JSON.stringify({
|
|
score: score,
|
|
level_reached: levelReached > 0 ? levelReached : 1,
|
|
lives_used: livesUsed,
|
|
duration_seconds: durationSecs,
|
|
end_reason: endReason
|
|
});
|
|
|
|
/* Stash stats for the beforeunload fallback */
|
|
Module._analyticsLastStats = body;
|
|
|
|
return fetch(Module._analyticsUrl + '/api/analytics/session/' + sid + '/end/', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-API-Key': Module._analyticsKey
|
|
},
|
|
body: body,
|
|
keepalive: true
|
|
})
|
|
.then(function(r) { return r.json(); })
|
|
.then(function(data) {
|
|
console.log('[analytics] Session ended: ' + sid +
|
|
(data.new_high_score ? ' (NEW HIGH SCORE!)' : ''));
|
|
})
|
|
.catch(function(err) {
|
|
console.error('[analytics] Session end failed:', err);
|
|
});
|
|
}
|
|
|
|
if (!Module._analyticsUrl) return;
|
|
|
|
var endReason = UTF8ToString(end_reason_ptr);
|
|
|
|
/* If session start is still in-flight, wait for it before ending. */
|
|
if (Module._analyticsStartPending) {
|
|
var pending = Module._analyticsStartPending;
|
|
/* Clear session synchronously so duplicate end calls are harmless */
|
|
Module._analyticsStartPending = null;
|
|
pending.then(function() {
|
|
var sid = Module._analyticsSessionId;
|
|
if (sid) {
|
|
Module._analyticsSessionId = null;
|
|
doEnd(sid, endReason, score, level_reached, lives_used, duration_secs);
|
|
}
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (!Module._analyticsSessionId) return;
|
|
|
|
var sid = Module._analyticsSessionId;
|
|
/* Clear synchronously before the async request to prevent races */
|
|
Module._analyticsSessionId = null;
|
|
doEnd(sid, endReason, score, level_reached, lives_used, duration_secs);
|
|
});
|
|
|
|
/* ── C wrappers ─────────────────────────────────────────────────── */
|
|
|
|
void analytics_init(void) {
|
|
js_analytics_init();
|
|
}
|
|
|
|
void analytics_session_start(void) {
|
|
js_analytics_session_start();
|
|
}
|
|
|
|
void analytics_session_end(GameStats *stats, const char *end_reason) {
|
|
stats_update_score(stats);
|
|
js_analytics_send_end(
|
|
stats->score,
|
|
stats->levels_completed > 0 ? stats->levels_completed : 1,
|
|
stats->deaths,
|
|
(int)stats->time_elapsed,
|
|
end_reason
|
|
);
|
|
}
|
|
|
|
#else
|
|
/* ── Non-WASM stubs ─────────────────────────────────────────────── */
|
|
|
|
void analytics_init(void) {
|
|
printf("[analytics] Analytics disabled (native build)\n");
|
|
}
|
|
|
|
void analytics_session_start(void) {}
|
|
|
|
void analytics_session_end(GameStats *stats, const char *end_reason) {
|
|
(void)stats;
|
|
(void)end_reason;
|
|
}
|
|
|
|
#endif /* __EMSCRIPTEN__ */
|