commit bfca84137e742c1f661b0f61088aee91a00b051c
parent bf9052015988e5d4bfc0faaeb88f0a90e441ec3b
Author: Francisco Armando Rosmino <franciscorosmino@rosmino.com.ar>
Date: Sat, 3 Oct 2026 12:19:18 -0300
Web: no perder lo guardado, una sola instancia, service worker consistente
- pre.js: autoPersist recien cuando la carga de IndexedDB salio bien; si fallo,
Module.noPersist y paths_flush no escribe (syncfs borraba de IndexedDB todo lo
que no estaba en memoria).
- shell.html: candado de instancia con Web Locks (dos pestanas se pisaban las
partidas); la recarga por version nueva solo si ya habia un service worker
(la primera visita se recargaba sola); placeholder 'toca aca para escribir'.
- sw.js: addAll con cache: 'reload' (podia quedar un index.js viejo con un wasm
nuevo); Makefile: la version del SW sale de todo lo publicado, no solo del
wasm; release/publish en .PHONY; regla de iconos con patron.
- app.c: sin 'web_user' como nombre por defecto; el tope de los campos de texto
va al cuadro del navegador (cortaba utf-8 a la mitad); text_input distingue
cancelado de 'no hay cuadro' (cancelar el chat dejaba el teclado en modo chat).
- guikit.c: se descartan los toques hechos con el cuadro abierto; el arrastre
se suelta si el boton ya no esta apretado; con timeout 0 cede al navegador;
sin FILL_DOCUMENT (no hacia nada con el canvas por CSS).
- paths.c: paths_flush espera solo a su propio sync; session.c: un sync por
turno en vez de uno por movida.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Diffstat:
11 files changed, 98 insertions(+), 40 deletions(-)
diff --git a/catan/Makefile b/catan/Makefile
@@ -102,10 +102,10 @@ web: $(WEB_SRC) $(HDRS) $(WEB_SDL)/lib/libSDL3.a web/shell.html web/pre.js web/s
mkdir -p $(WEB_OUT)
$(EMCC) $(CPPFLAGS) -I$(WEB_SDL)/include $(WEB_CFLAGS) $(WEB_LDFLAGS) -o $(WEB_OUT)/index.html $(WEB_SRC) $(WEB_SDL)/lib/libSDL3.a
cp web/manifest.json web/icon-192.png web/icon-512.png $(WEB_OUT)/
- sed "s/@VERSION@/$$(sha1sum $(WEB_OUT)/index.wasm | cut -c1-12)/" web/sw.js > $(WEB_OUT)/sw.js
+ sed "s/@VERSION@/$$(cat $(WEB_OUT)/index.html $(WEB_OUT)/index.js $(WEB_OUT)/index.wasm $(WEB_OUT)/manifest.json web/sw.js | sha1sum | cut -c1-12)/" web/sw.js > $(WEB_OUT)/sw.js
@ls -la $(WEB_OUT)/index.wasm | awk '{ print "wasm:", $$5, "bytes" }'
-web/icon-192.png web/icon-512.png: web/mkicon.py
+web/icon-%.png: web/mkicon.py
python3 web/mkicon.py
# sube web/dist al VPS (Caddy sirve /var/www/catan en https://catan.lu3dhn.xyz); sin rsync
@@ -143,4 +143,4 @@ clean:
rm -f catan catan-server catan-picosim catan.exe
rm -rf pico/build
-.PHONY: all debug test clean pico catan-server-static windows web deploy-web
+.PHONY: all debug test clean pico catan-server-static windows web deploy-web release publish
diff --git a/catan/linux/app.c b/catan/linux/app.c
@@ -24,8 +24,12 @@ void settings_load(Settings *st)
st->bot_hard = 1;
st->layout = LAYOUT_WIDE;
st->scale = 2;
+#ifdef __EMSCRIPTEN__
+ const char *u = 0; /* emscripten inventa USER=web_user */
+#else
const char *u = getenv("USER");
if (!u || !*u) u = getenv("USERNAME"); /* Windows */
+#endif
snprintf(st->name, sizeof st->name, "%s", u && *u ? u : "Yo");
FILE *f = fopen(config_path(), "r");
if (!f) return;
@@ -200,10 +204,9 @@ int run_form(Frontend *fe, Form *f, void (*on_change)(Form *, void *), void *ctx
if (r == FORM_NONE && x && x->type == F_TEXT && !x->disabled && fe->text_input) {
char tmp[sizeof x->text]; /* la web: cuadro de texto del navegador */
snprintf(tmp, sizeof tmp, "%s", x->text);
- if (fe->text_input(fe, x->label, tmp, sizeof tmp)) {
- if (x->maxlen && strlen(tmp) > x->maxlen) tmp[x->maxlen] = 0;
- snprintf(x->text, sizeof x->text, "%s", tmp);
- }
+ /* el tope en bytes va al cuadro, que corta en borde de caracter utf-8 */
+ int cap = x->maxlen && x->maxlen + 1 < (int)sizeof tmp ? x->maxlen + 1 : (int)sizeof tmp;
+ if (fe->text_input(fe, x->label, tmp, cap) > 0) snprintf(x->text, sizeof x->text, "%s", tmp);
}
}
}
@@ -323,7 +326,7 @@ void app_play(Frontend *fe, Session *s, Settings *st)
if (ev.type == EV_CLICK && !modal && !help) { /* botones de la pantalla tactil: valen como teclas */
const Hit *h = hit_find(&fe->hits, ev.x, ev.y);
if (h && h->kind == PK_APP) {
- int k = h->id == 7 ? K_BACK : h->id == 9 ? K_OK : h->id == 10 ? 'c' : h->id == 11 ? 'L' : 0;
+ int k = h->id == 7 ? K_BACK : h->id == 9 ? K_OK : h->id == 10 ? 'c' : 0;
if (k) { ev.type = EV_KEY; ev.key = k; typing = false; }
}
}
@@ -414,12 +417,13 @@ void app_play(Frontend *fe, Session *s, Settings *st)
if (app && h->id == 6) { /* caja de chat */
char tmp[sizeof chat];
snprintf(tmp, sizeof tmp, "%s", chat);
- if (fe->text_input && fe->text_input(fe, "Chat", tmp, sizeof tmp)) { /* la web */
+ int ti = fe->text_input ? fe->text_input(fe, "Chat", tmp, sizeof tmp) : -1; /* la web: cuadro propio */
+ if (ti > 0) {
c.msg[0] = 0;
const char *err = tmp[0] ? sess_say(s, tmp) : 0;
if (err) { snprintf(c.msg, sizeof c.msg, "%s", err); c.msg_err = true; }
else { chat[0] = 0; ui.log_scroll = 0; }
- } else typing = true;
+ } else if (ti < 0) typing = true; /* sin cuadro: se escribe con el teclado; cancelado: nada */
continue;
}
if (app && (h->id == 4 || h->id == 5)) { /* barra de scroll */
diff --git a/catan/linux/frontend.h b/catan/linux/frontend.h
@@ -13,8 +13,9 @@ struct Frontend {
int (*wait)(Frontend *f, int timeout_ms, const int *fds, int nfds, InEvent *ev);
void (*close)(Frontend *f);
/* Opcional: pide una linea de texto con un cuadro del sistema (la web, donde no hay
- * teclado tactil para el canvas). NULL o false: se escribe con el teclado. */
- bool (*text_input)(Frontend *f, const char *title, char *buf, int n);
+ * teclado tactil para el canvas). 1 = texto en buf, 0 = cancelado, -1 = no hay
+ * cuadro (NULL o -1: se escribe con el teclado). */
+ int (*text_input)(Frontend *f, const char *title, char *buf, int n);
bool is_gui;
bool touch; /* pantalla tactil (la web en el telefono): botones en vez de teclas */
HitList hits; /* zonas clickeables del ultimo dibujo */
diff --git a/catan/linux/gui_sdl.c b/catan/linux/gui_sdl.c
@@ -37,7 +37,7 @@ static void gui_close(Frontend *f)
gk_close();
}
-static bool gui_text_input(Frontend *f, const char *title, char *buf, int n)
+static int gui_text_input(Frontend *f, const char *title, char *buf, int n)
{
(void)f;
return gk_text_input(title, buf, n);
diff --git a/catan/linux/session.c b/catan/linux/session.c
@@ -139,7 +139,7 @@ static void local_on_move(void *ctx, const Move *m)
move_fmt(m, line, sizeof line);
fprintf(s->save, "M %s\n", line);
fflush(s->save);
- paths_flush();
+ if (m->type == M_END) paths_flush(); /* la web: un sync por turno alcanza (autoPersist hace el resto) */
}
if (s->net) net_on_local_move(s->net, m);
}
diff --git a/catan/web/pre.js b/catan/web/pre.js
@@ -1,18 +1,28 @@
// pre.js - antes de main(): monta /data en IDBFS (IndexedDB) para que la config, las
// partidas locales y los tokens (data_dir() en comun/linux/paths.c) sobrevivan a
-// cerrar la pestana. autoPersist copia a IndexedDB solo; paths_flush() ademas espera
-// a que termine despues de guardar algo importante.
+// cerrar la pestana. Se carga primero y recien si salio bien se activa autoPersist:
+// si la carga fallara y se escribiera igual, syncfs borraria de IndexedDB todo lo
+// que no esta en memoria. paths_flush() ademas espera el sync tras guardar algo
+// importante, y no hace nada si Module.noPersist.
Module.preRun = Module.preRun || [];
Module.preRun.push(function () {
try {
FS.mkdir('/data');
- FS.mount(IDBFS, { autoPersist: true }, '/data');
+ FS.mount(IDBFS, {}, '/data');
addRunDependency('idbfs');
FS.syncfs(true, function (err) {
- if (err) console.warn('IDBFS', err);
+ if (err) {
+ console.warn('IDBFS: no se pudo cargar, se juega sin guardar', err);
+ Module.noPersist = true;
+ if (Module.setStatus) Module.setStatus('Sin almacenamiento: esta sesion no guarda partidas');
+ } else {
+ var m = FS.lookupPath('/data').node.mount;
+ if (m && m.opts) m.opts.autoPersist = true;
+ }
removeRunDependency('idbfs');
});
} catch (e) {
console.warn('sin persistencia', e);
+ Module.noPersist = true;
}
});
diff --git a/catan/web/shell.html b/catan/web/shell.html
@@ -42,13 +42,15 @@
<div id="gk-input">
<label id="gk-label" for="gk-field"></label>
<div class="row">
- <input id="gk-field" type="text" autocomplete="off" autocapitalize="off" enterkeyhint="done">
+ <input id="gk-field" type="text" autocomplete="off" autocapitalize="off" enterkeyhint="done" placeholder="tocá acá para escribir">
<button id="gk-ok">OK</button>
<button id="gk-cancel" class="cancel">✕</button>
</div>
</div>
<canvas id="canvas" tabindex="-1" oncontextmenu="event.preventDefault()"></canvas>
<script>
+ // Si otra pestana tiene el candado, no se arranca el modulo (ver mas abajo)
+ var _origLoad = null;
// ---- entrada de texto para la app en C (gk_text_input en comun/linux/guikit.c)
var gkText = { done: 0, value: '' };
var gkBox = document.getElementById('gk-input'), gkField = document.getElementById('gk-field');
@@ -91,6 +93,18 @@
// ---- el modulo emscripten
var wsParam = new URLSearchParams(location.search).get('ws');
var Module = {
+ noStart: false,
+ // el candado de instancia se pide arriba de forma asincronica: antes de correr
+ // main se espera a saber si lo tenemos
+ preInit: [function () {
+ addRunDependency('lock');
+ var tries = 0;
+ (function wait() {
+ if (Module.noStart) return; // nunca se libera: la pagina queda en el aviso
+ if (Module.lockDone || !(navigator.locks && navigator.locks.request) || ++tries > 50) removeRunDependency('lock');
+ else setTimeout(wait, 20);
+ })();
+ }],
canvas: document.getElementById('canvas'),
// los sockets BSD del programa van por este WebSocket: /ws de la misma pagina
// (Caddy lo pasa al catan-server), o ?ws=ws://localhost:7373 para probar local
@@ -112,11 +126,24 @@
printErr: function (t) { console.warn(t); }
};
if ('serviceWorker' in navigator && location.protocol === 'https:') {
- navigator.serviceWorker.register('sw.js').catch(function (e) { console.warn('sw', e); });
// la app sale de la cache (cache-first); cuando una version nueva toma el control,
- // recargar si recien se abrio (todavia en el menu), para no cortar una partida
+ // recargar si recien se abrio (todavia en el menu), para no cortar una partida.
+ // En la primera visita no habia controlador: ahi el SW nuevo no es una actualizacion.
+ var hadSW = !!navigator.serviceWorker.controller;
+ navigator.serviceWorker.register('sw.js').catch(function (e) { console.warn('sw', e); });
navigator.serviceWorker.addEventListener('controllerchange', function () {
- if (performance.now() < 15000) location.reload();
+ if (hadSW && performance.now() < 15000) location.reload();
+ });
+ }
+ // Una sola instancia: dos pestanas (o la app instalada y una pestana) tienen cada una
+ // su copia de /data y la que guarda ultima pisa a la otra. Con Web Locks la segunda
+ // no arranca; en navegadores sin locks, arranca igual.
+ if (navigator.locks && navigator.locks.request) {
+ navigator.locks.request('catan', { ifAvailable: true }, function (lock) {
+ if (lock) { Module.lockDone = true; return new Promise(function () {}); } // se tiene mientras viva la pagina
+ document.getElementById('status-text').textContent = 'Catan ya está abierto en otra pestaña o ventana. Cerrá esa y recargá esta.';
+ Module.noStart = true;
+ return null;
});
}
</script>
diff --git a/catan/web/sw.js b/catan/web/sw.js
@@ -6,7 +6,11 @@ var CACHE = 'catan-@VERSION@';
var FILES = ['./', 'index.html', 'index.js', 'index.wasm', 'manifest.json', 'icon-192.png', 'icon-512.png'];
self.addEventListener('install', function (e) {
- e.waitUntil(caches.open(CACHE).then(function (c) { return c.addAll(FILES); }).then(function () { return self.skipWaiting(); }));
+ // cache: 'reload' saltea la cache HTTP del navegador: si no, podia quedar un index.js
+ // viejo junto a un index.wasm nuevo
+ e.waitUntil(caches.open(CACHE).then(function (c) {
+ return c.addAll(FILES.map(function (f) { return new Request(f, { cache: 'reload' }); }));
+ }).then(function () { return self.skipWaiting(); }));
});
self.addEventListener('activate', function (e) {
diff --git a/comun/linux/guikit.c b/comun/linux/guikit.c
@@ -171,6 +171,7 @@ static int translate(SDL_Event *e, InEvent *ev)
/* arrastrar con el boton apretado (o el dedo, en pantallas tactiles) vale como
* rueda sobre el punto donde empezo: cada 33 px, una muesca (3 filas del registro) */
if (!G.drag) return 0;
+ if (!(e->motion.state & SDL_BUTTON_LMASK)) { G.drag = false; return 0; } /* se solto fuera, o pointercancel */
int x, y;
fb_pos(e, e->motion.x, e->motion.y, &x, &y);
G.drag_acc += y - G.drag_last;
@@ -228,6 +229,9 @@ int gk_wait(int timeout_ms, int nfds, InEvent *ev)
/* con sockets abiertos no se puede bloquear en SDL: se vuelve seguido a mirarlos */
int wait = nfds > 0 && timeout_ms > 25 ? 25 : timeout_ms;
if (wait < 0) wait = 0;
+#ifdef __EMSCRIPTEN__
+ if (wait == 0) emscripten_sleep(0); /* con timeout 0 SDL no cede: que el navegador respire igual */
+#endif
if (!SDL_WaitEventTimeout(&e, wait)) return 0;
do {
if (translate(&e, ev)) return 1;
@@ -266,8 +270,8 @@ static void fluid_initial(int *w, int *h)
*w = G.minw * 2;
*h = G.minh * 2;
#ifdef __EMSCRIPTEN__
- /* el canvas llena el documento (FILL_DOCUMENT), pero SDL recien lo ajusta en el
- * proximo "resize": arrancar ya con el tamano del viewport (en px CSS) */
+ /* el canvas llena la ventana por CSS, pero SDL recien lo ajusta en el proximo
+ * "resize": arrancar ya con el tamano del viewport (en px CSS) */
*w = EM_ASM_INT({ return window.innerWidth; });
*h = EM_ASM_INT({ return window.innerHeight; });
if (*w < 1 || *h < 1) { *w = G.minw; *h = G.minh; }
@@ -288,10 +292,9 @@ Surface *gk_open_fluid(const char *title, int min_w, int min_h)
G.minh = min_h;
int w, h;
fluid_initial(&w, &h);
+ /* en la web el canvas lo dimensiona el CSS (100vw x 100dvh) y SDL lo sigue en cada
+ * resize del navegador, con devicePixelRatio por HIGH_PIXEL_DENSITY */
SDL_WindowFlags flags = SDL_WINDOW_RESIZABLE | SDL_WINDOW_HIGH_PIXEL_DENSITY;
-#ifdef __EMSCRIPTEN__
- flags |= SDL_WINDOW_FILL_DOCUMENT; /* sigue al viewport del navegador y a devicePixelRatio */
-#endif
if (!SDL_CreateWindowAndRenderer(title, w, h, flags, &G.win, &G.ren)) return NULL;
SDL_SetWindowMinimumSize(G.win, min_w / 2, min_h / 2);
if (!fb_resize(min_w, min_h)) return NULL;
@@ -315,19 +318,23 @@ char *gk_clipboard(void)
/* Un campo de texto HTML encima del canvas (web/shell.html lo define: #gk-input con
* un <input>, OK y Cancelar; ver gkTextOpen/gkTextDone). Se espera cediendo al
* navegador, como hace SDL con sus cuadros de dialogo en emscripten. */
-bool gk_text_input(const char *title, char *buf, int n)
+int gk_text_input(const char *title, char *buf, int n)
{
EM_ASM({ gkTextOpen(UTF8ToString($0), UTF8ToString($1), $2); }, title, buf, n - 1);
int r;
while ((r = EM_ASM_INT({ return gkTextDone(); })) == 0) emscripten_sleep(16);
- if (r < 0) return false;
+ /* lo que se toco en el canvas mientras el cuadro estaba abierto no vale */
+ SDL_FlushEvents(SDL_EVENT_MOUSE_MOTION, SDL_EVENT_MOUSE_WHEEL);
+ SDL_FlushEvents(SDL_EVENT_FINGER_DOWN, SDL_EVENT_FINGER_CANCELED);
+ G.drag = false;
+ if (r < 0) return 0;
EM_ASM({ stringToUTF8(gkTextValue(), $0, $1); }, buf, n);
- return true;
+ return 1;
}
#else
-bool gk_text_input(const char *title, char *buf, int n)
+int gk_text_input(const char *title, char *buf, int n)
{
(void)title; (void)buf; (void)n;
- return false;
+ return -1;
}
#endif
diff --git a/comun/linux/guikit.h b/comun/linux/guikit.h
@@ -23,8 +23,8 @@ void gk_close(void);
char *gk_clipboard(void); /* texto del portapapeles (malloc) o NULL */
/* Pide una linea de texto con un cuadro propio del sistema. Solo existe en la web
* (el navegador no muestra el teclado tactil para el canvas): ahi muestra un campo de
- * entrada y devuelve true con el texto en buf (false si se cancelo). En escritorio
- * devuelve false sin hacer nada y se escribe con el teclado como siempre. */
-bool gk_text_input(const char *title, char *buf, int n);
+ * entrada y devuelve 1 con el texto en buf, o 0 si se cancelo. En escritorio devuelve
+ * -1 sin hacer nada y se escribe con el teclado como siempre. */
+int gk_text_input(const char *title, char *buf, int n);
#endif
diff --git a/comun/linux/paths.c b/comun/linux/paths.c
@@ -93,11 +93,16 @@ void paths_flush(void)
#ifdef __EMSCRIPTEN__
/* FS.syncfs es asincronico: se espera (cediendo al navegador) hasta que termine,
* con un tope por si IndexedDB no responde */
- EM_ASM({
- Module.fsPending = (Module.fsPending || 0) + 1;
- FS.syncfs(false, function(err) { Module.fsPending--; if (err) console.warn('syncfs', err); });
+ if (EM_ASM_INT({ return Module.noPersist ? 1 : 0; })) return; /* la carga fallo: no pisar IndexedDB */
+ int id = EM_ASM_INT({
+ var id = Module.fsSeq = (Module.fsSeq || 0) + 1;
+ Module.fsDone = Module.fsDone || {};
+ FS.syncfs(false, function(err) { Module.fsDone[id] = true; if (err) console.warn('syncfs', err); });
+ return id;
});
- for (int i = 0; i < 200 && EM_ASM_INT({ return Module.fsPending || 0; }) > 0; i++) emscripten_sleep(10);
+ /* se espera solo a este sync (si uno viejo nunca volvio, no bloquea a los demas) */
+ for (int i = 0; i < 200 && !EM_ASM_INT({ return Module.fsDone[$0] ? 1 : 0; }, id); i++) emscripten_sleep(10);
+ EM_ASM({ delete Module.fsDone[$0]; }, id);
#endif
}