nio-cat 0.3.5 → 0.4.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 +18 -66
- package/package.json +1 -1
- package/src/proc.js +1 -0
- package/src/server.js +20 -0
- package/src/ssh.js +111 -0
- package/web/app/app.js +228 -135
- package/web/app/index.html +2 -0
- package/web/app/style.css +42 -0
- package/web/index.html +4 -2
- package/web/sw.js +1 -1
package/bin/nio.js
CHANGED
|
@@ -4,13 +4,13 @@ const os = require('os');
|
|
|
4
4
|
const path = require('path');
|
|
5
5
|
const fs = require('fs');
|
|
6
6
|
const crypto = require('crypto');
|
|
7
|
-
const net = require('net');
|
|
8
7
|
const { spawn } = require('child_process');
|
|
9
8
|
const config = require('../src/config');
|
|
10
9
|
const { Hub } = require('../src/hub');
|
|
11
10
|
const { createServer } = require('../src/server');
|
|
12
11
|
const { detectAll } = require('../src/agents');
|
|
13
12
|
const { startTunnel } = require('../src/tunnel');
|
|
13
|
+
const { addServer } = require('../src/ssh');
|
|
14
14
|
const { pairLink, terminalQr, writePairPage, makeCode, codeLink, terminalQrSize } = require('../src/pair');
|
|
15
15
|
const { which } = require('../src/proc');
|
|
16
16
|
|
|
@@ -111,73 +111,26 @@ async function showPair(publicUrl) {
|
|
|
111
111
|
}
|
|
112
112
|
}
|
|
113
113
|
|
|
114
|
-
// nio ssh usuari@servidor: engega nio al servidor
|
|
115
|
-
const shq = (v) => `'${String(v).replace(/'/g, `'\\''`)}'`;
|
|
116
|
-
function remoteScript(port, dir) {
|
|
117
|
-
return `set -e
|
|
118
|
-
command -v node >/dev/null 2>&1 || { echo NIO_NO_NODE; exit 0; }
|
|
119
|
-
up() { (exec 3<>/dev/tcp/127.0.0.1/${port}) 2>/dev/null; }
|
|
120
|
-
if ! up; then
|
|
121
|
-
mkdir -p "$HOME/.nio"
|
|
122
|
-
if command -v setsid >/dev/null 2>&1; then S=setsid; else S=; fi
|
|
123
|
-
$S nohup npx --yes nio-cat@latest --no-open --no-tunnel --port ${port} ${dir ? shq(dir) : '"$HOME"'} > "$HOME/.nio/daemon.log" 2>&1 < /dev/null &
|
|
124
|
-
for i in $(seq 1 80); do up && break; sleep 0.5; done
|
|
125
|
-
up || { echo NIO_NO_START; tail -n 5 "$HOME/.nio/daemon.log" 2>/dev/null; exit 0; }
|
|
126
|
-
fi
|
|
127
|
-
node -e "console.log('NIO_TOKEN='+require(require('os').homedir()+'/.nio/config.json').token)"
|
|
128
|
-
`;
|
|
129
|
-
}
|
|
130
|
-
function sshBase(target) {
|
|
131
|
-
const a = ['-o', 'ServerAliveInterval=30'];
|
|
132
|
-
if (process.platform !== 'win32') a.push('-o', 'ControlMaster=auto', '-o', 'ControlPath=/tmp/nio-%C', '-o', 'ControlPersist=10m');
|
|
133
|
-
return a;
|
|
134
|
-
}
|
|
135
|
-
function waitPort(port, ms = 15000) {
|
|
136
|
-
const end = Date.now() + ms;
|
|
137
|
-
return new Promise((resolve) => {
|
|
138
|
-
const t = () => {
|
|
139
|
-
const s = net.connect(port, '127.0.0.1');
|
|
140
|
-
s.once('connect', () => { s.destroy(); resolve(true); });
|
|
141
|
-
s.once('error', () => { s.destroy(); Date.now() > end ? resolve(false) : setTimeout(t, 300); });
|
|
142
|
-
};
|
|
143
|
-
t();
|
|
144
|
-
});
|
|
145
|
-
}
|
|
114
|
+
// nio ssh usuari@servidor: engega nio al servidor amb un túnel i obre el panell amb el servidor ja afegit.
|
|
146
115
|
async function sshStart() {
|
|
147
116
|
const target = pos[0];
|
|
148
|
-
if (!target
|
|
149
|
-
console.log(`\n Ús: ${c.b('nio ssh usuari@servidor')} ${c.dim('[--port 7717] [--
|
|
117
|
+
if (!target) {
|
|
118
|
+
console.log(`\n Ús: ${c.b('nio ssh usuari@servidor')} ${c.dim('[--port 7717] [--dir /ruta/al/servidor]')}\n`);
|
|
150
119
|
process.exit(1);
|
|
151
120
|
}
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
});
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
if (
|
|
165
|
-
if (out.buf.includes('NIO_NO_START')) { console.error(c.warn('\n nio no s’ha engegat al servidor:\n') + out.buf.replace('NIO_NO_START', '').trim() + '\n'); process.exit(1); }
|
|
166
|
-
const token = (out.buf.match(/NIO_TOKEN=(\S+)/) || [])[1];
|
|
167
|
-
if (!token) { console.error(c.warn('\n No he pogut llegir la clau del servidor.\n')); process.exit(1); }
|
|
168
|
-
|
|
169
|
-
const tun = spawn('ssh', [...sshBase(target), '-N', '-T', '-o', 'ExitOnForwardFailure=yes', '-L', `127.0.0.1:${lport}:127.0.0.1:${rport}`, '--', target], { stdio: ['ignore', 'ignore', 'inherit'] });
|
|
170
|
-
tun.on('close', () => { console.log(c.warn('\n S’ha tallat el túnel SSH.\n')); process.exit(0); });
|
|
171
|
-
const bye = () => { tun.kill(); process.exit(0); };
|
|
172
|
-
process.on('SIGINT', bye);
|
|
173
|
-
process.on('SIGTERM', bye);
|
|
174
|
-
if (!(await waitPort(lport))) { console.error(c.warn(`\n No puc obrir el port ${lport} en aquest ordinador. Prova amb --local-port 7719.\n`)); tun.kill(); process.exit(1); }
|
|
175
|
-
|
|
176
|
-
const local = `http://127.0.0.1:${lport}`, link = pairLink(local, local, token);
|
|
177
|
-
console.log(` ${c.ok('✓')} Connectat. nio funciona al servidor i el panell és a ${local}`);
|
|
178
|
-
if (canOpenBrowser() && openUrl(link)) console.log(` ${c.dim('Panell obert al navegador.')}`);
|
|
179
|
-
else console.log(` ${c.dim('Obre aquest enllaç:')} ${link}`);
|
|
180
|
-
console.log(c.dim('\n Deixa aquesta finestra oberta mentre facis servir el servidor. Amb Ctrl+C es tanca el túnel (nio continua al servidor).\n'));
|
|
121
|
+
console.log(`\n ${c.b('nio')} ${c.dim('· afegint')} ${target}${c.dim('…')}`);
|
|
122
|
+
let r;
|
|
123
|
+
try {
|
|
124
|
+
r = await addServer(target, { port: Number(flags.port || 7717), dir: flags.dir, batch: false, onStep: (t) => console.log(` ${c.dim('·')} ${t}…`) });
|
|
125
|
+
} catch (e) {
|
|
126
|
+
console.error(c.warn(`\n ${e.message}\n`));
|
|
127
|
+
process.exit(1);
|
|
128
|
+
}
|
|
129
|
+
const app = flags.app || cfg.app, link = pairLink(app, r.url, r.token), code = makeCode(r.url, r.token);
|
|
130
|
+
console.log(` ${c.ok('✓')} ${r.host} és a punt${r.claude ? '' : c.warn(' (Claude Code no hi és: instal·la’l i inicia sessió al servidor)')}`);
|
|
131
|
+
console.log(`\n ${c.b('Codi:')} ${code}`);
|
|
132
|
+
console.log(c.dim(' Enganxa’l a nio.cat → Ordinadors → Afegeix, al mòbil o a l’ordinador.\n'));
|
|
133
|
+
if (canOpenBrowser() && openUrl(link)) console.log(c.dim(' He obert nio.cat amb el servidor ja afegit.\n'));
|
|
181
134
|
}
|
|
182
135
|
|
|
183
136
|
async function start() {
|
|
@@ -294,9 +247,8 @@ async function start() {
|
|
|
294
247
|
--open Obre al navegador la pàgina del codi QR
|
|
295
248
|
nio pair Mostra el codi QR una altra vegada
|
|
296
249
|
|
|
297
|
-
nio ssh usuari@servidor
|
|
250
|
+
nio ssh usuari@servidor Afegeix un servidor (VPS) per SSH: hi engega nio amb un túnel i el pots fer servir des del mòbil.
|
|
298
251
|
--port <n> Port de nio al servidor (7717)
|
|
299
|
-
--local-port <n> Port en aquest ordinador (7718)
|
|
300
252
|
--dir <ruta> Carpeta del servidor on s'engega (per defecte, la teva carpeta d'inici)
|
|
301
253
|
|
|
302
254
|
Servidor amb adreça pública: nio --url https://la-teva-adreça --no-open
|
package/package.json
CHANGED
package/src/proc.js
CHANGED
|
@@ -6,6 +6,7 @@ const path = require('path');
|
|
|
6
6
|
const { spawn, execFile } = require('child_process');
|
|
7
7
|
|
|
8
8
|
const EXTRA_DIRS = [
|
|
9
|
+
path.join(os.homedir(), '.nio', 'bin'),
|
|
9
10
|
path.join(os.homedir(), '.local', 'bin'),
|
|
10
11
|
path.join(os.homedir(), '.npm-global', 'bin'),
|
|
11
12
|
path.join(os.homedir(), '.bun', 'bin'),
|
package/src/server.js
CHANGED
|
@@ -10,6 +10,7 @@ const ollama = require('./agents/ollama');
|
|
|
10
10
|
const { save } = require('./config');
|
|
11
11
|
const { httpErr } = require('./hub');
|
|
12
12
|
const github = require('./github');
|
|
13
|
+
const { addServer, knownHosts } = require('./ssh');
|
|
13
14
|
const { pairLink, qrSvg, makeCode, codeLink } = require('./pair');
|
|
14
15
|
|
|
15
16
|
// L'app web: copiada dins del paquet en publicar-lo, o l'arrel del repositori en desenvolupament.
|
|
@@ -172,6 +173,7 @@ function createServer({ cfg, hub, getPublicUrl, getApp }) {
|
|
|
172
173
|
id: cfg.id,
|
|
173
174
|
};
|
|
174
175
|
}
|
|
176
|
+
if (p === '/api/ssh/hosts' && method === 'GET') return knownHosts();
|
|
175
177
|
if (p === '/api/projects' && method === 'GET') return listProjects(cfg);
|
|
176
178
|
if (p === '/api/fs' && method === 'GET') return listDir(cfg, url.searchParams.get('path'));
|
|
177
179
|
if (p === '/api/ollama/models' && method === 'GET') return (await ollama.listModels()) || [];
|
|
@@ -361,6 +363,12 @@ function createServer({ cfg, hub, getPublicUrl, getApp }) {
|
|
|
361
363
|
}
|
|
362
364
|
}
|
|
363
365
|
|
|
366
|
+
// Sense clau: només diu que nio és aquí (la web ho fa servir per detectar «Aquest ordinador»).
|
|
367
|
+
if (url.pathname === '/api/hello' && req.method === 'GET') {
|
|
368
|
+
res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
|
|
369
|
+
return res.end(JSON.stringify({ nio: true, id: cfg.id, name: os.hostname() }));
|
|
370
|
+
}
|
|
371
|
+
|
|
364
372
|
if (url.pathname.startsWith('/api/')) {
|
|
365
373
|
if (!tokenOk(cfg, req)) {
|
|
366
374
|
res.writeHead(401, { 'content-type': 'application/json' });
|
|
@@ -378,6 +386,18 @@ function createServer({ cfg, hub, getPublicUrl, getApp }) {
|
|
|
378
386
|
req.on('close', () => streams.delete(res));
|
|
379
387
|
return;
|
|
380
388
|
}
|
|
389
|
+
// Afegir un servidor per SSH: el progrés va en línies JSON (una per pas) i l'última és el resultat.
|
|
390
|
+
if (url.pathname === '/api/ssh/add' && req.method === 'POST') {
|
|
391
|
+
res.writeHead(200, { 'content-type': 'application/x-ndjson; charset=utf-8', 'cache-control': 'no-store', 'x-accel-buffering': 'no' });
|
|
392
|
+
const send = (o) => res.write(JSON.stringify(o) + '\n');
|
|
393
|
+
try {
|
|
394
|
+
const b = await readBody(req);
|
|
395
|
+
send({ done: true, ...(await addServer(String(b.target || '').trim(), { onStep: (step) => send({ step }) })) });
|
|
396
|
+
} catch (e) {
|
|
397
|
+
send({ error: e.message });
|
|
398
|
+
}
|
|
399
|
+
return res.end();
|
|
400
|
+
}
|
|
381
401
|
try {
|
|
382
402
|
const out = await api(req, res, url);
|
|
383
403
|
res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
|
package/src/ssh.js
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Afegeix un servidor (VPS) per SSH: hi entra, hi engega nio amb un túnel de Cloudflare i torna l'adreça i la clau.
|
|
3
|
+
// El servidor queda connectat directament al mòbil i al panell; aquest ordinador no ha de continuar enmig.
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const os = require('os');
|
|
6
|
+
const path = require('path');
|
|
7
|
+
const { spawn } = require('child_process');
|
|
8
|
+
|
|
9
|
+
const TARGET = /^[A-Za-z0-9_][\w.@:\-\[\]]*$/;
|
|
10
|
+
const shq = (v) => `'${String(v).replace(/'/g, `'\\''`)}'`;
|
|
11
|
+
|
|
12
|
+
function remoteScript(port, dir) {
|
|
13
|
+
return `set -e
|
|
14
|
+
export PATH="$HOME/.nio/bin:$HOME/.local/bin:$PATH"
|
|
15
|
+
echo "NIO_STEP=Entrant al servidor"
|
|
16
|
+
command -v node >/dev/null 2>&1 || { echo NIO_NO_NODE; exit 0; }
|
|
17
|
+
up() { (exec 3<>/dev/tcp/127.0.0.1/${port}) 2>/dev/null; }
|
|
18
|
+
mkdir -p "$HOME/.nio/bin"
|
|
19
|
+
if ! command -v cloudflared >/dev/null 2>&1 && [ "$(uname -s)" = Linux ]; then
|
|
20
|
+
echo "NIO_STEP=Instal·lant cloudflared"
|
|
21
|
+
case "$(uname -m)" in x86_64) A=amd64;; aarch64|arm64) A=arm64;; *) A=;; esac
|
|
22
|
+
if [ -n "$A" ]; then
|
|
23
|
+
U="https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-$A"
|
|
24
|
+
{ curl -fsSL -o "$HOME/.nio/bin/cloudflared" "$U" 2>/dev/null || wget -qO "$HOME/.nio/bin/cloudflared" "$U" 2>/dev/null; } && chmod +x "$HOME/.nio/bin/cloudflared" || rm -f "$HOME/.nio/bin/cloudflared"
|
|
25
|
+
fi
|
|
26
|
+
fi
|
|
27
|
+
if ! up; then
|
|
28
|
+
echo "NIO_STEP=Engegant nio"
|
|
29
|
+
node -e "const f=require('os').homedir()+'/.nio/config.json',fs=require('fs');try{const c=JSON.parse(fs.readFileSync(f));delete c.lastUrl;fs.writeFileSync(f,JSON.stringify(c,null,2))}catch{}"
|
|
30
|
+
if command -v setsid >/dev/null 2>&1; then S=setsid; else S=; fi
|
|
31
|
+
$S nohup npx --yes nio-cat@latest --no-open --port ${port} ${dir ? shq(dir) : '"$HOME"'} > "$HOME/.nio/daemon.log" 2>&1 < /dev/null &
|
|
32
|
+
for i in $(seq 1 80); do up && break; sleep 0.5; done
|
|
33
|
+
up || { echo NIO_NO_START; tail -n 5 "$HOME/.nio/daemon.log" 2>/dev/null; exit 0; }
|
|
34
|
+
fi
|
|
35
|
+
echo "NIO_STEP=Obrint el túnel"
|
|
36
|
+
URL=
|
|
37
|
+
for i in $(seq 1 60); do
|
|
38
|
+
URL=$(node -e "try{console.log(JSON.parse(require('fs').readFileSync(require('os').homedir()+'/.nio/config.json')).lastUrl||'')}catch{}")
|
|
39
|
+
[ -n "$URL" ] && break
|
|
40
|
+
sleep 1
|
|
41
|
+
done
|
|
42
|
+
[ -n "$URL" ] || { echo NIO_NO_TUNNEL; exit 0; }
|
|
43
|
+
node -e "console.log('NIO_TOKEN='+JSON.parse(require('fs').readFileSync(require('os').homedir()+'/.nio/config.json')).token)"
|
|
44
|
+
echo "NIO_URL=$URL"
|
|
45
|
+
echo "NIO_HOST=$(hostname)"
|
|
46
|
+
if command -v claude >/dev/null 2>&1; then echo NIO_CLAUDE=1; else echo NIO_CLAUDE=0; fi
|
|
47
|
+
`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// batch: sense preguntes (des del panell). Sense batch (terminal), ssh pot demanar la contrasenya.
|
|
51
|
+
function addServer(target, { port = 7717, dir, batch = true, onStep = () => {} } = {}) {
|
|
52
|
+
return new Promise((resolve, reject) => {
|
|
53
|
+
if (!TARGET.test(String(target || ''))) return reject(new Error('Escriu l’usuari i el servidor, per exemple root@el-meu-servidor.com'));
|
|
54
|
+
const args = ['-o', 'ConnectTimeout=12', '-o', 'StrictHostKeyChecking=accept-new', ...(batch ? ['-o', 'BatchMode=yes'] : []), '-T', '--', target, 'bash -l -s'];
|
|
55
|
+
let p;
|
|
56
|
+
try { p = spawn('ssh', args, { stdio: ['pipe', 'pipe', batch ? 'pipe' : 'inherit'] }); } catch { return reject(new Error('No trobo l’ordre ssh en aquest ordinador')); }
|
|
57
|
+
let buf = '', err = '', done = false;
|
|
58
|
+
const out = {};
|
|
59
|
+
const line = (l) => {
|
|
60
|
+
const m = l.match(/^NIO_(STEP|TOKEN|URL|HOST|CLAUDE)=(.*)$/);
|
|
61
|
+
if (!m) return;
|
|
62
|
+
if (m[1] === 'STEP') onStep(m[2]);
|
|
63
|
+
else out[m[1].toLowerCase()] = m[2].trim();
|
|
64
|
+
};
|
|
65
|
+
p.stdout.on('data', (d) => {
|
|
66
|
+
buf += d;
|
|
67
|
+
let i;
|
|
68
|
+
while ((i = buf.indexOf('\n')) >= 0) { line(buf.slice(0, i)); buf = buf.slice(i + 1); }
|
|
69
|
+
if (/NIO_NO_(NODE|START|TUNNEL)/.test(buf + '')) out.flag = (buf.match(/NIO_NO_\w+/) || [])[0];
|
|
70
|
+
});
|
|
71
|
+
if (batch) p.stderr.on('data', (d) => (err += d));
|
|
72
|
+
const fail = (m) => { if (!done) { done = true; reject(new Error(m)); } };
|
|
73
|
+
p.on('error', () => fail('No trobo l’ordre ssh en aquest ordinador'));
|
|
74
|
+
p.on('close', (code) => {
|
|
75
|
+
if (buf) line(buf);
|
|
76
|
+
if (done) return;
|
|
77
|
+
done = true;
|
|
78
|
+
const text = `${err}\n${buf}`;
|
|
79
|
+
if (/NIO_NO_NODE/.test(text)) return reject(new Error('Al servidor falta Node.js 18 o més nou. Instal·la’l i torna-ho a provar.'));
|
|
80
|
+
if (/NIO_NO_START/.test(text)) return reject(new Error('nio no s’ha pogut engegar al servidor. Mira ~/.nio/daemon.log allà.'));
|
|
81
|
+
if (/NIO_NO_TUNNEL/.test(text)) return reject(new Error('El túnel no s’ha obert. Al servidor cal Linux amb curl o wget (per instal·lar cloudflared), o una adreça pròpia amb --url.'));
|
|
82
|
+
if (out.token && out.url) return resolve({ url: out.url, token: out.token, host: out.host || target, claude: out.claude === '1' });
|
|
83
|
+
if (/Permission denied|publickey/i.test(err)) return reject(new Error(`No puc entrar sense contrasenya. Al terminal d’aquest ordinador executa: ssh-copy-id ${target}`));
|
|
84
|
+
if (/Could not resolve|Connection (refused|timed out)|No route|timed out/i.test(err)) return reject(new Error('No arribo al servidor. Comprova l’adreça i que el SSH hi està obert.'));
|
|
85
|
+
reject(new Error((err.trim().split('\n').pop() || 'No he pogut afegir el servidor') + (code ? ` (ssh ${code})` : '')));
|
|
86
|
+
});
|
|
87
|
+
p.stdin.end(remoteScript(port, dir));
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Servidors que ja tens a ~/.ssh/config (sense comodins), perquè el panell els ofereixi i no calgui escriure'ls.
|
|
92
|
+
function knownHosts() {
|
|
93
|
+
let txt = '';
|
|
94
|
+
try { txt = fs.readFileSync(path.join(os.homedir(), '.ssh', 'config'), 'utf8'); } catch { return []; }
|
|
95
|
+
const out = [];
|
|
96
|
+
let cur = null;
|
|
97
|
+
for (const raw of txt.split('\n')) {
|
|
98
|
+
const l = raw.trim();
|
|
99
|
+
const h = l.match(/^Host\s+(.+)$/i);
|
|
100
|
+
if (h) {
|
|
101
|
+
cur = null;
|
|
102
|
+
for (const name of h[1].split(/\s+/)) if (TARGET.test(name) && !/[*?!]/.test(name)) { cur = { target: name, host: '' }; out.push(cur); }
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
const hn = l.match(/^HostName\s+(\S+)/i);
|
|
106
|
+
if (hn && cur) cur.host = hn[1];
|
|
107
|
+
}
|
|
108
|
+
return out.slice(0, 30);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
module.exports = { addServer, remoteScript, knownHosts, TARGET };
|
package/web/app/app.js
CHANGED
|
@@ -28,6 +28,8 @@ const ICON = {
|
|
|
28
28
|
more: '<circle cx="5" cy="12" r="1.3" fill="currentColor"/><circle cx="12" cy="12" r="1.3" fill="currentColor"/><circle cx="19" cy="12" r="1.3" fill="currentColor"/>',
|
|
29
29
|
refresh: '<path d="M20 11a8 8 0 1 0-2.3 5.7M20 4v7h-7"/>', back: '<path d="M15 5l-7 7 7 7"/>',
|
|
30
30
|
bell: '<path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/>',
|
|
31
|
+
server: '<rect x="3" y="4" width="18" height="6" rx="2"/><rect x="3" y="14" width="18" height="6" rx="2"/><path d="M7 7h.01M7 17h.01"/>',
|
|
32
|
+
scan: '<path d="M4 8V5a1 1 0 0 1 1-1h3M16 4h3a1 1 0 0 1 1 1v3M20 16v3a1 1 0 0 1-1 1h-3M8 20H5a1 1 0 0 1-1-1v-3M4 12h16"/>',
|
|
31
33
|
check: '<path class="tick" d="M5 12.5l4.5 4.5L19 7.5"/>',
|
|
32
34
|
copy: '<rect x="8" y="8" width="12" height="12" rx="2"/><path d="M16 8V5a1 1 0 0 0-1-1H5a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h3"/>'
|
|
33
35
|
};
|
|
@@ -217,13 +219,32 @@ function parseCode(text) {
|
|
|
217
219
|
}
|
|
218
220
|
// Un codi o un enllaç de connexió (#u=…&t=…).
|
|
219
221
|
const pairFrom = (text) => parsePair(text) || parseCode(text);
|
|
220
|
-
|
|
222
|
+
// Cap pantalla ensenya adreces: cada ordinador és un nom, una icona i un estat.
|
|
223
|
+
const hostOf = (u) => { try { return new URL(u).hostname; } catch { return ''; } };
|
|
224
|
+
const isLoop = (u) => ['127.0.0.1', 'localhost', '[::1]'].includes(hostOf(u));
|
|
225
|
+
const machineIcon = (m) => (m.kind === 'vps' ? ICON.server : ICON.machine);
|
|
226
|
+
const machineSub = (m) => (m.kind === 'vps' ? `Servidor${m.via ? ` · ${m.via}` : ''}` : isLoop(m.url) ? 'Aquest ordinador' : 'Ordinador');
|
|
227
|
+
function addMachine({ url, token, name, kind, via }) {
|
|
221
228
|
const ms = store.machines; let i = ms.findIndex((m) => m.url === url);
|
|
222
|
-
if (i >= 0) ms[i].token = token;
|
|
229
|
+
if (i >= 0) { ms[i].token = token; if (kind) ms[i].kind = kind; if (via) ms[i].via = via; }
|
|
230
|
+
else { ms.push({ url, token, name: name || hostOf(url), ...(kind ? { kind } : {}), ...(via ? { via } : {}) }); i = ms.length - 1; }
|
|
223
231
|
store.machines = ms; store.active = i;
|
|
224
232
|
state.sessions.clear(); state.approvals.clear(); state.events.clear(); state.loaded.clear(); state.info = null; state.ollama = null;
|
|
225
233
|
connect();
|
|
226
234
|
}
|
|
235
|
+
// Passa a un altre ordinador de la llista i es queda a la mateixa secció.
|
|
236
|
+
function switchMachine(i) {
|
|
237
|
+
closeSheet(); store.active = i; streamCtrl?.abort();
|
|
238
|
+
state.ollama = null; state.sessions.clear(); state.approvals.clear(); state.events.clear(); state.loaded.clear(); state.connected = false; state.info = null; state.lastError = null;
|
|
239
|
+
connect();
|
|
240
|
+
if (state.route.name === 'session') go('#/sessions'); else render();
|
|
241
|
+
}
|
|
242
|
+
function forgetMachine(i) {
|
|
243
|
+
const ms = store.machines, was = i === store.active; ms.splice(i, 1); store.machines = ms;
|
|
244
|
+
if (was) store.active = 0; else if (i < store.active) store.active = store.active - 1;
|
|
245
|
+
if (was) { streamCtrl?.abort(); state.connected = false; state.info = null; state.ollama = null; state.sessions.clear(); state.approvals.clear(); state.events.clear(); state.loaded.clear(); connect(); }
|
|
246
|
+
if (!ms.length) go('#/ordinadors'); else render();
|
|
247
|
+
}
|
|
227
248
|
function checkPairHash() {
|
|
228
249
|
const pair = parsePair(location.hash) || parseCode(location.hash); if (!pair) return false;
|
|
229
250
|
history.replaceState(null, '', location.pathname + '#/sessions');
|
|
@@ -235,20 +256,18 @@ function checkPairHash() {
|
|
|
235
256
|
const VIEWS = {
|
|
236
257
|
sessions: { label: 'Sessions', icon: ICON.sessions, group: 'Feina' },
|
|
237
258
|
approvals: { label: 'Aprovacions', icon: ICON.approvals, group: 'Feina' },
|
|
238
|
-
|
|
239
|
-
|
|
259
|
+
ordinadors: { label: 'Ordinadors', icon: ICON.machine, group: 'Ordinadors' },
|
|
260
|
+
models: { label: 'Models', icon: ICON.models, group: 'Ordinadors' },
|
|
240
261
|
mobil: { label: 'Xat local', icon: ICON.phone, group: 'Eines' },
|
|
241
262
|
pissarra: { label: 'Pissarra', icon: ICON.board, group: 'Eines' }
|
|
242
263
|
};
|
|
243
|
-
const
|
|
244
|
-
|
|
245
|
-
models: { title: 'Models', lead: 'Models d’Ollama que funcionen a l’ordinador, sense internet i sense comptes.' },
|
|
246
|
-
maquina: { title: 'Ordinador', lead: 'Els agents instal·lats, els avisos al mòbil i els ordinadors connectats.' }
|
|
247
|
-
};
|
|
248
|
-
const HASH = { sessions: '#/sessions', approvals: '#/aprovacions', mobil: '#/mobil', models: '#/models', maquina: '#/ordinador', pissarra: '#/pissarra' };
|
|
264
|
+
const HASH = { sessions: '#/sessions', approvals: '#/aprovacions', ordinadors: '#/ordinadors', models: '#/models', mobil: '#/xat-local', pissarra: '#/pissarra' };
|
|
265
|
+
const NEEDS_MACHINE = new Set(['sessions', 'approvals', 'models', 'session']);
|
|
249
266
|
function parseRoute() {
|
|
250
267
|
const h = location.hash.replace(/^#\/?/, '');
|
|
251
268
|
if (h.startsWith('s/')) return { name: 'session', id: h.slice(2) };
|
|
269
|
+
if (h === 'ordinador') return { name: 'ordinadors' }; // adreces antigues
|
|
270
|
+
if (h === 'mobil') return { name: 'mobil' };
|
|
252
271
|
const k = Object.keys(HASH).find((k) => HASH[k] === '#/' + h);
|
|
253
272
|
return { name: k || 'sessions' };
|
|
254
273
|
}
|
|
@@ -257,6 +276,8 @@ let lastName = null;
|
|
|
257
276
|
function route() {
|
|
258
277
|
if (checkPairHash()) return;
|
|
259
278
|
state.route = parseRoute();
|
|
279
|
+
// Sense cap ordinador, tot porta al mateix lloc: la pàgina Ordinadors.
|
|
280
|
+
if (!machine() && NEEDS_MACHINE.has(state.route.name)) { history.replaceState(null, '', '#/ordinadors'); state.route = { name: 'ordinadors' }; }
|
|
260
281
|
if (state.route.name === 'session') openSession(state.route.id); else render();
|
|
261
282
|
}
|
|
262
283
|
|
|
@@ -269,10 +290,13 @@ function renderChrome() {
|
|
|
269
290
|
prev = v.group;
|
|
270
291
|
return `${head}<a href="${HASH[k]}" style="--i:${i}" title="${v.label}" ${k === active ? 'aria-current="page"' : ''}>${svg(v.icon)}<span class="l">${v.label}</span>${k === 'approvals' && n ? `<span class="badge">${n}</span>` : ''}</a>`;
|
|
271
292
|
}).join('');
|
|
272
|
-
const m = machine();
|
|
273
|
-
|
|
274
|
-
sc.
|
|
275
|
-
|
|
293
|
+
const m = machine(), sc = $('#sideConn');
|
|
294
|
+
sc.innerHTML = m ? `<i class="dot ${state.connected ? 'on' : ''}"></i><span>${esc(m.name)}</span>${svg(ICON.chev)}` : `<i class="dot"></i><span>Afegeix un ordinador</span>${svg(ICON.plus)}`;
|
|
295
|
+
sc.setAttribute('role', 'button'); sc.tabIndex = 0; sc.classList.add('switch'); sc.dataset.act = 'machines'; sc.title = 'Ordinadors';
|
|
296
|
+
const more = ['models', 'mobil', 'pissarra'].includes(r.name);
|
|
297
|
+
$('#tabbar').innerHTML = [['sessions', ICON.sessions, 'Sessions', active === 'sessions'], ['approvals', ICON.approvals, 'Aprovacions', active === 'approvals'], ['ordinadors', ICON.machine, 'Ordinadors', active === 'ordinadors']]
|
|
298
|
+
.map(([k, ic, l, on]) => `<a href="${HASH[k]}" ${on ? 'aria-current="page"' : ''}>${svg(ic)}<span>${l}</span>${k === 'approvals' && n ? `<b class="badge">${n}</b>` : ''}</a>`).join('')
|
|
299
|
+
+ `<button type="button" data-act="more" ${more ? 'aria-current="page"' : ''}>${svg(ICON.more)}<span>Més</span></button>`;
|
|
276
300
|
const s = r.name === 'session' ? state.sessions.get(r.id) : null;
|
|
277
301
|
$('#here').textContent = s ? s.title || 'Sessió nova' : VIEWS[r.name]?.label || '';
|
|
278
302
|
$('#mRight').innerHTML = r.name === 'session'
|
|
@@ -286,6 +310,7 @@ function render() {
|
|
|
286
310
|
renderChrome();
|
|
287
311
|
document.body.classList.toggle('board-mode', board);
|
|
288
312
|
document.body.classList.toggle('chat-mode', r.name === 'session' || (r.name === 'mobil' && !!local.engine));
|
|
313
|
+
document.body.classList.toggle('no-tabs', r.name === 'session' || board || (r.name === 'mobil' && !!local.engine));
|
|
289
314
|
if (!board && lastName === 'pissarra') window.NioBoard?.unmount();
|
|
290
315
|
if (board && lastName === 'pissarra' && $('#boardRoot')) return;
|
|
291
316
|
const changed = lastName !== r.name || r.name === 'session';
|
|
@@ -294,7 +319,7 @@ function render() {
|
|
|
294
319
|
else if (board) { view.innerHTML = '<div class="page"><div class="board-root" id="boardRoot"></div></div>'; window.NioBoard?.mount($('#boardRoot')); brainsForBoard(); }
|
|
295
320
|
else {
|
|
296
321
|
const html = r.name === 'mobil' ? viewLocal() : null;
|
|
297
|
-
view.innerHTML = html && local.engine ? html : `<div class="page">${html ? html : r.name === '
|
|
322
|
+
view.innerHTML = html && local.engine ? html : `<div class="page">${html ? html : r.name === 'ordinadors' ? viewComputers() : r.name === 'sessions' ? viewSessions() : r.name === 'approvals' ? viewApprovals() : viewModels()}</div>`;
|
|
298
323
|
if (r.name === 'mobil') bindLocal();
|
|
299
324
|
if (r.name === 'models' && machine()) loadOllama();
|
|
300
325
|
}
|
|
@@ -303,48 +328,164 @@ function render() {
|
|
|
303
328
|
tourUpdate();
|
|
304
329
|
}
|
|
305
330
|
|
|
306
|
-
/* ----------
|
|
307
|
-
// La connexió viu només a Sessions. Les altres pàgines, sense ordinador, hi envien.
|
|
331
|
+
/* ---------- Ordinadors (llista, afegir i detall de l'actiu) */
|
|
308
332
|
const isPhone = () => isTouch || matchMedia('(max-width: 820px)').matches;
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
<
|
|
316
|
-
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
+
const cmdBox = (c) => `<div class="cmd big"><code>${esc(c)}</code><button class="btn sm" data-copy="${esc(c)}" type="button">${svg(ICON.copy)}Copia</button></div>`;
|
|
334
|
+
function computerCard(m, i) {
|
|
335
|
+
const on = i === store.active;
|
|
336
|
+
const st = on ? (state.connected ? '<span class="chip ok"><i class="dot on"></i>Connectat</span>' : `<span class="chip">${esc(state.lastError ? 'Sense connexió' : 'Connectant…')}</span>`) : '';
|
|
337
|
+
return `<div class="card computer ${on ? 'active' : ''}">
|
|
338
|
+
<div class="row" style="flex-wrap:nowrap;gap:12px;align-items:flex-start"><span class="c-ico">${svg(machineIcon(m))}</span><div class="grow"><div class="row between"><b>${esc(m.name)}</b>${st}</div><div class="muted small">${esc(machineSub(m))}</div></div></div>
|
|
339
|
+
<div class="row c-actions">${on ? `<button class="btn ink sm" data-act="open-sessions" type="button">Sessions</button>${isPhone() ? '' : `<button class="btn sm" data-act="mobile-qr" type="button">Continua al mòbil</button>`}` : `<button class="btn ink sm" data-machine="${i}" type="button">Obre</button>`}${m.kind === 'vps' && m.via && (!on || !state.connected) ? `<button class="btn sm" data-reconnect="${i}" type="button">Torna a connectar</button>` : ''}<button class="btn ghost sm" data-forget="${i}" type="button">Oblida</button></div>
|
|
340
|
+
</div>`;
|
|
341
|
+
}
|
|
342
|
+
// Ajuda sempre a mà: com es fa cada cosa, i què mirar si no connecta.
|
|
343
|
+
function helpPanel(open) {
|
|
344
|
+
const q = (t, body) => `<details><summary>${t}</summary><div class="help-body">${body}</div></details>`;
|
|
345
|
+
return `<section class="panel help" id="help"><header><h2>Com es fa</h2></header><div class="body">
|
|
346
|
+
${q('Connectar aquest ordinador', `<p>Al terminal, des de la carpeta dels teus projectes, executa <span class="mono">npx nio-cat</span>. S’obre nio.cat ja connectat. Cal Node.js 18 o més nou.</p>`)}
|
|
347
|
+
${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 <span class="mono">nio-…</span>.</p><p>Al mòbil, obre <b>www.nio.cat/app</b> → Ordinadors → Afegeix ordinador, i escaneja el QR o enganxa el codi.</p><p>Per connectar-t’hi des de qualsevol xarxa cal <span class="mono">cloudflared</span> a l’ordinador (<span class="mono">brew install cloudflared</span>). Sense ell, el mòbil ha d’estar a la mateixa wifi.</p>`)}
|
|
348
|
+
${q('Afegir un servidor (VPS) amb Claude Code', `<p>Ordinadors → Afegeix ordinador → <b>Servidor (SSH)</b> → escriu <span class="mono">usuari@servidor</span>. L’ordinador hi entra, hi engega nio i el servidor surt a la llista.</p><p>Després, prem <b>Continua al mòbil</b> amb el servidor actiu per fer-lo servir des del mòbil.</p>`)}
|
|
349
|
+
${q('Què cal al servidor', `<ul><li>Entrar-hi per SSH sense contrasenya. Si no, executa <span class="mono">ssh-copy-id usuari@servidor</span> 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 <span class="mono">claude</span> un cop.</li><li>Linux amb <span class="mono">curl</span> o <span class="mono">wget</span> (nio hi instal·la <span class="mono">cloudflared</span> sol).</li></ul>`)}
|
|
350
|
+
${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 <span class="mono">npx nio-cat --local</span>, o fes servir Chrome.</li><li><b>La clau no és vàlida</b>: torna a afegir l’ordinador amb el codi nou.</li></ul>`)}
|
|
351
|
+
${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>`)}
|
|
352
|
+
<div class="row"><button class="btn sm" type="button" data-act="tour">Fes el tutorial</button></div>
|
|
353
|
+
</div></section>`;
|
|
354
|
+
}
|
|
355
|
+
function viewComputers() {
|
|
356
|
+
const ms = store.machines, m = machine(), info = state.info;
|
|
357
|
+
const head = `<div class="top"><div><h1>Ordinadors</h1><p>On treballen els agents: el teu ordinador o un servidor. Tria’n un i dona-li feina.</p></div><button class="btn ink" id="addComputer" data-act="add-computer" type="button">${svg(ICON.plus)}Afegeix ordinador</button></div>`;
|
|
358
|
+
if (!ms.length) {
|
|
359
|
+
const steps = isPhone()
|
|
360
|
+
? [['Engega nio a l’ordinador', 'Al terminal: <span class="mono">npx nio-cat</span>.'], ['Escaneja el QR', 'O escriu el codi <span class="mono">nio-…</span> que surt al terminal.'], ['Dona feina a un agent', 'Crea una sessió i segueix-la en directe.']]
|
|
361
|
+
: [['Engega nio', 'Al terminal: <span class="mono">npx nio-cat</span>. S’obre aquesta web ja connectada.'], ['O afegeix un servidor', 'Amb SSH: <span class="mono">usuari@servidor</span>. nio hi fa la resta.'], ['Continua al mòbil', 'Amb un QR o un codi, quan vulguis.']];
|
|
362
|
+
return `${head}<div class="panel empty">${bot('idle')}<h2>Connecta el teu primer ordinador</h2><ol class="stepper">${steps.map(([t, d], i) => `<li><span class="n">${i + 1}</span><div><b>${t}</b><p>${d}</p></div></li>`).join('')}</ol><button class="btn ink" data-act="add-computer" type="button">${svg(ICON.plus)}Afegeix ordinador</button></div>${helpPanel()}`;
|
|
333
363
|
}
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
<div class="
|
|
337
|
-
<section class="panel
|
|
338
|
-
|
|
339
|
-
<p class="muted small">S’obre aquest panell ja connectat. Cal Node.js 18 o més nou.</p></div></section>
|
|
340
|
-
<section class="panel choice"><header><h2>Un altre ordinador</h2></header><div class="body stack">
|
|
341
|
-
<p class="small">Engega nio a l’altre ordinador o a un servidor. Copia aquí el codi que mostra.</p>
|
|
342
|
-
${pasteForm(true)}
|
|
343
|
-
<p class="small"><b>Un servidor (VPS) per SSH.</b> Des d'aquest ordinador, sense obrir cap port:</p>${cmd('npx nio-cat ssh usuari@servidor')}
|
|
344
|
-
<p class="muted small">Engega nio al servidor, obre el túnel i el panell queda connectat.</p></div></section>
|
|
364
|
+
const detail = m && state.connected ? `<h2 class="sec">${esc(m.name)}</h2>
|
|
365
|
+
<div class="grid2">
|
|
366
|
+
<div class="stack">
|
|
367
|
+
<section class="panel"><header><h2>Agents</h2><button class="btn sm ghost" data-act="refresh-agents" type="button">${svg(ICON.refresh)}Torna a detectar</button></header><div class="body">${info ? info.agents.map((a) => `<div class="agent-row"><i class="dot ${a.available ? 'on' : ''}"></i><div class="grow"><div class="row"><span class="agent-tag" data-agent="${esc(a.id)}">${esc(a.name)}</span>${a.beta ? '<span class="chip beta">beta</span>' : ''}</div><div class="muted small mono">${esc(a.available ? a.version || 'Disponible' : a.login)}</div></div></div>`).join('') : '<p class="muted">Carregant…</p>'}</div></section>
|
|
368
|
+
${info ? `<section class="panel"><header><h2>Sistema</h2></header><div class="body"><dl class="kv"><dt>Sistema</dt><dd>${esc(info.platform)} · ${esc(info.user)}</dd><dt>Versió de nio</dt><dd>${esc(info.version)}</dd></dl></div></section>` : ''}
|
|
345
369
|
</div>
|
|
346
|
-
<
|
|
347
|
-
|
|
370
|
+
<div class="stack">
|
|
371
|
+
<section class="panel"><header><h2>Avisos al mòbil</h2></header><div class="body stack"><p class="small">Rep un avís quan un agent et demana permís o acaba la feina.</p>${isIOS && !standalone ? '<p class="muted small">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>' : ''}<div class="row"><button class="btn ink grow" data-act="push" type="button">Activa els avisos</button><button class="btn" data-act="push-test" type="button">Envia’n un de prova</button></div></div></section>
|
|
372
|
+
</div>
|
|
373
|
+
</div>` : '';
|
|
374
|
+
return `${head}<div class="computers">${ms.map(computerCard).join('')}</div>${detail}${helpPanel()}`;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/* ---------- Afegir un ordinador: aquest, un servidor per SSH o un codi */
|
|
378
|
+
async function probeLocal() {
|
|
379
|
+
try {
|
|
380
|
+
const c = new AbortController(); setTimeout(() => c.abort(), 1500);
|
|
381
|
+
const d = await (await fetch('http://127.0.0.1:7717/api/hello', { signal: c.signal, cache: 'no-store' })).json();
|
|
382
|
+
return d?.nio ? d : null;
|
|
383
|
+
} catch { return null; }
|
|
384
|
+
}
|
|
385
|
+
// Afegeix un servidor per SSH des de l'ordinador actiu. El progrés arriba línia a línia.
|
|
386
|
+
async function sshAdd(target, onStep) {
|
|
387
|
+
const m = machine(); if (!m) throw new Error('Primer connecta un ordinador');
|
|
388
|
+
const res = await fetch(`${m.url}/api/ssh/add`, { method: 'POST', headers: { Authorization: `Bearer ${m.token}`, 'content-type': 'application/json' }, body: JSON.stringify({ target }) });
|
|
389
|
+
if (!res.ok || !res.body) throw new Error((await res.json().catch(() => ({}))).error || 'Aquest ordinador no pot afegir servidors. Actualitza nio.');
|
|
390
|
+
const reader = res.body.getReader(), dec = new TextDecoder(); let buf = '', last = null;
|
|
391
|
+
for (;;) {
|
|
392
|
+
const { value, done } = await reader.read(); if (done) break;
|
|
393
|
+
buf += dec.decode(value, { stream: true }); let i;
|
|
394
|
+
while ((i = buf.indexOf('\n')) >= 0) {
|
|
395
|
+
const o = JSON.parse(buf.slice(0, i)); buf = buf.slice(i + 1);
|
|
396
|
+
if (o.step) onStep(o.step); else last = o;
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
if (!last || last.error) throw new Error(last?.error || 'S’ha tallat la connexió');
|
|
400
|
+
return last;
|
|
401
|
+
}
|
|
402
|
+
async function startScan(video, onText) {
|
|
403
|
+
const det = new BarcodeDetector({ formats: ['qr_code'] });
|
|
404
|
+
const stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: 'environment' } });
|
|
405
|
+
video.srcObject = stream; await video.play(); let alive = true;
|
|
406
|
+
(async () => { while (alive) { try { const r = await det.detect(video); if (r[0]) onText(r[0].rawValue); } catch { /* quadre sense QR */ } await sleep(250); } })();
|
|
407
|
+
return { stop() { alive = false; stream.getTracks().forEach((t) => t.stop()); } };
|
|
408
|
+
}
|
|
409
|
+
function addComputerSheet(tab, opts = {}) {
|
|
410
|
+
const phone = isPhone(), canScan = 'BarcodeDetector' in window && !!navigator.mediaDevices?.getUserMedia;
|
|
411
|
+
const TABS = phone ? [['code', 'Codi o QR'], ['vps', 'Servidor (SSH)']] : [['local', 'Aquest ordinador'], ['vps', 'Servidor (SSH)'], ['code', 'Tinc un codi']];
|
|
412
|
+
let cur = TABS.some((t) => t[0] === tab) ? tab : TABS[0][0], poll = 0, scan = null, shown = '', hosts = [];
|
|
413
|
+
openSheet('', (sh) => {
|
|
414
|
+
sh.innerHTML = `<div class="wiz-head"><div class="grab"></div><div class="row between" style="flex-wrap:nowrap"><h2>Afegeix un ordinador</h2><button class="icon-btn" type="button" data-close aria-label="Tanca">×</button></div><div class="seg" id="addTabs" role="group">${TABS.map(([k, l]) => `<button type="button" data-tab="${k}">${l}</button>`).join('')}</div></div><div class="wiz-body" id="addBody"></div>`;
|
|
415
|
+
const body = $('#addBody', sh);
|
|
416
|
+
const stop = () => { clearTimeout(poll); scan?.stop(); scan = null; shown = ''; };
|
|
417
|
+
const set = (key, html) => { if (key === shown) return false; shown = key; body.innerHTML = html; return true; };
|
|
418
|
+
const draw = () => {
|
|
419
|
+
stop(); if (cur === 'vps' && !hosts.length && machine()) api('/api/ssh/hosts').then((h) => { hosts = h; if (cur === 'vps' && shown === 'vps') { shown = ''; vps(); } }).catch(() => {});
|
|
420
|
+
$$('#addTabs button', sh).forEach((b) => b.setAttribute('aria-pressed', b.dataset.tab === cur));
|
|
421
|
+
body.scrollTop = 0;
|
|
422
|
+
if (cur === 'local') local(); else if (cur === 'vps') vps(); else code();
|
|
423
|
+
};
|
|
424
|
+
const local = async () => {
|
|
425
|
+
set('wait', '<p class="muted small">Mirant si nio funciona en aquest ordinador…</p>');
|
|
426
|
+
const d = await probeLocal(); if (cur !== 'local') return;
|
|
427
|
+
const saved = d && store.machines.findIndex((x) => x.id === d.id);
|
|
428
|
+
if (d && saved >= 0) set('saved', `<p class="wiz-q">Aquest ordinador ja és a la llista</p><p class="muted small">${esc(d.name)}</p><div class="row"><button class="btn ink" type="button" data-open-saved="${saved}">Obre</button></div>`);
|
|
429
|
+
else if (d) set('found', `<p class="wiz-q">nio funciona en aquest ordinador</p><p class="muted small">Aquest navegador encara no hi té accés. Torna a engegar nio perquè obri el panell ja connectat:</p>${cmdBox('npx nio-cat')}`);
|
|
430
|
+
else set('none', `<p class="wiz-q">Engega nio en aquest ordinador</p><p class="muted small">Al terminal, des de la carpeta dels teus projectes:</p>${cmdBox('npx nio-cat')}<p class="muted small">S’obrirà aquest panell ja connectat. Cal Node.js 18 o més nou.</p>`);
|
|
431
|
+
if (saved < 0 || !d) poll = setTimeout(local, 3000);
|
|
432
|
+
};
|
|
433
|
+
const runSsh = async (target) => {
|
|
434
|
+
const steps = $('#sshSteps', sh), btn = $('#sshForm button', sh); if (!steps) return; btn.disabled = true; steps.innerHTML = '';
|
|
435
|
+
const add = (t) => { $$('li.on', steps).forEach((l) => { l.className = 'ok'; }); steps.insertAdjacentHTML('beforeend', `<li class="on">${esc(t)}…</li>`); };
|
|
436
|
+
try {
|
|
437
|
+
const r = await sshAdd(target, add);
|
|
438
|
+
LS.set('nio.lastSsh', target);
|
|
439
|
+
const old = store.machines.findIndex((x) => x.via === target && x.kind === 'vps');
|
|
440
|
+
if (old >= 0) { const ms = store.machines; ms.splice(old, 1); store.machines = ms; store.active = Math.min(store.active, Math.max(0, ms.length - 1)); }
|
|
441
|
+
addMachine({ url: r.url, token: r.token, name: r.host, kind: 'vps', via: target });
|
|
442
|
+
shown = 'vps-ok';
|
|
443
|
+
body.innerHTML = `<p class="wiz-q">${esc(r.host)} és a punt</p>${r.claude ? '<p class="muted small">Ja el pots fer servir: crea-hi una sessió amb Claude Code.</p>' : '<p class="muted small">Claude Code no hi és. Entra al servidor, instal·la’l i inicia sessió amb <span class="mono">claude</span>.</p>'}<div class="row"><a class="btn ink" href="#/sessions" data-close>Obre sessions</a><button class="btn" type="button" data-act="mobile-qr">Continua al mòbil</button></div>`;
|
|
444
|
+
} catch (err) { $$('li.on', steps).forEach((l) => { l.className = 'err'; }); steps.insertAdjacentHTML('beforeend', `<li class="err">${esc(err.message)}</li>`); btn.disabled = false; }
|
|
445
|
+
};
|
|
446
|
+
const vps = () => {
|
|
447
|
+
const m = machine();
|
|
448
|
+
if (!m) return set('vps-no', `<p class="wiz-q">Primer, un ordinador</p><p class="muted small">El servidor s’afegeix des d’un ordinador que ja tinguis connectat, perquè és ell qui hi entra per SSH.</p>${phone ? '' : '<button class="btn" type="button" data-tab="local">Afegeix aquest ordinador</button>'}`);
|
|
449
|
+
set('vps', `<p class="wiz-q">Un servidor amb Claude Code</p>
|
|
450
|
+
<p class="muted small">${esc(m.name)} hi entra per SSH, hi engega nio amb un túnel segur i el deixa llest per al mòbil.</p>
|
|
451
|
+
${hosts.length ? `<div class="row">${hosts.map((h) => `<button class="chip" type="button" data-host="${esc(h.target)}" title="${esc(h.host)}">${esc(h.target)}</button>`).join('')}</div>` : ''}
|
|
452
|
+
<form class="pair-form" id="sshForm"><input type="text" id="sshIn" value="${esc(opts.target || LS.get('nio.lastSsh', ''))}" placeholder="usuari@servidor" autocomplete="off" autocapitalize="off" spellcheck="false" required aria-label="Usuari i servidor"><button class="btn ink" type="submit">Afegeix</button></form>
|
|
453
|
+
<ul class="steps" id="sshSteps"></ul>
|
|
454
|
+
<p class="muted small">Cal entrar-hi sense contrasenya (clau SSH) i que el servidor tingui Node.js 18 o més nou. Claude Code hi ha d’estar instal·lat i amb sessió iniciada.</p>`);
|
|
455
|
+
};
|
|
456
|
+
const code = () => {
|
|
457
|
+
set('code', `<p class="wiz-q">${phone ? 'Escaneja o escriu el codi' : 'Escriu el codi'}</p>
|
|
458
|
+
<form class="pair-form" id="pairForm"><input type="text" id="pairInput" placeholder="nio-…" autocomplete="off" autocapitalize="off" spellcheck="false" required aria-label="Codi de connexió"><button class="btn ink" type="submit">Connecta</button></form>
|
|
459
|
+
${canScan ? `<button class="btn" type="button" data-scan>${svg(ICON.scan)}Escaneja un QR</button><video class="scan" id="scanVideo" playsinline muted hidden></video>` : phone ? '<p class="muted small">Per escanejar, obre la càmera del mòbil i apunta al QR: s’obrirà nio.</p>' : ''}
|
|
460
|
+
<p class="muted small">El codi surt al terminal, sota el QR, o a «Continua al mòbil».</p>`);
|
|
461
|
+
};
|
|
462
|
+
sh.onclick = async (e) => {
|
|
463
|
+
const t = e.target.closest('[data-tab]'); if (t) { cur = t.dataset.tab; return draw(); }
|
|
464
|
+
const hb = e.target.closest('[data-host]'); if (hb) { $('#sshIn', sh).value = hb.dataset.host; return $('#sshForm', sh).requestSubmit(); }
|
|
465
|
+
const os = e.target.closest('[data-open-saved]'); if (os) { switchMachine(+os.dataset.openSaved); return go('#/sessions'); }
|
|
466
|
+
if (e.target.closest('[data-close]')) { stop(); return closeSheet(); }
|
|
467
|
+
if (e.target.closest('[data-scan]')) {
|
|
468
|
+
const v = $('#scanVideo', sh); v.hidden = false;
|
|
469
|
+
try { scan = await startScan(v, (text) => { const pair = pairFrom(text); if (pair) { stop(); closeSheet(); addMachine(pair); toast('Connectant…'); go('#/sessions'); } }); }
|
|
470
|
+
catch { v.hidden = true; toast('No puc obrir la càmera. Escriu el codi.'); }
|
|
471
|
+
}
|
|
472
|
+
};
|
|
473
|
+
sh.onsubmit = async (e) => {
|
|
474
|
+
if (e.target.id !== 'sshForm') return;
|
|
475
|
+
e.preventDefault(); e.stopPropagation();
|
|
476
|
+
runSsh($('#sshIn', sh).value.trim());
|
|
477
|
+
};
|
|
478
|
+
draw();
|
|
479
|
+
if (opts.auto && cur === 'vps') setTimeout(() => runSsh(opts.target), 50);
|
|
480
|
+
}, 'wiz');
|
|
481
|
+
}
|
|
482
|
+
function machinesSheet() {
|
|
483
|
+
const ms = store.machines;
|
|
484
|
+
openSheet(`<h2>Ordinadors</h2>${ms.length ? `<div class="stack">${ms.map((x, i) => `<button class="card row" type="button" data-machine="${i}" style="flex-wrap:nowrap"><span class="c-ico">${svg(machineIcon(x))}</span><span class="grow"><b>${esc(x.name)}</b><div class="muted small">${esc(machineSub(x))}</div></span>${i === store.active ? `<span class="chip ok"><i class="dot ${state.connected ? 'on' : ''}"></i>Actiu</span>` : ''}</button>`).join('')}</div>` : '<p class="muted small">Encara no hi ha cap ordinador.</p>'}
|
|
485
|
+
<div class="row between sheet-foot">${ms.length ? '<a class="btn ghost" href="#/ordinadors" data-close>Gestiona</a>' : '<button class="btn ghost" type="button" data-close>Tanca</button>'}<button class="btn ink" type="button" data-act="add-computer">${svg(ICON.plus)}Afegeix ordinador</button></div>`, (sh) => { $$('[data-close]', sh).forEach((b) => (b.onclick = closeSheet)); });
|
|
486
|
+
}
|
|
487
|
+
function moreSheet() {
|
|
488
|
+
openSheet(`<h2>Més</h2><div class="list">${['models', 'mobil', 'pissarra'].map((k) => `<a class="card row" href="${HASH[k]}" style="gap:12px">${svg(VIEWS[k].icon)}<b>${VIEWS[k].label}</b></a>`).join('')}<a class="card row" href="#/ordinadors" data-act="help" style="gap:12px"><svg class="ico" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M12 8v5M12 16v.1"/></svg><b>Com es fa</b></a><button class="card row" type="button" data-act="tour" style="gap:12px"><svg class="ico" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M9.6 9.3a2.5 2.5 0 0 1 4.8.9c0 1.7-2.4 2.2-2.4 3.6M12 17.2v.1"/></svg><b>Tutorial</b></button></div>`, (sh) => { sh.onclick = (e) => { if (e.target.closest('a, [data-act]')) closeSheet(); }; });
|
|
348
489
|
}
|
|
349
490
|
|
|
350
491
|
/* ---------- Sessions */
|
|
@@ -361,7 +502,7 @@ function viewSessions() {
|
|
|
361
502
|
const hint = mobileHint();
|
|
362
503
|
const head = `<div class="top"><div><h1>Sessions</h1><p>Cada sessió és una conversa amb un agent, dins d’una carpeta.</p>${switcher}</div><button class="btn ink" data-act="new" type="button">${svg(ICON.plus)}Nova sessió</button></div>${hint}`;
|
|
363
504
|
if (!state.connected && !list.length) {
|
|
364
|
-
return `${head}<div class="panel empty">${bot(state.lastError ? 'sleep' : 'work')}<h2>${esc(state.lastError || 'Connectant amb l’ordinador…')}</h2>${state.lastError ? `<p
|
|
505
|
+
return `${head}<div class="panel empty">${bot(state.lastError ? 'sleep' : 'work')}<h2>${esc(state.lastError || 'Connectant amb l’ordinador…')}</h2>${state.lastError ? `<p>${mach.kind === 'vps' ? 'Potser el servidor s’ha reiniciat i l’adreça ha canviat.' : 'Comprova que nio continua engegat a l’ordinador i que el túnel està obert.'}</p><div class="row" style="justify-content:center"><button class="btn" data-act="retry" type="button">${svg(ICON.refresh)}Torna-ho a provar</button>${mach.kind === 'vps' && mach.via ? `<button class="btn ink" data-reconnect="${store.active}" type="button">Torna a connectar</button>` : ''}<a class="btn ghost" href="#/ordinadors">Ordinadors</a></div>` : ''}</div>`;
|
|
365
506
|
}
|
|
366
507
|
return `${head}${list.length ? `<div class="list">${list.map((s) => `
|
|
367
508
|
<a class="card session-card" href="#/s/${esc(s.id)}">
|
|
@@ -573,7 +714,11 @@ async function newSessionSheet(preset = {}) {
|
|
|
573
714
|
let projects = [];
|
|
574
715
|
try { projects = await api('/api/projects'); } catch (e) { return toast(e.message); }
|
|
575
716
|
const first = agents.find((a) => a.available);
|
|
576
|
-
|
|
717
|
+
// Continuïtat: l'última tria d'aquest ordinador (agent, carpeta, permisos) i les carpetes recents.
|
|
718
|
+
const lastKey = `nio.last.${machine()?.id || machine()?.url}`, last = LS.get(lastKey, {});
|
|
719
|
+
const lastAgent = agents.find((a) => a.id === last.agent && a.available);
|
|
720
|
+
const sel = { agent: lastAgent?.id || first?.id || '', cwd: last.cwd || projects.find((p) => !p.root)?.path || projects[0]?.path || '', mode: last.mode || 'preguntar', model: '', text: '', ...preset };
|
|
721
|
+
const recents = (last.recent || []).filter((p) => p);
|
|
577
722
|
const STEPS = ['Agent', 'Carpeta', 'Opcions', 'Tasca'];
|
|
578
723
|
let step = preset.agent ? 1 : 0, fsd = null, gh = null, models = null;
|
|
579
724
|
const agentOf = () => agents.find((a) => a.id === sel.agent);
|
|
@@ -626,7 +771,7 @@ async function newSessionSheet(preset = {}) {
|
|
|
626
771
|
() => `<p class="wiz-q">Quin agent vols fer servir?</p><div class="picker">${agents.map((a) => `<button type="button" class="pick ${a.available ? '' : 'off'}" data-agent-id="${esc(a.id)}" aria-pressed="${a.id === sel.agent}"><div class="row between"><span class="agent-tag" data-agent="${esc(a.id)}">${esc(a.name)}</span>${a.beta ? '<span class="chip beta">beta</span>' : ''}</div><div class="sub">${esc(a.available ? a.version || 'Disponible' : 'No instal·lat')}</div></button>`).join('')}</div>`,
|
|
627
772
|
() => {
|
|
628
773
|
const crumbs = fsd ? fsd.path.split(/[\\/]/).filter(Boolean) : [];
|
|
629
|
-
const quick = projects.slice(0, 8).map((p) => `<button type="button" class="chip" data-fs="${esc(p.path)}">${esc(p.name)}</button>`).join('');
|
|
774
|
+
const quick = [...recents.map((p) => ({ path: p, name: basename(p) })), ...projects.filter((p) => !recents.includes(p.path))].slice(0, 8).map((p) => `<button type="button" class="chip" data-fs="${esc(p.path)}">${esc(p.name)}</button>`).join('');
|
|
630
775
|
return `<p class="wiz-q">A quina carpeta ha de treballar?</p>
|
|
631
776
|
<form class="pair-form" id="fsForm"><input type="text" id="fsIn" value="${esc(fsd?.path || sel.cwd)}" placeholder="/ruta/exacta/de/la/carpeta" autocomplete="off" autocapitalize="off" spellcheck="false" aria-label="Ruta de la carpeta"><button class="btn" type="submit">Obre</button></form>
|
|
632
777
|
<div class="fs-pick"><span class="muted small">Carpeta triada</span><b class="mono small">${esc(fsd?.path || sel.cwd || '—')}</b>${fsd?.git ? '<span class="chip">git</span>' : ''}</div>
|
|
@@ -650,7 +795,7 @@ async function newSessionSheet(preset = {}) {
|
|
|
650
795
|
const start = async () => {
|
|
651
796
|
if (!sel.text.trim()) { $('#firstMsg', sh)?.focus(); return toast('Explica a l’agent què ha de fer'); }
|
|
652
797
|
const btn = $('[data-start]', sh); btn.disabled = true;
|
|
653
|
-
try { const { text, ...rest } = sel; const s = await api('/api/sessions', { method: 'POST', body: { ...rest, text: text.trim() } }); state.sessions.set(s.id, s); closeSheet(); go(`#/s/${s.id}`); }
|
|
798
|
+
try { const { text, ...rest } = sel; const s = await api('/api/sessions', { method: 'POST', body: { ...rest, text: text.trim() } }); state.sessions.set(s.id, s); LS.set(lastKey, { agent: sel.agent, cwd: sel.cwd, mode: sel.mode, recent: [sel.cwd, ...recents.filter((p) => p !== sel.cwd)].slice(0, 6) }); closeSheet(); go(`#/s/${s.id}`); }
|
|
654
799
|
catch (e) { toast(e.message, 4200); btn.disabled = false; }
|
|
655
800
|
};
|
|
656
801
|
sh.onclick = (e) => {
|
|
@@ -715,24 +860,10 @@ function modeSheet(s) {
|
|
|
715
860
|
/* ---------- Ordinadors i mòbil */
|
|
716
861
|
const isLocalMachine = () => { const m = machine(); try { return !!m && ['127.0.0.1', 'localhost', '[::1]'].includes(new URL(m.url).hostname); } catch { return false; } };
|
|
717
862
|
function mobileHint() {
|
|
718
|
-
|
|
863
|
+
const m = machine(), key = `nio.mobHint.${m?.id || m?.url}`;
|
|
864
|
+
if (!m || isPhone() || !state.connected || LS.get(key, false)) return '';
|
|
719
865
|
return `<div class="panel hint"><div class="row between" style="flex-wrap:wrap;gap:12px"><div><b>Vols fer-lo servir al mòbil?</b><p class="muted small" style="margin-top:2px">Escaneja un codi i continues des del telèfon, amb les mateixes sessions.</p></div><div class="row"><button class="btn ghost" type="button" data-act="mobile-hint-off">Ara no</button><button class="btn ink" type="button" data-act="mobile-qr">Mostra el QR</button></div></div></div>`;
|
|
720
866
|
}
|
|
721
|
-
function machinesSheet() {
|
|
722
|
-
const ms = store.machines;
|
|
723
|
-
openSheet(`<h2>Ordinadors</h2><div class="stack">${ms.map((x, i) => `<button class="card row" type="button" data-machine="${i}" style="flex-wrap:nowrap"><i class="dot ${i === store.active && state.connected ? 'on' : ''}"></i><span class="grow"><b>${esc(x.name)}</b><div class="muted small mono" style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(x.url)}</div></span>${i === store.active ? '<span class="chip">Actiu</span>' : ''}</button>`).join('')}
|
|
724
|
-
<div class="row between sheet-foot"><button class="btn ghost" type="button" data-close>Tanca</button><button class="btn ink" type="button" data-act="add-machine">${svg(ICON.plus)}Connecta’n un altre</button></div></div>`, (sh) => { $('[data-close]', sh).onclick = closeSheet; });
|
|
725
|
-
}
|
|
726
|
-
function addMachineSheet() {
|
|
727
|
-
const cmd = (c) => `<div class="cmd big"><code>${esc(c)}</code><button class="btn sm" data-copy="${esc(c)}" type="button">${svg(ICON.copy)}Copia</button></div>`;
|
|
728
|
-
openSheet(`<h2>Connecta un altre ordinador</h2>
|
|
729
|
-
<div class="stack-lg">
|
|
730
|
-
<div class="stack"><b>Un ordinador</b><p class="muted small">Al terminal de l’altre ordinador, des de la carpeta dels projectes:</p>${cmd('npx nio-cat')}<p class="muted small">Escaneja el QR que surt, o enganxa aquí sota l’enllaç.</p></div>
|
|
731
|
-
<div class="stack"><b>Un servidor (VPS) per SSH</b><p class="muted small">Des de l’ordinador on tens aquest panell. No cal obrir cap port al servidor, només Node.js 18 o més nou:</p>${cmd('npx nio-cat ssh usuari@servidor')}<p class="muted small">Engega nio al servidor, obre un túnel SSH i et mostra el panell connectat.</p></div>
|
|
732
|
-
<div class="stack"><b>Un servidor amb adreça pública</b><p class="muted small">Si el servidor té una adreça amb HTTPS (Tailscale, Cloudflare o Caddy):</p>${cmd('npx nio-cat --url https://la-teva-adreça --no-open')}<p class="muted small">Deixa’l sota <span class="mono">tmux</span>, <span class="mono">pm2</span> o <span class="mono">systemd</span>. Copia aquí el codi que mostra.</p></div>
|
|
733
|
-
<form class="pair-form" id="pairForm"><input type="text" id="pairInput" placeholder="nio-… o enllaç" autocomplete="off" autocapitalize="off" spellcheck="false" required aria-label="Codi o enllaç de connexió"><button class="btn ink" type="submit">Connecta</button></form>
|
|
734
|
-
</div>`);
|
|
735
|
-
}
|
|
736
867
|
let qrTimer = 0;
|
|
737
868
|
function mobileQrSheet() {
|
|
738
869
|
clearTimeout(qrTimer);
|
|
@@ -741,11 +872,11 @@ function mobileQrSheet() {
|
|
|
741
872
|
let r; try { r = await api('/api/pair'); } catch (e) { box.innerHTML = `<p class="muted small">${esc(e.message)}</p>`; return; }
|
|
742
873
|
if (!$('#qrBox')) return;
|
|
743
874
|
if (!r.link) {
|
|
744
|
-
box.innerHTML =
|
|
875
|
+
box.innerHTML = n < 8 ? '<p class="muted small">Esperant el túnel…</p>' : `<p class="wiz-q">Falta una adreça per al mòbil</p><p class="muted small">Per connectar-t’hi des de qualsevol xarxa, instal·la <span class="mono">cloudflared</span> a l’ordinador i torna a engegar nio:</p>${cmdBox('brew install cloudflared')}<p class="muted small">Sense ell, el mòbil només es connecta per la mateixa wifi: engega nio amb <span class="mono">npx nio-cat --lan</span>.</p>`;
|
|
745
876
|
if (n < 8) qrTimer = setTimeout(() => paint(n + 1), 2000);
|
|
746
877
|
return;
|
|
747
878
|
}
|
|
748
|
-
box.innerHTML = `<div class="qr-box">${r.svg}</div><p class="muted small">Escaneja-ho amb la càmera del mòbil.${r.lan ? ' El mòbil ha d’estar a la mateixa wifi.' : ''}</p><div class="row"><button class="btn" type="button" data-copy="${esc(r.code)}">${svg(ICON.copy)}Copia el codi</button></div><p class="muted small">
|
|
879
|
+
box.innerHTML = `<div class="qr-box">${r.svg}</div><p class="muted small">Escaneja-ho amb la càmera del mòbil.${r.lan ? ' El mòbil ha d’estar a la mateixa wifi.' : ''}</p><div class="row"><button class="btn" type="button" data-copy="${esc(r.code)}">${svg(ICON.copy)}Copia el codi</button></div><p class="muted small">Al mòbil: www.nio.cat/app → Ordinadors → Afegeix ordinador. Escaneja el QR o enganxa el codi.</p>`;
|
|
749
880
|
};
|
|
750
881
|
openSheet(`<h2>Continua al mòbil</h2><div class="stack" id="qrBox"><p class="muted small">Preparant el codi…</p></div><div class="row sheet-foot"><button class="btn ghost" type="button" data-close>Tanca</button></div>`, (sh) => { $('[data-close]', sh).onclick = closeSheet; });
|
|
751
882
|
paint();
|
|
@@ -793,25 +924,6 @@ function sessionOptions(id) {
|
|
|
793
924
|
});
|
|
794
925
|
}
|
|
795
926
|
|
|
796
|
-
/* ---------- Ordinador */
|
|
797
|
-
function viewMachine() {
|
|
798
|
-
const m = machine(), info = state.info, ms = store.machines;
|
|
799
|
-
return `<div class="top"><div><h1>${esc(m.name)}</h1><p class="conn"><i class="dot ${state.connected ? 'on' : ''}"></i><span>${state.connected ? 'Connectat' : esc(state.lastError || 'Connectant…')}</span></p></div><div class="row"><button class="btn" data-act="refresh-agents" type="button">${svg(ICON.refresh)}Torna a detectar els agents</button><button class="btn ink" data-act="new" type="button">${svg(ICON.plus)}Nova sessió</button></div></div>
|
|
800
|
-
<div class="grid2">
|
|
801
|
-
<div class="stack">
|
|
802
|
-
<section class="panel"><header><h2>Agents</h2></header><div class="body">${info ? info.agents.map((a) => `<div class="agent-row"><i class="dot ${a.available ? 'on' : ''}"></i><div class="grow"><div class="row"><span class="agent-tag" data-agent="${esc(a.id)}">${esc(a.name)}</span>${a.beta ? '<span class="chip beta">beta</span>' : ''}</div><div class="muted small mono">${esc(a.available ? a.version || 'Disponible' : a.login)}</div></div></div>`).join('') : '<p class="muted">Sense connexió.</p>'}</div></section>
|
|
803
|
-
${info?.roots?.length ? `<section class="panel"><header><h2>Carpetes compartides</h2></header><div class="body mono small">${info.roots.map(esc).join('<br>')}</div></section>` : ''}
|
|
804
|
-
</div>
|
|
805
|
-
<div class="stack">
|
|
806
|
-
<section class="panel"><header><h2>Avisos al mòbil</h2></header><div class="body stack"><p class="small">Rep un avís quan un agent et demana permís o acaba la feina.</p>${isIOS && !standalone ? '<p class="muted small">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>' : ''}<div class="row"><button class="btn ink grow" data-act="push" type="button">Activa els avisos</button><button class="btn" data-act="push-test" type="button">Envia’n un de prova</button></div></div></section>
|
|
807
|
-
${'<section class="panel"><header><h2>Continua al mòbil</h2></header><div class="body stack"><p class="small">Escaneja un codi i obre aquest ordinador des del telèfon.</p><div class="row"><button class="btn ink grow" data-act="mobile-qr" type="button">Mostra el QR</button></div></div></section>'}
|
|
808
|
-
<section class="panel"><header><h2>Detalls</h2></header><div class="body"><dl class="kv"><dt>Adreça</dt><dd class="mono">${esc(m.url)}</dd>${info ? `<dt>Sistema</dt><dd>${esc(info.platform)} · ${esc(info.user)}</dd><dt>Versió</dt><dd>${esc(info.version)}</dd>` : ''}</dl></div></section>
|
|
809
|
-
<section class="panel"><header><h2>Ordinadors</h2></header><div class="body stack">${ms.map((x, i) => `<button class="card row" type="button" data-machine="${i}" style="flex-wrap:nowrap"><i class="dot ${i === store.active && state.connected ? 'on' : ''}"></i><span class="grow"><b>${esc(x.name)}</b><div class="muted small mono" style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(x.url)}</div></span>${i === store.active ? '<span class="chip">actiu</span>' : ''}</button>`).join('')}
|
|
810
|
-
<div class="row"><button class="btn" data-act="add-machine" type="button">${svg(ICON.plus)}Connecta’n un altre</button><button class="btn danger" data-act="forget" type="button">Oblida aquest</button></div></div></section>
|
|
811
|
-
</div>
|
|
812
|
-
</div>`;
|
|
813
|
-
}
|
|
814
|
-
|
|
815
927
|
/* ---------- Models d'Ollama */
|
|
816
928
|
const OLLAMA_PICKS = [
|
|
817
929
|
{ name: 'qwen3:4b', size: '2,5 GB', note: 'Ràpid i bo per a gairebé tot.' },
|
|
@@ -1030,7 +1142,7 @@ async function sendLocal() {
|
|
|
1030
1142
|
|
|
1031
1143
|
/* ---------------------------------------------------------------- accions */
|
|
1032
1144
|
document.addEventListener('click', async (e) => {
|
|
1033
|
-
const a = e.target.closest('[data-act], [data-copy], [data-decide], [data-machine], [data-lm]');
|
|
1145
|
+
const a = e.target.closest('[data-act], [data-copy], [data-decide], [data-machine], [data-forget], [data-reconnect], [data-lm]');
|
|
1034
1146
|
if (!a) return;
|
|
1035
1147
|
if (a.dataset.copy) {
|
|
1036
1148
|
try { await navigator.clipboard.writeText(a.dataset.copy); } catch { return toast(a.dataset.copy); }
|
|
@@ -1042,7 +1154,9 @@ document.addEventListener('click', async (e) => {
|
|
|
1042
1154
|
return;
|
|
1043
1155
|
}
|
|
1044
1156
|
if (a.dataset.decide) return decide(a.closest('[data-approval]'), a.dataset.decide);
|
|
1045
|
-
if (a.dataset.machine != null)
|
|
1157
|
+
if (a.dataset.machine != null) return switchMachine(Number(a.dataset.machine));
|
|
1158
|
+
if (a.dataset.reconnect != null) { const m = store.machines[Number(a.dataset.reconnect)]; return addComputerSheet('vps', { target: m.via, auto: true }); }
|
|
1159
|
+
if (a.dataset.forget != null) { if (confirm('Aquest dispositiu deixarà de tenir accés a aquest ordinador.')) forgetMachine(Number(a.dataset.forget)); return; }
|
|
1046
1160
|
if (a.dataset.lm) { if (local.loading) return; LS.set('nio.localModel', a.dataset.lm); return render(); }
|
|
1047
1161
|
switch (a.dataset.act) {
|
|
1048
1162
|
case 'new': return newSessionSheet();
|
|
@@ -1058,17 +1172,14 @@ document.addEventListener('click', async (e) => {
|
|
|
1058
1172
|
case 'refresh-agents': try { state.info = await api('/api/info?refresh=1'); render(); toast('Agents actualitzats'); } catch (err) { toast(err.message); } return;
|
|
1059
1173
|
case 'push': return enablePush();
|
|
1060
1174
|
case 'push-test': try { const r = await api('/api/push/test', { method: 'POST' }); toast(r.subs ? 'Enviat. Arribarà en uns segons.' : 'Primer activa els avisos'); } catch (err) { toast(err.message); } return;
|
|
1061
|
-
case 'add-
|
|
1175
|
+
case 'add-computer': return addComputerSheet();
|
|
1176
|
+
case 'open-sessions': return go('#/sessions');
|
|
1177
|
+
case 'help': go('#/ordinadors'); setTimeout(() => { const h = $('#help'); h?.scrollIntoView({ block: 'start' }); $('details', h)?.setAttribute('open', ''); }, 60); return;
|
|
1178
|
+
case 'more': return moreSheet();
|
|
1062
1179
|
case 'machines': return machinesSheet();
|
|
1063
1180
|
case 'mobile-qr': return mobileQrSheet();
|
|
1064
|
-
case 'mobile-hint-off': LS.set(
|
|
1181
|
+
case 'mobile-hint-off': { const m = machine(); LS.set(`nio.mobHint.${m?.id || m?.url}`, true); return render(); }
|
|
1065
1182
|
case 'git': return gitSheet(state.route.id);
|
|
1066
|
-
case 'forget': {
|
|
1067
|
-
if (!confirm('Aquest dispositiu deixarà de tenir accés a aquest ordinador.')) return;
|
|
1068
|
-
const list = store.machines; list.splice(store.active, 1); store.machines = list; store.active = 0;
|
|
1069
|
-
streamCtrl?.abort(); state.connected = false; state.info = null; state.sessions.clear(); state.approvals.clear();
|
|
1070
|
-
connect(); return render();
|
|
1071
|
-
}
|
|
1072
1183
|
case 'lm-load': return loadLocal(local.model || LS.get('nio.localModel', LOCAL_MODELS[0].id));
|
|
1073
1184
|
case 'lm-change': await local.engine?.unload?.(); local.engine = null; local.model = null; return render();
|
|
1074
1185
|
case 'lm-clear': local.messages = []; return render();
|
|
@@ -1091,7 +1202,9 @@ let palSel = 0;
|
|
|
1091
1202
|
function commands() {
|
|
1092
1203
|
const c = Object.entries(VIEWS).map(([k, v]) => ({ label: `Ves a ${v.label}`, hint: 'Navega', run: () => go(HASH[k]) }));
|
|
1093
1204
|
if (machine()) c.push({ label: 'Nova sessió', hint: 'Crea', run: newSessionSheet });
|
|
1205
|
+
c.push({ label: 'Afegeix un ordinador', hint: 'Crea', run: () => addComputerSheet() });
|
|
1094
1206
|
[...state.sessions.values()].slice(0, 20).forEach((s) => c.push({ label: s.title || 'Sessió nova', hint: AGENT_NAMES[s.agent] || s.agent, run: () => go(`#/s/${s.id}`) }));
|
|
1207
|
+
c.push({ label: 'Com es fa (ajuda)', hint: 'Ajuda', run: () => { go('#/ordinadors'); setTimeout(() => $('#help')?.scrollIntoView({ block: 'start' }), 60); } });
|
|
1095
1208
|
c.push({ label: 'Fes el tutorial', hint: 'Ajuda', run: tourStart });
|
|
1096
1209
|
return c;
|
|
1097
1210
|
}
|
|
@@ -1139,42 +1252,22 @@ $('#scrim').addEventListener('click', () => setNav(false));
|
|
|
1139
1252
|
$('#side').addEventListener('click', (e) => { if (e.target.closest('a, button')) setNav(false); });
|
|
1140
1253
|
addEventListener('keydown', (e) => { if (e.key === 'Escape' && document.body.classList.contains('nav-open')) setNav(false); });
|
|
1141
1254
|
mq.addEventListener('change', () => setNav(false));
|
|
1142
|
-
(() => {
|
|
1143
|
-
const side = $('#side'); let x0 = null, y0 = 0, dx = 0, mode = null;
|
|
1144
|
-
addEventListener('touchstart', (e) => {
|
|
1145
|
-
if (!mq.matches || $('#sheet').open || state.route.name === 'pissarra') return;
|
|
1146
|
-
const t = e.touches[0], open = document.body.classList.contains('nav-open');
|
|
1147
|
-
if (open || t.clientX < 22) { x0 = t.clientX; y0 = t.clientY; dx = 0; mode = open ? 'close' : 'open'; }
|
|
1148
|
-
}, { passive: true });
|
|
1149
|
-
addEventListener('touchmove', (e) => {
|
|
1150
|
-
if (x0 == null) return;
|
|
1151
|
-
const t = e.touches[0]; dx = t.clientX - x0;
|
|
1152
|
-
if (Math.abs(t.clientY - y0) > Math.abs(dx) && Math.abs(dx) < 12) { x0 = null; return; }
|
|
1153
|
-
const w = side.offsetWidth, pos = mode === 'open' ? Math.min(0, -w + Math.max(0, dx)) : Math.min(0, dx);
|
|
1154
|
-
side.classList.add('dragging'); side.style.transform = `translateX(${pos}px)`;
|
|
1155
|
-
}, { passive: true });
|
|
1156
|
-
addEventListener('touchend', () => {
|
|
1157
|
-
if (x0 == null) return;
|
|
1158
|
-
side.classList.remove('dragging'); side.style.transform = '';
|
|
1159
|
-
if (mode === 'open' && dx > 60) setNav(true); else if (mode === 'close' && dx < -60) setNav(false);
|
|
1160
|
-
x0 = null;
|
|
1161
|
-
});
|
|
1162
|
-
})();
|
|
1163
|
-
|
|
1164
1255
|
/* ---------------------------------------------------------------- tutorial */
|
|
1165
1256
|
const TOUR_KEY = 'nio.tour.v2';
|
|
1166
1257
|
const T = Object.assign({ on: false, i: 0, seen: false }, LS.get(TOUR_KEY, {}));
|
|
1167
1258
|
const saveTour = () => LS.set(TOUR_KEY, { on: T.on, i: T.i, seen: T.seen });
|
|
1168
1259
|
const TSTEPS = [
|
|
1169
|
-
{ center: true, mood: 'happy', title: 'Hola, sóc en Nio.', text: 'Faig de pont entre el teu mòbil i els agents d’IA del teu ordinador. En un minut t’ensenyo
|
|
1170
|
-
{ route: '
|
|
1171
|
-
{ route: '
|
|
1172
|
-
{ route: '
|
|
1173
|
-
{ route: '
|
|
1260
|
+
{ center: true, mood: 'happy', title: 'Hola, sóc en Nio.', text: 'Faig de pont entre el teu mòbil i els agents d’IA (Claude Code, Codex…) del teu ordinador o d’un servidor. En un minut t’ensenyo com es fa.', next: 'Comencem' },
|
|
1261
|
+
{ route: 'ordinadors', target: '#addComputer', m: '#addComputer', title: '1. Afegeix un ordinador', text: 'Hi ha tres maneres: aquest ordinador (<span class="mono">npx nio-cat</span>), un servidor per SSH (<span class="mono">usuari@servidor</span>) o un codi. nio fa la resta.' },
|
|
1262
|
+
{ route: 'ordinadors', skipIf: () => !machine(), target: '.card.computer.active .c-actions', m: '.card.computer.active .c-actions', title: '2. Continua al mòbil', text: 'A la targeta de l’ordinador prem «Continua al mòbil»: surt un QR i un codi. Al mòbil, obre www.nio.cat/app, Ordinadors, i escaneja el QR o enganxa el codi.' },
|
|
1263
|
+
{ route: 'sessions', skipIf: () => !machine(), target: '.top [data-act=new]', m: '.top [data-act=new]', title: '3. Dona feina a un agent', text: 'Una sessió és una conversa amb un agent dins d’una carpeta. Tries l’agent, la carpeta (qualsevol de l’ordinador), els permisos i li expliques què ha de fer.' },
|
|
1264
|
+
{ route: 'approvals', skipIf: () => !machine(), target: '.side nav a[href="#/aprovacions"]', m: '#tabbar a[href="#/aprovacions"]', title: '4. Tu decideixes', text: 'En mode «Demana permís», abans d’editar un fitxer o executar una ordre, l’agent t’ho pregunta aquí. Si actives els avisos, t’arriba al mòbil.' },
|
|
1265
|
+
{ route: 'models', skipIf: () => !machine(), target: '.side nav a[href="#/models"]', m: '#tabbar [data-act=more]', title: 'Models al teu ordinador', text: 'Si tens Ollama, aquí descarregues models i veus quins tens. Res no surt de casa i no gasta res.' },
|
|
1266
|
+
{ route: 'mobil', target: '.side nav a[href="#/xat-local"]', m: '.page .model-card', title: 'Un model sense internet', text: 'Si no tens l’ordinador a mà, pots fer servir un model petit que funciona dins del mateix mòbil.' },
|
|
1174
1267
|
{ route: 'pissarra', target: '.side nav a[href="#/pissarra"]', m: '#bdTools', title: 'Pensa en dibuix', text: 'A la pissarra pots fer diagrames a mà alçada o demanar-me’ls escrivint, amb Ollama o amb el model del mòbil.' },
|
|
1175
|
-
{ route: '
|
|
1176
|
-
{ target: '[data-act=palette]', m: '#
|
|
1177
|
-
{ center: true, mood: 'happy', title: 'Ja està tot a punt.', text: 'Si mai vols repassar-ho, tens el tutorial al
|
|
1268
|
+
{ route: 'ordinadors', target: '#help', m: '#help', title: 'Si alguna cosa no va', text: 'A «Com es fa» tens cada pas explicat, què cal al servidor i què mirar si no connecta.' },
|
|
1269
|
+
{ target: '[data-act=palette]', m: '#tabbar [data-act=more]', title: () => (mq.matches ? 'Més coses' : 'Un últim truc'), text: () => (mq.matches ? 'A «Més» tens els models, el xat local, la pissarra, l’ajuda i aquest tutorial.' : 'Prem ⌘K (o Ctrl+K) per anar a qualsevol lloc o obrir una sessió sense tocar el ratolí.') },
|
|
1270
|
+
{ center: true, mood: 'happy', title: 'Ja està tot a punt.', text: 'Si mai vols repassar-ho, tens el tutorial al menú i l’ajuda a Ordinadors.', next: 'Finalitza' }
|
|
1178
1271
|
];
|
|
1179
1272
|
const tval = (v) => (typeof v === 'function' ? v() : v);
|
|
1180
1273
|
function tourStart() { T.on = true; T.seen = true; tourGo(0); }
|
package/web/app/index.html
CHANGED
|
@@ -51,6 +51,7 @@
|
|
|
51
51
|
<nav id="nav" aria-label="Seccions"></nav>
|
|
52
52
|
<div class="foot">
|
|
53
53
|
<div class="conn" id="sideConn"></div>
|
|
54
|
+
<a class="kbd" href="#/ordinadors" data-act="help" title="Com es fa"><svg class="ico" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M12 8v5M12 16v.1"/></svg><span class="l">Com es fa</span></a>
|
|
54
55
|
<button class="kbd" data-act="tour" type="button" title="Tutorial"><svg class="ico" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M9.6 9.3a2.5 2.5 0 0 1 4.8.9c0 1.7-2.4 2.2-2.4 3.6M12 17.2v.1"/></svg><span class="l">Tutorial</span></button>
|
|
55
56
|
<button class="kbd" data-act="palette" type="button" title="Cerca o fes una acció (⌘K)"><svg class="ico" viewBox="0 0 24 24" aria-hidden="true"><circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5"/></svg><span class="l">Cerca o fes una acció</span><kbd>⌘K</kbd></button>
|
|
56
57
|
</div>
|
|
@@ -67,6 +68,7 @@
|
|
|
67
68
|
<div class="tour-spot" id="tourSpot"></div>
|
|
68
69
|
<div class="tour-card" id="tourCard" role="dialog" aria-modal="false" aria-labelledby="tourT"></div>
|
|
69
70
|
</div>
|
|
71
|
+
<nav class="tabbar" id="tabbar" aria-label="Navegació"></nav>
|
|
70
72
|
<div class="toasts" id="toasts" aria-live="polite"></div>
|
|
71
73
|
|
|
72
74
|
<script src="/bot.js"></script>
|
package/web/app/style.css
CHANGED
|
@@ -427,3 +427,45 @@ a.card, a.card:hover { text-decoration: none; color: inherit; }
|
|
|
427
427
|
.copied .tick { stroke-dasharray: 24; stroke-dashoffset: 24; animation: tick .38s var(--ease) forwards; }
|
|
428
428
|
@keyframes tick { to { stroke-dashoffset: 0; } }
|
|
429
429
|
@media (prefers-reduced-motion: reduce) { .copied .tick { animation: none; stroke-dashoffset: 0; } }
|
|
430
|
+
|
|
431
|
+
/* Ordinadors: targetes per nom, estat i accions; passos d'un servidor; barra inferior al mòbil */
|
|
432
|
+
.computers { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 12px; margin-bottom: 8px; }
|
|
433
|
+
.card.computer { display: grid; gap: 14px; cursor: default; }
|
|
434
|
+
.card.computer.active { border-color: var(--fg); box-shadow: inset 0 0 0 1px var(--fg); }
|
|
435
|
+
.c-ico { flex: none; width: 36px; height: 36px; border-radius: 10px; display: grid; place-items: center; background: var(--bg-2); }
|
|
436
|
+
.c-actions { gap: 8px; }
|
|
437
|
+
.chip.ok { color: var(--moss); }
|
|
438
|
+
.sec { font-size: 18px; font-weight: 500; letter-spacing: -.03em; margin: 28px 0 12px; }
|
|
439
|
+
.steps { list-style: none; display: grid; gap: 6px; font-size: 14px; }
|
|
440
|
+
.steps li { display: flex; gap: 8px; align-items: baseline; color: var(--fg-muted); }
|
|
441
|
+
.steps li::before { content: '✓'; color: var(--moss); font-weight: 700; }
|
|
442
|
+
.steps li.on { color: var(--fg); }
|
|
443
|
+
.steps li.on::before { content: '…'; color: var(--fg-muted); }
|
|
444
|
+
.steps li.err { color: var(--red); }
|
|
445
|
+
.steps li.err::before { content: '✕'; color: var(--red); }
|
|
446
|
+
video.scan { width: 100%; aspect-ratio: 1; object-fit: cover; border-radius: 14px; background: #000; }
|
|
447
|
+
.tabbar { display: none; }
|
|
448
|
+
@media (max-width: 820px) {
|
|
449
|
+
.burger { display: none; }
|
|
450
|
+
.tabbar { display: flex; position: fixed; left: 0; right: 0; bottom: 0; z-index: 30; padding: 6px 8px calc(6px + var(--safe-b)); background: color-mix(in srgb, var(--bg) 94%, transparent); -webkit-backdrop-filter: blur(14px); backdrop-filter: blur(14px); border-top: 1px solid var(--border); }
|
|
451
|
+
.tabbar > * { flex: 1; position: relative; display: grid; justify-items: center; gap: 2px; padding: 6px 0; border: 0; background: none; border-radius: 10px; font-size: 11px; font-weight: 600; color: var(--fg-muted); text-decoration: none; }
|
|
452
|
+
.tabbar [aria-current="page"] { color: var(--fg); }
|
|
453
|
+
.tabbar .badge { position: absolute; top: 2px; left: calc(50% + 6px); min-width: 16px; height: 16px; padding: 0 4px; border-radius: 8px; background: var(--accent); color: #fff; font-size: 10px; line-height: 16px; text-align: center; }
|
|
454
|
+
body:not(.no-tabs):not(.kb) .main { padding-bottom: calc(64px + var(--safe-b)); }
|
|
455
|
+
body.no-tabs .tabbar, body.kb .tabbar { display: none; }
|
|
456
|
+
.computers { grid-template-columns: 1fr; }
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/* Ajuda («Com es fa») */
|
|
460
|
+
.help { margin-top: 28px; }
|
|
461
|
+
.help details { border-bottom: 1px solid var(--border); }
|
|
462
|
+
.help details:last-of-type { border-bottom: 0; margin-bottom: 14px; }
|
|
463
|
+
.help summary { cursor: pointer; padding: 12px 0; font-weight: 600; list-style: none; display: flex; justify-content: space-between; gap: 12px; }
|
|
464
|
+
.help summary::-webkit-details-marker { display: none; }
|
|
465
|
+
.help summary::after { content: '+'; color: var(--fg-muted); font-weight: 400; }
|
|
466
|
+
.help details[open] summary::after { content: '−'; }
|
|
467
|
+
.help-body { display: grid; gap: 8px; padding: 0 0 14px; font-size: 14px; color: var(--fg-muted); }
|
|
468
|
+
.help-body ul { padding-left: 18px; display: grid; gap: 4px; }
|
|
469
|
+
.help-body b { color: var(--fg); font-weight: 600; }
|
|
470
|
+
.empty .stepper { text-align: left; max-width: 640px; margin: 16px auto 18px; }
|
|
471
|
+
a.kbd { text-decoration: none; color: inherit; }
|
package/web/index.html
CHANGED
|
@@ -107,9 +107,11 @@ main strong { font-weight: 600; }
|
|
|
107
107
|
|
|
108
108
|
<section aria-labelledby="com">
|
|
109
109
|
<h2 id="com">Com funciona</h2>
|
|
110
|
-
<p>Obre el terminal a la carpeta dels teus projectes i executa:</p>
|
|
110
|
+
<p><strong>1. Al teu ordinador.</strong> Obre el terminal a la carpeta dels teus projectes i executa:</p>
|
|
111
111
|
<div class="cmd"><code id="cmd">npx nio-cat</code><button class="copy" type="button" data-copy="npx nio-cat" aria-label="Copia l’ordre"><svg class="ic" viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2"/><path d="M16 8V5a1 1 0 0 0-1-1H5a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h3"/></svg><svg class="ok" viewBox="0 0 24 24" aria-hidden="true"><path d="M5 12.5l4.5 4.5L19 7.5"/></svg></button></div>
|
|
112
|
-
<p>
|
|
112
|
+
<p>S’obre nio.cat ja connectat. Només cal <a href="https://nodejs.org">Node.js</a>.</p>
|
|
113
|
+
<p><strong>2. Amb un servidor (opcional).</strong> A nio.cat, <em>Ordinadors → Afegeix ordinador → Servidor (SSH)</em>, escrius <code>usuari@servidor</code> i nio hi engega Claude Code i la resta. No cal obrir ports.</p>
|
|
114
|
+
<p><strong>3. Al mòbil.</strong> A l’ordinador, prem <em>Continua al mòbil</em>. Al mòbil, obre <a href="/app">nio.cat/app</a>, escaneja el QR o enganxa el codi, i ja ho controles. Des de qualsevol xarxa amb <code>cloudflared</code>; sense ell, per la mateixa wifi. Cap dada no passa per servidors nostres.</p>
|
|
113
115
|
</section>
|
|
114
116
|
|
|
115
117
|
<section aria-labelledby="limits">
|
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-v3';
|
|
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) => {
|