@danieltmn/openbridge 0.5.1 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,31 @@ Todos los cambios relevantes de OpenBridge. Formato basado en
4
4
  [Keep a Changelog](https://keepachangelog.com/es-ES/1.1.0/) y
5
5
  [Versionado Semantico](https://semver.org/lang/es/).
6
6
 
7
+ ## [0.6.0] - 2026-09-14
8
+
9
+ ### Agregado
10
+
11
+ - **Hub PHP** para correr la app en un hosting (cPanel) con URL fija, sin tunel
12
+ (`php/app` + `scripts/build-php-hub.mjs`; comparte el frontend con el hub Node).
13
+ Auth multiusuario con scrypt compatible con Node (usa `sodium`; si falta, scrypt
14
+ puro en PHP), roles, cookie de sesion firmada, rate limit y CSP.
15
+ - **Emparejamiento por codigo** (`openbridge pair <url>`): la PC muestra un codigo,
16
+ el usuario lo ingresa en la web (Dispositivos) y la PC recibe su **token propio**
17
+ y revocable. Cada usuario ve solo sus PCs; el admin, todas.
18
+ - Comando `openbridge pair` y vista **Dispositivos** en la web (solo en el hub PHP,
19
+ gateada por `features.pairing`).
20
+ - `docs/DEPLOY-PHP.md`: guia de despliegue del hub PHP.
21
+
22
+ ## [0.5.2] - 2026-09-14
23
+
24
+ ### Corregido
25
+
26
+ - Diagnostico de puerto ocupado: `status` y `server` muestran que **PID** usa el
27
+ puerto (suele ser otra casa con otro `--dir`) y, si el arranque en segundo plano
28
+ muere, se muestran las ultimas lineas de `server.log`.
29
+ - `store`: reintentos con backoff en el `rename` atomico (EPERM/EACCES/EBUSY en
30
+ Windows) y limpieza del `.tmp` si no se puede.
31
+
7
32
  ## [0.5.1] - 2026-09-14
8
33
 
9
34
  ### Corregido
package/README.md CHANGED
@@ -91,6 +91,7 @@ iniciar sesión** una vez; los chats, carpetas, túnel y push se conservan.
91
91
  | `openbridge logs` | Logs (`--follow`, `--server`, `--bridge`) |
92
92
  | `openbridge bridge` | Corre **solo** el puente (`--api --token --id --name`) |
93
93
  | `openbridge join` | Vincula esta PC como puente de un hub (`<url> --token --id --name`) |
94
+ | `openbridge pair` | Empareja esta PC con un **hub PHP** por código (`<url> [--id --name]`) |
94
95
  | `openbridge import` | Trae `data/` de OpenConex (`<data-dir> [--force]`) |
95
96
  | `openbridge reset` | Borra chats/datos (`--session <id>`, `--yes`) |
96
97
  | `openbridge autostart` | Arranque automático (`install`/`remove`) |
@@ -144,6 +145,23 @@ Ejemplo concreto:
144
145
  El puente remoto solo necesita salida a internet hacia el hub; no abre puertos ni
145
146
  túnel propio. Para que arranque solo en cada PC: `openbridge autostart install`.
146
147
 
148
+ ## Hub en hosting PHP (URL fija, sin túnel)
149
+
150
+ Además del hub Node, OpenBridge puede correr su hub en un **hosting PHP** (cPanel),
151
+ igual que OpenConex, y dejar que cada PC corra solo el puente:
152
+
153
+ ```
154
+ [Celular] --HTTPS--> openbridge.tamnora.com (hub PHP) --> [PC] openbridge pair
155
+ ```
156
+
157
+ - Web siempre arriba, **URL fija** y PWA/Web Push estables; sin túneles ni puertos.
158
+ - Cada PC se vincula con un **código**: en la PC `openbridge pair <url>` muestra un
159
+ código y en la web lo ingresás en **Dispositivos -> Agregar PC**. Cada PC queda
160
+ con su **token propio** (revocable); cada usuario ve solo sus PCs (el admin, todas).
161
+ - El frontend es el mismo del hub Node (se copia, no se duplica).
162
+
163
+ Guía completa (requisitos, build, subida y emparejamiento): [`docs/DEPLOY-PHP.md`](docs/DEPLOY-PHP.md).
164
+
147
165
  ## Túnel y URL estable
148
166
 
149
167
  Proveedores (`--tunnel` o `openbridge tunnel <prov>`): `tunnelmole` (gratis, sin
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danieltmn/openbridge",
3
- "version": "0.5.1",
3
+ "version": "0.6.0",
4
4
  "description": "Tu opencode en el celular, sin hosting: corre la app, el puente y un tunel publico desde tu PC.",
5
5
  "type": "commonjs",
6
6
  "author": "tamnora",
package/src/cli.js CHANGED
@@ -105,6 +105,70 @@ function pidAlive(pid) {
105
105
  try { process.kill(pid, 0); return true; } catch (e) { return false; }
106
106
  }
107
107
 
108
+ // PIDs escuchando en un puerto TCP (netstat en Windows; lsof en el resto). Sirve
109
+ // para explicar un EADDRINUSE: normalmente es OTRA casa (otro --dir) con el
110
+ // mismo puerto por defecto.
111
+ function portOwnerPids(port) {
112
+ try {
113
+ const win = process.platform === 'win32';
114
+ const r = spawnSync(win ? 'netstat' : 'lsof',
115
+ win ? ['-ano', '-p', 'tcp'] : ['-t', '-i', 'tcp:' + port, '-s', 'tcp:listen'],
116
+ { encoding: 'utf8', windowsHide: true, timeout: 15000 });
117
+ if (r.error || !r.stdout) return [];
118
+ const pids = new Set();
119
+ if (!win) {
120
+ for (const n of String(r.stdout).split(/\s+/)) {
121
+ const p = parseInt(n, 10);
122
+ if (p > 0) pids.add(p);
123
+ }
124
+ return [...pids];
125
+ }
126
+ const re = new RegExp(':' + port + '\\s');
127
+ for (const line of String(r.stdout).split(/\r?\n/)) {
128
+ if (!re.test(line) || !/LISTENING/i.test(line)) continue;
129
+ const parts = line.trim().split(/\s+/);
130
+ const pid = parseInt(parts[parts.length - 1], 10);
131
+ if (pid > 0) pids.add(pid);
132
+ }
133
+ return [...pids];
134
+ } catch (e) {
135
+ return [];
136
+ }
137
+ }
138
+
139
+ // Nombre del proceso de un PID (best-effort) para el diagnostico del puerto.
140
+ function describePid(pid) {
141
+ try {
142
+ if (process.platform === 'win32') {
143
+ const r = spawnSync('tasklist', ['/FI', 'PID eq ' + pid, '/FO', 'CSV', '/NH'], { encoding: 'utf8', windowsHide: true, timeout: 8000 });
144
+ const m = String(r.stdout || '').match(/^"([^"]+)"/m);
145
+ return m ? m[1] : '';
146
+ }
147
+ const r = spawnSync('ps', ['-p', String(pid), '-o', 'comm='], { encoding: 'utf8', windowsHide: true, timeout: 8000 });
148
+ return String(r.stdout || '').trim();
149
+ } catch (e) {
150
+ return '';
151
+ }
152
+ }
153
+
154
+ // "PID 123 (node.exe)" para el proceso que ocupa el puerto, o '' si no hay.
155
+ function portHint(port) {
156
+ const pids = portOwnerPids(port);
157
+ if (!pids.length) return '';
158
+ return pids.map((p) => 'PID ' + p + (describePid(p) ? ' (' + describePid(p) + ')' : '')).join(', ');
159
+ }
160
+
161
+ // Ultimas `n` lineas no vacias de un archivo (para mostrar el error real del
162
+ // server cuando arranca en segundo plano y muere).
163
+ function tailLines(file, n) {
164
+ try {
165
+ const lines = fs.readFileSync(file, 'utf8').split(/\r?\n/).filter((l) => l.trim() !== '');
166
+ return lines.slice(-n).join('\n');
167
+ } catch (e) {
168
+ return '';
169
+ }
170
+ }
171
+
108
172
  function runtimePath() { return paths.p('runtime.json'); }
109
173
  function readRuntime() {
110
174
  try { return JSON.parse(fs.readFileSync(runtimePath(), 'utf8')); } catch (e) { return null; }
@@ -328,6 +392,12 @@ async function cmdServer(argv) {
328
392
  await printStatus();
329
393
  if (!rt) {
330
394
  console.log('');
395
+ const tail = tailLines(paths.serverLogPath(), 8);
396
+ if (tail) {
397
+ console.log('Ultimas lineas de ' + paths.serverLogPath() + ':');
398
+ console.log(tail);
399
+ console.log('');
400
+ }
331
401
  console.log('No pude confirmar el arranque. Revisa: openbridge logs');
332
402
  }
333
403
  return 0;
@@ -349,6 +419,12 @@ async function cmdServer(argv) {
349
419
  } catch (e) {
350
420
  if (e && e.code === 'EADDRINUSE') {
351
421
  log.error('puerto ' + port + ' ocupado (¿ya corre OpenBridge?). Usa --port <otro> o `openbridge stop`.');
422
+ const hint = portHint(port);
423
+ if (hint) {
424
+ console.error(' lo esta usando: ' + hint);
425
+ console.error(' puede ser otra casa (otro --dir): proba `openbridge stop --dir <esa-casa>`'
426
+ + (process.platform === 'win32' ? ' o `taskkill /PID ' + portOwnerPids(port)[0] + ' /T /F`.' : '.'));
427
+ }
352
428
  return 1;
353
429
  }
354
430
  throw e;
@@ -443,6 +519,14 @@ async function printStatus() {
443
519
  if (paths.exists()) {
444
520
  console.log(' carpeta: ' + paths.baseDir());
445
521
  console.log(' datos : ' + paths.home());
522
+ try {
523
+ const app = config.readApp();
524
+ const hint = portHint(app.port);
525
+ if (hint) {
526
+ console.log(' aviso : el puerto ' + app.port + ' ya lo usa ' + hint);
527
+ console.log(' suele ser otra casa (otro --dir); cerrala con `openbridge stop --dir <esa-casa>` o usa --port <otro>.');
528
+ }
529
+ } catch (e) { /* sin app.json no hay puerto que mirar */ }
446
530
  }
447
531
  return false;
448
532
  }
@@ -666,6 +750,101 @@ async function cmdJoin(argv) {
666
750
  return cmdBridge([]);
667
751
  }
668
752
 
753
+ // ---------------------------------------------------------------------------
754
+ // pair: empareja esta PC con un hub PHP por codigo (device code). La PC pide
755
+ // un codigo, el usuario lo tipea en la web (Dispositivos -> Agregar PC) y la PC
756
+ // recibe su token propio. Necesita un hub PHP (openbridge.tamnora.com).
757
+ // ---------------------------------------------------------------------------
758
+ async function pairApi(apiUrl, action, body) {
759
+ const res = await fetch(apiUrl + '?action=' + action, {
760
+ method: 'POST',
761
+ headers: { 'Content-Type': 'application/json' },
762
+ body: JSON.stringify(body || {}),
763
+ });
764
+ let json = null;
765
+ try { json = await res.json(); } catch (e) { json = null; }
766
+ if (!json) throw new Error('respuesta invalida del hub (HTTP ' + res.status + ')');
767
+ return json;
768
+ }
769
+
770
+ async function cmdPair(argv) {
771
+ const { flags, _ } = parseArgs(argv);
772
+ const url = String(_[0] || flags.hub || flags.api || '').trim();
773
+ if (!/^https?:\/\//i.test(url)) {
774
+ console.error('Uso: openbridge pair <url-del-hub> [--id <pc>] [--name "<nombre>"] [--no-start]');
775
+ console.error('Ej.: openbridge pair https://openbridge.tamnora.com --name "PC 1"');
776
+ return 1;
777
+ }
778
+ paths.ensureDirs();
779
+ const cfg = config.readBridge();
780
+ const apiUrl = hubApiUrl(url);
781
+ const bridgeId = sanitizeId(flags.id || cfg.bridgeId || os.hostname());
782
+ const bridgeName = (typeof flags.name === 'string' && flags.name.trim())
783
+ ? flags.name.trim().slice(0, 40)
784
+ : (cfg.bridgeName || bridgeId);
785
+
786
+ let start;
787
+ try {
788
+ start = await pairApi(apiUrl, 'bridge_pair_start', { bridge_id: bridgeId, bridge_name: bridgeName });
789
+ } catch (e) {
790
+ console.error('No pude iniciar el emparejamiento: ' + e.message);
791
+ return 1;
792
+ }
793
+ if (!start.ok) {
794
+ console.error('El hub rechazo el emparejamiento: ' + (start.error || 'error'));
795
+ return 1;
796
+ }
797
+
798
+ const verify = start.verify_url || url;
799
+ console.log('Para vincular esta PC a tu cuenta:');
800
+ console.log(' 1. Abri ' + verify + ' y logueate.');
801
+ console.log(' 2. Anda a Dispositivos -> Agregar PC.');
802
+ console.log(' 3. Ingresa el codigo: ' + start.user_code);
803
+ console.log('');
804
+ console.log('Esperando aprobacion (vence en ' + Math.round((parseInt(start.expires_in, 10) || 600) / 60) + ' min)...');
805
+
806
+ const deadline = Date.now() + (parseInt(start.expires_in, 10) || 600) * 1000;
807
+ let token = '';
808
+ let finalId = bridgeId;
809
+ while (Date.now() < deadline) {
810
+ await new Promise((r) => setTimeout(r, 2500));
811
+ let p;
812
+ try {
813
+ p = await pairApi(apiUrl, 'bridge_pair_poll', { device_code: start.device_code });
814
+ } catch (e) {
815
+ process.stdout.write('.');
816
+ continue;
817
+ }
818
+ if (p.status === 'approved' && p.bridge_token) {
819
+ token = p.bridge_token;
820
+ finalId = p.bridge_id || bridgeId;
821
+ break;
822
+ }
823
+ if (p.status === 'expired' || p.status === 'unknown') {
824
+ console.error('\nEl codigo vencio. Volve a correr: openbridge pair ' + url);
825
+ return 1;
826
+ }
827
+ process.stdout.write('.');
828
+ }
829
+ if (!token) {
830
+ console.error('\nSe agoto el tiempo. Volve a correr: openbridge pair ' + url);
831
+ return 1;
832
+ }
833
+
834
+ cfg.apiUrl = apiUrl;
835
+ cfg.apiToken = token;
836
+ cfg.bridgeId = finalId;
837
+ cfg.bridgeName = bridgeName;
838
+ config.writeBridge(cfg);
839
+ console.log('\nPC emparejada: ' + finalId + ' (' + bridgeName + ')');
840
+ if (flags['no-start']) {
841
+ console.log('Arrancala con: openbridge bridge');
842
+ return 0;
843
+ }
844
+ console.log('Arrancando el puente (Ctrl+C para salir)...');
845
+ return cmdBridge([]);
846
+ }
847
+
669
848
  // ---------------------------------------------------------------------------
670
849
  // import / reset (datos)
671
850
  // ---------------------------------------------------------------------------
@@ -1025,6 +1204,7 @@ function usage() {
1025
1204
  console.log(' logs Ultimas lineas de los logs (--follow --server --bridge)');
1026
1205
  console.log(' bridge Corre solo el puente (--api --token --id --name)');
1027
1206
  console.log(' join Vincula esta PC como puente de un hub (<url> --token --id --name)');
1207
+ console.log(' pair Empareja esta PC con un hub PHP por codigo (<url> [--id --name])');
1028
1208
  console.log(' import Trae data/ de OpenConex (<data-dir> [--force])');
1029
1209
  console.log(' reset Borra todos los chats/datos (--session <id> --yes)');
1030
1210
  console.log(' autostart Instala/quita el arranque automatico (install|remove)');
@@ -1060,6 +1240,7 @@ async function main(argv) {
1060
1240
  case 'logs': return cmdLogs(args.slice(1));
1061
1241
  case 'bridge': return cmdBridge(args.slice(1));
1062
1242
  case 'join': return cmdJoin(args.slice(1));
1243
+ case 'pair': return cmdPair(args.slice(1));
1063
1244
  case 'import': return cmdImport(args.slice(1));
1064
1245
  case 'reset': return cmdReset(args.slice(1));
1065
1246
  case 'autostart': return cmdAutostart(args.slice(1));
@@ -12,6 +12,29 @@ const fs = require('node:fs/promises');
12
12
 
13
13
  const locks = new Map();
14
14
 
15
+ function delay(ms) {
16
+ return new Promise((resolve) => setTimeout(resolve, ms));
17
+ }
18
+
19
+ // En Windows, rename() puede fallar con EPERM/EACCES si otro proceso (antivirus,
20
+ // indexador) tiene el archivo abierto un instante. Reintentamos con backoff y,
21
+ // si no hay forma, limpiamos el .tmp.
22
+ async function renameWithRetry(from, to, tries = 5) {
23
+ for (let i = 0; ; i++) {
24
+ try {
25
+ await fs.rename(from, to);
26
+ return;
27
+ } catch (e) {
28
+ const transient = e && (e.code === 'EPERM' || e.code === 'EACCES' || e.code === 'EBUSY');
29
+ if (!transient || i >= tries - 1) {
30
+ try { await fs.unlink(from); } catch (e2) { /* nada */ }
31
+ throw e;
32
+ }
33
+ await delay(20 * (i + 1));
34
+ }
35
+ }
36
+ }
37
+
15
38
  function withLock(key, fn) {
16
39
  const prev = locks.get(key) || Promise.resolve();
17
40
  const run = prev.then(fn, fn);
@@ -33,7 +56,7 @@ async function readJson(file, fallback) {
33
56
  async function writeAtomic(file, data) {
34
57
  const tmp = file + '.' + process.pid + '.' + Math.random().toString(36).slice(2) + '.tmp';
35
58
  await fs.writeFile(tmp, JSON.stringify(data, null, 2));
36
- await fs.rename(tmp, file);
59
+ await renameWithRetry(tmp, file);
37
60
  }
38
61
 
39
62
  /**
@@ -67,6 +67,7 @@ var els = {
67
67
  viewProcs: document.getElementById('viewProcs'),
68
68
  viewChanges: document.getElementById('viewChanges'),
69
69
  viewMcp: document.getElementById('viewMcp'),
70
+ viewDevices: document.getElementById('viewDevices'),
70
71
  themeColor: document.getElementById('themeColor'),
71
72
  themeLink: document.getElementById('themeStylesheet'),
72
73
  statusbar: document.getElementById('statusbar'),
@@ -146,6 +147,7 @@ var state = {
146
147
  catVer: '',
147
148
  bridges: [], // resumen de puentes registrados [{id,name,online,busy_session}]
148
149
  activeBridge: '', // puente seleccionado en el sidebar (id)
150
+ features: {}, // capacidades del hub (p. ej. {pairing:true} en el hub PHP)
149
151
  isSending: false,
150
152
  pendingImage: null,
151
153
  messages: [],
@@ -431,6 +433,11 @@ async function refreshBridgeData() {
431
433
  // Aplica la respuesta de bootstrap (o de un refresh tras cambio de puente).
432
434
  function applyBootPayload(data) {
433
435
  if (!data || !data.ok) return;
436
+ if (data.features) {
437
+ state.features = data.features;
438
+ var devBtn = document.querySelector('#sidebar .utilities button[data-view="devices"]');
439
+ if (devBtn) devBtn.style.display = state.features.pairing ? '' : 'none';
440
+ }
434
441
  var newB = Array.isArray(data.bridges) ? data.bridges : null;
435
442
  if (newB && newB.length) {
436
443
  // El puente activo guardado puede no existir (o ser el legacy '' tras
@@ -1011,6 +1018,7 @@ function showView(name) {
1011
1018
  els.viewProcs.style.display = 'none';
1012
1019
  els.viewChanges.style.display = 'none';
1013
1020
  els.viewMcp.style.display = 'none';
1021
+ if (els.viewDevices) els.viewDevices.style.display = 'none';
1014
1022
 
1015
1023
  if (name !== 'procs') stopProcView();
1016
1024
 
@@ -1089,6 +1097,12 @@ function showView(name) {
1089
1097
  els.sendForm.style.display = 'none';
1090
1098
  els.btnCmds.style.display = 'none';
1091
1099
  renderMcpView();
1100
+ } else if (name === 'devices') {
1101
+ els.viewDevices.style.display = '';
1102
+ els.btnBack.style.display = 'none';
1103
+ els.sendForm.style.display = 'none';
1104
+ els.btnCmds.style.display = 'none';
1105
+ renderDevicesView();
1092
1106
  }
1093
1107
 
1094
1108
  if (els.fabNew) els.fabNew.classList.toggle('show', name === 'home');
@@ -3062,6 +3076,79 @@ async function loadMcp() {
3062
3076
  + esc(output || 'No hay servidores MCP configurados.') + '</pre>';
3063
3077
  }
3064
3078
 
3079
+ // ---------------------------------------------------------------------------
3080
+ // Vista Dispositivos: PCs emparejadas a la cuenta (solo hub PHP). El usuario
3081
+ // corre `openbridge pair <url>` en su PC, ve un codigo y lo ingresa aca.
3082
+ // ---------------------------------------------------------------------------
3083
+ function renderDevicesView() {
3084
+ els.hTitle.textContent = 'dispositivos';
3085
+ els.hSub.textContent = 'tus PCs emparejadas';
3086
+ loadDevices();
3087
+ }
3088
+
3089
+ async function loadDevices() {
3090
+ if (!els.viewDevices) return;
3091
+ var head = '<div class="view-head"><h2>mis PCs</h2>'
3092
+ + '<div class="view-sub">emparejadas a tu cuenta</div></div>';
3093
+ var res = await api('api.php?action=bridges');
3094
+ if (!res.ok) {
3095
+ els.viewDevices.innerHTML = head + '<div class="placeholder">' + esc(res.error || 'no se pudo consultar') + '</div>';
3096
+ return;
3097
+ }
3098
+ els.viewDevices.innerHTML = head
3099
+ + '<div class="dev-add">'
3100
+ + '<input type="text" id="devCode" placeholder="codigo (ej: ABCD-EFGH)" autocomplete="off" spellcheck="false">'
3101
+ + '<button type="button" class="linkbtn" id="devAdd">agregar PC</button>'
3102
+ + '</div>'
3103
+ + '<div class="view-sub">En tu PC: <code>openbridge pair ' + esc(location.origin) + '</code> y te muestra un codigo. Ingresalo aca.</div>'
3104
+ + '<div id="devList"></div>';
3105
+ var input = document.getElementById('devCode');
3106
+ var btn = document.getElementById('devAdd');
3107
+ if (btn) btn.addEventListener('click', function () { addDevice(input.value); });
3108
+ if (input) input.addEventListener('keydown', function (e) { if (e.key === 'Enter') addDevice(input.value); });
3109
+ renderDeviceList(res.bridges || []);
3110
+ }
3111
+
3112
+ function renderDeviceList(list) {
3113
+ var box = document.getElementById('devList');
3114
+ if (!box) return;
3115
+ if (!list.length) {
3116
+ box.innerHTML = '<div class="placeholder">Todavia no emparejaste ninguna PC.</div>';
3117
+ return;
3118
+ }
3119
+ var html = '';
3120
+ for (var i = 0; i < list.length; i++) {
3121
+ var b = list[i];
3122
+ html += '<div class="device"><span class="dot"' + (b.online ? '' : ' style="background:var(--muted)"') + '></span>'
3123
+ + '<b>' + esc(b.name) + '</b><span class="dnote">' + esc(b.id) + '</span>'
3124
+ + '<button type="button" class="linkbtn" data-revoke="' + esc(b.id) + '">desvincular</button></div>';
3125
+ }
3126
+ box.innerHTML = html;
3127
+ var btns = box.querySelectorAll('[data-revoke]');
3128
+ for (var j = 0; j < btns.length; j++) {
3129
+ btns[j].addEventListener('click', function () { revokeDevice(this.getAttribute('data-revoke')); });
3130
+ }
3131
+ }
3132
+
3133
+ async function addDevice(code) {
3134
+ code = (code || '').trim();
3135
+ if (!code) return;
3136
+ var res = await fetch('api.php?action=bridge_pair_approve', apiCsrf('POST', { user_code: code }))
3137
+ .then(function (r) { return r.json(); })
3138
+ .catch(function () { return { ok: false, error: 'network' }; });
3139
+ if (!res.ok) { alert(res.error || 'no se pudo emparejar'); return; }
3140
+ loadDevices();
3141
+ }
3142
+
3143
+ async function revokeDevice(id) {
3144
+ if (!window.confirm('Desvincular la PC "' + id + '"?')) return;
3145
+ var res = await fetch('api.php?action=bridge_revoke', apiCsrf('POST', { id: id }))
3146
+ .then(function (r) { return r.json(); })
3147
+ .catch(function () { return { ok: false, error: 'network' }; });
3148
+ if (!res.ok) { alert(res.error || 'no se pudo desvincular'); return; }
3149
+ loadDevices();
3150
+ }
3151
+
3065
3152
  function chgStatusClass(st) {
3066
3153
  if (st === '??' || st === 'A') return 'add';
3067
3154
  if (st.indexOf('D') >= 0) return 'del';
@@ -378,6 +378,14 @@
378
378
  }
379
379
 
380
380
  /* ----- Tab de procesos (dev servers de la sesión activa) ----- */
381
+ #viewDevices .dev-add { display: flex; gap: 8px; margin: 10px 0; }
382
+ #viewDevices .dev-add input { flex: 1; min-width: 0; padding: 10px 12px; border: 1px solid var(--border); border-radius: 10px; background: var(--panel); color: var(--text); font: inherit; }
383
+ #viewDevices .dev-add input:focus { border-color: var(--accent); outline: none; }
384
+ #viewDevices .device { display: flex; align-items: center; gap: 8px; padding: 10px 12px; border: 1px solid var(--border); border-radius: 10px; margin-bottom: 8px; background: var(--panel); }
385
+ #viewDevices .device .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--ok, #22c55e); flex-shrink: 0; }
386
+ #viewDevices .device .dnote { color: var(--muted); font-size: 11.5px; }
387
+ #viewDevices .device .linkbtn { margin-left: auto; }
388
+ #viewDevices code { background: var(--panel); border: 1px solid var(--border); border-radius: 6px; padding: 1px 6px; font-size: 11.5px; }
381
389
  #viewProcs .pp-head { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; }
382
390
  #viewProcs .pp-head b { font-size: 16px; font-weight: 600; }
383
391
  #viewProcs .pp-head b::before { content: "❯ "; color: var(--mine); }
@@ -1204,6 +1212,7 @@
1204
1212
  <button data-view="procs" type="button" title="Procesos de la sesión">procs</button>
1205
1213
  <button data-view="changes" type="button" title="Cambios del proyecto (git)">cambios</button>
1206
1214
  <button data-view="mcp" type="button" title="Servidores MCP de opencode">mcp</button>
1215
+ <button data-view="devices" type="button" title="PCs emparejadas a tu cuenta" style="display:none">dispositivos</button>
1207
1216
  </div>
1208
1217
  <div class="footer">
1209
1218
  <div class="row" id="bridgeStatusRow">
@@ -1287,6 +1296,7 @@
1287
1296
  <div id="viewProcs" class="view" style="display:none"></div>
1288
1297
  <div id="viewChanges" class="view" style="display:none"></div>
1289
1298
  <div id="viewMcp" class="view" style="display:none"></div>
1299
+ <div id="viewDevices" class="view" style="display:none"></div>
1290
1300
 
1291
1301
  <footer id="statusbar">
1292
1302
  <span class="dot" id="sbDot"></span>