nio-cat 0.8.3 → 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 +3 -1
- package/package.json +1 -1
- package/src/remote.js +295 -0
- package/web/app/app.js +200 -4
- package/web/app/style.css +23 -0
- package/web/sw.js +1 -1
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
|
|
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
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/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
|
-
|
|
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-
|
|
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) => {
|