diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/draw.c | 4 | ||||
| -rw-r--r-- | src/main.c | 33 | ||||
| -rw-r--r-- | src/screen.c | 26 | ||||
| -rw-r--r-- | src/vega.h | 12 | ||||
| -rw-r--r-- | src/web.c | 436 |
5 files changed, 490 insertions, 21 deletions
@@ -412,9 +412,7 @@ void vega_draw(VegaGrid *grid, PureApp *app, const VegaScreen *screen, const cha write_cstr(&head, count); unsigned area = share - 1, visible = part->kind == VEGA_TABLE && area ? area - 1 : area; size_t *first = &grid->first[i]; - if (focus < *first) *first = focus; - if (visible && focus >= *first + visible) *first = focus - visible + 1; - if (*first + visible > rows) *first = rows > visible ? rows - visible : 0; + *first = vega_scroll(*first, focus, visible, rows); if (part->kind == VEGA_LIST) draw_list(grid, app, part, (int)i, *first, row + 1, area); else draw_table(grid, app, part, (int)i, *first, row + 1, area); row += share; @@ -17,6 +17,8 @@ * tui --paint WxH the bytes each frame sends to the terminal, driven by a script * cli plain lines for pipes, driven by a script * say sentences (the first natural-language surface), driven by a script + * web [--listen ADDRESS] [--port N] + * a web page (a GUI), on 127.0.0.1 unless another address is given * With --store the app's states live in that tree store: the app continues * where it was, whichever surface runs it. * Script commands, one per line: key KEYNAME | text HEX|- | type HEX | enter | @@ -63,11 +65,7 @@ static bool load(const char *path, const char *store) { return true; } -static int key_input(const char *key) { - for (uint32_t i = 0; i < module.inputs; ++i) - if (module.input[i].kind == PURE_KIND_KEY && !strcmp(module.input_keys[i], key)) return (int)i; - return -1; -} +static int key_input(const char *key) { return vega_key_input(&module, key); } static bool frames(void) { for (uint32_t h = 0; h < module.handlers; ++h) if (module.handler[h].event == 0) return true; @@ -77,16 +75,10 @@ static bool frames(void) { /* A key press and release; false when the app has no such key or the press failed. */ static bool press(const char *key, int *input) { *input = key_input(key); - if (*input < 0) return false; - bool ok = pure_app_input(&app, (uint32_t)*input, PURE_EDGE_DOWN) == PURE_OK; - pure_app_input(&app, (uint32_t)*input, PURE_EDGE_UP); - return ok; + return *input >= 0 && vega_press(&app, key); } -static bool commit(const char *text, size_t length) { - if (screen.prompt < 0) return false; - return pure_app_text(&app, (uint32_t)screen.prompt, (PureSpan){(const unsigned char *)text, length}) == PURE_OK; -} +static bool commit(const char *text, size_t length) { return vega_commit(&app, &screen, text, length); } static const VegaPart *part_named(const char *name) { for (unsigned i = 0; i < screen.count; ++i) @@ -383,7 +375,7 @@ static int interactive(void) { /* ---- scripts -------------------------------------------------------- */ -typedef enum { SURFACE_CLI, SURFACE_SAY, SURFACE_TUI } Surface; +typedef enum { SURFACE_CLI, SURFACE_SAY, SURFACE_TUI, SURFACE_WEB } Surface; /* painting: show writes the terminal bytes of the frame, not the grid. */ static int script(Surface surface, unsigned width, unsigned height, bool painting) { @@ -466,11 +458,14 @@ static int script(Surface surface, unsigned width, unsigned height, bool paintin } int main(int argc, char **argv) { - unsigned width = 0, height = 0; + unsigned width = 0, height = 0, port = 8080; bool dumped = false, painting = false, usage = argc < 3 || argc % 2 == 0; - const char *store = NULL; + const char *store = NULL, *listen_on = "127.0.0.1"; for (int i = 3; !usage && i + 1 < argc; i += 2) { if (!strcmp(argv[i], "--store")) store = argv[i + 1]; + else if (!strcmp(argv[i], "--listen") && !strcmp(argv[1], "web")) listen_on = argv[i + 1]; + else if (!strcmp(argv[i], "--port") && !strcmp(argv[1], "web") && sscanf(argv[i + 1], "%u", &port) == 1 && + port < 65536) {} else if ((!strcmp(argv[i], "--dump") || !strcmp(argv[i], "--paint")) && sscanf(argv[i + 1], "%ux%u", &width, &height) == 2 && width && height && width <= 1000 && height <= 1000) dumped = true, painting = !strcmp(argv[i], "--paint"); @@ -479,6 +474,7 @@ int main(int argc, char **argv) { if (usage || (dumped && strcmp(argv[1], "tui")) || (!strcmp(argv[1], "check") && argc != 3)) { fputs("vega tui|cli|say MODULE.pmod [--store PATH]\n" "vega tui MODULE.pmod [--store PATH] --dump|--paint WIDTHxHEIGHT < script\n" + "vega web MODULE.pmod [--store PATH] [--listen ADDRESS] [--port N]\n" "vega check MODULE.pmod\n", stderr); return 2; } @@ -487,10 +483,11 @@ int main(int argc, char **argv) { return vega_check(&module, print_finding, argv[2]) ? 1 : 0; } Surface surface = !strcmp(argv[1], "cli") ? SURFACE_CLI : !strcmp(argv[1], "say") ? SURFACE_SAY : - !strcmp(argv[1], "tui") ? SURFACE_TUI : (Surface)-1; + !strcmp(argv[1], "tui") ? SURFACE_TUI : !strcmp(argv[1], "web") ? SURFACE_WEB : (Surface)-1; if ((int)surface < 0) { fprintf(stderr, "vega: unknown surface %s\n", argv[1]); return 2; } if (!load(argv[2], store)) return 2; - int status = surface == SURFACE_TUI && !dumped ? interactive() : script(surface, width, height, painting); + int status = surface == SURFACE_WEB ? vega_web(&app, &screen, listen_on, port) : + surface == SURFACE_TUI && !dumped ? interactive() : script(surface, width, height, painting); /* Frame changes are stored with events; keep the last ones too. */ if (pure_app_save(&app) != PURE_OK) { fputs("vega: cannot save\n", stderr); status = status ? status : 1; } pure_app_stop(&app); diff --git a/src/screen.c b/src/screen.c index 3e8b2c4..bb50de1 100644 --- a/src/screen.c +++ b/src/screen.c @@ -157,6 +157,32 @@ size_t vega_focus(PureApp *app, const VegaPart *part) { return focus >= (double)(rows - 1) ? rows - 1 : (size_t)focus; } +size_t vega_scroll(size_t first, size_t focus, size_t visible, size_t rows) { + if (focus < first) first = focus; + if (visible && focus >= first + visible) first = focus - visible + 1; + if (first + visible > rows) first = rows > visible ? rows - visible : 0; + return first; +} + +int vega_key_input(const PureModule *m, const char *key) { + for (uint32_t i = 0; i < m->inputs; ++i) + if (m->input[i].kind == PURE_KIND_KEY && !strcmp(m->input_keys[i], key)) return (int)i; + return -1; +} + +bool vega_press(PureApp *app, const char *key) { + int input = vega_key_input(app->module, key); + if (input < 0) return false; + bool ok = pure_app_input(app, (uint32_t)input, PURE_EDGE_DOWN) == PURE_OK; + pure_app_input(app, (uint32_t)input, PURE_EDGE_UP); + return ok; +} + +bool vega_commit(PureApp *app, const VegaScreen *screen, const char *text, size_t length) { + return screen->prompt >= 0 && + pure_app_text(app, (uint32_t)screen->prompt, (PureSpan){(const unsigned char *)text, length}) == PURE_OK; +} + bool vega_point(PureApp *app, const VegaPart *part, size_t row) { return part->pointer >= 0 && pure_app_point(app, (uint32_t)part->pointer, row) == PURE_OK; } @@ -51,6 +51,15 @@ size_t vega_focus(PureApp *, const VegaPart *); /* Focus a row, as a user pointing at it (a click); false when the part's * focus is not a plain index position or the row is not there. */ bool vega_point(PureApp *, const VegaPart *, size_t row); +/* The first row to show of `rows` when `visible` fit: the least scrolling + * from first that keeps the focused row in view. */ +size_t vega_scroll(size_t first, size_t focus, size_t visible, size_t rows); +/* The key input answering a key name (Pure's names, as KeyboardEvent.code + * spells them), or -1; press sends its press and release. */ +int vega_key_input(const PureModule *, const char *key); +bool vega_press(PureApp *, const char *key); +/* Commit text to the screen's text input. */ +bool vega_commit(PureApp *, const VegaScreen *, const char *text, size_t length); VegaTone vega_tone(PureApp *, const VegaPart *, size_t row); /* Values evaluated so far, for checking that surfaces stay lazy. */ unsigned long vega_evaluations(void); @@ -68,6 +77,9 @@ unsigned vega_text_width(const char *text, size_t length); void vega_print(FILE *, PureApp *, const VegaScreen *, bool color); /* NUI: the screen as sentences (all), or only the focused item of the first list. */ void vega_say(FILE *, PureApp *, const VegaScreen *, bool all); +/* GUI: the screen as a web page on listen:port (0: any free port, printed as + * {"port":N}); runs until SIGINT or SIGTERM. */ +int vega_web(PureApp *, const VegaScreen *, const char *listen, unsigned port); /* TUI: a grid of cells, redrawn from the app on every change. first keeps each * list's scroll between frames; the focused row stays visible. shown is what diff --git a/src/web.c b/src/web.c new file mode 100644 index 0000000..a48401a --- /dev/null +++ b/src/web.c @@ -0,0 +1,436 @@ +#define _GNU_SOURCE +#include "vega.h" +#include <arpa/inet.h> +#include <errno.h> +#include <fcntl.h> +#include <netinet/in.h> +#include <poll.h> +#include <signal.h> +#include <stdlib.h> +#include <string.h> +#include <sys/socket.h> +#include <unistd.h> + +/* vega web: the screen as a web page, a GUI surface. The page asks for the + * screen as JSON with the rows it can show per list or table, so only the + * cells on the page are evaluated; it sends keys (KeyboardEvent.code names are + * Pure's), committed text and clicks on rows, and an event stream tells every + * open page when the app changed. + * + * GET / the page + * GET /screen?rows=R&first=F,G title, fields, each list or table with R rows + * from its last first row (F, G, ... by part), keys + * POST /key KEYNAME press and release the input bound to the key + * POST /text UTF-8 commit text to the screen's text input + * POST /point PART ROW focus a row of a list or table (0 first) + * GET /events server-sent events: one per change */ + +enum { CLIENTS = 64, REQUEST = 1 << 16, ROWS_MAX = 500 }; + +typedef struct { char *bytes; size_t used, capacity; } Buffer; + +typedef struct { + int fd; + bool events; /* an open event stream */ + bool closing; + char in[REQUEST]; + size_t in_used; + Buffer out; + size_t out_at; +} Client; + +static const char *const tone_names[VEGA_TONES] = {"normal", "muted", "accent", "good", "warning", "danger"}; +static volatile sig_atomic_t stopping; + +static void on_stop(int signal_number) { + (void)signal_number; + stopping = 1; +} + +static void put(Buffer *b, const void *bytes, size_t size) { + if (b->used + size > b->capacity) { + size_t capacity = b->capacity ? b->capacity : 4096; + while (capacity < b->used + size) capacity *= 2; + char *grown = realloc(b->bytes, capacity); + if (!grown) return; + b->bytes = grown; + b->capacity = capacity; + } + memcpy(b->bytes + b->used, bytes, size); + b->used += size; +} + +static void puts_(Buffer *b, const char *text) { put(b, text, strlen(text)); } + +static void putf(Buffer *b, const char *format, ...) __attribute__((format(printf, 2, 3))); +#include <stdarg.h> +static void putf(Buffer *b, const char *format, ...) { + char line[256]; + va_list args; + va_start(args, format); + int n = vsnprintf(line, sizeof(line), format, args); + va_end(args); + if (n > 0) put(b, line, (size_t)n < sizeof(line) ? (size_t)n : sizeof(line) - 1); +} + +/* A JSON string: UTF-8 as it is, quotes, backslashes and control characters escaped. */ +static void json_string(Buffer *b, const char *text, size_t length) { + put(b, "\"", 1); + for (size_t i = 0; i < length; ++i) { + unsigned char c = (unsigned char)text[i]; + if (c == '"' || c == '\\') { put(b, "\\", 1); put(b, &text[i], 1); } + else if (c < 0x20 || c == 0x7F) putf(b, "\\u%04x", c); + else put(b, &text[i], 1); + } + put(b, "\"", 1); +} + +static void json_name(Buffer *b, const char *name) { json_string(b, name, strlen(name)); } + +/* A view's text at a row, or null. */ +static void json_view(Buffer *b, PureApp *app, uint32_t view, size_t row) { + char text[VEGA_TEXT]; + size_t length; + if (vega_text(app, view, row, text, &length)) json_string(b, text, length); + else puts_(b, "null"); +} + +static void screen_json(Buffer *b, PureApp *app, const VegaScreen *screen, size_t visible, size_t *firsts) { + const PureModule *m = app->module; + puts_(b, "{\"title\":"); + if (screen->title >= 0) json_view(b, app, (uint32_t)screen->title, 0); + else puts_(b, "null"); + puts_(b, ",\"fields\":["); + bool comma = false; + for (unsigned i = 0; i < screen->count; ++i) { + const VegaPart *part = &screen->parts[i]; + if (part->kind != VEGA_FIELD) continue; + puts_(b, comma ? ",{\"name\":" : "{\"name\":"); + json_name(b, m->view_names[part->view]); + puts_(b, ",\"text\":"); + json_view(b, app, part->view, 0); + puts_(b, "}"); + comma = true; + } + puts_(b, "],\"parts\":["); + comma = false; + for (unsigned i = 0; i < screen->count; ++i) { + const VegaPart *part = &screen->parts[i]; + if (part->kind == VEGA_FIELD) continue; + size_t rows = vega_rows(app, part), focus = vega_focus(app, part); + size_t first = vega_scroll(firsts[i], focus, visible, rows); + firsts[i] = first; + puts_(b, comma ? ",{\"part\":" : "{\"part\":"); + putf(b, "%u,\"name\":", i); + json_name(b, m->view_names[part->view]); + putf(b, ",\"kind\":\"%s\",\"rows\":%zu,\"focus\":%zu,\"first\":%zu,\"pointable\":%s,\"columns\":[", + part->kind == VEGA_LIST ? "list" : "table", rows, rows ? focus : 0, first, part->pointer >= 0 ? "true" : "false"); + if (part->kind == VEGA_TABLE) + for (unsigned c = 0; c < part->column_count; ++c) { + if (c) puts_(b, ","); + json_name(b, m->view_names[part->columns[c]]); + } + puts_(b, "],\"items\":["); + for (size_t row = first; row < first + visible && row < rows; ++row) { + putf(b, "%s{\"row\":%zu,\"tone\":\"%s\"", row > first ? "," : "", row, + tone_names[vega_tone(app, part, row)]); + if (part->kind == VEGA_LIST) { + const int columns[] = {part->label, part->badge, part->detail}; + const char *const names[] = {"label", "badge", "detail"}; + for (unsigned k = 0; k < 3; ++k) { + if (columns[k] < 0) continue; + putf(b, ",\"%s\":", names[k]); + json_view(b, app, (uint32_t)columns[k], row); + } + } else { + puts_(b, ",\"cells\":["); + for (unsigned c = 0; c < part->column_count; ++c) { + if (c) puts_(b, ","); + json_view(b, app, part->columns[c], row); + } + puts_(b, "]"); + } + puts_(b, "}"); + } + puts_(b, "]}"); + comma = true; + } + puts_(b, "],\"keys\":["); + comma = false; + for (uint32_t i = 0; i < m->inputs; ++i) { + if (m->input[i].kind != PURE_KIND_KEY) continue; + bool bound = false; + for (uint32_t h = 0; h < m->handlers && !bound; ++h) bound = m->handler[h].event == 1 + 2 * i; + if (!bound) continue; + puts_(b, comma ? ",{\"input\":" : "{\"input\":"); + json_name(b, m->input_names[i]); + puts_(b, ",\"key\":"); + json_name(b, m->input_keys[i]); + puts_(b, "}"); + comma = true; + } + puts_(b, "],\"prompt\":"); + if (screen->prompt >= 0) json_name(b, m->input_names[screen->prompt]); + else puts_(b, "null"); + putf(b, ",\"events\":%llu}", (unsigned long long)app->events); +} + +/* The page: one file, no dependencies. It keeps each part's first row, asks + * for as many rows as fit, and redraws when the event stream says so. */ +static const char *const page_parts[] = { +"<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\">" +"<meta name=\"viewport\" content=\"width=device-width,initial-scale=1\"><title>Vega</title><style>" +":root{color-scheme:light dark;--fg:#1d1d1f;--bg:#fbfbfd;--line:#d2d2d7;--focus:#0a58ca;--muted:#86868b;" +"--accent:#0071e3;--good:#1e8e3e;--warning:#b06000;--danger:#d93025}" +"@media(prefers-color-scheme:dark){:root{--fg:#f5f5f7;--bg:#161618;--line:#3a3a3c;--focus:#4c8dff;--muted:#8e8e93;" +"--accent:#64a8ff;--good:#5ec26a;--warning:#f0a33a;--danger:#ff6b5e}}" +"body{margin:0;font:15px/1.5 system-ui,-apple-system,'Noto Sans KR',sans-serif;color:var(--fg);background:var(--bg)}" +"main{max-width:760px;margin:0 auto;padding:24px 16px}h1{font-size:22px;margin:0 0 12px}" +"dl{display:grid;grid-template-columns:max-content 1fr;gap:2px 16px;margin:0 0 16px}dt{color:var(--muted)}dd{margin:0}" +"section{margin:0 0 20px}h2{font-size:14px;font-weight:600;margin:0 0 6px}h2 span{color:var(--muted);font-weight:400}" +"ul{list-style:none;margin:0;padding:0;border:1px solid var(--line);border-radius:8px;overflow:hidden}" +"li,tbody tr{cursor:pointer}li{display:flex;gap:10px;padding:7px 12px;border-top:1px solid var(--line)}" +"li:first-child{border-top:0}.label{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:pre}" +".detail{color:var(--muted);white-space:pre}.badge{font-size:12px;padding:1px 8px;border:1px solid currentColor;" +"border-radius:10px;align-self:center}[aria-selected=true]{background:var(--focus);color:#fff}" +"table{width:100%;border-collapse:collapse;border:1px solid var(--line)}th{text-align:left;color:var(--muted);" +"font-weight:500}th,td{padding:6px 12px;border-top:1px solid var(--line);white-space:pre}" +".muted{color:var(--muted)}.accent{color:var(--accent)}.good{color:var(--good)}.warning{color:var(--warning)}" +".danger{color:var(--danger)}[aria-selected=true].muted,[aria-selected=true] *{color:#fff}" +"form{display:flex;gap:8px;margin-top:16px}input{flex:1;font:inherit;padding:8px 10px;border:1px solid var(--line);" +"border-radius:8px;background:transparent;color:inherit}footer{margin-top:14px;color:var(--muted);font-size:13px}" +"kbd{font:12px ui-monospace,monospace;border:1px solid var(--line);border-radius:4px;padding:0 5px;margin-right:4px}" +"</style></head><body><main>", +"<h1 id=title></h1><dl id=fields></dl><div id=parts></div>" +"<form id=prompt hidden><input id=entry autocomplete=off><button>Enter</button></form><footer id=keys></footer></main>" +"<script>", +"'use strict';let screen=null,firsts=[],busy=false,again=false;const rows=Math.max(5,Math.floor((innerHeight-220)/34));" +"const $=id=>document.getElementById(id);" +"const el=(tag,props,...kids)=>{const e=document.createElement(tag);Object.assign(e,props||{});" +"for(const k of kids)if(k!=null)e.append(k);return e};" +"async function post(path,body){await fetch(path,{method:'POST',body});refresh()}" +"async function refresh(){if(busy){again=true;return}busy=true;try{" +"const r=await fetch('/screen?rows='+rows+'&first='+firsts.join(','));screen=await r.json();render()}" +"finally{busy=false;if(again){again=false;refresh()}}}" +"function render(){const s=screen;$('title').textContent=s.title?\?'';document.title=s.title?\?'Vega';" +"$('fields').replaceChildren(...s.fields.flatMap(f=>[el('dt',{textContent:f.name}),el('dd',{textContent:f.text?\?'-'})]));" +"firsts=s.parts.map(p=>p.first);" +"$('parts').replaceChildren(...s.parts.map(p=>{const head=el('h2',{},p.name+' ',el('span',{textContent:'('+p.rows+')'}));" +"const pick=row=>{if(p.pointable)post('/point',p.part+' '+row)};" +"if(p.kind==='list'){const list=el('ul',{role:'listbox'});list.setAttribute('aria-label',p.name);" +"for(const it of p.items){const item=el('li',{className:it.tone,onclick:()=>pick(it.row)}," +"el('span',{className:'label',textContent:it.label?\?''}),it.detail?el('span',{className:'detail',textContent:it.detail}):null," +"it.badge?el('span',{className:'badge',textContent:it.badge}):null);item.setAttribute('role','option');" +"item.setAttribute('aria-selected',it.row===p.focus);list.append(item)}" +"list.onwheel=e=>{e.preventDefault();pick(Math.max(0,Math.min(p.rows-1,p.focus+Math.sign(e.deltaY))))};" +"return el('section',{},head,list)}" +"const table=el('table',{},el('thead',{},el('tr',{},...p.columns.map(c=>el('th',{textContent:c})))));" +"const body=el('tbody');for(const it of p.items){const tr=el('tr',{className:it.tone,onclick:()=>pick(it.row)}," +"...it.cells.map(c=>el('td',{textContent:c?\?'-'})));tr.setAttribute('aria-selected',it.row===p.focus);body.append(tr)}" +"table.append(body);table.onwheel=e=>{e.preventDefault();pick(Math.max(0,Math.min(p.rows-1,p.focus+Math.sign(e.deltaY))))};" +"return el('section',{},head,table)}));" +"$('prompt').hidden=!s.prompt;if(s.prompt)$('entry').placeholder=s.prompt;" +"$('keys').replaceChildren(...s.keys.map(k=>el('span',{},el('kbd',{textContent:k.key.replace(/^(Key|Digit)/,'')}),k.input+' ')))}" +"$('prompt').onsubmit=e=>{e.preventDefault();const v=$('entry').value;if(v){$('entry').value='';post('/text',v)}};" +"addEventListener('keydown',e=>{if(!screen||e.ctrlKey||e.metaKey||e.altKey)return;" +"const typing=document.activeElement===$('entry');" +"if(typing&&!['ArrowUp','ArrowDown','PageUp','PageDown','Escape'].includes(e.code))return;" +"if(screen.keys.some(k=>k.key===e.code)){e.preventDefault();post('/key',e.code)}});" +"new EventSource('/events').onmessage=refresh;refresh();" +"</script></body></html>"}; + +/* The page, joined once from its pieces. */ +static const Buffer *page(void) { + static Buffer joined; + if (!joined.used) + for (size_t i = 0; i < sizeof(page_parts) / sizeof(*page_parts); ++i) puts_(&joined, page_parts[i]); + return &joined; +} + +static void respond(Client *c, int status, const char *reason, const char *type, const char *body, size_t length) { + putf(&c->out, "HTTP/1.1 %d %s\r\nContent-Type: %s\r\nContent-Length: %zu\r\nCache-Control: no-store\r\n%s\r\n", + status, reason, type, length, c->closing ? "Connection: close\r\n" : ""); + put(&c->out, body, length); +} + +static bool query_number(const char *query, const char *name, size_t *out) { + size_t length = strlen(name); + for (const char *at = query; at && *at; at = strchr(at, '&') ? strchr(at, '&') + 1 : NULL) + if (!strncmp(at, name, length) && at[length] == '=') { + *out = strtoul(at + length + 1, NULL, 10); + return true; + } + return false; +} + +/* first=F,G,...: the parts' first rows, in the order of the screen's parts. */ +static void query_firsts(const char *query, const VegaScreen *screen, size_t *firsts) { + const char *at = query ? strstr(query, "first=") : NULL; + if (!at || (at != query && at[-1] != '&')) return; + at += 6; + for (unsigned i = 0; i < screen->count && *at && *at != '&'; ++i) { + if (screen->parts[i].kind == VEGA_FIELD) continue; + firsts[i] = strtoul(at, (char **)&at, 10); + if (*at == ',') ++at; + } +} + +/* One request. Returns whether the app changed. */ +static bool handle(Client *c, PureApp *app, const VegaScreen *screen, const char *method, char *target, + const char *body, size_t body_size) { + char *query = strchr(target, '?'); + if (query) *query++ = 0; + bool get = !strcmp(method, "GET"), post = !strcmp(method, "POST"); + uint64_t before = app->events; + if (get && !strcmp(target, "/")) { + respond(c, 200, "OK", "text/html; charset=utf-8", page()->bytes, page()->used); + return false; + } + if (get && !strcmp(target, "/screen")) { + size_t visible = 50, firsts[VEGA_PARTS] = {0}; + query_number(query, "rows", &visible); + if (visible > ROWS_MAX) visible = ROWS_MAX; + query_firsts(query, screen, firsts); + Buffer json = {0}; + screen_json(&json, app, screen, visible, firsts); + respond(c, 200, "OK", "application/json", json.bytes ? json.bytes : "", json.used); + free(json.bytes); + return false; + } + if (get && !strcmp(target, "/events")) { + puts_(&c->out, "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nCache-Control: no-store\r\n\r\n"); + putf(&c->out, "data: %llu\n\n", (unsigned long long)app->events); + c->events = true; + return false; + } + if (post && (!strcmp(target, "/key") || !strcmp(target, "/text") || !strcmp(target, "/point"))) { + char text[VEGA_TEXT]; + size_t length = body_size < sizeof(text) - 1 ? body_size : sizeof(text) - 1; + memcpy(text, body, length); + text[length] = 0; + bool ok = false; + if (target[1] == 'k') ok = vega_press(app, text); + else if (target[1] == 't') ok = vega_commit(app, screen, text, length); + else { + unsigned part = 0; + long row = -1; + ok = sscanf(text, "%u %ld", &part, &row) == 2 && part < screen->count && row >= 0 && + screen->parts[part].kind != VEGA_FIELD && vega_point(app, &screen->parts[part], (size_t)row); + } + const char *answer = ok ? "{\"ok\":true}" : "{\"ok\":false}"; + respond(c, 200, "OK", "application/json", answer, strlen(answer)); + return app->events != before; + } + respond(c, 404, "Not Found", "text/plain", "not found\n", 10); + return false; +} + +/* The requests complete in c->in, answered in order. */ +static bool serve(Client *c, PureApp *app, const VegaScreen *screen) { + bool changed = false; + while (!c->events && !c->closing) { + char *end = memmem(c->in, c->in_used, "\r\n\r\n", 4); + if (!end) { + if (c->in_used == sizeof(c->in)) c->closing = true; + return changed; + } + size_t head = (size_t)(end - c->in) + 4, body_size = 0; + *end = 0; + char *length = strcasestr(c->in, "\r\ncontent-length:"); + if (length) body_size = strtoul(length + 17, NULL, 10); + if (body_size > sizeof(c->in) - head) { c->closing = true; return changed; } + if (c->in_used < head + body_size) { *end = '\r'; return changed; } + if (strcasestr(c->in, "\r\nconnection: close")) c->closing = true; + char method[8] = "", target[1024] = ""; + if (sscanf(c->in, "%7s %1023s", method, target) == 2) + changed = handle(c, app, screen, method, target, c->in + head, body_size) || changed; + else c->closing = true; + memmove(c->in, c->in + head + body_size, c->in_used - head - body_size); + c->in_used -= head + body_size; + } + return changed; +} + +static void drop(Client *c) { + close(c->fd); + free(c->out.bytes); + c->fd = -1; +} + +int vega_web(PureApp *app, const VegaScreen *screen, const char *listen_on, unsigned port) { + int listener = socket(AF_INET, SOCK_STREAM | SOCK_NONBLOCK | SOCK_CLOEXEC, 0), one = 1; + struct sockaddr_in address = {.sin_family = AF_INET, .sin_port = htons((uint16_t)port)}; + socklen_t size = sizeof(address); + if (listener < 0 || inet_pton(AF_INET, listen_on, &address.sin_addr) != 1 || + setsockopt(listener, SOL_SOCKET, SO_REUSEADDR, &one, sizeof(one)) || + bind(listener, (struct sockaddr *)&address, sizeof(address)) || listen(listener, 64) || + getsockname(listener, (struct sockaddr *)&address, &size)) { + fprintf(stderr, "vega: cannot listen on %s:%u\n", listen_on, port); + return 2; + } + printf("{\"port\":%u}\n", ntohs(address.sin_port)); + fflush(stdout); + signal(SIGINT, on_stop); + signal(SIGTERM, on_stop); + signal(SIGPIPE, SIG_IGN); + static Client clients[CLIENTS]; + for (unsigned i = 0; i < CLIENTS; ++i) clients[i].fd = -1; + bool ticking = false; + for (uint32_t h = 0; h < app->module->handlers; ++h) ticking = ticking || app->module->handler[h].event == 0; + while (!stopping) { + struct pollfd fds[CLIENTS + 1]; + unsigned slot[CLIENTS + 1], count = 1; + fds[0] = (struct pollfd){listener, POLLIN, 0}; + for (unsigned i = 0; i < CLIENTS; ++i) { + Client *c = &clients[i]; + if (c->fd < 0) continue; + fds[count] = (struct pollfd){c->fd, (short)(POLLIN | (c->out_at < c->out.used ? POLLOUT : 0)), 0}; + slot[count++] = i; + } + int ready = poll(fds, count, ticking ? 33 : 1000); + if (ready < 0 && errno != EINTR) break; + bool changed = false; + if (ready == 0 && ticking) changed = pure_app_frame(app) == PURE_OK; + if (fds[0].revents & POLLIN) + for (int fd; (fd = accept4(listener, NULL, NULL, SOCK_NONBLOCK | SOCK_CLOEXEC)) >= 0;) { + unsigned free_slot = CLIENTS; + for (unsigned i = 0; i < CLIENTS && free_slot == CLIENTS; ++i) if (clients[i].fd < 0) free_slot = i; + if (free_slot == CLIENTS) { close(fd); continue; } + clients[free_slot] = (Client){.fd = fd}; + } + for (unsigned k = 1; k < count; ++k) { + Client *c = &clients[slot[k]]; + if (fds[k].revents & (POLLIN | POLLHUP | POLLERR)) { + ssize_t n = read(c->fd, c->in + c->in_used, sizeof(c->in) - c->in_used); + if (n > 0) { + c->in_used += (size_t)n; + changed = serve(c, app, screen) || changed; + } else if (n == 0 || (errno != EAGAIN && errno != EINTR)) { drop(c); continue; } + } + } + /* Every open page hears of a change once. */ + if (changed) + for (unsigned i = 0; i < CLIENTS; ++i) + if (clients[i].fd >= 0 && clients[i].events) + putf(&clients[i].out, "data: %llu\n\n", (unsigned long long)app->events); + for (unsigned i = 0; i < CLIENTS; ++i) { + Client *c = &clients[i]; + if (c->fd < 0) continue; + while (c->out_at < c->out.used) { + ssize_t n = write(c->fd, c->out.bytes + c->out_at, c->out.used - c->out_at); + if (n <= 0) { + if (n < 0 && (errno == EAGAIN || errno == EINTR)) break; + c->closing = true; + c->out_at = c->out.used; + break; + } + c->out_at += (size_t)n; + } + if (c->out_at == c->out.used) c->out_at = c->out.used = 0; + if (c->closing && !c->out.used) drop(c); + } + } + for (unsigned i = 0; i < CLIENTS; ++i) if (clients[i].fd >= 0) drop(&clients[i]); + close(listener); + return 0; +} |
