openvibers/openvibe.tools Commits
Recent tools in the launcher (D31: anonymous and account history): every tool page view puts the tool on top of this browser's ov_recent_tools cookie (first-party, .openvibe.tools, 12 ids, no page script needed across tool hosts); GET /api/v1/me/recent-tools answers a signed-in person's tools.usage module (plus what is not yet flushed) or the cookie list, registry-filtered and never cached; the home page shows 'Your recent tools' (every device, or this browser) and Ctrl/Cmd+K focuses search
Changes
@@@ -53,16 +53,30 @@ const fetchImpl = async (url, opts) => {// The page middleware: a signed-in document request for a tool host counts; assets, APIs and unknown hosts do not.const got = [];const mw = usagePages({ snapshot: () => ({ tools: [{ id: 'jsonminify', hosts: ['json-minifier.openvibe.tools', 'jsonminify.openvibe.tools'] }] }), rec: { enabled: true, record: (s, id) => got.push(id) } });-- const req = (host, p, headers = {}, user = { subject_id: USR }) => ({ method: 'GET', hostname: host, path: p, user, get: (h) => headers[h.toLowerCase()] });-- const run = (q) => mw(q, {}, () => {});++ const req = (host, p, headers = {}, user = { subject_id: USR }) => ({ method: 'GET', hostname: host, path: p, user, headers, get: (h) => headers[h.toLowerCase()] });++ const cookies = [];++ const run = (q) => mw(q, { append: (k, v) => { assert.strictEqual(k, 'Set-Cookie'); cookies.push(v); } }, () => {});run(req('json-minifier.openvibe.tools', '/', { 'sec-fetch-dest': 'document' }));run(req('jsonminify.openvibe.tools', '/', { accept: 'text/html' }));run(req('json-minifier.openvibe.tools', '/app.js', { 'sec-fetch-dest': 'script' }));run(req('json-minifier.openvibe.tools', '/api/x', { accept: 'text/html' }));run(req('openvibe.tools', '/', { 'sec-fetch-dest': 'document', 'x-ov-tool': 'evil' }));-- run(req('json-minifier.openvibe.tools', '/', { 'sec-fetch-dest': 'document' }, null));assert.deepStrictEqual(got, ['jsonminify', 'jsonminify']);++ // Anonymous history: no account write, but the browser's cookie list gets the tool on top.++ cookies.length = 0;++ run(req('json-minifier.openvibe.tools', '/', { 'sec-fetch-dest': 'document', cookie: 'a=1; ov_recent_tools=png.jsonminify.yaml; b=2' }, null));++ assert.deepStrictEqual(got, ['jsonminify', 'jsonminify'], 'nobody signed in: no module write');++ assert.strictEqual(cookies.length, 1);++ assert.ok(cookies[0].startsWith('ov_recent_tools=jsonminify.png.yaml;'), cookies[0]);++ assert.ok(/Domain=\.openvibe\.tools/.test(cookies[0]) && /Secure/.test(cookies[0]) && /SameSite=Lax/.test(cookies[0]));++ const { recentFromCookie } = require('../usage');++ assert.deepStrictEqual(recentFromCookie({ headers: { cookie: 'ov_recent_tools=a-b.x..<script>.a-b' } }), ['a-b', 'x'], 'only tool ids, deduplicated');++ cookies.length = 0;++ const mirror = usagePages({ snapshot: () => ({ tools: [{ id: 'yaml', hosts: ['yaml.example.org'] }] }), rec: { enabled: false } });++ mirror(req('yaml.example.org', '/', { 'sec-fetch-dest': 'document' }, null), { append: (k, v) => cookies.push(v) }, () => {});++ assert.strictEqual(cookies.length, 0, 'no zone cookie from a custom domain');++const off = createRecorder({ env: {}, fetchImpl });assert.strictEqual(off.enabled, false);assert.strictEqual(off.record(USR, 'yaml'), false, 'off without a service secret');@@@ -7,6 +7,10 @@// app.use(usagePages({ snapshot: toolRegistry.snapshot })) after guard.identify: a page view of// a tool by a signed-in person counts// recorder().record(subjectId, toolId) e.g. a run API call by a person++// recorder().recent(subjectId), recentFromCookie(req) what the launcher shows++//++// Everyone's page views (signed in or not) also go into the ov_recent_tools cookie on .openvibe.tools:++// the launcher's anonymous history.//// Cheap by design: a person counts once per tool per 10 minutes, and a process flushes what it// gathered every minute: per person one read and one conditional write (If-Match), merged with what@@@ -96,7 +100,33 @@ function createRecorder({ env = process.env, fetchImpl = globalThis.fetch, now =function start() { if (timer || !enabled) return; timer = setInterval(() => { flush().catch(() => {}); }, flushMs); if (timer.unref) timer.unref(); }function stop() { if (timer) clearInterval(timer); timer = null; }-- return { enabled, record, flush, stop, stats: () => ({ enabled, pending: pending.size, ...stats }) };++ /** The person's recent tools (the module, with anything not yet flushed on top): [{ tool, at }]. */++ async function recent(subject) {++ if (!enabled || !USR_RE.test(String(subject || ''))) return [];++ const cur = await call('GET', subject);++ const had = cur.status === 200 && cur.body && cur.body.data && Array.isArray(cur.body.data.recent) ? cur.body.data.recent : [];++ const fresh = pending.get(subject);++ const top = fresh ? [...fresh].reverse().map(([tool, at]) => ({ tool, at })) : [];++ return [...top, ...had.filter((e) => e && (!fresh || !fresh.has(e.tool)))].slice(0, MAX);++ }++++ return { enabled, record, recent, flush, stop, stats: () => ({ enabled, pending: pending.size, ...stats }) };++}++++// Anonymous history: the same list, per browser, in a first-party cookie on the tools zone, so every++// tool host (each its own origin) adds to one list without any page script.++const COOKIE = 'ov_recent_tools';++const COOKIE_MAX = 12;++function recentFromCookie(req) {++ const m = String((req.headers && req.headers.cookie) || '').match(/(?:^|;\s*)ov_recent_tools=([^;]*)/);++ if (!m) return [];++ let v = ''; try { v = decodeURIComponent(m[1]); } catch { return []; }++ return [...new Set(v.split('.').filter((id) => TOOL_RE.test(id)))].slice(0, COOKIE_MAX);++}++function rememberInCookie(req, res, tool, host) {++ if (!res || typeof res.append !== 'function' || !/(^|\.)openvibe\.tools$/.test(host)) return;++ const list = [tool, ...recentFromCookie(req).filter((id) => id !== tool)].slice(0, COOKIE_MAX);++ res.append('Set-Cookie', `${COOKIE}=${list.join('.')}; Domain=.openvibe.tools; Path=/; Max-Age=${180 * 24 * 3600}; SameSite=Lax; Secure`);}let _recorder = null;@@@ -117,18 +147,21 @@ function usagePages({ snapshot, rec = null }) {mapFor = s;return map;};-- return function toolUsage(req, _res, next) {++ return function toolUsage(req, res, next) {try {-- const sid = req.user && req.user.subject_id;-- const r = rec || recorder();-- if (sid && r.enabled && req.method === 'GET' && !req.path.startsWith('/api/') && !/\.[a-z0-9]{1,8}$/i.test(req.path)) {++ if (req.method === 'GET' && !req.path.startsWith('/api/') && !/\.[a-z0-9]{1,8}$/i.test(req.path)) {const dest = req.get('sec-fetch-dest');if (dest ? dest === 'document' : /text\/html/.test(req.get('accept') || '')) {const host = String(req.hostname || '').toLowerCase();const m = hostMap();const named = String(req.get('x-ov-tool') || '');const tool = m.get(host) || (m.ids.has(named) ? named : null);-- if (tool) r.record(sid, tool);++ if (tool) {++ rememberInCookie(req, res, tool, host);++ const sid = req.user && req.user.subject_id;++ const r = rec || recorder();++ if (sid && r.enabled) r.record(sid, tool);++ }}}} catch { /* usage is best-effort */ }@@@ -136,4 +169,4 @@ function usagePages({ snapshot, rec = null }) {};}--module.exports = { createRecorder, recorder, usagePages, NS };++module.exports = { createRecorder, recorder, usagePages, recentFromCookie, NS, COOKIE };@@@ -19,7 +19,8 @@var input = document.getElementById('q'), results = document.getElementById('results');if (!input || !results) return;var grid = document.getElementById('results-grid'), empty = document.getElementById('results-empty');-- var catalog = null, loading = null, others = [].slice.call(document.querySelectorAll('main > section:not(#results)'));++ var catalog = null, loading = null, others = [].slice.call(document.querySelectorAll('main > section:not(#results):not(#recent)'));++ var recentSec = document.getElementById('recent'), recentReady = false;function load() {if (catalog) return Promise.resolve(catalog);@@@ -49,11 +50,11 @@q = q.trim();var url = new URL(location.href); if (q) url.searchParams.set('q', q); else url.searchParams.delete('q');if (url.href !== location.href) history[push ? 'pushState' : 'replaceState']({ q: q }, '', url);-- if (!q) { results.hidden = true; others.forEach(function (s) { s.hidden = false; }); return; }++ if (!q) { results.hidden = true; others.forEach(function (s) { s.hidden = false; }); if (recentSec) recentSec.hidden = !recentReady; return; }load().then(function () {if (input.value.trim() !== q) return;var hits = find(q); grid.replaceChildren.apply(grid, hits.map(card)); empty.hidden = hits.length > 0;-- results.hidden = false; others.forEach(function (s) { s.hidden = true; });++ results.hidden = false; others.forEach(function (s) { s.hidden = true; }); if (recentSec) recentSec.hidden = true;if (window.OpenVibeIcons) OpenVibeIcons.mount(grid); else if (!document.getElementById('ov-icons-loader')) { var sc = document.createElement('script'); sc.id = 'ov-icons-loader'; sc.src = 'https://openvibe.network/shared/ov-icons.js'; document.head.appendChild(sc); }});}@@@ -62,6 +63,24 @@input.addEventListener('input', function () { clearTimeout(timer); timer = setTimeout(function () { show(input.value, false); }, 90); });input.form.addEventListener('submit', function (e) { e.preventDefault(); show(input.value, true); var first = grid.querySelector('a'); if (first && input.value.trim()) first.focus(); });window.addEventListener('popstate', function () { var q = new URL(location.href).searchParams.get('q') || ''; input.value = q; show(q, false); });-- document.addEventListener('keydown', function (e) { if (e.key === '/' && document.activeElement !== input && !/input|textarea|select/i.test(document.activeElement.tagName)) { e.preventDefault(); input.focus(); } });++ document.addEventListener('keydown', function (e) {++ var k = (e.key || '').toLowerCase();++ if ((k === 'k' && (e.ctrlKey || e.metaKey) && !e.altKey) || (e.key === '/' && document.activeElement !== input && !/input|textarea|select/i.test(document.activeElement.tagName))) { e.preventDefault(); input.focus(); input.select(); }++ });++++ // Recent tools: this person's (every device they sign in to) or this browser's. Filled in after load.++ function mountIcons(el) { if (window.OpenVibeIcons) OpenVibeIcons.mount(el); else if (!document.getElementById('ov-icons-loader')) { var sc = document.createElement('script'); sc.id = 'ov-icons-loader'; sc.src = 'https://openvibe.network/shared/ov-icons.js'; document.head.appendChild(sc); } }++ if (recentSec) fetch('/api/v1/me/recent-tools', { credentials: 'same-origin' }).then(function (r) { return r.ok ? r.json() : null; }).then(function (d) {++ if (!d || !d.recent || !d.recent.length) return null;++ return load().then(function () {++ var byId = {}; catalog.forEach(function (c) { byId[c.t.id] = c.t; });++ var list = d.recent.map(function (e) { return byId[e.tool]; }).filter(Boolean).slice(0, 8);++ if (!list.length) return;++ var g = document.getElementById('recent-grid'); g.replaceChildren.apply(g, list.map(card));++ document.getElementById('recent-note').textContent = d.mode === 'account' ? 'On every device you sign in to' : 'On this browser';++ recentReady = true; if (!input.value.trim()) recentSec.hidden = false;++ mountIcons(g);++ });++ }).catch(function () { /* optional */ });var q0 = new URL(location.href).searchParams.get('q'); if (q0) { input.value = q0; show(q0, false); }})();@@@ -192,6 +192,20 @@ app.use('/auth/', rateLimit({ windowMs: 15 * 60_000, max: 60, keyGenerator: (req// the /api/ limiter above. /api/catalog.json stays as it is (Network reads it).app.use(createToolsApi({ snapshot: toolRegistry.snapshot }));++// The launcher's recent tools: a signed-in person's tools.usage module (every device), otherwise this++// browser's ov_recent_tools cookie. Only tools the registry still lists; never cached.++app.get('/api/v1/me/recent-tools', async (req, res) => {++ const usage = require('../../_shared/usage');++ res.set('Cache-Control', 'private, no-store');++ const known = new Set((toolRegistry.snapshot().tools || []).filter((t) => t.status !== 'unavailable').map((t) => t.id));++ const sid = req.user && req.user.subject_id;++ let mode = 'device', recent = usage.recentFromCookie(req).map((tool) => ({ tool }));++ if (sid && usage.recorder().enabled) {++ try { recent = await usage.recorder().recent(sid); mode = 'account'; } catch { /* the cookie list stands in */ }++ }++ res.json({ mode, recent: recent.filter((e) => e && known.has(e.tool)).slice(0, 12) });++});++// ── Net.OpenVibe and Dev.OpenVibe routes (the pages' API; the run API calls the same handlers) ──const netRouter = createNetRoutes(null, requireAuth, { guard });const devRouter = createDevRoutes(null, requireAuth, { guard });@@@ -160,6 +160,7 @@ ${searchForm('')}<ul class="stats"><li><b>${tools.length}</b> tools</li><li><b>${fams.length}</b> families</li><li><b>0</b> installs</li><li><b>1</b> account for all of OpenVibe</li></ul></div><nav class="jump" aria-label="Tool families">${fams.map(f => `<a href="#f-${f.id}">${icon(f.icon, 22)}${esc(f.name)}<i>${tools.filter(t => t.family === f.id).length}</i></a>`).join('')}</nav><section id="results" hidden aria-live="polite"><div class="sec-h"><h2>Results</h2></div><div class="grid" id="results-grid"></div><p class="empty" id="results-empty" hidden>No tool matches that yet. <a href="/all-tools">Browse every tool</a>.</p></section>++<section id="recent" hidden aria-labelledby="recent-h"><div class="sec-h"><h2 id="recent-h">Your recent tools</h2><span class="more" id="recent-note"></span></div><div class="grid" id="recent-grid"></div></section><section aria-labelledby="pop-h"><div class="sec-h"><h2 id="pop-h">What people reach for</h2><a class="more" href="/all-tools">A to Z list</a></div><div class="grid">${popular.slice(0, 8).map(toolCard).join('')}</div></section>${fams.map(f => { const list = tools.filter(t => t.family === f.id); const next = planned.filter(t => t.family === f.id); return `<section class="fam" aria-labelledby="f-${f.id}"><div class="sec-h">${icon(f.icon, 40)}<div><h2 id="f-${f.id}"><a href="${esc(f.path)}">${esc(f.name)}</a></h2><p class="fam-tag">${esc(f.tagline)}</p></div><a class="more" href="${esc(f.path)}">About these ${list.length}</a></div><div class="tiles">${list.map(tile).join('')}${next.map(soon).join('')}</div></section>`; }).join('')}<section aria-labelledby="acct-h"><div class="sec-h"><h2 id="acct-h">Better with an account, fine without</h2></div><div class="perks">
@@ -53,16 +53,30 @@ const fetchImpl = async (url, opts) => {
@@ -53,16 +53,30 @@ const fetchImpl = async (url, opts) => {
// The page middleware: a signed-in document request for a tool host counts; assets, APIs and unknown hosts do not.
// The page middleware: a signed-in document request for a tool host counts; assets, APIs and unknown hosts do not.
const got = [];
const got = [];
const mw = usagePages({ snapshot: () => ({ tools: [{ id: 'jsonminify', hosts: ['json-minifier.openvibe.tools', 'jsonminify.openvibe.tools'] }] }), rec: { enabled: true, record: (s, id) => got.push(id) } });
const mw = usagePages({ snapshot: () => ({ tools: [{ id: 'jsonminify', hosts: ['json-minifier.openvibe.tools', 'jsonminify.openvibe.tools'] }] }), rec: { enabled: true, record: (s, id) => got.push(id) } });
- const req = (host, p, headers = {}, user = { subject_id: USR }) => ({ method: 'GET', hostname: host, path: p, user, get: (h) => headers[h.toLowerCase()] });
- const run = (q) => mw(q, {}, () => {});
+ const req = (host, p, headers = {}, user = { subject_id: USR }) => ({ method: 'GET', hostname: host, path: p, user, headers, get: (h) => headers[h.toLowerCase()] });
+ const cookies = [];
+ const run = (q) => mw(q, { append: (k, v) => { assert.strictEqual(k, 'Set-Cookie'); cookies.push(v); } }, () => {});
run(req('json-minifier.openvibe.tools', '/', { 'sec-fetch-dest': 'document' }));
run(req('json-minifier.openvibe.tools', '/', { 'sec-fetch-dest': 'document' }));
run(req('jsonminify.openvibe.tools', '/', { accept: 'text/html' }));
run(req('jsonminify.openvibe.tools', '/', { accept: 'text/html' }));
run(req('json-minifier.openvibe.tools', '/app.js', { 'sec-fetch-dest': 'script' }));
run(req('json-minifier.openvibe.tools', '/app.js', { 'sec-fetch-dest': 'script' }));
run(req('json-minifier.openvibe.tools', '/api/x', { accept: 'text/html' }));
run(req('json-minifier.openvibe.tools', '/api/x', { accept: 'text/html' }));
run(req('openvibe.tools', '/', { 'sec-fetch-dest': 'document', 'x-ov-tool': 'evil' }));
run(req('openvibe.tools', '/', { 'sec-fetch-dest': 'document', 'x-ov-tool': 'evil' }));
- run(req('json-minifier.openvibe.tools', '/', { 'sec-fetch-dest': 'document' }, null));
assert.deepStrictEqual(got, ['jsonminify', 'jsonminify']);
assert.deepStrictEqual(got, ['jsonminify', 'jsonminify']);
+ // Anonymous history: no account write, but the browser's cookie list gets the tool on top.
+ cookies.length = 0;
+ run(req('json-minifier.openvibe.tools', '/', { 'sec-fetch-dest': 'document', cookie: 'a=1; ov_recent_tools=png.jsonminify.yaml; b=2' }, null));
+ assert.deepStrictEqual(got, ['jsonminify', 'jsonminify'], 'nobody signed in: no module write');
+ assert.strictEqual(cookies.length, 1);
+ assert.ok(cookies[0].startsWith('ov_recent_tools=jsonminify.png.yaml;'), cookies[0]);
+ assert.ok(/Domain=\.openvibe\.tools/.test(cookies[0]) && /Secure/.test(cookies[0]) && /SameSite=Lax/.test(cookies[0]));
+ const { recentFromCookie } = require('../usage');
+ assert.deepStrictEqual(recentFromCookie({ headers: { cookie: 'ov_recent_tools=a-b.x..<script>.a-b' } }), ['a-b', 'x'], 'only tool ids, deduplicated');
+ cookies.length = 0;
+ const mirror = usagePages({ snapshot: () => ({ tools: [{ id: 'yaml', hosts: ['yaml.example.org'] }] }), rec: { enabled: false } });
+ mirror(req('yaml.example.org', '/', { 'sec-fetch-dest': 'document' }, null), { append: (k, v) => cookies.push(v) }, () => {});
+ assert.strictEqual(cookies.length, 0, 'no zone cookie from a custom domain');
+
const off = createRecorder({ env: {}, fetchImpl });
const off = createRecorder({ env: {}, fetchImpl });
assert.strictEqual(off.enabled, false);
assert.strictEqual(off.enabled, false);
assert.strictEqual(off.record(USR, 'yaml'), false, 'off without a service secret');
assert.strictEqual(off.record(USR, 'yaml'), false, 'off without a service secret');
@@ -7,6 +7,10 @@
@@ -7,6 +7,10 @@
// app.use(usagePages({ snapshot: toolRegistry.snapshot })) after guard.identify: a page view of
// app.use(usagePages({ snapshot: toolRegistry.snapshot })) after guard.identify: a page view of
// a tool by a signed-in person counts
// a tool by a signed-in person counts
// recorder().record(subjectId, toolId) e.g. a run API call by a person
// recorder().record(subjectId, toolId) e.g. a run API call by a person
+// recorder().recent(subjectId), recentFromCookie(req) what the launcher shows
+//
+// Everyone's page views (signed in or not) also go into the ov_recent_tools cookie on .openvibe.tools:
+// the launcher's anonymous history.
//
//
// Cheap by design: a person counts once per tool per 10 minutes, and a process flushes what it
// Cheap by design: a person counts once per tool per 10 minutes, and a process flushes what it
// gathered every minute: per person one read and one conditional write (If-Match), merged with what
// gathered every minute: per person one read and one conditional write (If-Match), merged with what
@@ -96,7 +100,33 @@ function createRecorder({ env = process.env, fetchImpl = globalThis.fetch, now =
@@ -96,7 +100,33 @@ function createRecorder({ env = process.env, fetchImpl = globalThis.fetch, now =
function start() { if (timer || !enabled) return; timer = setInterval(() => { flush().catch(() => {}); }, flushMs); if (timer.unref) timer.unref(); }
function start() { if (timer || !enabled) return; timer = setInterval(() => { flush().catch(() => {}); }, flushMs); if (timer.unref) timer.unref(); }
function stop() { if (timer) clearInterval(timer); timer = null; }
function stop() { if (timer) clearInterval(timer); timer = null; }
- return { enabled, record, flush, stop, stats: () => ({ enabled, pending: pending.size, ...stats }) };
+ /** The person's recent tools (the module, with anything not yet flushed on top): [{ tool, at }]. */
+ async function recent(subject) {
+ if (!enabled || !USR_RE.test(String(subject || ''))) return [];
+ const cur = await call('GET', subject);
+ const had = cur.status === 200 && cur.body && cur.body.data && Array.isArray(cur.body.data.recent) ? cur.body.data.recent : [];
+ const fresh = pending.get(subject);
+ const top = fresh ? [...fresh].reverse().map(([tool, at]) => ({ tool, at })) : [];
+ return [...top, ...had.filter((e) => e && (!fresh || !fresh.has(e.tool)))].slice(0, MAX);
+ }
+
+ return { enabled, record, recent, flush, stop, stats: () => ({ enabled, pending: pending.size, ...stats }) };
+}
+
+// Anonymous history: the same list, per browser, in a first-party cookie on the tools zone, so every
+// tool host (each its own origin) adds to one list without any page script.
+const COOKIE = 'ov_recent_tools';
+const COOKIE_MAX = 12;
+function recentFromCookie(req) {
+ const m = String((req.headers && req.headers.cookie) || '').match(/(?:^|;\s*)ov_recent_tools=([^;]*)/);
+ if (!m) return [];
+ let v = ''; try { v = decodeURIComponent(m[1]); } catch { return []; }
+ return [...new Set(v.split('.').filter((id) => TOOL_RE.test(id)))].slice(0, COOKIE_MAX);
+}
+function rememberInCookie(req, res, tool, host) {
+ if (!res || typeof res.append !== 'function' || !/(^|\.)openvibe\.tools$/.test(host)) return;
+ const list = [tool, ...recentFromCookie(req).filter((id) => id !== tool)].slice(0, COOKIE_MAX);
+ res.append('Set-Cookie', `${COOKIE}=${list.join('.')}; Domain=.openvibe.tools; Path=/; Max-Age=${180 * 24 * 3600}; SameSite=Lax; Secure`);
}
}
let _recorder = null;
let _recorder = null;
@@ -117,18 +147,21 @@ function usagePages({ snapshot, rec = null }) {
@@ -117,18 +147,21 @@ function usagePages({ snapshot, rec = null }) {
mapFor = s;
mapFor = s;
return map;
return map;
};
};
- return function toolUsage(req, _res, next) {
+ return function toolUsage(req, res, next) {
try {
try {
- const sid = req.user && req.user.subject_id;
- const r = rec || recorder();
- if (sid && r.enabled && req.method === 'GET' && !req.path.startsWith('/api/') && !/\.[a-z0-9]{1,8}$/i.test(req.path)) {
+ if (req.method === 'GET' && !req.path.startsWith('/api/') && !/\.[a-z0-9]{1,8}$/i.test(req.path)) {
const dest = req.get('sec-fetch-dest');
const dest = req.get('sec-fetch-dest');
if (dest ? dest === 'document' : /text\/html/.test(req.get('accept') || '')) {
if (dest ? dest === 'document' : /text\/html/.test(req.get('accept') || '')) {
const host = String(req.hostname || '').toLowerCase();
const host = String(req.hostname || '').toLowerCase();
const m = hostMap();
const m = hostMap();
const named = String(req.get('x-ov-tool') || '');
const named = String(req.get('x-ov-tool') || '');
const tool = m.get(host) || (m.ids.has(named) ? named : null);
const tool = m.get(host) || (m.ids.has(named) ? named : null);
- if (tool) r.record(sid, tool);
+ if (tool) {
+ rememberInCookie(req, res, tool, host);
+ const sid = req.user && req.user.subject_id;
+ const r = rec || recorder();
+ if (sid && r.enabled) r.record(sid, tool);
+ }
}
}
}
}
} catch { /* usage is best-effort */ }
} catch { /* usage is best-effort */ }
@@ -136,4 +169,4 @@ function usagePages({ snapshot, rec = null }) {
@@ -136,4 +169,4 @@ function usagePages({ snapshot, rec = null }) {
};
};
}
}
-module.exports = { createRecorder, recorder, usagePages, NS };
+module.exports = { createRecorder, recorder, usagePages, recentFromCookie, NS, COOKIE };
@@ -19,7 +19,8 @@
@@ -19,7 +19,8 @@
var input = document.getElementById('q'), results = document.getElementById('results');
var input = document.getElementById('q'), results = document.getElementById('results');
if (!input || !results) return;
if (!input || !results) return;
var grid = document.getElementById('results-grid'), empty = document.getElementById('results-empty');
var grid = document.getElementById('results-grid'), empty = document.getElementById('results-empty');
- var catalog = null, loading = null, others = [].slice.call(document.querySelectorAll('main > section:not(#results)'));
+ var catalog = null, loading = null, others = [].slice.call(document.querySelectorAll('main > section:not(#results):not(#recent)'));
+ var recentSec = document.getElementById('recent'), recentReady = false;
function load() {
function load() {
if (catalog) return Promise.resolve(catalog);
if (catalog) return Promise.resolve(catalog);
@@ -49,11 +50,11 @@
@@ -49,11 +50,11 @@
q = q.trim();
q = q.trim();
var url = new URL(location.href); if (q) url.searchParams.set('q', q); else url.searchParams.delete('q');
var url = new URL(location.href); if (q) url.searchParams.set('q', q); else url.searchParams.delete('q');
if (url.href !== location.href) history[push ? 'pushState' : 'replaceState']({ q: q }, '', url);
if (url.href !== location.href) history[push ? 'pushState' : 'replaceState']({ q: q }, '', url);
- if (!q) { results.hidden = true; others.forEach(function (s) { s.hidden = false; }); return; }
+ if (!q) { results.hidden = true; others.forEach(function (s) { s.hidden = false; }); if (recentSec) recentSec.hidden = !recentReady; return; }
load().then(function () {
load().then(function () {
if (input.value.trim() !== q) return;
if (input.value.trim() !== q) return;
var hits = find(q); grid.replaceChildren.apply(grid, hits.map(card)); empty.hidden = hits.length > 0;
var hits = find(q); grid.replaceChildren.apply(grid, hits.map(card)); empty.hidden = hits.length > 0;
- results.hidden = false; others.forEach(function (s) { s.hidden = true; });
+ results.hidden = false; others.forEach(function (s) { s.hidden = true; }); if (recentSec) recentSec.hidden = true;
if (window.OpenVibeIcons) OpenVibeIcons.mount(grid); else if (!document.getElementById('ov-icons-loader')) { var sc = document.createElement('script'); sc.id = 'ov-icons-loader'; sc.src = 'https://openvibe.network/shared/ov-icons.js'; document.head.appendChild(sc); }
if (window.OpenVibeIcons) OpenVibeIcons.mount(grid); else if (!document.getElementById('ov-icons-loader')) { var sc = document.createElement('script'); sc.id = 'ov-icons-loader'; sc.src = 'https://openvibe.network/shared/ov-icons.js'; document.head.appendChild(sc); }
});
});
}
}
@@ -62,6 +63,24 @@
@@ -62,6 +63,24 @@
input.addEventListener('input', function () { clearTimeout(timer); timer = setTimeout(function () { show(input.value, false); }, 90); });
input.addEventListener('input', function () { clearTimeout(timer); timer = setTimeout(function () { show(input.value, false); }, 90); });
input.form.addEventListener('submit', function (e) { e.preventDefault(); show(input.value, true); var first = grid.querySelector('a'); if (first && input.value.trim()) first.focus(); });
input.form.addEventListener('submit', function (e) { e.preventDefault(); show(input.value, true); var first = grid.querySelector('a'); if (first && input.value.trim()) first.focus(); });
window.addEventListener('popstate', function () { var q = new URL(location.href).searchParams.get('q') || ''; input.value = q; show(q, false); });
window.addEventListener('popstate', function () { var q = new URL(location.href).searchParams.get('q') || ''; input.value = q; show(q, false); });
- document.addEventListener('keydown', function (e) { if (e.key === '/' && document.activeElement !== input && !/input|textarea|select/i.test(document.activeElement.tagName)) { e.preventDefault(); input.focus(); } });
+ document.addEventListener('keydown', function (e) {
+ var k = (e.key || '').toLowerCase();
+ if ((k === 'k' && (e.ctrlKey || e.metaKey) && !e.altKey) || (e.key === '/' && document.activeElement !== input && !/input|textarea|select/i.test(document.activeElement.tagName))) { e.preventDefault(); input.focus(); input.select(); }
+ });
+
+ // Recent tools: this person's (every device they sign in to) or this browser's. Filled in after load.
+ function mountIcons(el) { if (window.OpenVibeIcons) OpenVibeIcons.mount(el); else if (!document.getElementById('ov-icons-loader')) { var sc = document.createElement('script'); sc.id = 'ov-icons-loader'; sc.src = 'https://openvibe.network/shared/ov-icons.js'; document.head.appendChild(sc); } }
+ if (recentSec) fetch('/api/v1/me/recent-tools', { credentials: 'same-origin' }).then(function (r) { return r.ok ? r.json() : null; }).then(function (d) {
+ if (!d || !d.recent || !d.recent.length) return null;
+ return load().then(function () {
+ var byId = {}; catalog.forEach(function (c) { byId[c.t.id] = c.t; });
+ var list = d.recent.map(function (e) { return byId[e.tool]; }).filter(Boolean).slice(0, 8);
+ if (!list.length) return;
+ var g = document.getElementById('recent-grid'); g.replaceChildren.apply(g, list.map(card));
+ document.getElementById('recent-note').textContent = d.mode === 'account' ? 'On every device you sign in to' : 'On this browser';
+ recentReady = true; if (!input.value.trim()) recentSec.hidden = false;
+ mountIcons(g);
+ });
+ }).catch(function () { /* optional */ });
var q0 = new URL(location.href).searchParams.get('q'); if (q0) { input.value = q0; show(q0, false); }
var q0 = new URL(location.href).searchParams.get('q'); if (q0) { input.value = q0; show(q0, false); }
})();
})();
@@ -192,6 +192,20 @@ app.use('/auth/', rateLimit({ windowMs: 15 * 60_000, max: 60, keyGenerator: (req
@@ -192,6 +192,20 @@ app.use('/auth/', rateLimit({ windowMs: 15 * 60_000, max: 60, keyGenerator: (req
// the /api/ limiter above. /api/catalog.json stays as it is (Network reads it).
// the /api/ limiter above. /api/catalog.json stays as it is (Network reads it).
app.use(createToolsApi({ snapshot: toolRegistry.snapshot }));
app.use(createToolsApi({ snapshot: toolRegistry.snapshot }));
+// The launcher's recent tools: a signed-in person's tools.usage module (every device), otherwise this
+// browser's ov_recent_tools cookie. Only tools the registry still lists; never cached.
+app.get('/api/v1/me/recent-tools', async (req, res) => {
+ const usage = require('../../_shared/usage');
+ res.set('Cache-Control', 'private, no-store');
+ const known = new Set((toolRegistry.snapshot().tools || []).filter((t) => t.status !== 'unavailable').map((t) => t.id));
+ const sid = req.user && req.user.subject_id;
+ let mode = 'device', recent = usage.recentFromCookie(req).map((tool) => ({ tool }));
+ if (sid && usage.recorder().enabled) {
+ try { recent = await usage.recorder().recent(sid); mode = 'account'; } catch { /* the cookie list stands in */ }
+ }
+ res.json({ mode, recent: recent.filter((e) => e && known.has(e.tool)).slice(0, 12) });
+});
+
// ── Net.OpenVibe and Dev.OpenVibe routes (the pages' API; the run API calls the same handlers) ──
// ── Net.OpenVibe and Dev.OpenVibe routes (the pages' API; the run API calls the same handlers) ──
const netRouter = createNetRoutes(null, requireAuth, { guard });
const netRouter = createNetRoutes(null, requireAuth, { guard });
const devRouter = createDevRoutes(null, requireAuth, { guard });
const devRouter = createDevRoutes(null, requireAuth, { guard });
@@ -160,6 +160,7 @@ ${searchForm('')}
@@ -160,6 +160,7 @@ ${searchForm('')}
<ul class="stats"><li><b>${tools.length}</b> tools</li><li><b>${fams.length}</b> families</li><li><b>0</b> installs</li><li><b>1</b> account for all of OpenVibe</li></ul></div>
<ul class="stats"><li><b>${tools.length}</b> tools</li><li><b>${fams.length}</b> families</li><li><b>0</b> installs</li><li><b>1</b> account for all of OpenVibe</li></ul></div>
<nav class="jump" aria-label="Tool families">${fams.map(f => `<a href="#f-${f.id}">${icon(f.icon, 22)}${esc(f.name)}<i>${tools.filter(t => t.family === f.id).length}</i></a>`).join('')}</nav>
<nav class="jump" aria-label="Tool families">${fams.map(f => `<a href="#f-${f.id}">${icon(f.icon, 22)}${esc(f.name)}<i>${tools.filter(t => t.family === f.id).length}</i></a>`).join('')}</nav>
<section id="results" hidden aria-live="polite"><div class="sec-h"><h2>Results</h2></div><div class="grid" id="results-grid"></div><p class="empty" id="results-empty" hidden>No tool matches that yet. <a href="/all-tools">Browse every tool</a>.</p></section>
<section id="results" hidden aria-live="polite"><div class="sec-h"><h2>Results</h2></div><div class="grid" id="results-grid"></div><p class="empty" id="results-empty" hidden>No tool matches that yet. <a href="/all-tools">Browse every tool</a>.</p></section>
+<section id="recent" hidden aria-labelledby="recent-h"><div class="sec-h"><h2 id="recent-h">Your recent tools</h2><span class="more" id="recent-note"></span></div><div class="grid" id="recent-grid"></div></section>
<section aria-labelledby="pop-h"><div class="sec-h"><h2 id="pop-h">What people reach for</h2><a class="more" href="/all-tools">A to Z list</a></div><div class="grid">${popular.slice(0, 8).map(toolCard).join('')}</div></section>
<section aria-labelledby="pop-h"><div class="sec-h"><h2 id="pop-h">What people reach for</h2><a class="more" href="/all-tools">A to Z list</a></div><div class="grid">${popular.slice(0, 8).map(toolCard).join('')}</div></section>
${fams.map(f => { const list = tools.filter(t => t.family === f.id); const next = planned.filter(t => t.family === f.id); return `<section class="fam" aria-labelledby="f-${f.id}"><div class="sec-h">${icon(f.icon, 40)}<div><h2 id="f-${f.id}"><a href="${esc(f.path)}">${esc(f.name)}</a></h2><p class="fam-tag">${esc(f.tagline)}</p></div><a class="more" href="${esc(f.path)}">About these ${list.length}</a></div><div class="tiles">${list.map(tile).join('')}${next.map(soon).join('')}</div></section>`; }).join('')}
${fams.map(f => { const list = tools.filter(t => t.family === f.id); const next = planned.filter(t => t.family === f.id); return `<section class="fam" aria-labelledby="f-${f.id}"><div class="sec-h">${icon(f.icon, 40)}<div><h2 id="f-${f.id}"><a href="${esc(f.path)}">${esc(f.name)}</a></h2><p class="fam-tag">${esc(f.tagline)}</p></div><a class="more" href="${esc(f.path)}">About these ${list.length}</a></div><div class="tiles">${list.map(tile).join('')}${next.map(soon).join('')}</div></section>`; }).join('')}
<section aria-labelledby="acct-h"><div class="sec-h"><h2 id="acct-h">Better with an account, fine without</h2></div><div class="perks">
<section aria-labelledby="acct-h"><div class="sec-h"><h2 id="acct-h">Better with an account, fine without</h2></div><div class="perks">