nio-cat 0.3.0 → 0.3.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 CHANGED
@@ -11,7 +11,7 @@ const { Hub } = require('../src/hub');
11
11
  const { createServer } = require('../src/server');
12
12
  const { detectAll } = require('../src/agents');
13
13
  const { startTunnel } = require('../src/tunnel');
14
- const { pairLink, terminalQr, writePairPage } = require('../src/pair');
14
+ const { pairLink, terminalQr, writePairPage, makeCode } = require('../src/pair');
15
15
  const { which } = require('../src/proc');
16
16
 
17
17
  const argv = process.argv.slice(2);
@@ -81,13 +81,14 @@ function openDashboard() {
81
81
  else console.log(` ${c.dim('Panell:')} ${link}`);
82
82
  }
83
83
 
84
- function showPair(publicUrl) {
85
- const link = pairLink(flags.app || cfg.app, publicUrl, cfg.token);
86
- const file = writePairPage(link);
84
+ async function showPair(publicUrl) {
85
+ const link = pairLink(flags.app || cfg.app, publicUrl, cfg.token), code = makeCode(publicUrl, cfg.token);
86
+ const file = writePairPage(link, code);
87
87
  console.log(`\n ${c.b('Escaneja aquest codi amb el mòbil')} ${c.dim('(o obre ' + file + ')')}\n`);
88
88
  console.log(terminalQr(link));
89
- console.log(`\n ${c.dim('Enllaç:')} ${link}\n`);
90
- console.log(c.dim(' Aquest enllaç dona accés als teus agents. Guarda\'l com una contrasenya.\n'));
89
+ console.log(`\n ${c.b('Codi:')} ${code}`);
90
+ console.log(c.dim(' Copia\'l i enganxa\'l a Sessions → Tria un ordinador.'));
91
+ console.log(c.dim(' Aquest codi dona accés als teus agents. Guarda\'l com una contrasenya.\n'));
91
92
  if (flags.open) {
92
93
  const opener = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
93
94
  try {
@@ -98,7 +99,6 @@ function showPair(publicUrl) {
98
99
  }
99
100
  }
100
101
 
101
-
102
102
  // nio ssh usuari@servidor: engega nio al servidor (si no hi és), obre un túnel SSH i obre el panell ja connectat.
103
103
  const shq = (v) => `'${String(v).replace(/'/g, `'\\''`)}'`;
104
104
  function remoteScript(port, dir) {
@@ -260,7 +260,7 @@ async function start() {
260
260
  console.log(' Encara no hi ha cap adreça desada. Engega nio primer.');
261
261
  break;
262
262
  }
263
- showPair(flags.url || cfg.lastUrl);
263
+ await showPair(flags.url || cfg.lastUrl);
264
264
  break;
265
265
  case 'reset-token':
266
266
  cfg.token = crypto.randomBytes(24).toString('base64url');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nio-cat",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "description": "nio: fes servir Claude Code, Codex, Grok, Antigravity i Ollama del teu ordinador des del mòbil.",
5
5
  "license": "MIT",
6
6
  "author": "f.cat",
package/src/pair.js CHANGED
@@ -12,6 +12,18 @@ function pairLink(app, url, token) {
12
12
  return `${app.replace(/\/$/, '')}/app#u=${u}&t=${encodeURIComponent(token)}`;
13
13
  }
14
14
 
15
+ // Codi de connexió: l'adreça i la clau dins d'una sola cadena («nio-…»). No cal cap servidor.
16
+ // La web ja sap posar-hi l'enllaç de davant. Les adreces de Cloudflare es comprimeixen (≈ 60 caràcters).
17
+ const CF = '.trycloudflare.com';
18
+ function makeCode(url, token) {
19
+ const raw = Buffer.from(token, 'base64url');
20
+ const sub = url.startsWith('https://') && url.endsWith(CF) ? url.slice(8, -CF.length) : null;
21
+ const bytes = raw.length === 24 && raw.toString('base64url') === token && sub && /^[\w-]+$/.test(sub)
22
+ ? Buffer.concat([Buffer.from([1]), raw, Buffer.from(sub)])
23
+ : Buffer.concat([Buffer.from([0]), Buffer.from(`${url}\n${token}`)]);
24
+ return `nio-${bytes.toString('base64url')}`;
25
+ }
26
+
15
27
  function qr(text) {
16
28
  const q = qrcode(0, 'L');
17
29
  q.addData(text);
@@ -19,34 +31,49 @@ function qr(text) {
19
31
  return q;
20
32
  }
21
33
 
22
- // QR per al terminal: dues files de mòduls per línia amb mitjos blocs, i colors fixos
23
- // (negre sobre blanc) perquè la càmera el llegeixi igual amb tema clar o fosc.
34
+ // QR per al terminal: cada mòdul són dos espais amb color de fons (negre o blanc). No depèn de cap glif,
35
+ // així que no es talla amb tipus de lletra o interlineats estranys. Sempre negre sobre blanc, amb tema clar o fosc.
24
36
  function terminalQr(text, { color = !process.env.NO_COLOR } = {}) {
25
37
  const q = qr(text);
26
38
  const n = q.getModuleCount();
27
39
  const M = 2; // marge blanc al voltant, en mòduls
28
40
  const dark = (r, c) => r >= 0 && c >= 0 && r < n && c < n && q.isDark(r, c);
29
41
  const lines = [];
30
- for (let r = -M; r < n + M; r += 2) {
31
- let line = '';
42
+ for (let r = -M; r < n + M; r++) {
43
+ let line = '', prev = null;
32
44
  for (let c = -M; c < n + M; c++) {
33
- const top = dark(r, c);
34
- const bot = r + 1 < n + M && dark(r + 1, c);
35
- if (color) line += top && bot ? '█' : top ? '▀' : bot ? '▄' : ' ';
36
- else line += top && bot ? ' ' : top ? '▄' : bot ? '▀' : '█';
45
+ const d = dark(r, c);
46
+ if (color) {
47
+ if (d !== prev) line += d ? '\x1b[40m' : '\x1b[107m';
48
+ line += ' ';
49
+ } else line += d ? ' ' : '██';
50
+ prev = d;
37
51
  }
38
- lines.push(color ? ` \x1b[30;107m${line}\x1b[0m` : ` ${line}`);
52
+ lines.push(` ${line}${color ? '\x1b[0m' : ''}`);
39
53
  }
40
54
  return lines.join('\n');
41
55
  }
42
56
 
57
+ // SVG propi: una sola forma amb tires horitzontals de mòduls, vora de 4 mòduls i vores nítides.
58
+ // (El de la llibreria dibuixa cada mòdul per separat i, en escalar-lo, deixa línies fines entre mòduls.)
43
59
  function qrSvg(text) {
44
- return qr(text).createSvgTag({ cellSize: 6, margin: 4, scalable: true });
60
+ const q = qr(text), n = q.getModuleCount(), M = 4;
61
+ let d = '';
62
+ for (let r = 0; r < n; r++) {
63
+ for (let c = 0; c < n; c++) {
64
+ if (!q.isDark(r, c)) continue;
65
+ let w = 1;
66
+ while (c + w < n && q.isDark(r, c + w)) w++;
67
+ d += `M${c + M} ${r + M}h${w}v1h-${w}z`;
68
+ c += w - 1;
69
+ }
70
+ }
71
+ const size = n + M * 2;
72
+ return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${size} ${size}" shape-rendering="crispEdges" role="img" aria-label="Codi QR"><rect width="${size}" height="${size}" fill="#fff"/><path d="${d}" fill="#000"/></svg>`;
45
73
  }
46
74
 
47
- function writePairPage(link) {
48
- const q = qr(link);
49
- const svg = q.createSvgTag({ cellSize: 6, margin: 4, scalable: true });
75
+ function writePairPage(link, code) {
76
+ const svg = qrSvg(link);
50
77
  const html = `<!doctype html><html lang="ca"><head><meta charset="utf-8">
51
78
  <meta name="viewport" content="width=device-width,initial-scale=1"><title>nio // connecta el mòbil</title>
52
79
  <link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600&display=swap" rel="stylesheet">
@@ -55,11 +82,10 @@ main{width:min(420px,100% - 40px);padding:40px 0}h1{font-size:30px;font-weight:5
55
82
  .qr{background:#fffefd;border:1px solid #cec7bd;padding:18px;border-radius:16px;margin:24px 0}.qr svg{width:100%;height:auto;display:block}
56
83
  code{display:block;word-break:break-all;font:12px/1.5 ui-monospace,Menlo,monospace;background:#e6e1d8;padding:12px 14px;border-radius:12px;color:#272224}small{display:block;margin-top:20px;font-size:13px;color:#69615c}</style></head>
57
84
  <body><main><h1>Connecta el mòbil</h1><p>Escaneja el codi amb la càmera. S’obrirà nio ja connectat a aquest ordinador.</p><div class="qr">${svg}</div>
58
- <p>Si fas servir nio instal·lat a la pantalla d’inici, copia aquest enllaç i enganxa’l a <b>Connecta un ordinador</b>:</p><code>${link.replace(/&/g, '&amp;')}</code>
59
- <small>Aquest enllaç dona accés als teus agents. Guarda’l com una contrasenya.</small></main></body></html>`;
85
+ <p>O copia aquest codi i enganxa'l a <b>Sessions → Tria un ordinador</b>:</p><code>${code}</code><small>Aquest codi dona accés als teus agents. Guarda'l com una contrasenya.</small></main></body></html>`;
60
86
  const file = path.join(HOME, 'pair.html');
61
87
  fs.writeFileSync(file, html, { mode: 0o600 });
62
88
  return file;
63
89
  }
64
90
 
65
- module.exports = { pairLink, terminalQr, writePairPage, qrSvg };
91
+ module.exports = { pairLink, terminalQr, writePairPage, qrSvg, makeCode };
package/src/server.js CHANGED
@@ -10,7 +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 { pairLink, qrSvg } = require('./pair');
13
+ const { pairLink, qrSvg, makeCode } = require('./pair');
14
14
 
15
15
  // L'app web: copiada dins del paquet en publicar-lo, o l'arrel del repositori en desenvolupament.
16
16
  const WEB_DIRS = [path.join(__dirname, '..', 'web'), path.join(__dirname, '..', '..')];
@@ -232,7 +232,7 @@ function createServer({ cfg, hub, getPublicUrl, getApp }) {
232
232
  const pub = getPublicUrl();
233
233
  if (!pub || /^https?:\/\/(127\.0\.0\.1|localhost|\[::1\])(:|\/|$)/i.test(pub)) return { link: null };
234
234
  const link = pairLink(getApp ? getApp() : cfg.app, pub, cfg.token);
235
- return { link, svg: qrSvg(link), url: pub, lan: /^http:\/\//.test(pub) };
235
+ return { link, code: makeCode(pub, cfg.token), svg: qrSvg(link), url: pub, lan: /^http:\/\//.test(pub) };
236
236
  }
237
237
  if (p === '/api/github' && method === 'GET') return github.status();
238
238
  if (p === '/api/github/repos' && method === 'GET') return github.repos(url.searchParams.get('owner') || '');
package/web/app/app.js CHANGED
@@ -191,6 +191,19 @@ function parsePair(text) {
191
191
  if (!url || !token) return null;
192
192
  return { url: url.replace(/\/$/, ''), token };
193
193
  }
194
+ // Codi «nio-…»: porta l'adreça i la clau dins (vegeu makeCode al programa). Es descodifica aquí, sense servidor.
195
+ const CF = '.trycloudflare.com';
196
+ function parseCode(text) {
197
+ const m = String(text || '').replace(/\s+/g, '').match(/^nio-([\w-]+)$/i); if (!m) return null;
198
+ try {
199
+ const bin = atob(m[1].replace(/-/g, '+').replace(/_/g, '/') + '='.repeat((4 - (m[1].length % 4)) % 4)), u = Uint8Array.from(bin, (c) => c.charCodeAt(0));
200
+ if (u[0] === 1) return { url: `https://${new TextDecoder().decode(u.slice(25))}${CF}`, token: btoa(String.fromCharCode(...u.slice(1, 25))).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') };
201
+ if (u[0] === 0) { const [url, token] = new TextDecoder().decode(u.slice(1)).split('\n'); if (url && token) return { url: url.replace(/\/$/, ''), token }; }
202
+ } catch { /* codi malmès */ }
203
+ return null;
204
+ }
205
+ // Un codi o un enllaç de connexió (#u=…&t=…).
206
+ const pairFrom = (text) => parsePair(text) || parseCode(text);
194
207
  function addMachine({ url, token, name }) {
195
208
  const ms = store.machines; let i = ms.findIndex((m) => m.url === url);
196
209
  if (i >= 0) ms[i].token = token; else { ms.push({ url, token, name: name || new URL(url).hostname }); i = ms.length - 1; }
@@ -285,9 +298,9 @@ function viewNoMachine(what) {
285
298
  <div class="panel empty">${bot('sleep')}<h2>Sembla que no hi ha cap sessió iniciada</h2><p>Crea’n una de nova a la pàgina Sessions. Allà connectes l’ordinador i hi dones feina.</p><a class="btn ink" href="#/sessions">Ves a Sessions</a></div>`;
286
299
  }
287
300
  function pasteForm(open) {
288
- return `<details class="paste" ${open ? 'open' : ''}><summary>Ja tens l’enllaç de connexió?</summary>
289
- <form class="pair-form" id="pairForm"><input type="url" id="pairInput" placeholder="https://www.nio.cat/app#u=…&t=…" autocomplete="off" autocapitalize="off" spellcheck="false" required aria-label="Enllaç de connexió"><button class="btn ink" type="submit">Connecta</button></form>
290
- <p class="muted small">Surt al terminal, sota el codi QR.</p></details>`;
301
+ return `<details class="paste" ${open ? 'open' : ''}><summary>Tens un codi?</summary>
302
+ <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>
303
+ <p class="muted small">Surt al terminal, sota el codi QR. Copia’l sencer.</p></details>`;
291
304
  }
292
305
  function viewConnect() {
293
306
  const CMD = 'npx nio-cat', 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>`;
@@ -312,7 +325,7 @@ function viewConnect() {
312
325
  <p class="small">Al terminal, des de la carpeta dels teus projectes:</p>${cmd(CMD)}
313
326
  <p class="muted small">S’obre aquest panell ja connectat. Cal Node.js 18 o més nou.</p></div></section>
314
327
  <section class="panel choice"><header><h2>Un altre ordinador</h2></header><div class="body stack">
315
- <p class="small">Engega nio a l’altre ordinador o a un servidor. Copia l’enllaç que mostra i enganxa’l aquí.</p>
328
+ <p class="small">Engega nio a l’altre ordinador o a un servidor. Copia aquí el codi que mostra.</p>
316
329
  ${pasteForm(true)}
317
330
  <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')}
318
331
  <p class="muted small">Engega nio al servidor, obre el túnel i el panell queda connectat.</p></div></section>
@@ -674,8 +687,8 @@ function addMachineSheet() {
674
687
  <div class="stack-lg">
675
688
  <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>
676
689
  <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>
677
- <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 l’enllaç que mostra i enganxa’l aquí.</p></div>
678
- <form class="pair-form" id="pairForm"><input type="url" id="pairInput" placeholder="https://…/app#u=…&t=…" autocomplete="off" autocapitalize="off" spellcheck="false" required aria-label="Enllaç de connexió"><button class="btn ink" type="submit">Connecta</button></form>
690
+ <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>
691
+ <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>
679
692
  </div>`);
680
693
  }
681
694
  let qrTimer = 0;
@@ -690,7 +703,7 @@ function mobileQrSheet() {
690
703
  if (n < 8) qrTimer = setTimeout(() => paint(n + 1), 2000);
691
704
  return;
692
705
  }
693
- 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><button class="btn" type="button" data-copy="${esc(r.link)}">${svg(ICON.copy)}Copia l’enllaç</button>`;
706
+ 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">Si no pots escanejar, enganxa el codi a Sessions → Tria un ordinador.</p>`;
694
707
  };
695
708
  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; });
696
709
  paint();
@@ -1017,8 +1030,8 @@ document.addEventListener('submit', (e) => {
1017
1030
  if (e.target.id === 'pullForm') { e.preventDefault(); return pullModel($('#pullIn').value); }
1018
1031
  if (e.target.id !== 'pairForm') return;
1019
1032
  e.preventDefault();
1020
- const pair = parsePair($('#pairInput', e.target).value);
1021
- if (!pair) return toast('Aquest enllaç no és de connexió');
1033
+ const pair = pairFrom($('#pairInput', e.target).value);
1034
+ if (!pair) return toast('Aquest codi no és de connexió. Copia’l sencer del terminal.');
1022
1035
  closeSheet(); addMachine(pair); toast('Connectant…'); go('#/sessions');
1023
1036
  });
1024
1037
 
@@ -1211,6 +1224,11 @@ const qs = new URLSearchParams(location.search).get('s');
1211
1224
  if (qs) history.replaceState(null, '', location.pathname + `#/s/${qs}`);
1212
1225
  // Les dades del panell antic (simulació d'agents d'operacions) ja no es fan servir.
1213
1226
  try { localStorage.removeItem('nio.panell.v1'); localStorage.removeItem('nio.tour.v1'); } catch { /* res */ }
1227
+ // A la còpia local (127.0.0.1, localhost) la icona té el fons groc suau, per distingir-la de www.nio.cat.
1228
+ if (['127.0.0.1', 'localhost', '[::1]'].includes(location.hostname)) {
1229
+ const ico = document.querySelector('link[rel="icon"]');
1230
+ if (ico) ico.href = 'data:image/svg+xml,' + encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="8" fill="#f6e9a0"/><circle cx="16" cy="16" r="10.2" fill="none" stroke="#272224" stroke-width="3.6"/><rect x="11.9" y="12.6" width="2.6" height="6" rx="1.3" fill="#272224"/><rect x="17.5" y="12.6" width="2.6" height="6" rx="1.3" fill="#272224"/></svg>');
1231
+ }
1214
1232
  setNav(false);
1215
1233
  if (!checkPairHash()) state.route = parseRoute();
1216
1234
  if (machine()) connect();
package/web/app/style.css CHANGED
@@ -417,3 +417,5 @@ dialog.sheet.wiz .sheet-in { padding: 0; gap: 0; flex: 1; min-height: 0; display
417
417
  }
418
418
  #fsForm { display: flex; gap: 8px; }
419
419
  #fsForm input { flex: 1; min-width: 0; }
420
+ .pair-code { display: grid; gap: 4px; text-align: center; }
421
+ .pair-code b { font-size: 30px; letter-spacing: .14em; font-weight: 600; cursor: copy; }