lampson 0.2.8 → 0.2.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +10 -1
- package/README.md +8 -3
- package/bin/lampson.js +1 -1
- package/chat.syn +111 -7
- package/cli.syn +143 -7
- package/edge.syn +26 -0
- package/hub.tpl.syn +127 -6
- package/lampson.ps1 +43 -2
- package/lampson.sh +40 -2
- package/lib/agents.syn +27 -6
- package/lib/approvals.syn +2 -2
- package/lib/auth.syn +274 -0
- package/lib/computer.syn +2000 -0
- package/lib/computer_judge.syn +410 -0
- package/lib/fs.syn +19 -3
- package/lib/git.syn +2 -2
- package/lib/judge.syn +78 -0
- package/lib/line.syn +2 -2
- package/lib/loop.syn +36 -4
- package/lib/lsp.syn +4 -4
- package/lib/mcp.syn +20 -4
- package/lib/md.syn +11 -11
- package/lib/origin.syn +67 -0
- package/lib/permission.syn +21 -2
- package/lib/prompt.syn +9 -1
- package/lib/provider.syn +70 -20
- package/lib/schedule.syn +12 -12
- package/lib/session.syn +5 -5
- package/lib/settings.syn +65 -1
- package/lib/skills.syn +22 -6
- package/lib/tools/bash.syn +1 -1
- package/lib/tools/common.syn +1 -1
- package/lib/tools/fetch.syn +98 -71
- package/lib/tools/grep.syn +1 -1
- package/lib/tools/memo.syn +3 -3
- package/lib/tools/proc.syn +38 -20
- package/lib/tools/qr.js +26 -0
- package/lib/tools/url.syn +5 -5
- package/lib/tools.syn +23 -2
- package/lib/trace.syn +2 -2
- package/lib/tree.syn +29 -13
- package/lib/update.syn +1 -1
- package/lib/workspaces.syn +202 -36
- package/package.json +3 -2
- package/public/apple-touch-icon.png +0 -0
- package/public/badge-96.png +0 -0
- package/public/css/code.css +48 -0
- package/public/css/hub.css +9 -0
- package/public/css/login.css +16 -0
- package/public/css/panel.css +30 -0
- package/public/css/tokens.css +1 -1
- package/public/hub.html +11 -1
- package/public/icon-192.png +0 -0
- package/public/icon-512.png +0 -0
- package/public/icon-maskable-512.png +0 -0
- package/public/icons-src/badge.svg +12 -0
- package/public/icons-src/icon-maskable.svg +15 -0
- package/public/icons-src/icon.svg +13 -0
- package/public/index.html +14 -3
- package/public/js/agents.js +1 -1
- package/public/js/chat.js +1 -1
- package/public/js/config.js +145 -2
- package/public/js/core.js +4 -2
- package/public/js/devices.js +80 -0
- package/public/js/editor.js +232 -0
- package/public/js/login.js +78 -0
- package/public/js/procs.js +1 -1
- package/public/js/pwa.js +5 -0
- package/public/js/tree.js +12 -4
- package/public/login.html +43 -0
- package/public/manifest.webmanifest +16 -0
- package/public/offline.html +24 -0
- package/public/sw.js +63 -0
- package/public/vendor/highlight.min.js +1343 -0
- package/public/vendor/qrcode.js +2297 -0
- package/skills/computer-use/SKILL.md +178 -0
- package/skills/lampson/SKILL.md +5 -0
- package/web.syn +67 -6
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
// editor.js — editor de archivos del visor: un textarea transparente (caret, selección, teclado nativo) sobre un
|
|
2
|
+
// <pre> con el mismo texto resaltado por highlight.js (vendor/highlight.min.js = build "common" + powershell +
|
|
3
|
+
// dockerfile; Synsema se registra acá, traducido de la gramática TextMate oficial del repo kitecosmic/synsema,
|
|
4
|
+
// editors/vscode/syntaxes/synsema.tmLanguage.json). El gutter con los números va aparte y sigue el scroll.
|
|
5
|
+
//
|
|
6
|
+
// Guardar: Ctrl+S o el botón → POST /api/fs {op:"write"} (lib/fs.syn: solo archivos que existen, dentro del
|
|
7
|
+
// workspace). Antes de guardar se relee el archivo: si cambió en disco (el agente lo editó mientras tanto) se
|
|
8
|
+
// avisa y se elige sobrescribir o recargar, nunca se pisa a ciegas. Cambiar de archivo con cambios sin guardar
|
|
9
|
+
// no los pierde: quedan como borrador en memoria y vuelven al reabrirlo (se pierden al recargar la página, con aviso).
|
|
10
|
+
// Solo lectura: archivos truncados por la API (> 200k) y binarios — guardar un recorte destruiría el archivo.
|
|
11
|
+
|
|
12
|
+
const LANG_BY_EXT = {
|
|
13
|
+
js: 'javascript', mjs: 'javascript', cjs: 'javascript', jsx: 'javascript', ts: 'typescript', mts: 'typescript', cts: 'typescript', tsx: 'typescript',
|
|
14
|
+
css: 'css', scss: 'scss', less: 'less', html: 'xml', htm: 'xml', xml: 'xml', svg: 'xml', vue: 'xml', svelte: 'xml', xsl: 'xml', plist: 'xml',
|
|
15
|
+
json: 'json', jsonc: 'json', md: 'markdown', markdown: 'markdown', py: 'python', rs: 'rust', go: 'go',
|
|
16
|
+
sh: 'bash', bash: 'bash', zsh: 'bash', env: 'bash', ps1: 'powershell', psm1: 'powershell', psd1: 'powershell',
|
|
17
|
+
yml: 'yaml', yaml: 'yaml', sql: 'sql', c: 'c', h: 'c', cpp: 'cpp', cc: 'cpp', cxx: 'cpp', hpp: 'cpp', hh: 'cpp', cs: 'csharp',
|
|
18
|
+
java: 'java', kt: 'kotlin', kts: 'kotlin', swift: 'swift', rb: 'ruby', php: 'php', lua: 'lua', r: 'r', pl: 'perl',
|
|
19
|
+
ini: 'ini', toml: 'ini', cfg: 'ini', conf: 'ini', properties: 'ini', diff: 'diff', patch: 'diff', graphql: 'graphql', gql: 'graphql',
|
|
20
|
+
wasm: 'wasm', wat: 'wasm', syn: 'synsema', fsyn: 'synsema', txt: 'plaintext', log: 'plaintext', csv: 'plaintext', lock: 'plaintext',
|
|
21
|
+
prisma: 'prisma', tsbuildinfo: 'json', map: 'json', webmanifest: 'json', jsonl: 'json', har: 'json', mdx: 'markdown', proto: 'protobuf',
|
|
22
|
+
bat: 'dos', cmd: 'dos', psql: 'sql', pgsql: 'sql', mysql: 'sql', m: 'objectivec', csproj: 'xml', xaml: 'xml', astro: 'xml', ejs: 'xml', hbs: 'xml',
|
|
23
|
+
// nombres de fence habituales en el chat que no son extensiones
|
|
24
|
+
javascript: 'javascript', typescript: 'typescript', python: 'python', rust: 'rust', shell: 'bash', powershell: 'powershell',
|
|
25
|
+
synsema: 'synsema', html5: 'xml', dockerfile: 'dockerfile', makefile: 'makefile', text: 'plaintext', plaintext: 'plaintext'
|
|
26
|
+
};
|
|
27
|
+
const LANG_BY_NAME = { dockerfile: 'dockerfile', makefile: 'makefile', gnumakefile: 'makefile', '.env': 'bash', '.env.example': 'bash', '.gitignore': 'plaintext', '.gitattributes': 'plaintext', '.npmrc': 'ini', '.editorconfig': 'ini', '.nvmrc': 'plaintext', '.babelrc': 'json', '.eslintrc': 'json', '.prettierrc': 'json', '.swcrc': 'json', 'nginx.conf': 'nginx', procfile: 'plaintext' };
|
|
28
|
+
function langOf(path) {
|
|
29
|
+
const base = String(path || '').split('/').pop().toLowerCase();
|
|
30
|
+
if (LANG_BY_NAME[base]) return LANG_BY_NAME[base];
|
|
31
|
+
if (base.startsWith('.env')) return 'bash'; // .env.local, .env.production…
|
|
32
|
+
if (base.startsWith('dockerfile')) return 'dockerfile'; // Dockerfile.dev
|
|
33
|
+
const i = base.lastIndexOf('.'); const ext = i >= 0 ? base.slice(i + 1) : '';
|
|
34
|
+
return LANG_BY_EXT[ext] || 'plaintext';
|
|
35
|
+
}
|
|
36
|
+
// código en un bloque ``` del chat (core.js md()): resaltado si el fence trae un lenguaje conocido
|
|
37
|
+
function hlCode(code, lang) {
|
|
38
|
+
if (typeof hljs === 'undefined') return esc(code);
|
|
39
|
+
const l = String(lang || '').toLowerCase(); const name = LANG_BY_EXT[l] || (l && hljs.getLanguage(l) ? l : '');
|
|
40
|
+
if (!name || name === 'plaintext' || code.length > 60000) return esc(code);
|
|
41
|
+
try { return hljs.highlight(code, { language: name, ignoreIllegals: true }).value; } catch (e) { return esc(code); }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ---- Synsema para highlight.js (misma partición que el tmLanguage: control, tipos, seguridad, agentes, LLM,
|
|
45
|
+
// humano, observabilidad, serve, módulos; builtins solo cuando se llaman; task/type/agent + nombre = definición) ----
|
|
46
|
+
function synsemaGrammar(hljs) {
|
|
47
|
+
const KEYWORDS = [
|
|
48
|
+
'when', 'otherwise', 'each', 'while', 'repeat', 'match', 'is', 'try', 'recover', 'give', 'stop', 'then', 'and_then', 'return',
|
|
49
|
+
'let', 'be', 'set', 'to', 'task', 'type', 'agent', 'state',
|
|
50
|
+
'and', 'or', 'not', 'in', 'of', 'as', 'with',
|
|
51
|
+
'spawn', 'share', 'observe', 'signal', 'wait_for',
|
|
52
|
+
'analyze', 'decide', 'reason', 'generate',
|
|
53
|
+
'approve', 'confirm', 'ask', 'show',
|
|
54
|
+
'trace', 'log', 'measure', 'checkpoint',
|
|
55
|
+
'serve', 'on', 'route', 'auth', 'errors', 'requires', 'expect', 'max_body', 'max_streams', 'stream', 'send', 'static', 'cache', 'fallback', 'cors', 'describe', 'private', 'from', 'domain', 'tls', 'redirect', 'proxy', 'host', 'paged', 'per', 'rate_limit', 'unlimited', 'none', 'mount', 'at',
|
|
56
|
+
'use', 'export', 'routes', 'enum'
|
|
57
|
+
];
|
|
58
|
+
const SECURITY = ['require', 'allow', 'deny', 'sandbox', 'intent', 'invariant', 'verify'];
|
|
59
|
+
const BUILTINS = ['print', 'text', 'length', 'append', 'range', 'contains', 'split', 'join', 'type_of', 'matches', 'find_all', 'capture', 'replace_re', 'fetch', 'read_file', 'write_file', 'http', 'http_get', 'http_post', 'sql', 'sql_exec', 'sql_tables', 'db_open', 'db_close', 'format_time', 'parse_time', 'date_parts', 'now', 'sleep', 'apply', 'where', 'reduce', 'collect', 'transform', 'sort_by', 'group_by', 'every', 'some', 'flatten', 'parallel_map', 'chunk', 'remember', 'recall', 'forget_memory', 'add_rule', 'check_rules', 'get_rules', 'memory_summary', 'create_progress', 'start_step', 'complete_step', 'fail_step', 'resume_point', 'progress_display', 'progress_percent', 'ok', 'created', 'not_found', 'fail', 'html', 'respond', 'render', 'page', 'heading', 'prose', 'list', 'ordered_list', 'link', 'image', 'section', 'code', 'raw', 'content'];
|
|
60
|
+
const NUMBER = { scope: 'number', match: /\b[0-9][0-9_]*(\.[0-9][0-9_]*)?([eE][+-]?[0-9]+)?\b/ };
|
|
61
|
+
const CALL = { scope: 'built_in', match: new RegExp('\\b(' + BUILTINS.join('|') + ')(?=\\s*\\()') };
|
|
62
|
+
// {expr} dentro de un string: termina en }, pero también ante la comilla que cierra el string o al final de la
|
|
63
|
+
// línea, así una llave sin cerrar no arrastra el color
|
|
64
|
+
const subst = (quote) => ({ scope: 'subst', begin: /\{/, end: new RegExp('\\}|(?=' + quote + ')|$'), keywords: { keyword: KEYWORDS, literal: ['true', 'false', 'nothing'] }, contains: [NUMBER, CALL] });
|
|
65
|
+
return {
|
|
66
|
+
name: 'Synsema', aliases: ['syn', 'fsyn'],
|
|
67
|
+
keywords: { $pattern: /[A-Za-z_][A-Za-z0-9_]*/, keyword: KEYWORDS, literal: ['true', 'false', 'nothing'] },
|
|
68
|
+
contains: [
|
|
69
|
+
hljs.COMMENT('--', '$'),
|
|
70
|
+
// los strings no cruzan líneas: end también en $ (hljs compila con la bandera m). Sin esto una comilla o un
|
|
71
|
+
// backtick sin cerrar pintaban el resto del archivo como string (visto 2026-09-05 con un ` suelto en un .syn)
|
|
72
|
+
{ scope: 'string', begin: /"/, end: /"|$/, contains: [{ scope: 'char.escape', match: /\\./ }, subst('"')] },
|
|
73
|
+
{ scope: 'string', begin: /`/, end: /`|$/, contains: [{ scope: 'char.escape', match: /\\./ }, subst('`')] },
|
|
74
|
+
{ match: [/\b(task|type|agent)\b/, /\s+/, /[A-Za-z_][A-Za-z0-9_]*/], scope: { 1: 'keyword', 3: 'title.function' } },
|
|
75
|
+
{ scope: 'keyword.security', match: new RegExp('\\b(' + SECURITY.join('|') + ')\\b') },
|
|
76
|
+
CALL,
|
|
77
|
+
NUMBER
|
|
78
|
+
]
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
// Prisma (schema.prisma): highlight.js no lo trae. Bloques model/enum/datasource/generator, tipos escalares,
|
|
82
|
+
// atributos @id/@@map, env("…"), comentarios // y ///.
|
|
83
|
+
function prismaGrammar(hljs) {
|
|
84
|
+
return {
|
|
85
|
+
name: 'Prisma', aliases: ['prisma'],
|
|
86
|
+
keywords: {
|
|
87
|
+
keyword: ['model', 'enum', 'type', 'view', 'datasource', 'generator'],
|
|
88
|
+
built_in: ['String', 'Int', 'BigInt', 'Float', 'Decimal', 'Boolean', 'DateTime', 'Json', 'Bytes', 'Unsupported'],
|
|
89
|
+
literal: ['true', 'false', 'null']
|
|
90
|
+
},
|
|
91
|
+
contains: [
|
|
92
|
+
hljs.COMMENT('///', '$'), hljs.COMMENT('//', '$'),
|
|
93
|
+
{ scope: 'string', begin: /"/, end: /"|$/, contains: [{ scope: 'char.escape', match: /\\./ }] },
|
|
94
|
+
{ match: [/\b(model|enum|type|view|datasource|generator)\b/, /\s+/, /[A-Za-z_]\w*/], scope: { 1: 'keyword', 3: 'title.class' } },
|
|
95
|
+
{ scope: 'attribute', match: /@@?[A-Za-z_][\w.]*/ },
|
|
96
|
+
{ scope: 'built_in', match: /\benv(?=\()/ },
|
|
97
|
+
{ scope: 'number', match: /\b\d+(\.\d+)?\b/ }
|
|
98
|
+
]
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
if (typeof hljs !== 'undefined') { hljs.registerLanguage('synsema', synsemaGrammar); hljs.registerLanguage('prisma', prismaGrammar); }
|
|
102
|
+
|
|
103
|
+
// ---- estado ----
|
|
104
|
+
const ED = { path: '', loaded: null, lang: 'plaintext', dirty: false, readonly: false, saving: false };
|
|
105
|
+
const drafts = new Map(); // path → texto sin guardar (al cambiar de archivo no se pierde nada)
|
|
106
|
+
const eta = $('#eta'), ehl = $('#ehl'), egut = $('#egut'), edBox = $('#editor');
|
|
107
|
+
const HL_MAX = 120000; // más que esto se muestra sin resaltar (hljs tardaría en cada tecla)
|
|
108
|
+
const HL_SYNC_MAX = 30000; // hasta acá el espejo se redibuja en el mismo tick que la tecla; después con debounce corto
|
|
109
|
+
|
|
110
|
+
function edVisible() { return !edBox.hidden && $('#viewer').style.display !== 'none' && !!ED.path; }
|
|
111
|
+
function edDirty() { return ED.dirty; }
|
|
112
|
+
|
|
113
|
+
// abre la respuesta de GET /api/file en el editor (tree.js openFile). Un borrador guardado gana al disco.
|
|
114
|
+
function edOpen(d) {
|
|
115
|
+
edStash();
|
|
116
|
+
ED.path = d.path; ED.loaded = String(d.content ?? ''); ED.lang = langOf(d.path); ED.saving = false;
|
|
117
|
+
const binary = /\u0000/.test(ED.loaded);
|
|
118
|
+
ED.readonly = !!d.truncated || binary;
|
|
119
|
+
const draft = drafts.get(ED.path);
|
|
120
|
+
eta.value = !ED.readonly && draft != null && draft !== ED.loaded ? draft : ED.loaded;
|
|
121
|
+
ED.dirty = eta.value !== ED.loaded;
|
|
122
|
+
eta.readOnly = ED.readonly;
|
|
123
|
+
$('#vbody').hidden = true; edBox.hidden = false; $('#viewer').classList.add('edit');
|
|
124
|
+
edRender(); edMeta(d.truncated ? 'archivo muy grande · solo lectura' : binary ? 'binario · solo lectura' : '');
|
|
125
|
+
eta.scrollTop = 0; eta.scrollLeft = 0; edSync();
|
|
126
|
+
if (!ED.readonly) eta.focus({ preventScroll: true });
|
|
127
|
+
}
|
|
128
|
+
function edStash() { if (!ED.path) return; if (ED.dirty) drafts.set(ED.path, eta.value); else drafts.delete(ED.path); }
|
|
129
|
+
// cierra el editor (✕ del visor, tras confirmar si había cambios); el visor vuelve a su <pre> de texto
|
|
130
|
+
function edClose(discard) {
|
|
131
|
+
if (discard) drafts.delete(ED.path); else edStash();
|
|
132
|
+
ED.path = ''; ED.loaded = null; ED.dirty = false;
|
|
133
|
+
edBox.hidden = true; $('#vbody').hidden = false; $('#viewer').classList.remove('edit');
|
|
134
|
+
}
|
|
135
|
+
// el visor va a mostrar texto plano (logs de procesos/agentes, showText): el editor se esconde sin perder borradores
|
|
136
|
+
function viewerText() { if (!edBox.hidden) edClose(false); }
|
|
137
|
+
|
|
138
|
+
function edRender() {
|
|
139
|
+
const src = eta.value; let html;
|
|
140
|
+
if (src.length > HL_MAX || ED.lang === 'plaintext' || typeof hljs === 'undefined') html = esc(src);
|
|
141
|
+
else { try { html = hljs.highlight(src, { language: ED.lang, ignoreIllegals: true }).value; } catch (e) { html = esc(src); } }
|
|
142
|
+
// el espacio final: un <pre> no pinta la última línea vacía, el textarea sí; así ambos miden lo mismo
|
|
143
|
+
ehl.firstElementChild.innerHTML = html + '\n ';
|
|
144
|
+
const n = src.split('\n').length;
|
|
145
|
+
if (egut.childElementCount !== n) { let g = ''; for (let i = 1; i <= n; i++) g += '<span>' + i + '</span>'; egut.innerHTML = g; }
|
|
146
|
+
}
|
|
147
|
+
function edSync() { ehl.scrollTop = eta.scrollTop; ehl.scrollLeft = eta.scrollLeft; egut.style.transform = 'translateY(' + (-eta.scrollTop) + 'px)'; }
|
|
148
|
+
function edMeta(note, bad) {
|
|
149
|
+
const n = eta.value.split('\n').length;
|
|
150
|
+
const parts = [ED.lang === 'plaintext' ? 'texto' : ED.lang, n + (n === 1 ? ' línea' : ' líneas')];
|
|
151
|
+
if (ED.dirty) parts.push('<b class="dirty">● sin guardar</b>');
|
|
152
|
+
if (note) parts.push(`<span class="${bad ? 'err' : 'note'}">${esc(note)}</span>`);
|
|
153
|
+
$('#vmeta').innerHTML = parts.join(' · ');
|
|
154
|
+
const b = $('#vsave'); b.hidden = ED.readonly; b.disabled = !ED.dirty || ED.saving;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ---- guardar ----
|
|
158
|
+
async function edSave(force) {
|
|
159
|
+
if (!edVisible() || ED.readonly || ED.saving) return;
|
|
160
|
+
const content = eta.value, path = ED.path;
|
|
161
|
+
if (!ED.dirty && !force) return;
|
|
162
|
+
ED.saving = true; edMeta('guardando…');
|
|
163
|
+
try {
|
|
164
|
+
if (!force) { // ¿cambió en disco desde que lo abrimos? (el agente pudo escribirlo mientras tanto)
|
|
165
|
+
const r0 = await fetch(BASE + '/api/file?path=' + encodeURIComponent(path)); const d0 = await r0.json();
|
|
166
|
+
if (r0.ok && String(d0.content) !== ED.loaded) { ED.saving = false; edConflict(); return; }
|
|
167
|
+
}
|
|
168
|
+
const r = await api(BASE + '/api/fs', { op: 'write', path, content });
|
|
169
|
+
if (!r.ok) { ED.saving = false; edMeta(r.data.error || 'no se pudo guardar', true); return; }
|
|
170
|
+
if (ED.path === path) { ED.loaded = content; ED.dirty = eta.value !== content; } // el usuario pudo seguir tipeando
|
|
171
|
+
drafts.delete(path); ED.saving = false;
|
|
172
|
+
edMeta('guardado'); setTimeout(() => { if (ED.path === path && !ED.dirty) edMeta(); }, 1800);
|
|
173
|
+
if (typeof treeChanged === 'function') treeChanged();
|
|
174
|
+
} catch (e) { ED.saving = false; edMeta('no se pudo guardar: ' + e.message, true); }
|
|
175
|
+
}
|
|
176
|
+
function edConflict() {
|
|
177
|
+
$('#vmeta').innerHTML = `<span class="warn">cambió en disco mientras lo editabas</span> · <a href="#" id="edover">sobrescribir</a> · <a href="#" id="edreload">recargar (pierde tus cambios)</a>`;
|
|
178
|
+
$('#edover').onclick = ev => { ev.preventDefault(); edSave(true); };
|
|
179
|
+
$('#edreload').onclick = ev => { ev.preventDefault(); drafts.delete(ED.path); ED.dirty = false; openFile(ED.path); };
|
|
180
|
+
}
|
|
181
|
+
// una tool del agente pudo tocar el archivo abierto (chat.js): sin cambios locales se recarga solo conservando el
|
|
182
|
+
// scroll; con cambios locales se avisa y el conflicto se resuelve al guardar
|
|
183
|
+
async function edTouched(path) {
|
|
184
|
+
if (!edVisible()) return;
|
|
185
|
+
const p = path ? String(path).replace(/\\/g, '/').replace(/^\.\//, '') : '';
|
|
186
|
+
if (p && p !== ED.path) return;
|
|
187
|
+
try {
|
|
188
|
+
const r = await fetch(BASE + '/api/file?path=' + encodeURIComponent(ED.path)); const d = await r.json();
|
|
189
|
+
if (!r.ok || String(d.content) === ED.loaded) return;
|
|
190
|
+
if (ED.dirty) { edMeta('el agente cambió este archivo en disco · al guardar podés sobrescribir o recargar', true); return; }
|
|
191
|
+
const st = eta.scrollTop, sl = eta.scrollLeft;
|
|
192
|
+
ED.loaded = String(d.content); eta.value = ED.loaded; ED.readonly = !!d.truncated; eta.readOnly = ED.readonly;
|
|
193
|
+
edRender(); edMeta('actualizado: el agente lo cambió'); eta.scrollTop = st; eta.scrollLeft = sl; edSync();
|
|
194
|
+
setTimeout(() => { if (!ED.dirty) edMeta(); }, 2500);
|
|
195
|
+
} catch (e) {}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// ---- teclado y eventos ----
|
|
199
|
+
function edInsert(t) {
|
|
200
|
+
if (!document.execCommand || !document.execCommand('insertText', false, t)) { // execCommand conserva el undo del navegador
|
|
201
|
+
eta.setRangeText(t, eta.selectionStart, eta.selectionEnd, 'end'); eta.dispatchEvent(new Event('input'));
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
eta.addEventListener('input', () => {
|
|
205
|
+
ED.dirty = eta.value !== ED.loaded;
|
|
206
|
+
if (eta.value.length > HL_SYNC_MAX) debounce('edhl', edRender, 40); else edRender();
|
|
207
|
+
edMeta(); edSync();
|
|
208
|
+
});
|
|
209
|
+
eta.addEventListener('scroll', edSync);
|
|
210
|
+
eta.addEventListener('keydown', ev => {
|
|
211
|
+
if (ED.readonly) return;
|
|
212
|
+
if (ev.key === 'Tab' && !ev.ctrlKey && !ev.altKey) {
|
|
213
|
+
ev.preventDefault();
|
|
214
|
+
const v = eta.value, s = eta.selectionStart, e = eta.selectionEnd;
|
|
215
|
+
if (s === e || !v.slice(s, e).includes('\n')) { if (!ev.shiftKey) edInsert(' '); return; } // 4 espacios: la indentación de Synsema
|
|
216
|
+
// varias líneas seleccionadas: indentar / desindentar el bloque
|
|
217
|
+
const ls = v.lastIndexOf('\n', s - 1) + 1; let le = v.indexOf('\n', e - 1); if (le < 0) le = v.length;
|
|
218
|
+
const lines = v.slice(ls, le).split('\n');
|
|
219
|
+
const out = lines.map(l => ev.shiftKey ? l.replace(/^( |\t| {1,3})/, '') : ' ' + l).join('\n');
|
|
220
|
+
eta.setSelectionRange(ls, le); edInsert(out); eta.setSelectionRange(ls, ls + out.length);
|
|
221
|
+
} else if (ev.key === 'Enter' && !ev.ctrlKey && !ev.altKey && !ev.shiftKey) {
|
|
222
|
+
ev.preventDefault();
|
|
223
|
+
const v = eta.value, s = eta.selectionStart; const ls = v.lastIndexOf('\n', s - 1) + 1;
|
|
224
|
+
const ind = (v.slice(ls, s).match(/^[ \t]*/) || [''])[0];
|
|
225
|
+
edInsert('\n' + ind); // conserva la indentación de la línea anterior
|
|
226
|
+
} else if (ev.key === 'Escape') { eta.blur(); }
|
|
227
|
+
});
|
|
228
|
+
document.addEventListener('keydown', ev => {
|
|
229
|
+
if ((ev.ctrlKey || ev.metaKey) && !ev.altKey && ev.key.toLowerCase() === 's' && edVisible()) { ev.preventDefault(); edSave(); }
|
|
230
|
+
});
|
|
231
|
+
$('#vsave').onclick = () => edSave();
|
|
232
|
+
window.addEventListener('beforeunload', ev => { if (ED.dirty || drafts.size) { ev.preventDefault(); ev.returnValue = ''; } });
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// login.js — /login (passkey de un dispositivo ya emparejado) y /pair#<código> (registrar una passkey nueva).
|
|
2
|
+
// Los binarios de WebAuthn viajan en base64url: el server manda challenge / user.id / ids así, y recibe la
|
|
3
|
+
// credencial en la forma de PublicKeyCredential.toJSON() (armada a mano: toJSON no está en todos los Safari).
|
|
4
|
+
const $ = s => document.querySelector(s);
|
|
5
|
+
const b64uToBuf = (s) => { s = s.replace(/-/g, '+').replace(/_/g, '/'); while (s.length % 4) s += '='; const bin = atob(s); const u = new Uint8Array(bin.length); for (let i = 0; i < bin.length; i++) u[i] = bin.charCodeAt(i); return u.buffer; };
|
|
6
|
+
const bufToB64u = (buf) => { const u = new Uint8Array(buf); let s = ''; for (let i = 0; i < u.length; i++) s += String.fromCharCode(u[i]); return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); };
|
|
7
|
+
function say(text, kind) { const m = $('#msg'); m.textContent = text || ''; m.className = 'msg' + (kind ? ' ' + kind : ''); }
|
|
8
|
+
async function post(path, body) { const r = await fetch(path, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body || {}) }); let data = {}; try { data = await r.json(); } catch (e) {} return { ok: r.ok, status: r.status, data }; }
|
|
9
|
+
function credJSON(c) {
|
|
10
|
+
const r = c.response, out = { id: c.id, rawId: bufToB64u(c.rawId), type: c.type, clientExtensionResults: c.getClientExtensionResults ? c.getClientExtensionResults() : {}, response: { clientDataJSON: bufToB64u(r.clientDataJSON) } };
|
|
11
|
+
if (c.authenticatorAttachment) out.authenticatorAttachment = c.authenticatorAttachment;
|
|
12
|
+
if (r.attestationObject) { out.response.attestationObject = bufToB64u(r.attestationObject); if (r.getTransports) out.response.transports = r.getTransports(); }
|
|
13
|
+
if (r.authenticatorData) out.response.authenticatorData = bufToB64u(r.authenticatorData);
|
|
14
|
+
if (r.signature) out.response.signature = bufToB64u(r.signature);
|
|
15
|
+
if (r.userHandle) out.response.userHandle = bufToB64u(r.userHandle);
|
|
16
|
+
return out;
|
|
17
|
+
}
|
|
18
|
+
function why(e) {
|
|
19
|
+
if (!e) return 'algo falló';
|
|
20
|
+
if (e.name === 'NotAllowedError') return 'cancelado o sin respuesta — volvé a intentar';
|
|
21
|
+
if (e.name === 'InvalidStateError') return 'este dispositivo ya tiene una passkey de lampson para este dominio — entrá desde /login';
|
|
22
|
+
if (e.name === 'SecurityError') return 'el navegador rechazó el dominio: las passkeys necesitan https (o localhost)';
|
|
23
|
+
return (e.name ? e.name + ': ' : '') + (e.message || String(e));
|
|
24
|
+
}
|
|
25
|
+
function guessName() {
|
|
26
|
+
const ua = navigator.userAgent;
|
|
27
|
+
const os = /iPhone/.test(ua) ? 'iPhone' : /iPad/.test(ua) ? 'iPad' : /Android/.test(ua) ? 'Android' : /Mac OS X/.test(ua) ? 'Mac' : /Windows/.test(ua) ? 'Windows' : /Linux/.test(ua) ? 'Linux' : 'dispositivo';
|
|
28
|
+
const br = /EdgA?\//.test(ua) ? 'Edge' : /SamsungBrowser/.test(ua) ? 'Samsung' : /CriOS|Chrome\//.test(ua) ? 'Chrome' : /FxiOS|Firefox\//.test(ua) ? 'Firefox' : /Safari\//.test(ua) ? 'Safari' : '';
|
|
29
|
+
return br ? os + ' · ' + br : os;
|
|
30
|
+
}
|
|
31
|
+
const next = () => { const n = new URLSearchParams(location.search).get('next'); return n && n.startsWith('/') && !n.startsWith('//') ? n : '/'; };
|
|
32
|
+
const supported = () => window.isSecureContext && window.PublicKeyCredential && navigator.credentials;
|
|
33
|
+
|
|
34
|
+
async function doPair(code) {
|
|
35
|
+
const btn = $('#doPair'); btn.disabled = true; say('preparando…');
|
|
36
|
+
try {
|
|
37
|
+
const o = await post('/api/auth/register/options', { code, name: $('#devName').value.trim() || guessName() });
|
|
38
|
+
if (!o.ok) { say(o.data.error || ('error ' + o.status), 'err'); btn.disabled = false; return; }
|
|
39
|
+
const pk = o.data.publicKey;
|
|
40
|
+
pk.challenge = b64uToBuf(pk.challenge); pk.user.id = b64uToBuf(pk.user.id);
|
|
41
|
+
say('seguí las instrucciones del dispositivo…');
|
|
42
|
+
const cred = await navigator.credentials.create({ publicKey: pk });
|
|
43
|
+
const r = await post('/api/auth/register', { code, challenge_id: o.data.challenge_id, name: $('#devName').value.trim() || guessName(), credential: credJSON(cred) });
|
|
44
|
+
if (!r.ok) { say(r.data.error || ('error ' + r.status), 'err'); btn.disabled = false; return; }
|
|
45
|
+
say('listo · entrando…', 'ok');
|
|
46
|
+
history.replaceState(null, '', '/pair'); // el código ya no sirve: que no quede en el historial
|
|
47
|
+
setTimeout(() => { location.href = '/'; }, 500);
|
|
48
|
+
} catch (e) { say(why(e), 'err'); btn.disabled = false; }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function doLogin() {
|
|
52
|
+
const btn = $('#doLogin'); btn.disabled = true; say('');
|
|
53
|
+
try {
|
|
54
|
+
const o = await post('/api/auth/login/options', {});
|
|
55
|
+
if (!o.ok) { say(o.data.error || ('error ' + o.status), 'err'); btn.disabled = false; return; }
|
|
56
|
+
if (!o.data.known) { say('no hay dispositivos emparejados para este dominio: emparejá este desde la PC', 'err'); btn.disabled = false; return; }
|
|
57
|
+
const pk = o.data.publicKey;
|
|
58
|
+
pk.challenge = b64uToBuf(pk.challenge); pk.allowCredentials = (pk.allowCredentials || []).map(c => ({ type: c.type, id: b64uToBuf(c.id) }));
|
|
59
|
+
const cred = await navigator.credentials.get({ publicKey: pk });
|
|
60
|
+
const r = await post('/api/auth/login', { challenge_id: o.data.challenge_id, credential: credJSON(cred) });
|
|
61
|
+
if (!r.ok) { say(r.data.error || ('error ' + r.status), 'err'); btn.disabled = false; return; }
|
|
62
|
+
say('entrando…', 'ok'); location.href = next();
|
|
63
|
+
} catch (e) { say(why(e), 'err'); btn.disabled = false; }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
(async () => {
|
|
67
|
+
const code = location.pathname === '/pair' ? decodeURIComponent(location.hash.slice(1)) : '';
|
|
68
|
+
if (code) {
|
|
69
|
+
$('#pair').hidden = false; $('#devName').value = guessName();
|
|
70
|
+
$('#doPair').onclick = () => doPair(code);
|
|
71
|
+
} else {
|
|
72
|
+
// ¿ya adentro? (esta PC o una sesión viva) → directo al hub
|
|
73
|
+
try { const me = await (await fetch('/api/auth/me')).json(); if (me.authenticated) { location.href = next(); return; } } catch (e) {}
|
|
74
|
+
$('#login').hidden = false; $('#doLogin').onclick = doLogin;
|
|
75
|
+
if (location.pathname === '/pair') say('este link no trae código: escaneá el QR de nuevo', 'err');
|
|
76
|
+
}
|
|
77
|
+
if (!supported()) { say('este navegador no puede usar passkeys aquí: hace falta https (el túnel) y un navegador actual', 'err'); document.querySelectorAll('button.primary').forEach(b => b.disabled = true); }
|
|
78
|
+
})();
|
package/public/js/procs.js
CHANGED
|
@@ -44,6 +44,6 @@ async function openProc(name) {
|
|
|
44
44
|
async function refreshProcViewer(name) {
|
|
45
45
|
if (procOpen !== name || $('#viewer').style.display === 'none') { clearInterval(procTimer); return; }
|
|
46
46
|
const x = await (await fetch(BASE + '/api/proc/log?name=' + encodeURIComponent(name) + '&tail=400')).json();
|
|
47
|
-
$('#vbody').textContent = x.log || ''; $('#vmeta').textContent = x.running ? '● corriendo · log en vivo' : '○ terminado';
|
|
47
|
+
viewerText(); $('#vbody').textContent = x.log || ''; $('#vmeta').textContent = x.running ? '● corriendo · log en vivo' : '○ terminado';
|
|
48
48
|
if (!x.running) { clearInterval(procTimer); loadProcs(); }
|
|
49
49
|
}
|
package/public/js/pwa.js
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
// pwa.js — registra el service worker (instalable en el celular y en el escritorio). Solo en contexto seguro:
|
|
2
|
+
// https (túnel, VPS) o localhost; por http en la LAN el navegador no lo permite y la app sigue como sitio.
|
|
3
|
+
if ('serviceWorker' in navigator && window.isSecureContext) {
|
|
4
|
+
window.addEventListener('load', () => { navigator.serviceWorker.register('/sw.js', { scope: '/' }).catch(() => {}); });
|
|
5
|
+
}
|
package/public/js/tree.js
CHANGED
|
@@ -241,15 +241,23 @@ function showMenu(x, y, e, only) {
|
|
|
241
241
|
if (only === 'delete') { const first = m.querySelector('.it'); if (first) first.click(); }
|
|
242
242
|
}
|
|
243
243
|
|
|
244
|
+
// abre el archivo en el editor (js/editor.js): colores por tipo de archivo, Ctrl+S guarda. El <pre> #vbody queda para logs.
|
|
244
245
|
async function openFile(path, row) {
|
|
245
246
|
document.querySelectorAll('.row.active').forEach(x => x.classList.remove('active')); if (row) row.classList.add('active');
|
|
246
247
|
const r = await fetch(BASE + '/api/file?path=' + encodeURIComponent(path)); const d = await r.json();
|
|
247
|
-
if (!r.ok) {
|
|
248
|
-
|
|
249
|
-
|
|
248
|
+
if (!r.ok) { viewerNotice(path, 'no se pudo abrir', String(d.error || 'error')); return; }
|
|
249
|
+
if (!row) { const ar = rowOf(d.path); if (ar) ar.classList.add('active'); }
|
|
250
|
+
if (d.binary) { viewerNotice(d.path, 'binario · ' + fmtSize(d.size), 'Este archivo no es texto (base de datos, imagen, fuente, ejecutable…): no se puede mostrar ni editar acá.' + (d.error ? '\n\n' + d.error : '')); return; }
|
|
251
|
+
$('#vpath').textContent = d.path; $('#vbody').className = '';
|
|
252
|
+
edOpen(d);
|
|
250
253
|
showPane('viewer'); $('#stage').scrollTop = 0;
|
|
251
254
|
}
|
|
255
|
+
// avisos del visor (binario, error al abrir): en el propio visor, no como mensaje en el chat
|
|
256
|
+
function viewerNotice(path, meta, text) { showText(path, meta, text, false); }
|
|
257
|
+
function fmtSize(n) { n = Number(n) || 0; return n < 1024 ? n + ' B' : n < 1048576 ? (n / 1024).toFixed(1) + ' KB' : (n / 1048576).toFixed(1) + ' MB'; }
|
|
252
258
|
// el árbol se refresca solo cuando una tool pudo crear/borrar archivos, con un pequeño debounce; ↻ lo fuerza a mano
|
|
253
259
|
function treeChanged() { debounce('tree', loadTree, 400); }
|
|
254
260
|
$('#tree-reload').onclick = e => { e.stopPropagation(); const b = e.currentTarget; b.classList.remove('spin'); void b.offsetWidth; b.classList.add('spin'); loadTree(); };
|
|
255
|
-
|
|
261
|
+
function closeViewer(discard) { showPane('log'); procOpen = null; clearInterval(procTimer); $('#vbody').className = ''; document.querySelectorAll('.row.active, .m.active, .p.active').forEach(x => x.classList.remove('active')); memOpen = null; edClose(discard); const x = $('#vclose'); x.classList.remove('ask'); x.textContent = '✕ cerrar'; }
|
|
262
|
+
// con cambios sin guardar, el ✕ pregunta antes (mismo patrón en línea que borrar en el árbol)
|
|
263
|
+
$('#vclose').onclick = () => { if (edVisible() && edDirty()) { inlineConfirm($('#vclose'), '¿descartar cambios?', () => closeViewer(true)); return; } closeViewer(false); };
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="es">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
|
6
|
+
<meta name="theme-color" content="#131412">
|
|
7
|
+
<meta name="mobile-web-app-capable" content="yes">
|
|
8
|
+
<meta name="apple-mobile-web-app-capable" content="yes">
|
|
9
|
+
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
|
10
|
+
<link rel="manifest" href="/manifest.webmanifest">
|
|
11
|
+
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
|
|
12
|
+
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
|
|
13
|
+
<title>Lampson · entrar</title>
|
|
14
|
+
<link rel="stylesheet" href="/css/tokens.css">
|
|
15
|
+
<link rel="stylesheet" href="/css/login.css">
|
|
16
|
+
<script>(function(){try{var t=localStorage.getItem('lampson.theme');if(t!=='light'&&t!=='dark'){t='dark';}document.documentElement.setAttribute('data-theme',t);}catch(e){}})();</script>
|
|
17
|
+
</head>
|
|
18
|
+
<body class="auth">
|
|
19
|
+
<main class="authbox">
|
|
20
|
+
<img src="/favicon.svg" alt="" class="logo" width="48" height="48">
|
|
21
|
+
<!-- login: dispositivo ya emparejado -->
|
|
22
|
+
<section id="login" hidden>
|
|
23
|
+
<p class="eyebrow">lampson</p>
|
|
24
|
+
<h1>Entrar</h1>
|
|
25
|
+
<p class="lead">Usá la passkey de este dispositivo: huella, cara o el bloqueo de pantalla.</p>
|
|
26
|
+
<button class="primary" id="doLogin">Entrar con passkey</button>
|
|
27
|
+
<p class="hint">¿Dispositivo nuevo? En la PC donde corre lampson abrí <b>dispositivos</b> → <b>emparejar</b> y escaneá el QR.</p>
|
|
28
|
+
</section>
|
|
29
|
+
<!-- emparejar: llegó desde el QR (/pair#<código>) -->
|
|
30
|
+
<section id="pair" hidden>
|
|
31
|
+
<p class="eyebrow">emparejar dispositivo</p>
|
|
32
|
+
<h1>Conectar con tu PC</h1>
|
|
33
|
+
<p class="lead">Vas a crear una passkey para este dispositivo. Después entrás solo con la huella o la cara, sin contraseñas.</p>
|
|
34
|
+
<label class="field">nombre del dispositivo<input id="devName" maxlength="60" autocomplete="off" spellcheck="false"></label>
|
|
35
|
+
<button class="primary" id="doPair">Crear passkey</button>
|
|
36
|
+
<p class="hint">El código del QR vale 5 minutos y sirve una sola vez.</p>
|
|
37
|
+
</section>
|
|
38
|
+
<p class="msg" id="msg" role="status"></p>
|
|
39
|
+
</main>
|
|
40
|
+
<script src="/js/pwa.js"></script>
|
|
41
|
+
<script src="/js/login.js"></script>
|
|
42
|
+
</body>
|
|
43
|
+
</html>
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "/",
|
|
3
|
+
"name": "Lampson",
|
|
4
|
+
"short_name": "Lampson",
|
|
5
|
+
"description": "Tu agente de código: workspaces, sesiones y tareas desde el celular.",
|
|
6
|
+
"start_url": "/",
|
|
7
|
+
"scope": "/",
|
|
8
|
+
"display": "standalone",
|
|
9
|
+
"background_color": "#131412",
|
|
10
|
+
"theme_color": "#131412",
|
|
11
|
+
"icons": [
|
|
12
|
+
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
|
|
13
|
+
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
|
|
14
|
+
{ "src": "/icon-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
|
|
15
|
+
]
|
|
16
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="es">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
|
6
|
+
<meta name="theme-color" content="#131412">
|
|
7
|
+
<title>Lampson · sin conexión</title>
|
|
8
|
+
<link rel="stylesheet" href="/css/tokens.css">
|
|
9
|
+
<style>
|
|
10
|
+
body { display:flex; align-items:center; justify-content:center; min-height:100vh; padding:24px; }
|
|
11
|
+
.box { max-width:420px; }
|
|
12
|
+
h1 { font:600 22px/1.3 var(--serif); margin:0 0 8px; }
|
|
13
|
+
p { color:var(--ink-2); margin:0 0 16px; }
|
|
14
|
+
button { font:400 13px var(--mono); padding:8px 14px; background:transparent; color:var(--accent); border:1px solid var(--accent); border-radius:var(--r); }
|
|
15
|
+
</style>
|
|
16
|
+
</head>
|
|
17
|
+
<body>
|
|
18
|
+
<div class="box">
|
|
19
|
+
<h1>Tu PC no responde</h1>
|
|
20
|
+
<p>Lampson corre en tu PC: tiene que estar prendida, con lampson y el túnel andando (<code>lampson --install</code> lo arranca solo al iniciar sesión). Si estás sin señal, revisá tu conexión.</p>
|
|
21
|
+
<button onclick="location.reload()">reintentar</button>
|
|
22
|
+
</div>
|
|
23
|
+
</body>
|
|
24
|
+
</html>
|
package/public/sw.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// sw.js — service worker de la PWA de lampson (base: el scaffold de `synsema init --pwa`).
|
|
2
|
+
// Red primero para todo: lampson cambia seguido y es una herramienta en vivo; mostrar una versión vieja de la UI
|
|
3
|
+
// sería peor que avisar. El caché solo sirve para abrir sin red: íconos, CSS/JS/fuentes y una página «sin conexión».
|
|
4
|
+
// Nunca se cachea el API (/api/…, /w/<slug>/api/…) ni una respuesta que falló, ni las páginas con datos del usuario.
|
|
5
|
+
const CACHE = "lampson-shell-v2";
|
|
6
|
+
const SHELL = ["/offline.html", "/manifest.webmanifest", "/icon-192.png", "/favicon.svg", "/css/tokens.css"];
|
|
7
|
+
|
|
8
|
+
self.addEventListener("install", (e) => {
|
|
9
|
+
e.waitUntil(caches.open(CACHE).then((c) => c.addAll(SHELL)).then(() => self.skipWaiting()));
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
self.addEventListener("activate", (e) => {
|
|
13
|
+
e.waitUntil(
|
|
14
|
+
caches.keys()
|
|
15
|
+
.then((keys) => Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k))))
|
|
16
|
+
.then(() => self.clients.claim())
|
|
17
|
+
);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const tunnelDown = (res) => res.headers.get("x-synsema-tunnel") === "offline" || [502, 503, 504, 521, 522, 523, 524, 530].includes(res.status);
|
|
21
|
+
const isApi = (p) => p.startsWith("/api/") || /^\/w\/[^/]+\/(api|approve)\//.test(p);
|
|
22
|
+
const isAsset = (p) => /^\/(css|js|fonts|vendor)\//.test(p) || /\.(png|svg|webmanifest|woff2)$/.test(p);
|
|
23
|
+
|
|
24
|
+
self.addEventListener("fetch", (e) => {
|
|
25
|
+
const url = new URL(e.request.url);
|
|
26
|
+
if (e.request.method !== "GET" || url.origin !== self.location.origin) return;
|
|
27
|
+
if (isApi(url.pathname)) return; // SSE, WebSocket y JSON van directo a la red, sin tocar
|
|
28
|
+
if (e.request.mode === "navigate") {
|
|
29
|
+
// PC apagada o lampson caído: sin red → error de fetch; detrás de un túnel, el túnel SÍ responde, con su propia
|
|
30
|
+
// página de error (Cloudflare: 530/1033, 502, 504; un túnel de la plataforma marca X-Synsema-Tunnel: offline).
|
|
31
|
+
// En los dos casos, la pantalla de lampson. El hub nunca responde una página con esos códigos.
|
|
32
|
+
e.respondWith(fetch(e.request).then((res) => {
|
|
33
|
+
if (tunnelDown(res)) return caches.match("/offline.html").then((c) => c || res);
|
|
34
|
+
return res;
|
|
35
|
+
}).catch(() => caches.match("/offline.html")));
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
if (isAsset(url.pathname)) {
|
|
39
|
+
e.respondWith(fetch(e.request).then(async (res) => {
|
|
40
|
+
if (res.ok) { const c = await caches.open(CACHE); await c.put(e.request, res.clone()); }
|
|
41
|
+
return res;
|
|
42
|
+
}).catch(async () => (await caches.match(e.request)) || Response.error()));
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
// push (para después: avisos de aprobaciones y de tareas terminadas)
|
|
47
|
+
self.addEventListener("push", (e) => {
|
|
48
|
+
let data = {};
|
|
49
|
+
try { data = e.data ? e.data.json() : {}; } catch (_) { data = { body: e.data ? e.data.text() : "" }; }
|
|
50
|
+
const opts = { body: data.body || "", icon: "/icon-192.png", badge: "/badge-96.png", data };
|
|
51
|
+
if (data.tag) { opts.tag = String(data.tag); opts.renotify = true; }
|
|
52
|
+
e.waitUntil(self.registration.showNotification(data.title || "Lampson", opts));
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
self.addEventListener("notificationclick", (e) => {
|
|
56
|
+
e.notification.close();
|
|
57
|
+
const url = (e.notification.data && e.notification.data.url) || "/";
|
|
58
|
+
e.waitUntil(clients.matchAll({ type: "window", includeUncontrolled: true }).then((wins) => {
|
|
59
|
+
const win = wins.find((w) => "focus" in w);
|
|
60
|
+
if (win) { win.postMessage({ type: "notificationclick", url }); return win.focus(); }
|
|
61
|
+
return clients.openWindow(url);
|
|
62
|
+
}));
|
|
63
|
+
});
|