nio-cat 0.8.3 → 0.9.1
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/src/telegram.js +21 -2
- package/web/app/app.js +315 -9
- package/web/app/style.css +39 -0
- package/web/board.css +50 -0
- package/web/board.js +29 -5
- package/web/index.html +66 -7
- 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/src/telegram.js
CHANGED
|
@@ -12,6 +12,22 @@ const httpErr = (status, message) => Object.assign(new Error(message), { status
|
|
|
12
12
|
const TOKEN_RE = /^\d{5,15}:[\w-]{30,60}$/;
|
|
13
13
|
const clip = (s, n) => (String(s).length > n ? `${String(s).slice(0, n)}…` : String(s));
|
|
14
14
|
|
|
15
|
+
// Markdown de l'agent -> HTML de Telegram (negreta, cursiva, codi, enllaços, títols i llistes).
|
|
16
|
+
const esc = (t) => t.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
17
|
+
function toHtml(src) {
|
|
18
|
+
const keep = [];
|
|
19
|
+
const hold = (h) => `\u0000${keep.push(h) - 1}\u0000`;
|
|
20
|
+
let t = String(src).replace(/```[^\n`]*\n?([\s\S]*?)```/g, (_, c) => hold(`<pre>${esc(c.replace(/\n$/, ''))}</pre>`));
|
|
21
|
+
t = t.replace(/`([^`\n]+)`/g, (_, c) => hold(`<code>${esc(c)}</code>`));
|
|
22
|
+
t = esc(t);
|
|
23
|
+
t = t.replace(/^#{1,6}\s+(.+)$/gm, '<b>$1</b>')
|
|
24
|
+
.replace(/^\s*[-*+]\s+/gm, '• ')
|
|
25
|
+
.replace(/\[([^\]\n]+)\]\((https?:\/\/[^\s)]+)\)/g, '<a href="$2">$1</a>')
|
|
26
|
+
.replace(/\*\*([^*\n]+?)\*\*/g, '<b>$1</b>')
|
|
27
|
+
.replace(/(^|[\s(])\*([^*\s][^*\n]*?)\*(?=[\s).,;:!?]|$)/g, '$1<i>$2</i>');
|
|
28
|
+
return t.replace(/\u0000(\d+)\u0000/g, (_, i) => keep[Number(i)]);
|
|
29
|
+
}
|
|
30
|
+
|
|
15
31
|
class Telegram {
|
|
16
32
|
constructor({ cfg, hub, audit, available = () => ['claude', 'codex'] }) {
|
|
17
33
|
this.cfg = cfg;
|
|
@@ -153,7 +169,8 @@ class Telegram {
|
|
|
153
169
|
this.chatOf.set(sid, chat);
|
|
154
170
|
this.hub.send(sid, text, this.hookUrl ? this.hookUrl() : undefined);
|
|
155
171
|
this.audit.add({ type: 'telegram', what: `Missatge de Telegram: ${clip(text, 120)}`, by: `Telegram · ${who}`, session: sid });
|
|
156
|
-
|
|
172
|
+
this.call('sendChatAction', { chat_id: chat, action: 'typing' }).catch(() => {});
|
|
173
|
+
return;
|
|
157
174
|
} catch (e) {
|
|
158
175
|
return this.send(chat, `No he pogut començar: ${e.message}`);
|
|
159
176
|
}
|
|
@@ -211,7 +228,9 @@ class Telegram {
|
|
|
211
228
|
}
|
|
212
229
|
|
|
213
230
|
send(chat, text) {
|
|
214
|
-
|
|
231
|
+
const raw = clip(text, 3500);
|
|
232
|
+
return this.call('sendMessage', { chat_id: chat, text: toHtml(raw), parse_mode: 'HTML', link_preview_options: { is_disabled: true } })
|
|
233
|
+
.catch(() => this.call('sendMessage', { chat_id: chat, text: raw }).catch(() => {}));
|
|
215
234
|
}
|
|
216
235
|
|
|
217
236
|
async download(fileId) {
|
package/web/app/app.js
CHANGED
|
@@ -552,12 +552,60 @@ function computerCard(m, i) {
|
|
|
552
552
|
// Ajuda sempre a mà: com es fa cada cosa, i què mirar si no connecta.
|
|
553
553
|
function helpPanel(open) {
|
|
554
554
|
const q = (t, body) => `<details><summary>${t}</summary><div class="help-body">${body}</div></details>`;
|
|
555
|
+
const k = (t) => `<span class="mono">${t}</span>`;
|
|
556
|
+
const blk = (t) => `<pre class="cmdb"><code>${t}</code></pre>`;
|
|
557
|
+
const rows = (list) => `<div class="cmdlist">${list.map(([c, d]) => `<div><span class="mono">${c}</span><span>${d}</span></div>`).join('')}</div>`;
|
|
558
|
+
const grp = (t) => `<h3 class="help-grp">${t}</h3>`;
|
|
555
559
|
return `<section class="panel help" id="help"><header><h2>Com es fa</h2></header><div class="body">
|
|
556
|
-
${
|
|
557
|
-
${q('
|
|
558
|
-
${q('
|
|
559
|
-
${q('
|
|
560
|
-
${q('
|
|
560
|
+
${grp('Per començar')}
|
|
561
|
+
${q('Què és nio', `<p>nio et deixa donar feina als agents d’IA del teu ordinador (Claude Code, Codex, Grok, Antigravity i Ollama) des del mòbil. Els agents <b>treballen a l’ordinador o al servidor</b>, amb els teus comptes. El mòbil és el comandament: hi segueixes la feina en directe i aproves el que facin.</p>`)}
|
|
562
|
+
${q('Connectar aquest ordinador', `<p>Al terminal, des de la carpeta dels teus projectes, executa:</p>${blk('npx nio-cat')}<p>S’obre nio.cat ja connectat. Cal Node.js 18 o més nou. Pots indicar més carpetes: ${k('npx nio-cat ~/projectes')}.</p>`)}
|
|
563
|
+
${q('Fer-lo servir des del mòbil', `<p>A l’ordinador, a Ordinadors, prem <b>Continua al mòbil</b>. Surt un QR i un codi ${k('nio-…')}.</p><p>Al mòbil, obre <b>www.nio.cat/app</b> → Ordinadors → Afegeix ordinador, i escaneja el QR o enganxa el codi. Guarda el codi com una contrasenya: qui el té, pot manar a l’ordinador.</p><p>Per connectar-t’hi des de qualsevol xarxa cal ${k('cloudflared')} a l’ordinador (${k('brew install cloudflared')}). Sense ell, el mòbil ha d’estar a la mateixa wifi. Si reinicies nio, l’adreça del túnel canvia: torna a escanejar el codi.</p>`)}
|
|
564
|
+
${q('Afegir un servidor (VPS) amb Claude Code', `<p>Ordinadors → Afegeix ordinador → <b>Servidor (SSH)</b> → escriu ${k('usuari@servidor')}. L’ordinador hi entra, hi engega nio i el servidor surt a la llista. Després, prem <b>Continua al mòbil</b> amb el servidor actiu.</p><p>Des del terminal també es pot: ${k('npx nio-cat ssh usuari@servidor')}.</p>`)}
|
|
565
|
+
${q('Què cal al servidor', `<ul><li>Entrar-hi per SSH sense contrasenya. Si no, executa ${k('ssh-copy-id usuari@servidor')} a l’ordinador.</li><li>Node.js 18 o més nou.</li><li>Claude Code instal·lat i amb sessió iniciada: entra al servidor i executa ${k('claude')} un cop.</li><li>Linux amb ${k('curl')} o ${k('wget')} (nio hi instal·la ${k('cloudflared')} sol).</li></ul>`)}
|
|
566
|
+
|
|
567
|
+
${grp('Treballar amb els agents')}
|
|
568
|
+
${q('Crear una sessió', `<p>A <b>Sessions</b>, prem <b>Nova sessió</b>: tria l’agent, la carpeta (hi ha un explorador de carpetes de l’ordinador) i el nivell de permisos. Escriu què vols i l’agent es posa a treballar. La resposta et va arribant en directe.</p><p>També pots <b>clonar un repositori de GitHub</b> des de Nova sessió: nio fa servir el ${k('gh')} de l’ordinador (cal haver fet ${k('gh auth login')}).</p>`)}
|
|
569
|
+
${q('Els tres nivells de permisos', `<ul><li><b>Només llegir</b>: l’agent llegeix i proposa, però no toca cap fitxer.</li><li><b>Demana permís</b>: llegeix sol; abans d’editar o executar alguna cosa, t’ho pregunta (Claude Code).</li><li><b>Via lliure</b>: treballa sense preguntar. Fes-ho servir només en carpetes de confiança.</li></ul><p>Es pot canviar enmig d’una sessió amb ${k('/mode')}.</p>`)}
|
|
570
|
+
${q('Aprovacions', `<p>Quan un agent vol editar un fitxer o executar una ordre, surt una targeta amb el que farà. <b>Aprova</b> o <b>rebutja</b> amb un toc. A <b>Aprovacions</b> tens totes les pendents de totes les sessions juntes.</p>`)}
|
|
571
|
+
${q('Comandes amb «/»', `<p>Al missatge d’una sessió, escriu ${k('/')} i surt la llista amb suggeriments. Les de nio funcionen sempre; les de l’agent (comandes i skills) se li envien tal qual.</p>${rows([
|
|
572
|
+
['/mode', 'Canvia els permisos: ' + k('l') + ' llegir, ' + k('p') + ' preguntar, ' + k('t') + ' tot'],
|
|
573
|
+
['/model', 'Canvia de model'],
|
|
574
|
+
['/stop', 'Atura l’agent ara mateix'],
|
|
575
|
+
['/nova', 'Nova sessió amb el mateix agent i carpeta'],
|
|
576
|
+
['/carpeta', 'Canvia la carpeta de la sessió'],
|
|
577
|
+
['/git', 'Git i GitHub: veure canvis, fer commit i crear un PR'],
|
|
578
|
+
['/nom', 'Posa nom a la sessió (' + k('/nom Feina web') + ')'],
|
|
579
|
+
['/opcions', 'Opcions de la sessió'],
|
|
580
|
+
['/ajuda', 'Llista de comandes']
|
|
581
|
+
])}<p>Exemple: ${k('/mode p')} passa la sessió a «Demana permís».</p>`)}
|
|
582
|
+
${q('Git i GitHub', `<p>Dins una sessió, ${k('/git')} (o Opcions → Git i GitHub) mostra els canvis, fa <b>commit</b> (en una branca nova si ets a ${k('main')}) i crea un <b>PR</b>. Cap clau no passa per nio: fa servir el ${k('git')} i el ${k('gh')} de l’ordinador.</p>`)}
|
|
583
|
+
${q('Avisos al mòbil', `<p>A <b>Ajustos → Avisos</b>, prem <b>Activa els avisos</b>. Rebràs una notificació quan un agent et demana permís o acaba la feina. L’avís arriba buit; el detall el demana el mòbil directament a l’ordinador.</p><p>A l’iPhone, primer afegeix nio a la pantalla d’inici (Comparteix → Afegeix a la pantalla d’inici) i obre’l des d’allà.</p>`)}
|
|
584
|
+
${q('Veu', `<p>A <b>Ajustos → Veu</b> pots posar la teva clau d’ElevenLabs per <b>dictar</b> els missatges amb el micròfon i <b>escoltar</b> les respostes. La clau es queda només en aquest navegador.</p>`)}
|
|
585
|
+
|
|
586
|
+
${grp('Eines')}
|
|
587
|
+
${q('Pantalla remota', `<p>Mira i controla l’escriptori d’un ordinador des del mòbil. A <b>Ordinadors</b>, baixa fins a la secció <b>Pantalla</b> i prem <b>Obre la pantalla</b>. S’obre a pantalla completa. Cal activar abans <b>Apps de l’escriptori</b> a la mateixa secció.</p><ul><li><b>Mirar</b>: només veus la pantalla, en directe. Amb un dit la mous quan has fet zoom.</li><li><b>Controlar</b>: toc = clic; doble toc = doble clic; mantenir premut = clic dret; dos dits = desplaçar o fer zoom. El botó <b>1 dit</b> canvia entre desplaçar i arrossegar, i el botó <b>2 dits</b> entre desplaçar l’ordinador i fer zoom amb pessics. <b>Teclat</b> obre el teclat del mòbil, i dalt hi tens ${k('⌘')} ${k('⌥')} ${k('⌃')} ${k('⇧')}, ${k('Esc')}, ${k('Tab')} i fletxes.</li><li><b>Zoom amb dos dits</b>: pessiga per apropar i arrossega amb dos dits per moure’t. Al mode Mirar sempre va així, amb doble toc per alternar entre enquadrar i apropar. Al mode Controlar, tria al botó <b>2 dits</b> si els dos dits desplacen l’ordinador o fan zoom. A l’ordinador, pessiga al trackpad o ${k('Ctrl')} + roda. Els botons <b>− 100% +</b> i <b>1:1</b> fan el mateix i funcionen en tots dos modes.</li><li><b>Desa captura</b>: baixa el fotograma actual com a imatge. <b>Captura</b> (a la secció) en fa una de sola, sense obrir la pantalla.</li><li><b>Si surt negra</b>: al Mac, dona el permís de <b>Gravació de pantalla</b> (i <b>Accessibilitat</b> per controlar) al Terminal on corre <span class="mono">nio-cat</span> i reinicia’l. A Linux, per controlar cal <span class="mono">xdotool</span>.</li></ul>`)}
|
|
588
|
+
${q('Pissarra', `<p>Un llenç infinit per fer diagrames a mà alçada: fluxos, mapes mentals i organigrames. Hi arribes des de <b>Més → Pissarra</b> (al mòbil) o al menú (a l’ordinador).</p><ul><li><b>Eines</b>: selecciona, mou el llenç, rectangle, el·lipse, rombe, fletxa, línia, llapis, text i goma. El cadenat manté l’eina activa.</li><li><b>Zoom amb dos dits</b>: al mòbil, pessiga sobre el llenç; amb els dos dits alhora també el mous. A l’ordinador, pessiga al trackpad o fes ${k('Ctrl')} + roda del ratolí.</li><li><b>Moure’s</b>: amb un dit, a l’espai buit (eina Selecciona), o amb ${k('Espai')} + arrossegar, o la roda del ratolí.</li><li><b>Text</b>: doble toc o doble clic per editar; doble toc a l’espai buit crea un text nou.</li><li><b>Zoom ràpid</b>: la xifra del percentatge torna al 100%; el botó de les cantonades ho enquadra tot.</li><li><b>Desfés / refés</b>: els botons de dalt a l’esquerra, o ${k('Ctrl Z')} i ${k('Ctrl ⇧ Z')}.</li><li><b>Estil</b>: color, gruix i traç (al mòbil, el botó de la paleta). Menú ⋯: exporta a PNG, SVG o JSON, i quadrícula.</li></ul>`)}
|
|
589
|
+
${q('Demanar-li el dibuix a en Nio', `<p>Prem <b>Nio</b> (dalt a la dreta) per obrir el xat de la pissarra. Descriu què vols i ho dibuixa. Exemples:</p>${rows([
|
|
590
|
+
['Flux: A -> B -> C', 'Una fletxa per línia; afegeix ' + k(': etiqueta') + ' si vols'],
|
|
591
|
+
['Mapa mental de X: a, b, c', 'Tema central amb branques'],
|
|
592
|
+
['Organigrama: Direcció > Finances, Operacions', 'Qui depèn de qui'],
|
|
593
|
+
['Afegeix un rombe “Quadra?”', 'Afegeix una figura'],
|
|
594
|
+
['Connecta Banc amb ERP', 'Uneix dues figures amb una fletxa'],
|
|
595
|
+
['Pinta Banc de verd', 'Canvia el color'],
|
|
596
|
+
['Reanomena Banc a Caixa', 'Canvia el text'],
|
|
597
|
+
['Esborra ERP', 'Treu una figura'],
|
|
598
|
+
['Ordena-ho · Centra · Desfés', 'Ordena, enquadra o desfà'],
|
|
599
|
+
['Plantilla agent', 'També ' + k('Plantilla d’aprovació') + ' i ' + k('Plantilla conciliació')],
|
|
600
|
+
['Exporta PNG', 'També ' + k('SVG') + ' o ' + k('JSON')]
|
|
601
|
+
])}<p>Al desplegable <b>Amb</b> tries què t’entén: les <b>ordres ràpides</b> (sense gastar res) o un model.</p>`)}
|
|
602
|
+
${q('Xat local', `<p>A <b>Més → Xat local</b> tens un model petit que funciona <b>dins del navegador del mòbil</b>, sense ordinador ni connexió un cop descarregat. Necessita un navegador amb WebGPU. Si la GPU del mòbil es reinicia, el xat es recupera sol.</p>`)}
|
|
603
|
+
${q('Models d’Ollama', `<p>A <b>Models</b> veus els models d’Ollama de l’ordinador, en pots descarregar de nous amb el progrés en directe, esborrar-los i obrir-hi sessions. Funcionen al teu ordinador i no gasten res.</p>`)}
|
|
604
|
+
${q('Buscador i dreceres', `<p>Prem ${k('Ctrl K')} (o ${k('⌘ K')}) per obrir el buscador d’accions: nova sessió, afegir ordinador, configurar la veu, fer el tutorial… Al mòbil, llisca cap als costats per canviar de pàgina.</p>`)}
|
|
605
|
+
|
|
606
|
+
${grp('Si alguna cosa no va')}
|
|
607
|
+
${q('No es connecta', `<ul><li><b>«No arribo a l’ordinador»</b>: comprova que nio continua engegat al terminal.</li><li><b>El servidor ha deixat de respondre</b>: si s’ha reiniciat, l’adreça canvia. Prem <b>Torna a connectar</b> a la seva targeta.</li><li><b>Safari no connecta</b>: obre nio amb ${k('npx nio-cat --local')}, o fes servir Chrome.</li><li><b>La clau no és vàlida</b>: torna a afegir l’ordinador amb el codi nou. Per invalidar tots els codis antics: ${k('npx nio-cat reset-token')}.</li><li><b>Quins agents té l’ordinador?</b> ${k('npx nio-cat doctor')} els llista.</li></ul>`)}
|
|
608
|
+
${q('Seguretat', `<ul><li>L’enllaç de connexió porta una clau aleatòria que el navegador no envia a cap servidor web.</li><li>Cap dada no passa per servidors de nio: el mòbil parla directament amb el teu ordinador.</li><li>Totes les crides demanen la clau, que només tens tu.</li></ul>`)}
|
|
561
609
|
${q('D’on es gasta', `<p>nio no fa cap crida pel seu compte. Cada agent treballa a l’ordinador o al servidor amb el teu compte (Claude, Codex, Grok…). Ollama funciona al teu ordinador i no gasta res.</p>`)}
|
|
562
610
|
</div></section>`;
|
|
563
611
|
}
|
|
@@ -1824,7 +1872,8 @@ async function panicNow() {
|
|
|
1824
1872
|
try { const r = await api('/api/panic', { method: 'POST' }); toast(`Aturat tot (${r.stopped} agents)`); } catch (e) { toast(e.message, 4500); }
|
|
1825
1873
|
}
|
|
1826
1874
|
function screenSection() {
|
|
1827
|
-
|
|
1875
|
+
const on = controlOn();
|
|
1876
|
+
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
1877
|
}
|
|
1829
1878
|
function screenHtml() {
|
|
1830
1879
|
if (!screen) return `<div class="panel empty">${bot('work')}<h2>Carregant…</h2></div>`;
|
|
@@ -1834,26 +1883,282 @@ function screenHtml() {
|
|
|
1834
1883
|
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
1884
|
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
1885
|
};
|
|
1886
|
+
const d = screen.desk, warn = [];
|
|
1887
|
+
if (d && c.escriptori?.enabled) {
|
|
1888
|
+
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.');
|
|
1889
|
+
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.');
|
|
1890
|
+
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>.');
|
|
1891
|
+
}
|
|
1837
1892
|
return `<div class="grid2 one">
|
|
1838
1893
|
<div class="stack">
|
|
1894
|
+
${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
1895
|
<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
1896
|
<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
1897
|
<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
1898
|
<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
1899
|
</div>
|
|
1845
1900
|
</div>`;
|
|
1846
1901
|
}
|
|
1847
1902
|
async function loadScreen() {
|
|
1848
1903
|
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) };
|
|
1904
|
+
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)]);
|
|
1905
|
+
screen = { caps, desk, last: cur.last, actions: log.filter((e) => /^(npx\s+)?agent-(browser|desktop)\b/.test(e.what || '')).slice(0, 30) };
|
|
1851
1906
|
} catch (e) { screen = { error: e.message }; }
|
|
1852
1907
|
const box = $('#screenBody'); if (box) { box.innerHTML = screenHtml(); hydrateShots(box); }
|
|
1853
1908
|
renderPanic();
|
|
1854
1909
|
}
|
|
1855
1910
|
function syncScreenBtn() { const b = $('[data-act=screen-now]'); if (b) b.disabled = !controlOn(); }
|
|
1856
1911
|
function onScreen(msg) { if (screen && !screen.error) { screen.last = msg.shot; { const box = $('#screenBody'); if (box) box.innerHTML = screenHtml(); hydrateShots(box); } } }
|
|
1912
|
+
/* ---------------------------------------------------------------- pantalla remota (veure i controlar l'ordinador) */
|
|
1913
|
+
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 };
|
|
1914
|
+
const RD_KEYS = [['Escape', 'Esc'], ['Tab', 'Tab'], ['ArrowUp', '↑'], ['ArrowDown', '↓'], ['ArrowLeft', '←'], ['ArrowRight', '→'], ['Enter', '⏎'], ['Backspace', '⌫']];
|
|
1915
|
+
const RD_MODS = [['meta', '⌘'], ['alt', '⌥'], ['ctrl', '⌃'], ['shift', '⇧']];
|
|
1916
|
+
const RD_SPECIAL = /^(Escape|Tab|Arrow(Up|Down|Left|Right)|Home|End|PageUp|PageDown|Delete|F([1-9]|1[0-2]))$/;
|
|
1917
|
+
|
|
1918
|
+
async function remoteOpen() {
|
|
1919
|
+
const m = machine(); if (!m || RD.on) return;
|
|
1920
|
+
if (!(state.info?.control?.escriptori?.enabled || screen?.caps?.escriptori?.enabled)) return toast('Activa primer «Apps de l’escriptori» en aquest ordinador.', 5000);
|
|
1921
|
+
Object.assign(RD, { two: 'scroll', z: 1, px: 0, py: 0, on: true, ctl: false, mods: new Set(), drag: false, blank: 0, err: 0, tap: null, scroll: null });
|
|
1922
|
+
const root = document.createElement('div');
|
|
1923
|
+
root.className = 'rd'; root.id = 'rd'; root.setAttribute('role', 'dialog'); root.setAttribute('aria-label', 'Pantalla remota');
|
|
1924
|
+
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>
|
|
1925
|
+
<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 class="rd-tools" role="group" aria-label="Zoom i captura"><button class="rd-btn" data-rd="zout" type="button" aria-label="Allunya">−</button><button class="rd-btn rd-pct" data-rd="zfit" type="button" id="rdPct" aria-label="Enquadra">100%</button><button class="rd-btn" data-rd="zin" type="button" aria-label="Apropa">+</button><button class="rd-btn" data-rd="z11" type="button" aria-label="Mida real">1:1</button><span class="rd-sep"></span><button class="rd-btn" data-rd="save" type="button">Desa captura</button></div></div>
|
|
1926
|
+
<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><button class="rd-btn" data-rd="two" type="button" id="rdTwo">2 dits: desplaça</button></div>
|
|
1927
|
+
<textarea class="rd-type" id="rdType" autocapitalize="off" autocorrect="off" autocomplete="off" spellcheck="false" aria-label="Teclat de l’ordinador"></textarea>`;
|
|
1928
|
+
document.body.appendChild(root);
|
|
1929
|
+
document.documentElement.classList.add('rd-open');
|
|
1930
|
+
if (document.fullscreenEnabled && !matchMedia('(pointer: coarse)').matches) $('#rdFull').hidden = false;
|
|
1931
|
+
rdBind(root);
|
|
1932
|
+
api('/api/desktop/session', { method: 'POST', body: { event: 'open', wake: true } }).catch(() => {});
|
|
1933
|
+
api('/api/desktop/status').then((s) => { RD.status = s; rdHint(); }).catch(() => {});
|
|
1934
|
+
rdLoop();
|
|
1935
|
+
}
|
|
1936
|
+
function remoteClose() {
|
|
1937
|
+
if (!RD.on) return;
|
|
1938
|
+
RD.on = false;
|
|
1939
|
+
if (document.fullscreenElement) document.exitFullscreen?.().catch(() => {});
|
|
1940
|
+
if (RD.objUrl) URL.revokeObjectURL(RD.objUrl); RD.objUrl = null;
|
|
1941
|
+
$('#rd')?.remove();
|
|
1942
|
+
document.documentElement.classList.remove('rd-open');
|
|
1943
|
+
}
|
|
1944
|
+
function rdMsg(text) { const el = $('#rdMsg'); if (!el) return; el.textContent = text || ''; el.hidden = !text; }
|
|
1945
|
+
function rdHint() {
|
|
1946
|
+
const s = RD.status; if (!s || !RD.on) return;
|
|
1947
|
+
if (RD.blank >= 2) {
|
|
1948
|
+
rdMsg(s.screen === false
|
|
1949
|
+
? '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.'
|
|
1950
|
+
: 'La pantalla surt negra: l’ordinador pot estar bloquejat o amb la pantalla apagada. Desbloqueja’l, o mou-hi el ratolí.');
|
|
1951
|
+
} 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.');
|
|
1952
|
+
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.');
|
|
1953
|
+
else rdMsg('');
|
|
1954
|
+
}
|
|
1955
|
+
async function rdLoop() {
|
|
1956
|
+
const m = machine();
|
|
1957
|
+
let n = 0, t0 = performance.now();
|
|
1958
|
+
while (RD.on) {
|
|
1959
|
+
if (document.hidden) { await sleep(500); continue; }
|
|
1960
|
+
const started = performance.now();
|
|
1961
|
+
try {
|
|
1962
|
+
const stage = $('#rdStage'); if (!stage) break;
|
|
1963
|
+
const w = Math.min(2400, Math.max(640, Math.round(stage.clientWidth * Math.min(devicePixelRatio || 1, 2) * Math.min(RD.z, 2))));
|
|
1964
|
+
const res = await fetch(`${m.url}/api/desktop/frame?w=${w}&q=${RD.q}&_=${Date.now()}`, { headers: { Authorization: `Bearer ${m.token}` }, cache: 'no-store' });
|
|
1965
|
+
if (!res.ok) { const d = await res.json().catch(() => ({})); throw new Error(d.error || `Error ${res.status}`); }
|
|
1966
|
+
const blob = await res.blob();
|
|
1967
|
+
if (!RD.on) break;
|
|
1968
|
+
const img = $('#rdImg'), old = RD.objUrl, url = URL.createObjectURL(blob);
|
|
1969
|
+
RD.objUrl = url; img.onload = () => { if (old) URL.revokeObjectURL(old); }; img.src = url;
|
|
1970
|
+
RD.sw = Number(res.headers.get('x-screen-w')) || RD.sw;
|
|
1971
|
+
RD.blank = res.headers.get('x-blank') === '1' ? RD.blank + 1 : 0;
|
|
1972
|
+
if (RD.err || RD.blank === 0 || RD.blank === 2) { RD.err = 0; rdHint(); }
|
|
1973
|
+
if (RD.blank === 2) api('/api/desktop/session', { method: 'POST', body: { wake: true } }).catch(() => {});
|
|
1974
|
+
n++; const dt = performance.now() - t0;
|
|
1975
|
+
if (dt > 1500) { const st = $('#rdStat'); if (st) st.textContent = `${(n * 1000 / dt).toFixed(1)} img/s`; n = 0; t0 = performance.now(); }
|
|
1976
|
+
} catch (e) {
|
|
1977
|
+
RD.err++; rdMsg(RD.err > 1 ? `${e.message || 'Sense connexió'}${/^Error|Activa|captura/.test(e.message) ? '' : ' · reconnectant…'}` : '');
|
|
1978
|
+
await sleep(Math.min(3000, 500 * RD.err));
|
|
1979
|
+
}
|
|
1980
|
+
await sleep(Math.max(30, 110 - (performance.now() - started)));
|
|
1981
|
+
}
|
|
1982
|
+
}
|
|
1983
|
+
function rdSend(ev) {
|
|
1984
|
+
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); } });
|
|
1985
|
+
}
|
|
1986
|
+
function rdKey(key, extra = []) {
|
|
1987
|
+
const mods = [...new Set([...RD.mods, ...extra])];
|
|
1988
|
+
if (RD.mods.size) { RD.mods.clear(); rdSyncMods(); }
|
|
1989
|
+
rdSend({ type: 'key', key, mods });
|
|
1990
|
+
}
|
|
1991
|
+
function rdSyncMods() { $$('#rd [data-mod]').forEach((b) => b.setAttribute('aria-pressed', RD.mods.has(b.dataset.mod))); }
|
|
1992
|
+
function rdPos(e) {
|
|
1993
|
+
const r = $('#rdImg').getBoundingClientRect(); if (!r.width) return null;
|
|
1994
|
+
const x = (e.clientX - r.left) / r.width, y = (e.clientY - r.top) / r.height;
|
|
1995
|
+
return x < 0 || x > 1 || y < 0 || y > 1 ? null : [x, y];
|
|
1996
|
+
}
|
|
1997
|
+
function rdScroll(dx, dy, pos) {
|
|
1998
|
+
const s = RD.scroll || (RD.scroll = { dx: 0, dy: 0, pos, t: null });
|
|
1999
|
+
s.dx += dx; s.dy += dy; if (pos) s.pos = pos;
|
|
2000
|
+
if (s.t) return;
|
|
2001
|
+
s.t = setTimeout(() => {
|
|
2002
|
+
const k = RD.scroll; RD.scroll = null;
|
|
2003
|
+
const r = $('#rdImg')?.getBoundingClientRect(), f = r && r.width ? (RD.sw || r.width) / r.width : 1;
|
|
2004
|
+
const ev = { type: 'scroll', dx: Math.round(k.dx * f), dy: Math.round(k.dy * f) };
|
|
2005
|
+
if (k.pos) { ev.x = k.pos[0]; ev.y = k.pos[1]; }
|
|
2006
|
+
if (ev.dx || ev.dy) rdSend(ev);
|
|
2007
|
+
}, 70);
|
|
2008
|
+
}
|
|
2009
|
+
|
|
2010
|
+
function rdView() {
|
|
2011
|
+
const img = $('#rdImg'), st = $('#rdStage'); if (!img || !st) return;
|
|
2012
|
+
const z = RD.z, mx = Math.max(0, (img.offsetWidth * z - st.clientWidth) / 2), my = Math.max(0, (img.offsetHeight * z - st.clientHeight) / 2);
|
|
2013
|
+
RD.px = Math.max(-mx, Math.min(mx, RD.px)); RD.py = Math.max(-my, Math.min(my, RD.py));
|
|
2014
|
+
if (z === 1) RD.px = RD.py = 0;
|
|
2015
|
+
img.style.transform = z === 1 ? '' : `translate(${RD.px}px,${RD.py}px) scale(${z})`;
|
|
2016
|
+
const pct = $('#rdPct'); if (pct) pct.textContent = Math.round(z * 100) + '%';
|
|
2017
|
+
}
|
|
2018
|
+
function rdZoom(z, cx, cy) {
|
|
2019
|
+
const st = $('#rdStage'); if (!st) return;
|
|
2020
|
+
z = Math.max(1, Math.min(6, z));
|
|
2021
|
+
const r = st.getBoundingClientRect(), ox = (cx ?? r.left + r.width / 2) - (r.left + r.width / 2), oy = (cy ?? r.top + r.height / 2) - (r.top + r.height / 2), k = z / RD.z;
|
|
2022
|
+
RD.px = ox - (ox - RD.px) * k; RD.py = oy - (oy - RD.py) * k; RD.z = z; rdView();
|
|
2023
|
+
}
|
|
2024
|
+
function rdSave() {
|
|
2025
|
+
const img = $('#rdImg'); if (!img?.src) return toast('Encara no hi ha cap imatge');
|
|
2026
|
+
const d = new Date(), p = (n) => String(n).padStart(2, '0');
|
|
2027
|
+
const a = document.createElement('a'); a.href = img.src; a.download = `nio-pantalla-${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}.jpg`;
|
|
2028
|
+
document.body.appendChild(a); a.click(); a.remove(); toast('Captura desada');
|
|
2029
|
+
}
|
|
2030
|
+
function rdGestures(stage) {
|
|
2031
|
+
const pts = new Map(); let g = null, pan = null, tapT = 0;
|
|
2032
|
+
const dist = () => { const [a, b] = [...pts.values()]; return Math.hypot(a.x - b.x, a.y - b.y) || 1; };
|
|
2033
|
+
const mid = () => { const [a, b] = [...pts.values()]; return [(a.x + b.x) / 2, (a.y + b.y) / 2]; };
|
|
2034
|
+
stage.addEventListener('pointerdown', (e) => {
|
|
2035
|
+
if (e.target.closest('.rd-tools') || (RD.ctl && (RD.two !== 'zoom' || e.pointerType === 'mouse'))) return;
|
|
2036
|
+
try { stage.setPointerCapture(e.pointerId); } catch {}
|
|
2037
|
+
pts.set(e.pointerId, { x: e.clientX, y: e.clientY });
|
|
2038
|
+
if (pts.size === 2) { const [mx, my] = mid(); g = { d: dist(), z: RD.z, mx, my, px: RD.px, py: RD.py }; pan = null; }
|
|
2039
|
+
else if (pts.size === 1 && !RD.ctl) pan = { x: e.clientX, y: e.clientY, px: RD.px, py: RD.py };
|
|
2040
|
+
});
|
|
2041
|
+
stage.addEventListener('pointermove', (e) => {
|
|
2042
|
+
const p = pts.get(e.pointerId); if (!p) return; p.x = e.clientX; p.y = e.clientY;
|
|
2043
|
+
if (pts.size >= 2 && g) {
|
|
2044
|
+
const [mx, my] = mid(), st = stage.getBoundingClientRect(), z = Math.max(1, Math.min(6, g.z * dist() / g.d)), k = z / g.z;
|
|
2045
|
+
const ox = g.mx - (st.left + st.width / 2), oy = g.my - (st.top + st.height / 2);
|
|
2046
|
+
RD.z = z; RD.px = g.px * k + ox * (1 - k) + (mx - g.mx); RD.py = g.py * k + oy * (1 - k) + (my - g.my); rdView();
|
|
2047
|
+
} else if (pan && RD.z > 1 && !RD.ctl) { RD.px = pan.px + e.clientX - pan.x; RD.py = pan.py + e.clientY - pan.y; rdView(); }
|
|
2048
|
+
});
|
|
2049
|
+
const up = (e) => {
|
|
2050
|
+
if (!pts.has(e.pointerId)) return;
|
|
2051
|
+
const moved = pan && Math.hypot(e.clientX - pan.x, e.clientY - pan.y) > 8;
|
|
2052
|
+
pts.delete(e.pointerId); g = null;
|
|
2053
|
+
if (pts.size === 1 && !RD.ctl) { const [q] = pts.values(); pan = { x: q.x, y: q.y, px: RD.px, py: RD.py }; }
|
|
2054
|
+
else { pan = null; if (!pts.size && e.type === 'pointerup' && !moved && !RD.ctl) { const now = performance.now(); if (now - tapT < 320) { RD.z > 1 ? (RD.z = 1, rdView()) : rdZoom(2.5, e.clientX, e.clientY); tapT = 0; } else tapT = now; } }
|
|
2055
|
+
};
|
|
2056
|
+
stage.addEventListener('pointerup', up); stage.addEventListener('pointercancel', up);
|
|
2057
|
+
stage.addEventListener('wheel', (e) => { if (!(e.ctrlKey || e.metaKey) && RD.ctl) return; e.preventDefault(); if (e.ctrlKey || e.metaKey) rdZoom(RD.z * Math.exp(-Math.max(-40, Math.min(40, e.deltaY)) * 0.01), e.clientX, e.clientY); else if (RD.z > 1) { RD.px -= e.deltaX; RD.py -= e.deltaY; rdView(); } }, { passive: false });
|
|
2058
|
+
let gz = 1;
|
|
2059
|
+
stage.addEventListener('gesturestart', (e) => { e.preventDefault(); gz = RD.z; });
|
|
2060
|
+
stage.addEventListener('gesturechange', (e) => { e.preventDefault(); rdZoom(gz * e.scale, e.clientX, e.clientY); });
|
|
2061
|
+
window.addEventListener('resize', rdView);
|
|
2062
|
+
}
|
|
2063
|
+
function rdBind(root) {
|
|
2064
|
+
const stage = $('#rdStage', root), typer = $('#rdType', root);
|
|
2065
|
+
const ptrs = new Map();
|
|
2066
|
+
rdGestures(stage);
|
|
2067
|
+
const click = (pos, e) => {
|
|
2068
|
+
const now = performance.now(), prev = RD.tap, near = prev && Math.hypot(e.clientX - prev.x, e.clientY - prev.y) < 28;
|
|
2069
|
+
const count = prev && near && now - prev.t < 340 ? (prev.count >= 2 ? 3 : 2) : 1;
|
|
2070
|
+
RD.tap = { t: now, x: e.clientX, y: e.clientY, count };
|
|
2071
|
+
rdSend({ type: 'click', x: pos[0], y: pos[1], count });
|
|
2072
|
+
};
|
|
2073
|
+
stage.addEventListener('pointerdown', (e) => {
|
|
2074
|
+
if (!RD.ctl || e.target.closest('.rd-tools') || (e.pointerType === 'mouse' && e.button !== 0)) return;
|
|
2075
|
+
try { stage.setPointerCapture(e.pointerId); } catch {}
|
|
2076
|
+
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 };
|
|
2077
|
+
const pos = rdPos(e); if (pos) p.path.push(pos);
|
|
2078
|
+
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);
|
|
2079
|
+
ptrs.set(e.pointerId, p);
|
|
2080
|
+
if (RD.two === 'zoom' && p.touch && ptrs.size > 1) for (const q of ptrs.values()) { q.moved = q.zoomed = true; clearTimeout(q.lp); }
|
|
2081
|
+
if (!typer.matches(':focus') && !p.touch) typer.focus({ preventScroll: true });
|
|
2082
|
+
e.preventDefault();
|
|
2083
|
+
});
|
|
2084
|
+
stage.addEventListener('pointermove', (e) => {
|
|
2085
|
+
const p = ptrs.get(e.pointerId); if (!p) return;
|
|
2086
|
+
const dx = e.clientX - p.x, dy = e.clientY - p.y; p.x = e.clientX; p.y = e.clientY;
|
|
2087
|
+
if (!p.moved && Math.hypot(p.x - p.x0, p.y - p.y0) > 8) { p.moved = true; clearTimeout(p.lp); }
|
|
2088
|
+
if (!p.moved || p.long || p.zoomed) return;
|
|
2089
|
+
if (p.touch && (ptrs.size > 1 || !RD.drag)) { if (ptrs.size > 1 && e.isPrimary === false) return; rdScroll(dx, dy, rdPos(e)); return; }
|
|
2090
|
+
const now = performance.now(), pos = rdPos(e);
|
|
2091
|
+
if (pos && now - p.pt > 22) { p.path.push(pos); p.pt = now; }
|
|
2092
|
+
});
|
|
2093
|
+
const end = (e) => {
|
|
2094
|
+
const p = ptrs.get(e.pointerId); if (!p) return;
|
|
2095
|
+
ptrs.delete(e.pointerId); clearTimeout(p.lp);
|
|
2096
|
+
if (p.long || p.zoomed) return;
|
|
2097
|
+
const pos = rdPos(e);
|
|
2098
|
+
if (!p.moved) { if (pos && e.type === 'pointerup') click(pos, e); return; }
|
|
2099
|
+
if (p.touch && (ptrs.size > 0 || !RD.drag)) return;
|
|
2100
|
+
if (pos) p.path.push(pos);
|
|
2101
|
+
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 });
|
|
2102
|
+
};
|
|
2103
|
+
stage.addEventListener('pointerup', end);
|
|
2104
|
+
stage.addEventListener('pointercancel', end);
|
|
2105
|
+
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] }); });
|
|
2106
|
+
stage.addEventListener('wheel', (e) => {
|
|
2107
|
+
if (!RD.ctl) return; e.preventDefault();
|
|
2108
|
+
const k = e.deltaMode === 1 ? 16 : 1;
|
|
2109
|
+
rdScroll(-e.deltaX * k, -e.deltaY * k, rdPos(e));
|
|
2110
|
+
}, { passive: false });
|
|
2111
|
+
// Teclat: el text va per «beforeinput» (serveix per al teclat del mòbil); les tecles especials i les dreceres, per «keydown».
|
|
2112
|
+
typer.addEventListener('keydown', (e) => {
|
|
2113
|
+
if (!RD.ctl || e.isComposing || e.key === 'Process' || e.key === 'Unidentified') return;
|
|
2114
|
+
if (['Meta', 'Control', 'Alt', 'Shift', 'CapsLock'].includes(e.key)) return;
|
|
2115
|
+
const combo = e.metaKey || e.ctrlKey || e.altKey;
|
|
2116
|
+
if (!combo && !RD_SPECIAL.test(e.key)) return;
|
|
2117
|
+
e.preventDefault();
|
|
2118
|
+
const mods = [e.metaKey && 'meta', e.ctrlKey && 'ctrl', e.altKey && 'alt', e.shiftKey && 'shift'].filter(Boolean);
|
|
2119
|
+
rdKey(e.key.length === 1 ? e.key.toLowerCase() : e.key, mods);
|
|
2120
|
+
});
|
|
2121
|
+
typer.addEventListener('beforeinput', (e) => {
|
|
2122
|
+
if (!RD.ctl) return;
|
|
2123
|
+
const t = e.inputType;
|
|
2124
|
+
if (t === 'insertCompositionText') return;
|
|
2125
|
+
e.preventDefault();
|
|
2126
|
+
if (t === 'insertText' || t === 'insertFromPaste' || t === 'insertReplacementText') {
|
|
2127
|
+
const text = e.data ?? e.dataTransfer?.getData('text/plain') ?? '';
|
|
2128
|
+
if (!text) return;
|
|
2129
|
+
if (RD.mods.size && [...text].length === 1) rdKey(text.toLowerCase()); else rdSend({ type: 'text', text });
|
|
2130
|
+
} else if (t === 'deleteContentBackward') rdKey('Backspace');
|
|
2131
|
+
else if (t === 'deleteContentForward') rdKey('Delete');
|
|
2132
|
+
else if (t === 'insertLineBreak' || t === 'insertParagraph') rdKey('Enter');
|
|
2133
|
+
});
|
|
2134
|
+
typer.addEventListener('compositionend', (e) => { if (RD.ctl && e.data) rdSend({ type: 'text', text: e.data }); typer.value = ''; });
|
|
2135
|
+
root.addEventListener('click', (e) => {
|
|
2136
|
+
const b = e.target.closest('button'); if (!b) return;
|
|
2137
|
+
if (b.dataset.key) { rdKey(b.dataset.key); return; }
|
|
2138
|
+
if (b.dataset.mod) { RD.mods.has(b.dataset.mod) ? RD.mods.delete(b.dataset.mod) : RD.mods.add(b.dataset.mod); rdSyncMods(); return; }
|
|
2139
|
+
switch (b.dataset.rd) {
|
|
2140
|
+
case 'close': remoteClose(); break;
|
|
2141
|
+
case 'full': document.fullscreenElement ? document.exitFullscreen() : root.requestFullscreen?.().catch(() => {}); break;
|
|
2142
|
+
case 'view': case 'ctl': {
|
|
2143
|
+
RD.ctl = b.dataset.rd === 'ctl';
|
|
2144
|
+
$$('.rd-seg button', root).forEach((x) => x.setAttribute('aria-pressed', x.dataset.rd === b.dataset.rd));
|
|
2145
|
+
$('#rdKeys').hidden = !RD.ctl; root.classList.toggle('rd-ctl', RD.ctl);
|
|
2146
|
+
if (RD.ctl) { api('/api/desktop/session', { method: 'POST', body: { event: 'control' } }).catch(() => {}); if (!matchMedia('(pointer: coarse)').matches) typer.focus({ preventScroll: true }); }
|
|
2147
|
+
rdHint(); break;
|
|
2148
|
+
}
|
|
2149
|
+
case 'kbd': typer.focus(); break;
|
|
2150
|
+
case 'zin': rdZoom(RD.z * 1.5); break;
|
|
2151
|
+
case 'zout': rdZoom(RD.z / 1.5); break;
|
|
2152
|
+
case 'zfit': RD.z = 1; rdView(); break;
|
|
2153
|
+
case 'z11': { const im = $('#rdImg'); rdZoom(RD.sw && im.offsetWidth ? RD.sw / im.offsetWidth : 2); break; }
|
|
2154
|
+
case 'save': rdSave(); break;
|
|
2155
|
+
case 'two': RD.two = RD.two === 'zoom' ? 'scroll' : 'zoom'; b.textContent = RD.two === 'zoom' ? '2 dits: zoom' : '2 dits: desplaça'; break;
|
|
2156
|
+
case 'drag': RD.drag = !RD.drag; b.textContent = RD.drag ? '1 dit: arrossega' : '1 dit: desplaça'; break;
|
|
2157
|
+
}
|
|
2158
|
+
});
|
|
2159
|
+
}
|
|
2160
|
+
document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && RD.on && !RD.ctl) remoteClose(); });
|
|
2161
|
+
|
|
1857
2162
|
document.addEventListener('click', async (e) => {
|
|
1858
2163
|
const b = e.target.closest('[data-ctl]'); if (!b) return;
|
|
1859
2164
|
b.disabled = true;
|
|
@@ -2044,6 +2349,7 @@ document.addEventListener('click', async (e) => {
|
|
|
2044
2349
|
case 'revoke': return revokeDevice(a.dataset.dev);
|
|
2045
2350
|
case 'job-new': return newJobSheet();
|
|
2046
2351
|
case 'panic': return panicNow();
|
|
2352
|
+
case 'remote-open': remoteOpen(); return;
|
|
2047
2353
|
case 'screen-now': try { const s = await api('/api/screen', { method: 'POST' }); onScreen({ shot: s }); } catch (err) { toast(err.message, 4500); } return;
|
|
2048
2354
|
case 'mem-new': return memEdit(null);
|
|
2049
2355
|
case 'mem-all': if (mem) delete mem.hits; return loadMemory();
|
package/web/app/style.css
CHANGED
|
@@ -477,6 +477,14 @@ video.scan { width: 100%; aspect-ratio: 1; object-fit: cover; border-radius: 14p
|
|
|
477
477
|
.help-body { display: grid; gap: 8px; padding: 0 0 14px; font-size: 14px; color: var(--fg-muted); }
|
|
478
478
|
.help-body ul { padding-left: 18px; display: grid; gap: 4px; }
|
|
479
479
|
.help-body b { color: var(--fg); font-weight: 600; }
|
|
480
|
+
.help-body .mono { padding: 2px 7px; border-radius: 6px; background: var(--code); color: var(--fg); white-space: nowrap; }
|
|
481
|
+
.help-body .cmdb { margin: 2px 0; padding: 12px 14px; border-radius: 10px; background: var(--code); color: var(--fg); font: 14px/1.4 var(--mono); overflow-x: auto; }
|
|
482
|
+
.help-body .cmdlist { display: grid; gap: 6px; margin: 2px 0; }
|
|
483
|
+
.help-body .cmdlist > div { display: grid; grid-template-columns: minmax(0, 15rem) minmax(0, 1fr); gap: 4px 14px; align-items: baseline; padding: 8px 10px; border-radius: 10px; background: color-mix(in srgb, var(--code) 45%, transparent); }
|
|
484
|
+
.help-body .cmdlist .mono { justify-self: start; white-space: normal; overflow-wrap: anywhere; }
|
|
485
|
+
.help-grp { margin: 22px 0 2px; font-size: 12px; font-weight: 600; letter-spacing: .06em; text-transform: uppercase; color: var(--fg-muted); }
|
|
486
|
+
.help-grp:first-child { margin-top: 0; }
|
|
487
|
+
@media (max-width: 620px) { .help-body .cmdlist > div { grid-template-columns: 1fr; } }
|
|
480
488
|
.empty .stepper { text-align: left; max-width: 640px; margin: 16px auto 18px; }
|
|
481
489
|
a.kbd { text-decoration: none; color: inherit; }
|
|
482
490
|
|
|
@@ -645,3 +653,34 @@ dialog.sheet.wiz.compact .sheet-in, .compact .wiz-body { flex: 0 1 auto; }
|
|
|
645
653
|
.sec-head { display: flex; align-items: flex-end; justify-content: space-between; gap: 16px; flex-wrap: wrap; margin: 36px 0 14px; }
|
|
646
654
|
.sec-head .sec { margin: 0; }
|
|
647
655
|
.sec-head p { margin-top: 4px; max-width: 640px; }
|
|
656
|
+
|
|
657
|
+
/* Pantalla remota */
|
|
658
|
+
html.rd-open, html.rd-open body { overflow: hidden; }
|
|
659
|
+
.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); }
|
|
660
|
+
.rd-bar { display: flex; align-items: center; gap: 10px; padding: 10px 12px; background: #161617; border-bottom: 1px solid #2a2a2c; flex: none; }
|
|
661
|
+
.rd-title { font-size: 14px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
662
|
+
.rd-stat { font-size: 12px; color: #8d8c88; white-space: nowrap; }
|
|
663
|
+
.rd-bar .grow { flex: 1; }
|
|
664
|
+
.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; }
|
|
665
|
+
.rd-btn:hover { background: #2d2d31; }
|
|
666
|
+
.rd-btn[aria-pressed=true] { background: #f2f1ee; color: #161617; border-color: #f2f1ee; }
|
|
667
|
+
.rd-seg { display: flex; border: 1px solid #36363a; border-radius: 10px; overflow: hidden; flex: none; }
|
|
668
|
+
.rd-seg button { appearance: none; border: 0; background: transparent; color: #b9b8b3; font: inherit; font-size: 13px; padding: 9px 14px; cursor: pointer; }
|
|
669
|
+
.rd-seg button[aria-pressed=true] { background: #f2f1ee; color: #161617; font-weight: 600; }
|
|
670
|
+
.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; }
|
|
671
|
+
.rd-stage img { max-width: 100%; max-height: 100%; display: block; object-fit: contain; -webkit-user-drag: none; }
|
|
672
|
+
.rd.rd-ctl .rd-stage { cursor: crosshair; }
|
|
673
|
+
.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; }
|
|
674
|
+
.rd-msg[hidden] { display: none; }
|
|
675
|
+
.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; }
|
|
676
|
+
.rd-keys[hidden] { display: none; }
|
|
677
|
+
.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; }
|
|
678
|
+
@media (max-width: 560px) { .rd-bar { padding: 8px 10px; gap: 8px; } .rd-stat, #rdFull { display: none; } .rd-seg button { padding: 9px 11px; } }
|
|
679
|
+
|
|
680
|
+
.rd-stage img { transform-origin: center; will-change: transform; }
|
|
681
|
+
.rd-tools { position: absolute; left: 50%; bottom: calc(14px + env(safe-area-inset-bottom, 0px)); transform: translateX(-50%); display: flex; align-items: center; gap: 6px; padding: 6px; border-radius: 14px; background: rgba(22, 22, 23, .86); border: 1px solid #36363a; backdrop-filter: blur(8px); z-index: 2; max-width: calc(100% - 20px); }
|
|
682
|
+
.rd-tools .rd-btn { padding: 10px 12px; min-width: 40px; }
|
|
683
|
+
.rd-pct { min-width: 56px !important; font-variant-numeric: tabular-nums; }
|
|
684
|
+
.rd-sep { width: 1px; align-self: stretch; background: #36363a; }
|
|
685
|
+
.rd-msg { bottom: 84px; }
|
|
686
|
+
@media (max-width: 560px) { .rd-tools { gap: 4px; padding: 5px; } .rd-tools .rd-btn { padding: 12px 10px; min-width: 44px; font-size: 14px; } .rd-pct { min-width: 52px !important; } }
|
package/web/board.css
CHANGED
|
@@ -190,3 +190,53 @@
|
|
|
190
190
|
.bd-chat header { padding-top: 22px; }
|
|
191
191
|
.bd-chat .cin textarea { font-size: 16px; }
|
|
192
192
|
}
|
|
193
|
+
|
|
194
|
+
/* Tàctil: botons còmodes, sense menú de pressió llarga ni selecció */
|
|
195
|
+
.bd-stage, .bd-svg { -webkit-touch-callout: none; }
|
|
196
|
+
@media (pointer: coarse) {
|
|
197
|
+
.bd .ib, .bd .pct { height: 40px; }
|
|
198
|
+
.bd .ib { width: 40px; }
|
|
199
|
+
.bd .pct { min-width: 52px; font-size: 13px; }
|
|
200
|
+
.bd .tb { width: 44px; height: 44px; }
|
|
201
|
+
.bd .chat-t { height: 44px; }
|
|
202
|
+
.bd-menu { top: 50px; min-width: 250px; }
|
|
203
|
+
.bd-menu button, .bd-menu label { padding: 13px 12px; font-size: 15px; }
|
|
204
|
+
.bd-style .sw-c { width: 34px; height: 34px; }
|
|
205
|
+
.bd-style .seg2 button { height: 36px; }
|
|
206
|
+
.bd-chat .chip-s { height: 38px; line-height: 36px; font-size: 13px; }
|
|
207
|
+
.bd-chat .send { width: 46px; height: 46px; }
|
|
208
|
+
}
|
|
209
|
+
@media (pointer: coarse) and (max-width: 820px) {
|
|
210
|
+
.bd-zoom [data-bd="zin"], .bd-zoom [data-bd="zout"] { display: none; }
|
|
211
|
+
.bd-tools { gap: 0; scroll-snap-type: x proximity; -webkit-overflow-scrolling: touch; }
|
|
212
|
+
.bd .tb { scroll-snap-align: center; }
|
|
213
|
+
}
|
|
214
|
+
@container stage (max-width: 440px) {
|
|
215
|
+
.bd .tb { width: 40px; height: 44px; }
|
|
216
|
+
.bd-zoom { max-width: calc(100% - 128px); }
|
|
217
|
+
}
|
|
218
|
+
@container stage (max-width: 400px) {
|
|
219
|
+
.bd-tools .tb { min-width: 34px; height: 44px; }
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/* Teclat obert: el xat puja per damunt del teclat */
|
|
223
|
+
@media (max-width: 820px) {
|
|
224
|
+
.bd-chat { bottom: var(--kb, 0px); transition: transform .34s var(--bd-ease), bottom .2s, visibility 0s linear .34s; }
|
|
225
|
+
.chat-open .bd-chat { transition: transform .34s var(--bd-ease), bottom .2s, visibility 0s; }
|
|
226
|
+
.bd-chat.dragging { transition: none !important; }
|
|
227
|
+
}
|
|
228
|
+
@container stage (max-width: 720px) {
|
|
229
|
+
.bd-chat { bottom: var(--kb, 0px); height: min(78%, 600px, calc(100% - var(--kb, 0px) - 8px)); }
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/* Mòbil en horitzontal: poc d'alçada */
|
|
233
|
+
@media (max-height: 480px) and (max-width: 940px) {
|
|
234
|
+
.bd-chat { height: min(92%, 600px); }
|
|
235
|
+
.bd-tools { bottom: calc(6px + env(safe-area-inset-bottom)); left: auto; right: auto; max-width: calc(100% - 20px); }
|
|
236
|
+
.bd-tools { left: 50%; transform: translateX(-50%); }
|
|
237
|
+
.bd .tb { height: 40px; }
|
|
238
|
+
.style-open .bd-style:not([hidden]) { bottom: calc(58px + env(safe-area-inset-bottom)); max-height: calc(100% - 130px); overflow-y: auto; grid-template-columns: repeat(2, 1fr); }
|
|
239
|
+
.bd-help-in { padding: 14px; }
|
|
240
|
+
.bd-empty { display: none; }
|
|
241
|
+
}
|
|
242
|
+
|
package/web/board.js
CHANGED
|
@@ -42,6 +42,7 @@ const uid = () => Math.random().toString(36).slice(2, 10);
|
|
|
42
42
|
const norm = s => String(s || '').normalize('NFD').replace(/[̀-ͯ]/g, '').replace(/·/g, '').toLowerCase().trim();
|
|
43
43
|
const clamp = (v, a, b) => Math.max(a, Math.min(b, v));
|
|
44
44
|
const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
45
|
+
const coarse = matchMedia('(pointer: coarse)').matches;
|
|
45
46
|
|
|
46
47
|
/* ---------- Estat ---------- */
|
|
47
48
|
function fresh() {
|
|
@@ -311,13 +312,13 @@ function drawSel() {
|
|
|
311
312
|
if (list.length) {
|
|
312
313
|
const p = 6 / z;
|
|
313
314
|
if (list.length === 1 && isLinear(list[0])) {
|
|
314
|
-
const [a, b] = ends(list[0]), r = 6 / z;
|
|
315
|
+
const [a, b] = ends(list[0]), r = (coarse ? 11 : 6) / z;
|
|
315
316
|
s += `<circle class="hdl" data-ep="0" cx="${a[0]}" cy="${a[1]}" r="${r}" stroke-width="${1.5 / z}"/><circle class="hdl" data-ep="1" cx="${b[0]}" cy="${b[1]}" r="${r}" stroke-width="${1.5 / z}"/>`;
|
|
316
317
|
} else {
|
|
317
318
|
const b = union(list);
|
|
318
319
|
s += `<rect class="selbox" x="${b.x - p}" y="${b.y - p}" width="${b.w + p * 2}" height="${b.h + p * 2}" stroke-width="${1.2 / z}" stroke-dasharray="${4 / z} ${3 / z}"/>`;
|
|
319
320
|
if (list.length === 1 && list[0].type !== 'draw') {
|
|
320
|
-
const H = 9 / z, bx = b.x - p, by = b.y - p, bw = b.w + p * 2, bh = b.h + p * 2;
|
|
321
|
+
const H = (coarse ? 17 : 9) / z, bx = b.x - p, by = b.y - p, bw = b.w + p * 2, bh = b.h + p * 2;
|
|
321
322
|
const pos = { nw: [bx, by], n: [bx + bw / 2, by], ne: [bx + bw, by], e: [bx + bw, by + bh / 2], se: [bx + bw, by + bh], s: [bx + bw / 2, by + bh], sw: [bx, by + bh], w: [bx, by + bh / 2] };
|
|
322
323
|
HANDLES.forEach(k => { if (list[0].type === 'text' && (k === 'n' || k === 's' || k === 'e' || k === 'w')) return; s += `<rect class="hdl" data-h="${k}" x="${pos[k][0] - H / 2}" y="${pos[k][1] - H / 2}" width="${H}" height="${H}" rx="${2 / z}" stroke-width="${1.5 / z}"/>`; });
|
|
323
324
|
}
|
|
@@ -395,7 +396,7 @@ function onDown(e) {
|
|
|
395
396
|
if (editing) { endEdit(); if (tool === 'select') return; }
|
|
396
397
|
R.svg.setPointerCapture(e.pointerId);
|
|
397
398
|
pointers.set(e.pointerId, [e.clientX, e.clientY]);
|
|
398
|
-
if (pointers.size === 2) { const [a, b] = [...pointers.values()]; drag = { mode: 'pinch', d0: Math.hypot(a[0] - b[0], a[1] - b[1]), z0: B.view.z, v0: { ...B.view }, m0: [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2] }; marquee = null; return; }
|
|
399
|
+
if (pointers.size === 2) { abortDrag(); const [a, b] = [...pointers.values()]; drag = { mode: 'pinch', d0: Math.hypot(a[0] - b[0], a[1] - b[1]), z0: B.view.z, v0: { ...B.view }, m0: [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2] }; marquee = null; return; }
|
|
399
400
|
const [wx, wy] = toWorld(e.clientX, e.clientY);
|
|
400
401
|
const hitG = e.target.closest('[data-id]'), hit = hitG ? byId(hitG.dataset.id) : null;
|
|
401
402
|
const touch = e.pointerType === 'touch';
|
|
@@ -439,6 +440,13 @@ function onDown(e) {
|
|
|
439
440
|
drag = { mode: 'draw', el, last: [wx, wy] };
|
|
440
441
|
}
|
|
441
442
|
}
|
|
443
|
+
function abortDrag() {
|
|
444
|
+
const d = drag; if (!d) return;
|
|
445
|
+
if (d.mode === 'create' || d.mode === 'line' || d.mode === 'draw') { B.els = B.els.filter(x => x !== d.el); nodes.get(d.el.id)?.remove(); nodes.delete(d.el.id); hist.pop(); }
|
|
446
|
+
else if (d.mode === 'move' && d.rec) { Object.entries(d.orig).forEach(([id, o]) => { const el = byId(id); if (el) { el.x = o.x; el.y = o.y; } }); hist.pop(); full(); }
|
|
447
|
+
else if (d.mode === 'resize' && d.rec) { Object.assign(d.el, d.o); hist.pop(); full(); }
|
|
448
|
+
marquee = null; R.stage.classList.remove('panning'); drag = null; R.empty.hidden = !!B.els.length; drawSel();
|
|
449
|
+
}
|
|
442
450
|
function snapshotSel() { const m = {}; sel.forEach(id => { const el = byId(id); if (el) m[id] = { x: el.x, y: el.y }; }); return m; }
|
|
443
451
|
function onMove(e) {
|
|
444
452
|
if (!R) return;
|
|
@@ -559,8 +567,18 @@ function onWheel(e) {
|
|
|
559
567
|
if (!R) return;
|
|
560
568
|
e.preventDefault();
|
|
561
569
|
const r = R.svg.getBoundingClientRect();
|
|
562
|
-
|
|
563
|
-
|
|
570
|
+
const k = e.deltaMode === 1 ? 16 : e.deltaMode === 2 ? r.height : 1, dx = e.deltaX * k, dy = e.deltaY * k;
|
|
571
|
+
if (e.ctrlKey || e.metaKey) zoomAt(B.view.z * Math.exp(-clamp(dy, -40, 40) * .01), e.clientX - r.left, e.clientY - r.top);
|
|
572
|
+
else { B.view.x -= e.shiftKey && !dx ? dy : dx; B.view.y -= e.shiftKey && !dx ? 0 : dy; applyView(); drawSel(); save(); }
|
|
573
|
+
}
|
|
574
|
+
let gz = null;
|
|
575
|
+
function onGesture(e) {
|
|
576
|
+
if (!R) return;
|
|
577
|
+
e.preventDefault();
|
|
578
|
+
const r = R.svg.getBoundingClientRect();
|
|
579
|
+
if (e.type === 'gesturestart') { gz = { z0: B.view.z }; return; }
|
|
580
|
+
if (e.type === 'gestureend') { gz = null; return; }
|
|
581
|
+
if (gz) zoomAt(gz.z0 * e.scale, e.clientX - r.left, e.clientY - r.top);
|
|
564
582
|
}
|
|
565
583
|
|
|
566
584
|
/* ---------- Accions ---------- */
|
|
@@ -1169,6 +1187,7 @@ function mount(root) {
|
|
|
1169
1187
|
if (first) { const r = R.svg.getBoundingClientRect(); B.view = { x: r.width / 2, y: r.height / 2, z: 1 }; }
|
|
1170
1188
|
on(R.svg, 'pointerdown', onDown); on(window, 'pointermove', onMove); on(window, 'pointerup', onUp); on(window, 'pointercancel', onUp);
|
|
1171
1189
|
on(R.svg, 'wheel', onWheel, { passive: false });
|
|
1190
|
+
['gesturestart', 'gesturechange', 'gestureend'].forEach(t => on(R.svg, t, onGesture, { passive: false }));
|
|
1172
1191
|
on(window, 'keydown', onKey); on(window, 'keyup', onKeyUp);
|
|
1173
1192
|
on(root, 'click', onClick);
|
|
1174
1193
|
on(R.editor, 'input', placeEditor); on(R.editor, 'blur', () => setTimeout(endEdit, 0));
|
|
@@ -1180,6 +1199,11 @@ function mount(root) {
|
|
|
1180
1199
|
on(R.root.querySelector('#bdImport'), 'change', e => { const f = e.target.files[0]; if (f) importJSON(f); e.target.value = ''; });
|
|
1181
1200
|
on(document, 'click', e => { if (R && !e.target.closest('.bd-menuwrap')) { const m = R.root.querySelector('#bdMenu'); if (m) m.hidden = true; } });
|
|
1182
1201
|
on(window, 'resize', () => { applyView(); drawSel(); });
|
|
1202
|
+
const vv = window.visualViewport;
|
|
1203
|
+
if (vv) {
|
|
1204
|
+
const kb = () => R && R.root.style.setProperty('--kb', Math.max(0, Math.round(innerHeight - vv.height - vv.offsetTop)) + 'px');
|
|
1205
|
+
on(vv, 'resize', kb); on(vv, 'scroll', kb); kb();
|
|
1206
|
+
}
|
|
1183
1207
|
if (document.fonts) document.fonts.load(`500 24px Caveat`).then(() => { if (R) { B.els.forEach(el => { if (el.type === 'text' && el.text) fitText(el); }); full(); } }).catch(() => {});
|
|
1184
1208
|
chatSwipe(R.chat);
|
|
1185
1209
|
full(); drawChat(); drawBrains(); setTool(tool); openChat(B.opts.chat && !matchMedia('(max-width: 820px)').matches, false, false);
|
package/web/index.html
CHANGED
|
@@ -79,6 +79,15 @@ main .cmd code::-webkit-scrollbar { display: none; }
|
|
|
79
79
|
@media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation: none; transition: none; } }
|
|
80
80
|
.copy.done .ic { display: none; }
|
|
81
81
|
main strong { font-weight: 600; }
|
|
82
|
+
.feat { list-style: none; display: grid; gap: 14px; padding: 0; margin-top: 4px; }
|
|
83
|
+
.feat li { padding-left: 18px; position: relative; text-wrap: pretty; }
|
|
84
|
+
.feat li::before { content: ""; position: absolute; left: 0; top: .72em; width: 6px; height: 6px; border-radius: 50%; background: var(--accent); }
|
|
85
|
+
.cmds { display: grid; gap: 8px; margin: 16px 0; }
|
|
86
|
+
.cmds > div { display: grid; grid-template-columns: minmax(0, 15.5rem) minmax(0, 1fr); gap: 4px 16px; align-items: baseline; padding: 10px 14px; border-radius: 12px; background: #e6e1d8; }
|
|
87
|
+
.cmds dt code { background: #f6f3ee; }
|
|
88
|
+
.cmds dd { font-size: .86em; color: var(--fg-muted); }
|
|
89
|
+
@media (max-width: 620px) { .cmds > div { grid-template-columns: 1fr; } }
|
|
90
|
+
|
|
82
91
|
|
|
83
92
|
.footer .page { padding: 40px 0 56px; text-align: center; font-size: 13px; color: var(--fg-muted); }
|
|
84
93
|
.footer a { color: var(--fg); text-decoration: underline; text-decoration-color: var(--red); text-decoration-thickness: 1px; text-underline-offset: 3px; }
|
|
@@ -117,16 +126,66 @@ main strong { font-weight: 600; }
|
|
|
117
126
|
|
|
118
127
|
<section aria-labelledby="que">
|
|
119
128
|
<h2 id="que">Què hi pots fer</h2>
|
|
120
|
-
<ul class="
|
|
121
|
-
<li><strong>Sessions
|
|
122
|
-
<li><strong>Aprovacions
|
|
123
|
-
<li><strong>
|
|
124
|
-
<li><strong>
|
|
125
|
-
<li><strong>
|
|
126
|
-
<li><strong>
|
|
129
|
+
<ul class="feat">
|
|
130
|
+
<li><strong>Sessions en directe.</strong> Claude Code i Codex a qualsevol dels teus ordinadors: tria l’agent, la carpeta i el nivell de permisos, i la resposta et va arribant mentre treballa, amb les eines que fa servir.</li>
|
|
131
|
+
<li><strong>Aprovacions.</strong> Abans d’editar, executar o clicar res, te’n demana permís amb el motiu, i ho aproves o ho rebutges amb un toc. Tot queda registrat a Activitat.</li>
|
|
132
|
+
<li><strong>Avisos al mòbil.</strong> Una notificació quan un agent et demana permís o acaba la feina. Arriba buida: el detall el demana el mòbil directament a l’ordinador.</li>
|
|
133
|
+
<li><strong>Feines.</strong> Que es facin soles: ara, més tard o cada dia, i encadenades («quan Claude Code acabi, que Codex ho revisi»).</li>
|
|
134
|
+
<li><strong>Memòria compartida.</strong> El que recorda un agent, ho sap l’altre. Són fitxers markdown al teu ordinador.</li>
|
|
135
|
+
<li><strong>Ordinadors i servidors.</strong> El teu ordinador o un servidor (VPS), tots a la mateixa llista. Els afegeixes escrivint <code>usuari@servidor</code>.</li>
|
|
136
|
+
<li><strong>Pantalla remota.</strong> Mira i controla l’escriptori de l’ordinador des del mòbil, amb zoom amb dos dits i captures. Més avall.</li>
|
|
137
|
+
<li><strong>Navegador i escriptori per als agents.</strong> Poden mirar webs i apps; per actuar, sempre et demanen permís amb una captura.</li>
|
|
138
|
+
<li><strong>Telegram i veu.</strong> Parla amb el teu bot o amb notes de veu, aprova amb un botó, i escolta les respostes (ElevenLabs o OpenAI). Les claus es queden a l’ordinador.</li>
|
|
139
|
+
<li><strong>Git i GitHub.</strong> Clona un repositori, mira els canvis, fes commit i crea un PR des del mòbil, amb el <code>git</code> i el <code>gh</code> de l’ordinador.</li>
|
|
140
|
+
<li><strong>Models d’Ollama i xat local.</strong> Descarrega i fes servir models gratuïts a l’ordinador, o un model petit dins del navegador del mòbil.</li>
|
|
141
|
+
<li><strong>Pissarra.</strong> Un llenç per fer diagrames, i en Nio te’ls dibuixa si li ho expliques. Més avall.</li>
|
|
142
|
+
<li><strong>Com una app.</strong> Afegeix nio a la pantalla d’inici del mòbil; llisca als costats per canviar de pàgina i <code>Ctrl K</code> obre el buscador a l’ordinador.</li>
|
|
127
143
|
</ul>
|
|
128
144
|
</section>
|
|
129
145
|
|
|
146
|
+
<section aria-labelledby="cmds">
|
|
147
|
+
<h2 id="cmds">Comandes amb «/»</h2>
|
|
148
|
+
<p>Al missatge d’una sessió, escriu <code>/</code> i surten els suggeriments. Les de nio funcionen amb tots els agents; les de l’agent (comandes i skills) se li envien tal qual.</p>
|
|
149
|
+
<dl class="cmds">
|
|
150
|
+
<div><dt><code>/mode</code></dt><dd>Canvia els permisos: <code>l</code> llegir, <code>p</code> preguntar, <code>t</code> tot</dd></div>
|
|
151
|
+
<div><dt><code>/model</code></dt><dd>Canvia de model</dd></div>
|
|
152
|
+
<div><dt><code>/stop</code></dt><dd>Atura l’agent ara mateix</dd></div>
|
|
153
|
+
<div><dt><code>/nova</code></dt><dd>Nova sessió amb el mateix agent i carpeta</dd></div>
|
|
154
|
+
<div><dt><code>/carpeta</code></dt><dd>Canvia la carpeta de la sessió</dd></div>
|
|
155
|
+
<div><dt><code>/git</code></dt><dd>Veure canvis, fer commit i crear un PR</dd></div>
|
|
156
|
+
<div><dt><code>/nom</code></dt><dd>Posa nom a la sessió</dd></div>
|
|
157
|
+
<div><dt><code>/opcions</code></dt><dd>Opcions de la sessió</dd></div>
|
|
158
|
+
<div><dt><code>/ajuda</code></dt><dd>Llista de comandes</dd></div>
|
|
159
|
+
</dl>
|
|
160
|
+
</section>
|
|
161
|
+
|
|
162
|
+
<section aria-labelledby="screen">
|
|
163
|
+
<h2 id="screen">La pantalla remota</h2>
|
|
164
|
+
<p>Obre l’escriptori d’un dels teus ordinadors a pantalla completa, a la mateixa amplada que el mòbil, com un escriptori remot. Al mode <strong>Mirar</strong> només veus què fa l’agent; al mode <strong>Controlar</strong> pots clicar, fer doble clic, clic dret, arrossegar, desplaçar i escriure.</p>
|
|
165
|
+
<dl class="cmds">
|
|
166
|
+
<div><dt><code>− 100% +</code></dt><dd>Allunya, apropa o torna a enquadrar la imatge</dd></div>
|
|
167
|
+
<div><dt><code>1:1</code></dt><dd>Mida real, per llegir text petit</dd></div>
|
|
168
|
+
<div><dt><code>Desa captura</code></dt><dd>Baixa el fotograma actual com a imatge</dd></div>
|
|
169
|
+
<div><dt><code>Pantalla completa</code></dt><dd>A l’ordinador, amaga tota la resta</dd></div>
|
|
170
|
+
</dl>
|
|
171
|
+
<p><strong>Zoom amb dos dits.</strong> Pessiga per apropar i arrossega amb dos dits per moure’t; al mode Mirar, doble toc alterna entre enquadrar i apropar. Al mode Controlar, el botó «2 dits» tria si els dos dits desplacen l’ordinador o fan zoom. A l’ordinador, pessiga al trackpad o fes servir <code>Ctrl</code> i la roda. Cal activar «Apps de l’escriptori» a l’ordinador, i al Mac donar al terminal els permisos de Gravació de pantalla i Accessibilitat.</p>
|
|
172
|
+
</section>
|
|
173
|
+
|
|
174
|
+
<section aria-labelledby="board">
|
|
175
|
+
<h2 id="board">La pissarra</h2>
|
|
176
|
+
<p>Un llenç infinit per fer fluxos, mapes mentals i organigrames, al mòbil i a l’ordinador. Rectangles, el·lipses, rombes, fletxes que s’enganxen a les figures, línies, llapis, text i goma. Desfés i refés, quadrícula, i exportació a PNG, SVG o JSON.</p>
|
|
177
|
+
<p><strong>Zoom amb dos dits.</strong> Al mòbil, pessiga sobre el llenç per apropar o allunyar, i mou-lo amb els dos dits alhora. A l’ordinador, pessiga al trackpad o fes servir <code>Ctrl</code> i la roda del ratolí.</p>
|
|
178
|
+
<p><strong>Demana-ho a en Nio.</strong> Obre el xat de la pissarra i escriu què vols:</p>
|
|
179
|
+
<dl class="cmds">
|
|
180
|
+
<div><dt><code>Flux: A -> B -> C</code></dt><dd>Una fletxa per línia</dd></div>
|
|
181
|
+
<div><dt><code>Mapa mental de X: a, b, c</code></dt><dd>Tema central amb branques</dd></div>
|
|
182
|
+
<div><dt><code>Organigrama: Direcció > Finances</code></dt><dd>Qui depèn de qui</dd></div>
|
|
183
|
+
<div><dt><code>Connecta Banc amb ERP</code></dt><dd>Uneix dues figures</dd></div>
|
|
184
|
+
<div><dt><code>Pinta Banc de verd</code></dt><dd>Canvia el color</dd></div>
|
|
185
|
+
<div><dt><code>Plantilla d’aprovació</code></dt><dd>Un diagrama de partida</dd></div>
|
|
186
|
+
</dl>
|
|
187
|
+
</section>
|
|
188
|
+
|
|
130
189
|
<section aria-labelledby="teu">
|
|
131
190
|
<h2 id="teu">Tot és teu</h2>
|
|
132
191
|
<p>Fas servir els teus agents i les teves claus (Anthropic, OpenAI, ElevenLabs, el bot de Telegram). Les converses, la memòria i les claus es queden als teus ordinadors. nio.cat només serveix l’app i guarda, signada, l’adreça on trobar cada ordinador. Més detalls a <a href="/privadesa">privadesa</a>.</p>
|
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-v25';
|
|
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) => {
|