nio-cat 0.8.2 → 0.9.0

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/bin/nio.js CHANGED
@@ -19,6 +19,7 @@ const directory = require('../src/directory');
19
19
  const voice = require('../src/voice');
20
20
  const { Telegram } = require('../src/telegram');
21
21
  const { Control } = require('../src/control');
22
+ const { Remote } = require('../src/remote');
22
23
  const { Jobs } = require('../src/jobs');
23
24
  const { addServer } = require('../src/ssh');
24
25
  const { pairLink, terminalQr, writePairPage, makeCode, codeLink, terminalQrSize } = require('../src/pair');
@@ -216,7 +217,8 @@ async function start() {
216
217
  tg.start();
217
218
  if (cfg.telegram?.token) console.log(` ${c.dim('Telegram:')} actiu ${c.dim(`(${(cfg.telegram.allowed || []).length} xats autoritzats)`)}`);
218
219
  const control = new Control({ cfg, hub, audit });
219
- const routes = [...memory.routes(), ...jobs.routes(), ...voice.routes(cfg), ...tg.routes(), ...control.routes()];
220
+ const remote = new Remote({ cfg, audit });
221
+ const routes = [...memory.routes(), ...jobs.routes(), ...voice.routes(cfg), ...tg.routes(), ...control.routes(), ...remote.routes()];
220
222
  const server = createServer({ cfg, hub, auth, audit, routes, beforeApproval: (t, i) => control.beforeApproval(t, i), info: async () => ({ control: await control.caps().catch(() => null) }), getPublicUrl: () => publicUrl, getApp: () => flags.app || cfg.app });
221
223
 
222
224
  server.on('error', (e) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nio-cat",
3
- "version": "0.8.2",
3
+ "version": "0.9.0",
4
4
  "description": "nio: fes servir Claude Code, Codex, Grok, Antigravity i Ollama del teu ordinador des del mòbil.",
5
5
  "license": "UNLICENSED",
6
6
  "author": "f.cat",
package/src/remote.js ADDED
@@ -0,0 +1,295 @@
1
+ 'use strict';
2
+ // Pantalla remota: veure i controlar l'escriptori d'aquest ordinador des del mòbil, com un AnyDesk senzill.
3
+ // Fotogrames JPEG sota demanda (no cal cap programa extra) i entrada per macOS (CGEvent + System Events)
4
+ // o Linux/X11 (xdotool). Només funciona si el control de l'escriptori està activat en aquest ordinador.
5
+ const fs = require('fs');
6
+ const os = require('os');
7
+ const path = require('path');
8
+ const crypto = require('crypto');
9
+ const { execFile } = require('child_process');
10
+ const { which, childEnv } = require('./proc');
11
+ const { HOME } = require('./config');
12
+
13
+ const TMP = path.join(HOME, 'shots');
14
+ const DARWIN = process.platform === 'darwin';
15
+ const httpErr = (status, message) => Object.assign(new Error(message), { status });
16
+
17
+ function run(bin, args, timeout = 8000) {
18
+ return new Promise((resolve) => {
19
+ execFile(bin, args, { timeout, env: childEnv(), maxBuffer: 8 * 1024 * 1024 }, (err, stdout, stderr) => resolve({ ok: !err, out: String(stdout || ''), err: String(stderr || err?.message || '') }));
20
+ });
21
+ }
22
+
23
+ // Codis de tecla de macOS (disposició independent del teclat).
24
+ const MAC_KEYS = { Enter: 36, Tab: 48, ' ': 49, Backspace: 51, Escape: 53, Delete: 117, Home: 115, End: 119, PageUp: 116, PageDown: 121, ArrowLeft: 123, ArrowRight: 124, ArrowDown: 125, ArrowUp: 126, F1: 122, F2: 120, F3: 99, F4: 118, F5: 96, F6: 97, F7: 98, F8: 100, F9: 101, F10: 109, F11: 103, F12: 111 };
25
+ const MAC_MODS = { meta: 'command down', ctrl: 'control down', alt: 'option down', shift: 'shift down' };
26
+ const X_KEYS = { Enter: 'Return', Tab: 'Tab', ' ': 'space', Backspace: 'BackSpace', Escape: 'Escape', Delete: 'Delete', Home: 'Home', End: 'End', PageUp: 'Prior', PageDown: 'Next', ArrowLeft: 'Left', ArrowRight: 'Right', ArrowDown: 'Down', ArrowUp: 'Up' };
27
+ const X_MODS = { meta: 'super', ctrl: 'ctrl', alt: 'alt', shift: 'shift' };
28
+
29
+ // JXA: un únic script per a tots els esdeveniments de ratolí i per consultar mides i permisos.
30
+ const JXA = `
31
+ ObjC.import('ApplicationServices');
32
+ ObjC.import('CoreGraphics');
33
+ ObjC.import('Foundation');
34
+ function post(type, x, y, button, count) {
35
+ const ev = $.CGEventCreateMouseEvent(null, type, $.CGPointMake(x, y), button);
36
+ if (count) $.CGEventSetIntegerValueField(ev, $.kCGMouseEventClickState, count);
37
+ $.CGEventPost($.kCGHIDEventTap, ev);
38
+ }
39
+ function run(argv) {
40
+ const op = argv[0];
41
+ const b = $.CGDisplayBounds($.CGMainDisplayID());
42
+ const W = b.size.width, H = b.size.height, OX = b.origin.x, OY = b.origin.y;
43
+ if (op === 'info') {
44
+ let screen = null;
45
+ try { screen = !!$.CGPreflightScreenCaptureAccess(); } catch (e) {}
46
+ return JSON.stringify({ w: W, h: H, screen: screen, accessibility: !!$.AXIsProcessTrusted() });
47
+ }
48
+ const px = (v) => OX + Math.max(0, Math.min(1, parseFloat(v))) * W;
49
+ const py = (v) => OY + Math.max(0, Math.min(1, parseFloat(v))) * H;
50
+ if (op === 'click' || op === 'rclick') {
51
+ const x = px(argv[1]), y = py(argv[2]), n = parseInt(argv[3] || '1', 10);
52
+ const r = op === 'rclick';
53
+ post($.kCGEventMouseMoved, x, y, 0, 0);
54
+ post(r ? $.kCGEventRightMouseDown : $.kCGEventLeftMouseDown, x, y, r ? $.kCGMouseButtonRight : $.kCGMouseButtonLeft, n);
55
+ post(r ? $.kCGEventRightMouseUp : $.kCGEventLeftMouseUp, x, y, r ? $.kCGMouseButtonRight : $.kCGMouseButtonLeft, n);
56
+ } else if (op === 'move') {
57
+ post($.kCGEventMouseMoved, px(argv[1]), py(argv[2]), 0, 0);
58
+ } else if (op === 'drag') {
59
+ const pts = JSON.parse(argv[1]);
60
+ const first = pts[0];
61
+ post($.kCGEventMouseMoved, px(first[0]), py(first[1]), 0, 0);
62
+ post($.kCGEventLeftMouseDown, px(first[0]), py(first[1]), $.kCGMouseButtonLeft, 1);
63
+ for (let i = 1; i < pts.length; i++) { post($.kCGEventLeftMouseDragged, px(pts[i][0]), py(pts[i][1]), $.kCGMouseButtonLeft, 1); $.NSThread.sleepForTimeInterval(0.012); }
64
+ const last = pts[pts.length - 1];
65
+ post($.kCGEventLeftMouseUp, px(last[0]), py(last[1]), $.kCGMouseButtonLeft, 1);
66
+ } else if (op === 'scroll') {
67
+ if (argv[1] !== 'here') post($.kCGEventMouseMoved, px(argv[1]), py(argv[2]), 0, 0);
68
+ const ev = $.CGEventCreateScrollWheelEvent2(null, $.kCGScrollEventUnitPixel, 2, parseInt(argv[4], 10), parseInt(argv[3], 10), 0);
69
+ $.CGEventPost($.kCGHIDEventTap, ev);
70
+ }
71
+ return 'ok';
72
+ }
73
+ `;
74
+
75
+ class Remote {
76
+ constructor({ cfg, audit }) {
77
+ this.cfg = cfg;
78
+ this.audit = audit || { add() {} };
79
+ this.infoAt = 0;
80
+ this.frame = null;
81
+ this.busy = null;
82
+ this.queue = Promise.resolve();
83
+ this.jxa = path.join(os.tmpdir(), `nio-remote-${process.pid}.js`);
84
+ this.xdo = !DARWIN ? which('xdotool') : null;
85
+ fs.mkdirSync(TMP, { recursive: true, mode: 0o700 });
86
+ }
87
+
88
+ enabled() {
89
+ return !!this.cfg.control?.escriptori;
90
+ }
91
+ need() {
92
+ if (!this.enabled()) throw httpErr(409, 'Activa primer «Apps de l’escriptori» en aquest ordinador.');
93
+ }
94
+
95
+ async osa(...args) {
96
+ if (!fs.existsSync(this.jxa)) fs.writeFileSync(this.jxa, JXA, { mode: 0o600 });
97
+ const r = await run('/usr/bin/osascript', ['-l', 'JavaScript', this.jxa, ...args.map(String)], 10000);
98
+ if (!r.ok) throw httpErr(500, `No he pogut enviar-ho a l’ordinador: ${r.err.trim().slice(0, 200)}`);
99
+ return r.out.trim();
100
+ }
101
+
102
+ async info() {
103
+ if (this.infoCache && Date.now() - this.infoAt < 20000) return this.infoCache;
104
+ let i;
105
+ if (DARWIN) {
106
+ try { i = JSON.parse(await this.osa('info')); } catch { i = { w: 0, h: 0, screen: null, accessibility: null }; }
107
+ } else {
108
+ let w = 0, h = 0;
109
+ if (this.xdo) {
110
+ const r = await run(this.xdo, ['getdisplaygeometry'], 3000);
111
+ [w, h] = r.out.trim().split(/\s+/).map(Number);
112
+ }
113
+ i = { w: w || 0, h: h || 0, screen: null, accessibility: null };
114
+ }
115
+ this.infoCache = { ...i, platform: process.platform, input: DARWIN || !!this.xdo, capture: this.captureTool() !== null, enabled: this.enabled() };
116
+ this.infoAt = Date.now();
117
+ return this.infoCache;
118
+ }
119
+
120
+ captureTool() {
121
+ if (DARWIN) return ['screencapture'];
122
+ if (which('grim')) return ['grim'];
123
+ if (which('scrot')) return ['scrot'];
124
+ if (which('gnome-screenshot')) return ['gnome-screenshot'];
125
+ return null;
126
+ }
127
+
128
+ async grab(width, quality) {
129
+ const id = crypto.randomBytes(6).toString('hex');
130
+ const png = path.join(TMP, `live-${id}.png`), jpg = path.join(TMP, `live-${id}.jpg`);
131
+ try {
132
+ let ok = false;
133
+ if (DARWIN) {
134
+ ok = (await run('/usr/sbin/screencapture', ['-x', '-C', '-D', '1', '-t', 'png', png], 8000)).ok;
135
+ if (ok) ok = (await run('/usr/bin/sips', ['-s', 'format', 'jpeg', '-s', 'formatOptions', String(quality), '--resampleWidth', String(width), png, '--out', jpg], 8000)).ok;
136
+ } else {
137
+ const t = this.captureTool();
138
+ if (!t) throw httpErr(409, 'No hi ha cap eina per fer captures (cal grim, scrot o gnome-screenshot).');
139
+ const args = t[0] === 'grim' ? ['-t', 'jpeg', '-q', String(quality), jpg] : t[0] === 'scrot' ? ['-o', '-q', String(quality), jpg] : ['-f', jpg];
140
+ ok = (await run(which(t[0]), args, 8000)).ok;
141
+ }
142
+ if (!ok || !fs.existsSync(jpg)) throw httpErr(409, 'No he pogut capturar la pantalla (sense pantalla accessible o sense permís).');
143
+ return fs.readFileSync(jpg);
144
+ } finally {
145
+ fs.rm(png, { force: true }, () => {});
146
+ fs.rm(jpg, { force: true }, () => {});
147
+ }
148
+ }
149
+
150
+ // Fotograma: si ja n'hi ha un de molt recent, el reaprofita; si n'hi ha un en marxa, s'hi espera.
151
+ async getFrame(width, quality) {
152
+ this.need();
153
+ const w = Math.max(320, Math.min(2400, Math.round(width) || 1280));
154
+ const q = Math.max(25, Math.min(90, Math.round(quality) || 55));
155
+ const f = this.frame;
156
+ if (f && f.w === w && f.q === q && Date.now() - f.t < 90) return f;
157
+ if (this.busy && this.busyKey === `${w}/${q}`) return this.busy;
158
+ this.busyKey = `${w}/${q}`;
159
+ this.busy = (async () => {
160
+ try {
161
+ const buf = await this.grab(w, q);
162
+ const info = await this.info();
163
+ // Un JPEG de pantalla tota negra pesa molt poc: l'ordinador és bloquejat/adormit o falta el permís.
164
+ this.frame = { buf, t: Date.now(), w, q, blank: buf.length < 5000, sw: info.w, sh: info.h };
165
+ return this.frame;
166
+ } finally {
167
+ this.busy = null;
168
+ }
169
+ })();
170
+ return this.busy;
171
+ }
172
+
173
+ async wake() {
174
+ if (DARWIN) await run('/usr/bin/caffeinate', ['-u', '-t', '2'], 4000);
175
+ else if (this.xdo) await run(which('xset') || 'xset', ['dpms', 'force', 'on'], 3000);
176
+ }
177
+
178
+ // Esdeveniments d'entrada: en cua, perquè arribin en ordre.
179
+ input(ev) {
180
+ this.need();
181
+ const job = this.queue.then(() => this.doInput(ev));
182
+ this.queue = job.catch(() => {});
183
+ return job;
184
+ }
185
+
186
+ async doInput(ev) {
187
+ const n = (v) => Math.max(0, Math.min(1, Number(v)));
188
+ const mods = (Array.isArray(ev.mods) ? ev.mods : []).filter((m) => Object.hasOwn(MAC_MODS, m));
189
+ const kind = String(ev.type);
190
+ const info = await this.info();
191
+ if (!info.input) throw httpErr(501, DARWIN ? 'No es pot controlar aquest ordinador.' : 'Cal xdotool per controlar l’escriptori (sudo apt install xdotool).');
192
+ if (DARWIN) {
193
+ if (kind === 'click' || kind === 'rclick') return this.osa(kind, n(ev.x), n(ev.y), ev.count === 2 ? 2 : ev.count === 3 ? 3 : 1);
194
+ if (kind === 'move') return this.osa('move', n(ev.x), n(ev.y));
195
+ if (kind === 'drag') {
196
+ const pts = (Array.isArray(ev.path) ? ev.path : []).slice(0, 120).map((p) => [n(p[0]), n(p[1])]);
197
+ if (pts.length < 2) throw httpErr(400, 'Arrossegament no vàlid');
198
+ return this.osa('drag', JSON.stringify(pts));
199
+ }
200
+ if (kind === 'scroll') {
201
+ const dy = Math.max(-1500, Math.min(1500, Math.round(Number(ev.dy) || 0))), dx = Math.max(-1500, Math.min(1500, Math.round(Number(ev.dx) || 0)));
202
+ return this.osa('scroll', ev.x == null ? 'here' : n(ev.x), n(ev.y || 0), dx, dy);
203
+ }
204
+ if (kind === 'text') {
205
+ const t = String(ev.text || '').slice(0, 2000);
206
+ if (!t) return 'ok';
207
+ return this.script('on run argv\ntell application "System Events" to keystroke (item 1 of argv)\nend run', [t]);
208
+ }
209
+ if (kind === 'key') {
210
+ const using = mods.length ? ` using {${mods.map((m) => MAC_MODS[m]).join(', ')}}` : '';
211
+ const key = String(ev.key || '');
212
+ if (Object.hasOwn(MAC_KEYS, key)) return this.script(`tell application "System Events" to key code ${MAC_KEYS[key]}${using}`, []);
213
+ if (key.length === 1 || [...key].length === 1) return this.script(`on run argv\ntell application "System Events" to keystroke (item 1 of argv)${using}\nend run`, [key]);
214
+ throw httpErr(400, 'Tecla no admesa');
215
+ }
216
+ throw httpErr(400, 'Esdeveniment no admès');
217
+ }
218
+ // Linux / X11
219
+ const px = (v, max) => String(Math.round(n(v) * Math.max(0, max - 1)));
220
+ const xd = (...a) => run(this.xdo, a, 5000).then((r) => (r.ok ? 'ok' : Promise.reject(httpErr(500, r.err.trim().slice(0, 200)))));
221
+ if (kind === 'click' || kind === 'rclick') {
222
+ await xd('mousemove', px(ev.x, info.w), px(ev.y, info.h));
223
+ return xd('click', '--repeat', String(ev.count === 2 ? 2 : 1), '--delay', '40', kind === 'rclick' ? '3' : '1');
224
+ }
225
+ if (kind === 'move') return xd('mousemove', px(ev.x, info.w), px(ev.y, info.h));
226
+ if (kind === 'drag') {
227
+ const pts = (Array.isArray(ev.path) ? ev.path : []).slice(0, 120);
228
+ if (pts.length < 2) throw httpErr(400, 'Arrossegament no vàlid');
229
+ await xd('mousemove', px(pts[0][0], info.w), px(pts[0][1], info.h));
230
+ await xd('mousedown', '1');
231
+ for (const p of pts.slice(1)) await xd('mousemove', px(p[0], info.w), px(p[1], info.h));
232
+ return xd('mouseup', '1');
233
+ }
234
+ if (kind === 'scroll') {
235
+ const dy = Number(ev.dy) || 0, dx = Number(ev.dx) || 0;
236
+ if (ev.x != null) await xd('mousemove', px(ev.x, info.w), px(ev.y, info.h));
237
+ const steps = (v, neg, pos) => (v ? Array(Math.min(20, Math.max(1, Math.round(Math.abs(v) / 40)))).fill(v > 0 ? pos : neg) : []);
238
+ for (const b of [...steps(dy, '5', '4'), ...steps(dx, '7', '6')]) await xd('click', b);
239
+ return 'ok';
240
+ }
241
+ if (kind === 'text') return xd('type', '--delay', '8', '--', String(ev.text || '').slice(0, 2000));
242
+ if (kind === 'key') {
243
+ const key = String(ev.key || '');
244
+ const k = Object.hasOwn(X_KEYS, key) ? X_KEYS[key] : [...key].length === 1 ? key : null;
245
+ if (!k) throw httpErr(400, 'Tecla no admesa');
246
+ return xd('key', '--', [...mods.map((m) => X_MODS[m]), k].join('+'));
247
+ }
248
+ throw httpErr(400, 'Esdeveniment no admès');
249
+ }
250
+
251
+ async script(src, args) {
252
+ const r = await run('/usr/bin/osascript', ['-e', src, ...args], 8000);
253
+ if (!r.ok) {
254
+ const hint = /not allowed|assistive|1002|accessib/i.test(r.err) ? ' Dona permís d’Accessibilitat al Terminal (Ajustos del Sistema → Privadesa i seguretat).' : '';
255
+ throw httpErr(500, `No he pogut teclejar.${hint}`);
256
+ }
257
+ return 'ok';
258
+ }
259
+
260
+ routes() {
261
+ return [
262
+ { method: 'GET', path: '/api/desktop/status', handler: async () => this.info() },
263
+ {
264
+ method: 'GET',
265
+ path: '/api/desktop/frame',
266
+ handler: async ({ res, url }) => {
267
+ const f = await this.getFrame(Number(url.searchParams.get('w')), Number(url.searchParams.get('q')));
268
+ res.writeHead(200, {
269
+ 'content-type': 'image/jpeg',
270
+ 'content-length': f.buf.length,
271
+ 'cache-control': 'no-store',
272
+ 'x-screen-w': String(f.sw || 0),
273
+ 'x-screen-h': String(f.sh || 0),
274
+ 'x-blank': f.blank ? '1' : '0',
275
+ 'access-control-expose-headers': 'x-screen-w, x-screen-h, x-blank',
276
+ });
277
+ res.end(f.buf);
278
+ },
279
+ },
280
+ { method: 'POST', path: '/api/desktop/input', limit: 16384, handler: async ({ body }) => ({ ok: true, r: await this.input(body || {}) }) },
281
+ {
282
+ method: 'POST',
283
+ path: '/api/desktop/session',
284
+ handler: async ({ body, device }) => {
285
+ this.need();
286
+ if (body?.wake) await this.wake();
287
+ if (body?.event === 'open' || body?.event === 'control') this.audit.add({ type: 'control', what: body.event === 'control' ? 'Pantalla remota: control amb ratolí i teclat' : 'Pantalla remota oberta', by: device?.name });
288
+ return { ok: true };
289
+ },
290
+ },
291
+ ];
292
+ }
293
+ }
294
+
295
+ module.exports = { Remote };
package/src/server.js CHANGED
@@ -393,7 +393,7 @@ function createServer({ cfg, hub, auth, audit, getPublicUrl, getApp, routes = []
393
393
  res.setHeader('Access-Control-Allow-Origin', origin);
394
394
  res.setHeader('Vary', 'Origin');
395
395
  res.setHeader('Access-Control-Allow-Headers', 'authorization, content-type');
396
- res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PATCH, DELETE, OPTIONS');
396
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS');
397
397
  res.setHeader('Access-Control-Allow-Private-Network', 'true');
398
398
  res.setHeader('Access-Control-Max-Age', '600');
399
399
  }
package/web/app/app.js CHANGED
@@ -1824,7 +1824,8 @@ async function panicNow() {
1824
1824
  try { const r = await api('/api/panic', { method: 'POST' }); toast(`Aturat tot (${r.stopped} agents)`); } catch (e) { toast(e.message, 4500); }
1825
1825
  }
1826
1826
  function screenSection() {
1827
- return `<div class="sec-head" id="screenSec"><div><h2 class="sec">Pantalla</h2><p class="muted small">Els agents poden fer servir el navegador (agent-browser) i les apps de l’escriptori (agent-desktop). Mirar passa sol; clicar, escriure o enviar sempre et demana permís, amb una captura.</p></div><button class="btn" data-act="screen-now" type="button" ${controlOn() ? '' : 'disabled'}>${svg(ICON.screen)}Veure pantalla ara</button></div><div id="screenBody">${screenHtml()}</div>`;
1827
+ const on = controlOn();
1828
+ return `<div class="sec-head" id="screenSec"><div><h2 class="sec">Pantalla</h2><p class="muted small">Mira i controla l’escriptori d’aquest ordinador des del mòbil, com un escriptori remot. A més, els agents poden fer servir el navegador (agent-browser) i les apps (agent-desktop): mirar passa sol; clicar, escriure o enviar sempre et demana permís, amb una captura.</p></div><div class="row"><button class="btn" data-act="screen-now" type="button" ${on ? '' : 'disabled'}>${svg(ICON.screen)}Captura</button><button class="btn ink" data-act="remote-open" type="button" ${on ? '' : 'disabled'}>${svg(ICON.screen)}Obre la pantalla</button></div></div><div id="screenBody">${screenHtml()}</div>`;
1828
1829
  }
1829
1830
  function screenHtml() {
1830
1831
  if (!screen) return `<div class="panel empty">${bot('work')}<h2>Carregant…</h2></div>`;
@@ -1834,26 +1835,220 @@ function screenHtml() {
1834
1835
  const st = !x.installed ? `No instal·lat · <span class="mono">${hint}</span>` : x.ready === false ? esc(x.why || 'No està a punt') : x.enabled ? 'Activat' : 'Desactivat';
1835
1836
  return `<div class="agent-row"><i class="dot ${x.enabled ? 'on' : ''}"></i><span class="grow"><b>${name}</b><div class="muted small">${st}</div></span>${x.installed ? `<button class="btn sm ${x.enabled ? '' : 'ink'}" data-ctl="${k}" data-on="${!x.enabled}" type="button">${x.enabled ? 'Desactiva' : 'Activa'}</button>` : ''}</div>`;
1836
1837
  };
1838
+ const d = screen.desk, warn = [];
1839
+ if (d && c.escriptori?.enabled) {
1840
+ if (d.screen === false) warn.push('Falta el permís de <b>Gravació de pantalla</b>: per això les captures surten negres. Ajustos del Sistema → Privadesa i seguretat → Gravació de pantalla: activa el Terminal (o l’app des d’on obres nio) i reinicia nio.');
1841
+ if (d.accessibility === false) warn.push('Falta el permís d’<b>Accessibilitat</b> per controlar el ratolí i el teclat. Ajustos del Sistema → Privadesa i seguretat → Accessibilitat: activa el Terminal (o l’app des d’on obres nio) i reinicia nio.');
1842
+ if (d.input === false && d.platform === 'linux') warn.push('Per controlar l’escriptori cal <span class="mono">xdotool</span>: <span class="mono">sudo apt install xdotool</span>.');
1843
+ }
1837
1844
  return `<div class="grid2 one">
1838
1845
  <div class="stack">
1846
+ ${warn.length ? `<section class="panel"><header><h2>Falten permisos</h2></header><div class="body stack">${warn.map((w) => `<p class="small">${w}</p>`).join('')}</div></section>` : ''}
1839
1847
  <section class="panel"><header><h2>En aquest ordinador</h2></header><div class="body">${row('navegador', 'Navegador web', 'npm i -g agent-browser && agent-browser install')}${row('escriptori', 'Apps de l’escriptori', 'npm i -g agent-desktop')}<p class="muted small" style="margin-top:10px">En activar-ho, nio dona a Claude Code i a Codex les guies oficials. Desactivat per defecte. Claude Code demana permís per a cada acció; Codex no pot preguntar, així que només segueix les normes de la guia.</p></div></section>
1840
1848
  <section class="panel"><header><h2>Última captura</h2>${screen.last ? `<span class="muted small">${ago(screen.last.t)} · ${esc(screen.last.from)}</span>` : ''}</header><div class="body">${screen.last ? `<img class="screen-img" alt="Captura de la pantalla de l’ordinador" data-shot="${esc(screen.last.id)}">` : '<p class="muted small">Encara no n’hi ha cap. Prem «Veure pantalla ara».</p>'}</div></section>
1841
1849
  <section class="panel"><header><h2>Accions fetes</h2></header><div class="body">${screen.actions.length ? screen.actions.map((a) => `<div class="act-row"><span class="act-t mono">${new Date(a.t).toLocaleTimeString('ca', { hour: '2-digit', minute: '2-digit' })}</span><div class="grow"><div class="mono small act-what">${esc(a.what)}</div><div class="muted small">${esc(a.title || '')}</div></div></div>`).join('') : '<p class="muted small">Cap encara.</p>'}</div></section>
1842
1850
  <section class="panel"><header><h2>Aturada d’emergència</h2></header><div class="body stack"><p class="small">Atura tots els agents, rebutja el que estigui pendent i tanca el navegador.</p><button class="btn danger" data-act="panic" type="button">${svg(ICON.panic)}Atura-ho tot</button></div></section>
1843
- <section class="panel"><header><h2>Vols fer-ho tu?</h2></header><div class="body"><p class="muted small">nio no és un escriptori remot. Si necessites controlar la pantalla tu mateix, fes servir <a href="https://rustdesk.com" target="_blank" rel="noopener">RustDesk</a> o <a href="https://remotedesktop.google.com" target="_blank" rel="noopener">Chrome Remote Desktop</a>.</p></div></section>
1844
1851
  </div>
1845
1852
  </div>`;
1846
1853
  }
1847
1854
  async function loadScreen() {
1848
1855
  try {
1849
- const [caps, cur, log] = await Promise.all([api('/api/control'), api('/api/screen'), api('/api/audit?type=tool&limit=300')]);
1850
- screen = { caps, last: cur.last, actions: log.filter((e) => /^(npx\s+)?agent-(browser|desktop)\b/.test(e.what || '')).slice(0, 30) };
1856
+ const [caps, cur, log, desk] = await Promise.all([api('/api/control'), api('/api/screen'), api('/api/audit?type=tool&limit=300'), api('/api/desktop/status').catch(() => null)]);
1857
+ screen = { caps, desk, last: cur.last, actions: log.filter((e) => /^(npx\s+)?agent-(browser|desktop)\b/.test(e.what || '')).slice(0, 30) };
1851
1858
  } catch (e) { screen = { error: e.message }; }
1852
1859
  const box = $('#screenBody'); if (box) { box.innerHTML = screenHtml(); hydrateShots(box); }
1853
1860
  renderPanic();
1854
1861
  }
1855
1862
  function syncScreenBtn() { const b = $('[data-act=screen-now]'); if (b) b.disabled = !controlOn(); }
1856
1863
  function onScreen(msg) { if (screen && !screen.error) { screen.last = msg.shot; { const box = $('#screenBody'); if (box) box.innerHTML = screenHtml(); hydrateShots(box); } } }
1864
+ /* ---------------------------------------------------------------- pantalla remota (veure i controlar l'ordinador) */
1865
+ const RD = { on: false, ctl: false, mods: new Set(), drag: false, q: 55, blank: 0, err: 0, objUrl: null, chain: Promise.resolve(), sw: 0, tap: null, scroll: null, errAt: 0 };
1866
+ const RD_KEYS = [['Escape', 'Esc'], ['Tab', 'Tab'], ['ArrowUp', '↑'], ['ArrowDown', '↓'], ['ArrowLeft', '←'], ['ArrowRight', '→'], ['Enter', '⏎'], ['Backspace', '⌫']];
1867
+ const RD_MODS = [['meta', '⌘'], ['alt', '⌥'], ['ctrl', '⌃'], ['shift', '⇧']];
1868
+ const RD_SPECIAL = /^(Escape|Tab|Arrow(Up|Down|Left|Right)|Home|End|PageUp|PageDown|Delete|F([1-9]|1[0-2]))$/;
1869
+
1870
+ async function remoteOpen() {
1871
+ const m = machine(); if (!m || RD.on) return;
1872
+ if (!(state.info?.control?.escriptori?.enabled || screen?.caps?.escriptori?.enabled)) return toast('Activa primer «Apps de l’escriptori» en aquest ordinador.', 5000);
1873
+ Object.assign(RD, { on: true, ctl: false, mods: new Set(), drag: false, blank: 0, err: 0, tap: null, scroll: null });
1874
+ const root = document.createElement('div');
1875
+ root.className = 'rd'; root.id = 'rd'; root.setAttribute('role', 'dialog'); root.setAttribute('aria-label', 'Pantalla remota');
1876
+ root.innerHTML = `<div class="rd-bar"><button class="rd-btn" data-rd="close" type="button">Tanca</button><b class="rd-title">${esc(m.name || 'Ordinador')}</b><span class="rd-stat" id="rdStat"></span><span class="grow"></span><button class="rd-btn" data-rd="full" type="button" id="rdFull" hidden>Pantalla completa</button><div class="rd-seg" role="group" aria-label="Mode"><button data-rd="view" type="button" aria-pressed="true">Mirar</button><button data-rd="ctl" type="button" aria-pressed="false">Controlar</button></div></div>
1877
+ <div class="rd-stage" id="rdStage"><img id="rdImg" alt="Pantalla de l’ordinador" draggable="false"><div class="rd-msg" id="rdMsg">Connectant…</div></div>
1878
+ <div class="rd-keys" id="rdKeys" hidden><button class="rd-btn" data-rd="kbd" type="button">Teclat</button>${RD_MODS.map(([k, l]) => `<button class="rd-btn" data-mod="${k}" type="button" aria-pressed="false">${l}</button>`).join('')}${RD_KEYS.map(([k, l]) => `<button class="rd-btn" data-key="${k}" type="button">${l}</button>`).join('')}<button class="rd-btn" data-rd="drag" type="button" id="rdDrag">1 dit: desplaça</button></div>
1879
+ <textarea class="rd-type" id="rdType" autocapitalize="off" autocorrect="off" autocomplete="off" spellcheck="false" aria-label="Teclat de l’ordinador"></textarea>`;
1880
+ document.body.appendChild(root);
1881
+ document.documentElement.classList.add('rd-open');
1882
+ if (document.fullscreenEnabled && !matchMedia('(pointer: coarse)').matches) $('#rdFull').hidden = false;
1883
+ rdBind(root);
1884
+ api('/api/desktop/session', { method: 'POST', body: { event: 'open', wake: true } }).catch(() => {});
1885
+ api('/api/desktop/status').then((s) => { RD.status = s; rdHint(); }).catch(() => {});
1886
+ rdLoop();
1887
+ }
1888
+ function remoteClose() {
1889
+ if (!RD.on) return;
1890
+ RD.on = false;
1891
+ if (document.fullscreenElement) document.exitFullscreen?.().catch(() => {});
1892
+ if (RD.objUrl) URL.revokeObjectURL(RD.objUrl); RD.objUrl = null;
1893
+ $('#rd')?.remove();
1894
+ document.documentElement.classList.remove('rd-open');
1895
+ }
1896
+ function rdMsg(text) { const el = $('#rdMsg'); if (!el) return; el.textContent = text || ''; el.hidden = !text; }
1897
+ function rdHint() {
1898
+ const s = RD.status; if (!s || !RD.on) return;
1899
+ if (RD.blank >= 2) {
1900
+ rdMsg(s.screen === false
1901
+ ? 'La pantalla surt negra perquè falta el permís de Gravació de pantalla. Ajustos del Sistema → Privadesa i seguretat → Gravació de pantalla: activa el Terminal (o l’app des d’on obres nio) i reinicia nio.'
1902
+ : 'La pantalla surt negra: l’ordinador pot estar bloquejat o amb la pantalla apagada. Desbloqueja’l, o mou-hi el ratolí.');
1903
+ } else if (RD.ctl && s.accessibility === false) rdMsg('Per controlar-lo cal el permís d’Accessibilitat. Ajustos del Sistema → Privadesa i seguretat → Accessibilitat: activa el Terminal (o l’app des d’on obres nio) i reinicia nio.');
1904
+ else if (RD.ctl && s.input === false) rdMsg(s.platform === 'linux' ? 'Per controlar-lo cal xdotool: sudo apt install xdotool.' : 'Aquest ordinador no es pot controlar.');
1905
+ else rdMsg('');
1906
+ }
1907
+ async function rdLoop() {
1908
+ const m = machine();
1909
+ let n = 0, t0 = performance.now();
1910
+ while (RD.on) {
1911
+ if (document.hidden) { await sleep(500); continue; }
1912
+ const started = performance.now();
1913
+ try {
1914
+ const stage = $('#rdStage'); if (!stage) break;
1915
+ const w = Math.min(1800, Math.max(640, Math.round(stage.clientWidth * Math.min(devicePixelRatio || 1, 2))));
1916
+ const res = await fetch(`${m.url}/api/desktop/frame?w=${w}&q=${RD.q}&_=${Date.now()}`, { headers: { Authorization: `Bearer ${m.token}` }, cache: 'no-store' });
1917
+ if (!res.ok) { const d = await res.json().catch(() => ({})); throw new Error(d.error || `Error ${res.status}`); }
1918
+ const blob = await res.blob();
1919
+ if (!RD.on) break;
1920
+ const img = $('#rdImg'), old = RD.objUrl, url = URL.createObjectURL(blob);
1921
+ RD.objUrl = url; img.onload = () => { if (old) URL.revokeObjectURL(old); }; img.src = url;
1922
+ RD.sw = Number(res.headers.get('x-screen-w')) || RD.sw;
1923
+ RD.blank = res.headers.get('x-blank') === '1' ? RD.blank + 1 : 0;
1924
+ if (RD.err || RD.blank === 0 || RD.blank === 2) { RD.err = 0; rdHint(); }
1925
+ if (RD.blank === 2) api('/api/desktop/session', { method: 'POST', body: { wake: true } }).catch(() => {});
1926
+ n++; const dt = performance.now() - t0;
1927
+ if (dt > 1500) { const st = $('#rdStat'); if (st) st.textContent = `${(n * 1000 / dt).toFixed(1)} img/s`; n = 0; t0 = performance.now(); }
1928
+ } catch (e) {
1929
+ RD.err++; rdMsg(RD.err > 1 ? `${e.message || 'Sense connexió'}${/^Error|Activa|captura/.test(e.message) ? '' : ' · reconnectant…'}` : '');
1930
+ await sleep(Math.min(3000, 500 * RD.err));
1931
+ }
1932
+ await sleep(Math.max(30, 110 - (performance.now() - started)));
1933
+ }
1934
+ }
1935
+ function rdSend(ev) {
1936
+ RD.chain = RD.chain.then(() => api('/api/desktop/input', { method: 'POST', body: ev })).catch((e) => { if (Date.now() - RD.errAt > 4000) { RD.errAt = Date.now(); toast(e.message, 5000); } });
1937
+ }
1938
+ function rdKey(key, extra = []) {
1939
+ const mods = [...new Set([...RD.mods, ...extra])];
1940
+ if (RD.mods.size) { RD.mods.clear(); rdSyncMods(); }
1941
+ rdSend({ type: 'key', key, mods });
1942
+ }
1943
+ function rdSyncMods() { $$('#rd [data-mod]').forEach((b) => b.setAttribute('aria-pressed', RD.mods.has(b.dataset.mod))); }
1944
+ function rdPos(e) {
1945
+ const r = $('#rdImg').getBoundingClientRect(); if (!r.width) return null;
1946
+ const x = (e.clientX - r.left) / r.width, y = (e.clientY - r.top) / r.height;
1947
+ return x < 0 || x > 1 || y < 0 || y > 1 ? null : [x, y];
1948
+ }
1949
+ function rdScroll(dx, dy, pos) {
1950
+ const s = RD.scroll || (RD.scroll = { dx: 0, dy: 0, pos, t: null });
1951
+ s.dx += dx; s.dy += dy; if (pos) s.pos = pos;
1952
+ if (s.t) return;
1953
+ s.t = setTimeout(() => {
1954
+ const k = RD.scroll; RD.scroll = null;
1955
+ const r = $('#rdImg')?.getBoundingClientRect(), f = r && r.width ? (RD.sw || r.width) / r.width : 1;
1956
+ const ev = { type: 'scroll', dx: Math.round(k.dx * f), dy: Math.round(k.dy * f) };
1957
+ if (k.pos) { ev.x = k.pos[0]; ev.y = k.pos[1]; }
1958
+ if (ev.dx || ev.dy) rdSend(ev);
1959
+ }, 70);
1960
+ }
1961
+ function rdBind(root) {
1962
+ const stage = $('#rdStage', root), typer = $('#rdType', root);
1963
+ const ptrs = new Map();
1964
+ const click = (pos, e) => {
1965
+ const now = performance.now(), prev = RD.tap, near = prev && Math.hypot(e.clientX - prev.x, e.clientY - prev.y) < 28;
1966
+ const count = prev && near && now - prev.t < 340 ? (prev.count >= 2 ? 3 : 2) : 1;
1967
+ RD.tap = { t: now, x: e.clientX, y: e.clientY, count };
1968
+ rdSend({ type: 'click', x: pos[0], y: pos[1], count });
1969
+ };
1970
+ stage.addEventListener('pointerdown', (e) => {
1971
+ if (!RD.ctl || (e.pointerType === 'mouse' && e.button !== 0)) return;
1972
+ try { stage.setPointerCapture(e.pointerId); } catch {}
1973
+ const p = { x0: e.clientX, y0: e.clientY, x: e.clientX, y: e.clientY, touch: e.pointerType !== 'mouse', moved: false, long: false, path: [], pt: 0, lp: null };
1974
+ const pos = rdPos(e); if (pos) p.path.push(pos);
1975
+ if (p.touch) p.lp = setTimeout(() => { if (!p.moved && ptrs.size === 1 && ptrs.has(e.pointerId)) { p.long = true; navigator.vibrate?.(15); const q = rdPos(e); if (q) rdSend({ type: 'rclick', x: q[0], y: q[1] }); } }, 550);
1976
+ ptrs.set(e.pointerId, p);
1977
+ if (!typer.matches(':focus') && !p.touch) typer.focus({ preventScroll: true });
1978
+ e.preventDefault();
1979
+ });
1980
+ stage.addEventListener('pointermove', (e) => {
1981
+ const p = ptrs.get(e.pointerId); if (!p) return;
1982
+ const dx = e.clientX - p.x, dy = e.clientY - p.y; p.x = e.clientX; p.y = e.clientY;
1983
+ if (!p.moved && Math.hypot(p.x - p.x0, p.y - p.y0) > 8) { p.moved = true; clearTimeout(p.lp); }
1984
+ if (!p.moved || p.long) return;
1985
+ if (p.touch && (ptrs.size > 1 || !RD.drag)) { if (ptrs.size > 1 && e.isPrimary === false) return; rdScroll(dx, dy, rdPos(e)); return; }
1986
+ const now = performance.now(), pos = rdPos(e);
1987
+ if (pos && now - p.pt > 22) { p.path.push(pos); p.pt = now; }
1988
+ });
1989
+ const end = (e) => {
1990
+ const p = ptrs.get(e.pointerId); if (!p) return;
1991
+ ptrs.delete(e.pointerId); clearTimeout(p.lp);
1992
+ if (p.long) return;
1993
+ const pos = rdPos(e);
1994
+ if (!p.moved) { if (pos && e.type === 'pointerup') click(pos, e); return; }
1995
+ if (p.touch && (ptrs.size > 0 || !RD.drag)) return;
1996
+ if (pos) p.path.push(pos);
1997
+ if (p.path.length >= 2) rdSend({ type: 'drag', path: p.path.length > 100 ? p.path.filter((_, i) => i % Math.ceil(p.path.length / 100) === 0 || i === p.path.length - 1) : p.path });
1998
+ };
1999
+ stage.addEventListener('pointerup', end);
2000
+ stage.addEventListener('pointercancel', end);
2001
+ stage.addEventListener('contextmenu', (e) => { e.preventDefault(); if (!RD.ctl) return; const pos = rdPos(e); if (pos && e.pointerType !== 'touch' && !('ontouchstart' in window && e.sourceCapabilities?.firesTouchEvents)) rdSend({ type: 'rclick', x: pos[0], y: pos[1] }); });
2002
+ stage.addEventListener('wheel', (e) => {
2003
+ if (!RD.ctl) return; e.preventDefault();
2004
+ const k = e.deltaMode === 1 ? 16 : 1;
2005
+ rdScroll(-e.deltaX * k, -e.deltaY * k, rdPos(e));
2006
+ }, { passive: false });
2007
+ // Teclat: el text va per «beforeinput» (serveix per al teclat del mòbil); les tecles especials i les dreceres, per «keydown».
2008
+ typer.addEventListener('keydown', (e) => {
2009
+ if (!RD.ctl || e.isComposing || e.key === 'Process' || e.key === 'Unidentified') return;
2010
+ if (['Meta', 'Control', 'Alt', 'Shift', 'CapsLock'].includes(e.key)) return;
2011
+ const combo = e.metaKey || e.ctrlKey || e.altKey;
2012
+ if (!combo && !RD_SPECIAL.test(e.key)) return;
2013
+ e.preventDefault();
2014
+ const mods = [e.metaKey && 'meta', e.ctrlKey && 'ctrl', e.altKey && 'alt', e.shiftKey && 'shift'].filter(Boolean);
2015
+ rdKey(e.key.length === 1 ? e.key.toLowerCase() : e.key, mods);
2016
+ });
2017
+ typer.addEventListener('beforeinput', (e) => {
2018
+ if (!RD.ctl) return;
2019
+ const t = e.inputType;
2020
+ if (t === 'insertCompositionText') return;
2021
+ e.preventDefault();
2022
+ if (t === 'insertText' || t === 'insertFromPaste' || t === 'insertReplacementText') {
2023
+ const text = e.data ?? e.dataTransfer?.getData('text/plain') ?? '';
2024
+ if (!text) return;
2025
+ if (RD.mods.size && [...text].length === 1) rdKey(text.toLowerCase()); else rdSend({ type: 'text', text });
2026
+ } else if (t === 'deleteContentBackward') rdKey('Backspace');
2027
+ else if (t === 'deleteContentForward') rdKey('Delete');
2028
+ else if (t === 'insertLineBreak' || t === 'insertParagraph') rdKey('Enter');
2029
+ });
2030
+ typer.addEventListener('compositionend', (e) => { if (RD.ctl && e.data) rdSend({ type: 'text', text: e.data }); typer.value = ''; });
2031
+ root.addEventListener('click', (e) => {
2032
+ const b = e.target.closest('button'); if (!b) return;
2033
+ if (b.dataset.key) { rdKey(b.dataset.key); return; }
2034
+ if (b.dataset.mod) { RD.mods.has(b.dataset.mod) ? RD.mods.delete(b.dataset.mod) : RD.mods.add(b.dataset.mod); rdSyncMods(); return; }
2035
+ switch (b.dataset.rd) {
2036
+ case 'close': remoteClose(); break;
2037
+ case 'full': document.fullscreenElement ? document.exitFullscreen() : root.requestFullscreen?.().catch(() => {}); break;
2038
+ case 'view': case 'ctl': {
2039
+ RD.ctl = b.dataset.rd === 'ctl';
2040
+ $$('.rd-seg button', root).forEach((x) => x.setAttribute('aria-pressed', x.dataset.rd === b.dataset.rd));
2041
+ $('#rdKeys').hidden = !RD.ctl; root.classList.toggle('rd-ctl', RD.ctl);
2042
+ if (RD.ctl) { api('/api/desktop/session', { method: 'POST', body: { event: 'control' } }).catch(() => {}); if (!matchMedia('(pointer: coarse)').matches) typer.focus({ preventScroll: true }); }
2043
+ rdHint(); break;
2044
+ }
2045
+ case 'kbd': typer.focus(); break;
2046
+ case 'drag': RD.drag = !RD.drag; b.textContent = RD.drag ? '1 dit: arrossega' : '1 dit: desplaça'; break;
2047
+ }
2048
+ });
2049
+ }
2050
+ document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && RD.on && !RD.ctl) remoteClose(); });
2051
+
1857
2052
  document.addEventListener('click', async (e) => {
1858
2053
  const b = e.target.closest('[data-ctl]'); if (!b) return;
1859
2054
  b.disabled = true;
@@ -2044,6 +2239,7 @@ document.addEventListener('click', async (e) => {
2044
2239
  case 'revoke': return revokeDevice(a.dataset.dev);
2045
2240
  case 'job-new': return newJobSheet();
2046
2241
  case 'panic': return panicNow();
2242
+ case 'remote-open': remoteOpen(); return;
2047
2243
  case 'screen-now': try { const s = await api('/api/screen', { method: 'POST' }); onScreen({ shot: s }); } catch (err) { toast(err.message, 4500); } return;
2048
2244
  case 'mem-new': return memEdit(null);
2049
2245
  case 'mem-all': if (mem) delete mem.hits; return loadMemory();
package/web/app/style.css CHANGED
@@ -645,3 +645,26 @@ dialog.sheet.wiz.compact .sheet-in, .compact .wiz-body { flex: 0 1 auto; }
645
645
  .sec-head { display: flex; align-items: flex-end; justify-content: space-between; gap: 16px; flex-wrap: wrap; margin: 36px 0 14px; }
646
646
  .sec-head .sec { margin: 0; }
647
647
  .sec-head p { margin-top: 4px; max-width: 640px; }
648
+
649
+ /* Pantalla remota */
650
+ html.rd-open, html.rd-open body { overflow: hidden; }
651
+ .rd { position: fixed; inset: 0; z-index: 200; display: flex; flex-direction: column; background: #0b0b0c; color: #f2f1ee; padding: var(--safe-t, 0px) 0 var(--safe-b, 0px); }
652
+ .rd-bar { display: flex; align-items: center; gap: 10px; padding: 10px 12px; background: #161617; border-bottom: 1px solid #2a2a2c; flex: none; }
653
+ .rd-title { font-size: 14px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
654
+ .rd-stat { font-size: 12px; color: #8d8c88; white-space: nowrap; }
655
+ .rd-bar .grow { flex: 1; }
656
+ .rd-btn { appearance: none; border: 1px solid #36363a; background: #232326; color: #f2f1ee; font: inherit; font-size: 13px; line-height: 1; padding: 9px 12px; border-radius: 9px; cursor: pointer; white-space: nowrap; flex: none; }
657
+ .rd-btn:hover { background: #2d2d31; }
658
+ .rd-btn[aria-pressed=true] { background: #f2f1ee; color: #161617; border-color: #f2f1ee; }
659
+ .rd-seg { display: flex; border: 1px solid #36363a; border-radius: 10px; overflow: hidden; flex: none; }
660
+ .rd-seg button { appearance: none; border: 0; background: transparent; color: #b9b8b3; font: inherit; font-size: 13px; padding: 9px 14px; cursor: pointer; }
661
+ .rd-seg button[aria-pressed=true] { background: #f2f1ee; color: #161617; font-weight: 600; }
662
+ .rd-stage { position: relative; flex: 1; min-height: 0; display: flex; align-items: center; justify-content: center; background: #000; touch-action: none; user-select: none; -webkit-user-select: none; -webkit-touch-callout: none; overflow: hidden; }
663
+ .rd-stage img { max-width: 100%; max-height: 100%; display: block; object-fit: contain; -webkit-user-drag: none; }
664
+ .rd.rd-ctl .rd-stage { cursor: crosshair; }
665
+ .rd-msg { position: absolute; left: 50%; bottom: 18px; transform: translateX(-50%); max-width: min(560px, calc(100% - 32px)); padding: 12px 16px; border-radius: 12px; background: rgba(22, 22, 23, .92); border: 1px solid #36363a; color: #f2f1ee; font-size: 13px; line-height: 1.45; text-align: center; pointer-events: none; }
666
+ .rd-msg[hidden] { display: none; }
667
+ .rd-keys { display: flex; gap: 8px; padding: 10px 12px; background: #161617; border-top: 1px solid #2a2a2c; overflow-x: auto; flex: none; -webkit-overflow-scrolling: touch; }
668
+ .rd-keys[hidden] { display: none; }
669
+ .rd-type { position: fixed; left: 0; top: 0; width: 1px; height: 1px; opacity: 0; border: 0; padding: 0; resize: none; font-size: 16px; pointer-events: none; }
670
+ @media (max-width: 560px) { .rd-bar { padding: 8px 10px; gap: 8px; } .rd-stat, #rdFull { display: none; } .rd-seg button { padding: 9px 11px; } }
package/web/sw.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // nio · service worker: obre l'app encara que no hi hagi connexió i mostra els avisos.
2
- const SHELL = 'nio-shell-v22';
2
+ const SHELL = 'nio-shell-v23';
3
3
  const FILES = ['/app', '/app/style.css', '/app/app.js', '/bot.css', '/bot.js', '/board.css', '/board.js', '/favicon.svg', '/manifest.webmanifest'];
4
4
 
5
5
  self.addEventListener('install', (e) => {