@dutodev/cli 0.4.6 → 0.5.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/README.md +44 -0
- package/dist/{fullscreen-EPWB53EP.js → fullscreen-DKIFHB6J.js} +160 -49
- package/dist/index.js +589 -79
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -93,6 +93,47 @@ Em todos os casos, cadastre `https://<nome>-<usuário>.duto.dev/<caminho de reto
|
|
|
93
93
|
|
|
94
94
|
**Sobre o `X-Forwarded-*`.** `X-Forwarded-Host`, `X-Forwarded-Proto` e `X-Forwarded-For` são uma convenção antiga entre proxies reversos para contar ao app o que o cliente pediu de verdade. A versão padronizada é o cabeçalho `Forwarded` (RFC 7239), menos usado na prática. Frameworks costumam ler esses cabeçalhos só quando o proxy é marcado como confiável (`trusted proxies`, `trust proxy`, `USE_X_FORWARDED_HOST`), então o efeito de `headers:` depende de como o app está configurado. Por outro lado, se um app lê o `X-Forwarded-Host` sem essa checagem, qualquer cliente pode forjá-lo em produção. Vale garantir que o proxy da frente sobrescreva ou remova esse cabeçalho.
|
|
95
95
|
|
|
96
|
+
## Senha no túnel
|
|
97
|
+
|
|
98
|
+
Sem senha, qualquer pessoa com a URL acessa o seu ambiente de desenvolvimento. Para proteger um túnel, coloque `auth` (e, se precisar, `permitir`) no `duto.yml`:
|
|
99
|
+
|
|
100
|
+
```yaml
|
|
101
|
+
tunnels:
|
|
102
|
+
backend:
|
|
103
|
+
port: 3000
|
|
104
|
+
auth:
|
|
105
|
+
- cliente:${SENHA_CLIENTE}
|
|
106
|
+
- ana:${SENHA_ANA}
|
|
107
|
+
permitir: [stripe, github, 203.0.113.0/24]
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
- `auth` é uma lista de `usuario:senha`. Use um par por pessoa. O primeiro `:` separa o usuário da senha; a senha tem de 8 a 128 caracteres.
|
|
111
|
+
- `${VAR}` lê o valor de uma variável de ambiente. Use sempre: assim a senha não vai para o git junto com o `duto.yml`. Se a variável não existe, a CLI para com o número da linha. O nome só pode ter letras maiúsculas, dígitos e `_` (`${SENHA_ANA}`); qualquer outra coisa, como `${senha}`, fica no texto sem trocar e vira parte da senha.
|
|
112
|
+
- Na lista de uma linha, entre colchetes, o `${VAR}` precisa de aspas (`auth: ["cliente:${SENHA_CLIENTE}"]`), porque `{` é um caractere especial do YAML. Na lista em bloco, como acima, não precisa.
|
|
113
|
+
- Na linha de comando, para um túnel só: `duto 3000 --auth cliente:minhasenha1 --permitir stripe`. As duas opções se repetem. Elas só valem com a porta; junto com um `duto.yml`, coloque-as no arquivo.
|
|
114
|
+
- Cuidado: a senha passada em `--auth` fica no histórico do shell e aparece para quem rodar `ps` na máquina. Para algo além de um teste rápido, prefira o `duto.yml` com `${VAR}`.
|
|
115
|
+
|
|
116
|
+
Quem usa o túnel entra de um destes jeitos:
|
|
117
|
+
|
|
118
|
+
| Quem | Como |
|
|
119
|
+
|---|---|
|
|
120
|
+
| Pessoa no navegador | Abre a URL, vê a página "Este túnel tem senha" e entra com usuário e senha |
|
|
121
|
+
| Celular | Escaneia o QR code (tecla `m`, veja abaixo) e entra sem digitar |
|
|
122
|
+
| `curl`, Postman, scripts | `curl -u cliente:senha https://backend-<usuário>.duto.dev/` (`Authorization: Basic`) |
|
|
123
|
+
| API que já usa `Authorization: Bearer` | Cabeçalho `X-Duto-Auth: cliente:senha`, e o `Bearer` segue intacto até o app |
|
|
124
|
+
|
|
125
|
+
Sem credencial, um navegador é levado à página de entrada e os outros clientes recebem `401`. O app local nunca recebe a credencial do Duto: o `Authorization: Basic` que confere, o `X-Duto-Auth` e o cookie do túnel são removidos antes de repassar a requisição (os outros cookies chegam). Um `Authorization: Bearer` do próprio app passa sem mudança.
|
|
126
|
+
|
|
127
|
+
**Sair e sessão.** No navegador, o login vira um cookie que vale só para aquele túnel. Para sair, abra `/__duto/sair`: ele apaga o cookie e volta para a página de entrada. O cookie dura até o navegador fechar ou a CLI parar. Reiniciar a CLI desconecta todo mundo, porque a chave que assina os cookies é nova a cada sessão. O cookie só vale em requisições do próprio túnel: uma página de outra origem (outro túnel, outro site) que chame o túnel precisa mandar `X-Duto-Auth` ou `Authorization: Basic`. Por isso, abrir o túnel a partir de um link em outro endereço `*.duto.dev` (o painel ou outro túnel) pede a senha de novo; um link vindo de fora (e-mail, chat) ou a URL digitada entra direto.
|
|
128
|
+
|
|
129
|
+
**QR code.** Com a tela cheia aberta, a tecla `m` mostra o QR do túnel selecionado no lugar da lista de requisições. Num túnel sem senha, o QR leva a URL do túnel. Num túnel com senha, leva um link que entra direto, sem digitar nada, como o primeiro usuário da lista `auth`. Esse link vale por 5 minutos e só para um aparelho: depois de aberto uma vez, ou passado o prazo, cai na página de entrada. Na tela do QR, `g` gera um link novo (e invalida o anterior) e `esc` volta para a lista.
|
|
130
|
+
|
|
131
|
+
**CORS.** Um frontend em outro domínio continua funcionando. O preflight (`OPTIONS` com `Origin` e `Access-Control-Request-Method`, sem corpo) passa sem senha, mas dele só voltam o status e os cabeçalhos de CORS da resposta do app. Quando o app responde com CORS, o Duto acrescenta `X-Duto-Auth` ao `Access-Control-Allow-Headers`. Você não muda nada no app. A resposta `401` também leva os cabeçalhos de CORS, para o frontend ler o erro em vez de ver uma falha de rede.
|
|
132
|
+
|
|
133
|
+
**Webhooks.** `permitir` libera, sem senha, quem chega de certas origens: os nomes `stripe` e `github` (a CLI baixa a lista oficial de IPs deles, guarda por um dia em `~/.duto/` e usa uma cópia embutida se a rede falhar) e faixas de IP, IPv4 ou IPv6, como `203.0.113.0/24`. `permitir` só vale junto com `auth`.
|
|
134
|
+
|
|
135
|
+
Na tela, os túneis com senha aparecem com um cadeado (🔒) ao lado da URL. A lista de requisições mostra o usuário que entrou e o motivo das liberadas por `permitir`; as barradas com `401` levam "sem senha", e os navegadores barrados aparecem com o redirecionamento `303` para a página de entrada. Um túnel aberto com `n` na tela cheia não tem senha.
|
|
136
|
+
|
|
96
137
|
## A tela
|
|
97
138
|
|
|
98
139
|
Com os túneis no ar, o terminal mostra cada túnel (URL, destino local, requisições e erros) e as requisições chegando ao vivo: método, caminho, status, tempo e tamanho. WebSocket funciona normalmente e aparece como `WS 101`.
|
|
@@ -101,6 +142,7 @@ Com os túneis no ar, o terminal mostra cada túnel (URL, destino local, requisi
|
|
|
101
142
|
|---|---|
|
|
102
143
|
| `n` | Abre mais um túnel (só nesta sessão) |
|
|
103
144
|
| `c` / `o` | Copia / abre no navegador a URL do túnel selecionado |
|
|
145
|
+
| `m` | Mostra o QR code do túnel selecionado, para abrir no celular |
|
|
104
146
|
| `f` | Filtra por túnel, método, caminho ou status |
|
|
105
147
|
| `↵` | Detalhes da requisição: cabeçalhos e o começo dos corpos |
|
|
106
148
|
| `tab`, `↑↓` | Alterna entre túneis e requisições e move a seleção |
|
|
@@ -126,6 +168,8 @@ Fora de um terminal interativo (pipe, CI) ou com `--no-ui`, a CLI imprime um log
|
|
|
126
168
|
| `--name <nome>` | Endereço fixo `<nome>-<usuário>.duto.dev` |
|
|
127
169
|
| `--host <host>` | Host que o serviço local recebe no lugar do público |
|
|
128
170
|
| `--no-rewrite-redirects` | Com `--host`, mantém os redirecionamentos como o serviço mandou |
|
|
171
|
+
| `--auth <usuario:senha>` | Pede usuário e senha para abrir o endereço (repetível) |
|
|
172
|
+
| `--permitir <faixa>` | Com `--auth`: IP, faixa ou nome (`stripe`, `github`) que entra sem senha (repetível) |
|
|
129
173
|
| `--no-ui` / `--ui` | Desliga / força a tela cheia |
|
|
130
174
|
|
|
131
175
|
## Configuração
|
|
@@ -14,7 +14,7 @@ import { render } from "ink";
|
|
|
14
14
|
|
|
15
15
|
// src/ui/App.tsx
|
|
16
16
|
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
|
|
17
|
-
import { Box as
|
|
17
|
+
import { Box as Box5, Text as Text6, useInput, useStdout } from "ink";
|
|
18
18
|
|
|
19
19
|
// src/ui/Detail.tsx
|
|
20
20
|
import { Box, Text } from "ink";
|
|
@@ -107,43 +107,120 @@ function Prompt({ label, value, hint }) {
|
|
|
107
107
|
] });
|
|
108
108
|
}
|
|
109
109
|
|
|
110
|
-
// src/ui/
|
|
110
|
+
// src/ui/QrView.tsx
|
|
111
111
|
import { Box as Box2, Text as Text3 } from "ink";
|
|
112
|
-
|
|
112
|
+
|
|
113
|
+
// src/ui/qr.ts
|
|
114
|
+
import qrcode from "qrcode-generator";
|
|
115
|
+
var MARGEM = 2;
|
|
116
|
+
function qrLines(text) {
|
|
117
|
+
const qr = qrcode(0, "L");
|
|
118
|
+
qr.addData(text);
|
|
119
|
+
qr.make();
|
|
120
|
+
const n = qr.getModuleCount();
|
|
121
|
+
const size = n + MARGEM * 2;
|
|
122
|
+
const dark = (r, c) => {
|
|
123
|
+
const rr = r - MARGEM;
|
|
124
|
+
const cc = c - MARGEM;
|
|
125
|
+
return rr >= 0 && cc >= 0 && rr < n && cc < n && qr.isDark(rr, cc);
|
|
126
|
+
};
|
|
127
|
+
const lines = [];
|
|
128
|
+
for (let r = 0; r < size; r += 2) {
|
|
129
|
+
let line = "";
|
|
130
|
+
for (let c = 0; c < size; c++) {
|
|
131
|
+
const top = dark(r, c);
|
|
132
|
+
const bottom = dark(r + 1, c);
|
|
133
|
+
line += top && bottom ? "\u2588" : top ? "\u2580" : bottom ? "\u2584" : " ";
|
|
134
|
+
}
|
|
135
|
+
lines.push(line);
|
|
136
|
+
}
|
|
137
|
+
return lines;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// src/ui/QrView.tsx
|
|
141
|
+
import { Fragment as Fragment2, jsx as jsx3, jsxs as jsxs3 } from "react/jsx-runtime";
|
|
142
|
+
function countdown(ms) {
|
|
143
|
+
const s = Math.ceil(ms / 1e3);
|
|
144
|
+
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
|
|
145
|
+
}
|
|
146
|
+
function QrView({ tunnel, link, now }) {
|
|
147
|
+
const lines = qrLines(link.url);
|
|
148
|
+
const left = link.expiresAt === null ? null : link.expiresAt - now;
|
|
149
|
+
const expired = left !== null && Math.ceil(left / 1e3) <= 0;
|
|
150
|
+
return /* @__PURE__ */ jsxs3(Box2, { flexDirection: "column", children: [
|
|
151
|
+
/* @__PURE__ */ jsx3(Text3, { color: C.muted, children: "Abra no celular" }),
|
|
152
|
+
/* @__PURE__ */ jsxs3(Box2, { children: [
|
|
153
|
+
/* @__PURE__ */ jsx3(Box2, { flexDirection: "column", marginRight: 3, children: lines.map((l, i) => /* @__PURE__ */ jsx3(Text3, { backgroundColor: "white", color: "black", children: l }, i)) }),
|
|
154
|
+
/* @__PURE__ */ jsxs3(Box2, { flexDirection: "column", children: [
|
|
155
|
+
/* @__PURE__ */ jsxs3(Text3, { children: [
|
|
156
|
+
/* @__PURE__ */ jsx3(Text3, { bold: true, children: tunnel.key }),
|
|
157
|
+
" ",
|
|
158
|
+
/* @__PURE__ */ jsxs3(Text3, { color: C.muted, children: [
|
|
159
|
+
"\xB7 ",
|
|
160
|
+
tunnel.url
|
|
161
|
+
] })
|
|
162
|
+
] }),
|
|
163
|
+
left !== null && /* @__PURE__ */ jsxs3(Fragment2, { children: [
|
|
164
|
+
/* @__PURE__ */ jsx3(Text3, { children: " " }),
|
|
165
|
+
/* @__PURE__ */ jsx3(Text3, { children: "\u{1F512} Este t\xFAnel tem senha." }),
|
|
166
|
+
/* @__PURE__ */ jsx3(Text3, { color: C.muted, children: "O QR entra direto, sem digitar a senha." }),
|
|
167
|
+
/* @__PURE__ */ jsx3(Text3, { color: C.muted, children: "Vale por 5 minutos e s\xF3 para um aparelho." }),
|
|
168
|
+
/* @__PURE__ */ jsx3(Text3, { children: " " }),
|
|
169
|
+
expired ? /* @__PURE__ */ jsx3(Text3, { color: C.warn, children: "Expirou. Aperte g para gerar outro." }) : /* @__PURE__ */ jsx3(Text3, { color: C.signal, children: `Expira em ${countdown(left)}` })
|
|
170
|
+
] }),
|
|
171
|
+
/* @__PURE__ */ jsx3(Text3, { children: " " }),
|
|
172
|
+
/* @__PURE__ */ jsx3(Text3, { color: C.muted, children: left !== null ? "g gerar outro \xB7 esc voltar" : "esc voltar" })
|
|
173
|
+
] })
|
|
174
|
+
] })
|
|
175
|
+
] });
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// src/ui/RequestList.tsx
|
|
179
|
+
import { Box as Box3, Text as Text4 } from "ink";
|
|
180
|
+
import { jsx as jsx4, jsxs as jsxs4 } from "react/jsx-runtime";
|
|
181
|
+
function accessLabel(a) {
|
|
182
|
+
if (!a) return null;
|
|
183
|
+
if (a.kind === "user") return { text: a.user, color: C.muted };
|
|
184
|
+
if (a.kind === "allow") return { text: a.reason, color: C.muted };
|
|
185
|
+
return { text: "sem senha", color: C.warn };
|
|
186
|
+
}
|
|
113
187
|
function RequestList(props) {
|
|
114
188
|
const { records, selected, focused, height, width } = props;
|
|
115
189
|
const start = Math.max(0, Math.min(selected - Math.floor(height / 2), records.length - height));
|
|
116
190
|
const visible = records.slice(start, start + height);
|
|
117
|
-
const
|
|
191
|
+
const accessW = Math.max(0, ...visible.map((r) => (accessLabel(r.access)?.text.length ?? -1) + 1));
|
|
192
|
+
const pathW = Math.max(10, width - 52 - accessW);
|
|
118
193
|
const title = props.filter ? `REQUISI\xC7\xD5ES \xB7 filtro: ${props.filter} ` : "REQUISI\xC7\xD5ES ";
|
|
119
|
-
return /* @__PURE__ */
|
|
120
|
-
/* @__PURE__ */
|
|
121
|
-
records.length === 0 ? /* @__PURE__ */
|
|
194
|
+
return /* @__PURE__ */ jsxs4(Box3, { flexDirection: "column", children: [
|
|
195
|
+
/* @__PURE__ */ jsx4(Text4, { color: C.muted, children: title + "\u2500".repeat(Math.max(0, width - title.length)) }),
|
|
196
|
+
records.length === 0 ? /* @__PURE__ */ jsx4(Text4, { color: C.muted, children: props.filter ? "Nenhuma requisi\xE7\xE3o com esse filtro." : "Nenhuma requisi\xE7\xE3o ainda. Elas aparecem aqui assim que chegarem." }) : visible.map((r, i) => {
|
|
122
197
|
const sel = focused && start + i === selected;
|
|
123
198
|
const method = r.websocket ? "WS" : r.method;
|
|
124
199
|
const status = r.status === null ? "\u2026" : String(r.status);
|
|
125
200
|
const tail = r.websocket && r.status === 101 ? "aberto" : r.durationMs === null ? "" : formatDuration(r.durationMs);
|
|
126
|
-
|
|
127
|
-
|
|
201
|
+
const acc = accessLabel(r.access);
|
|
202
|
+
return /* @__PURE__ */ jsxs4(Text4, { inverse: sel, wrap: "truncate-end", children: [
|
|
203
|
+
/* @__PURE__ */ jsx4(Text4, { color: C.muted, children: formatTime(r.startedAt) }),
|
|
128
204
|
" ",
|
|
129
|
-
/* @__PURE__ */
|
|
205
|
+
/* @__PURE__ */ jsx4(Text4, { color: tunnelColor(props.tunnelIndex(r.tunnel)), children: fit(r.tunnel, 9) }),
|
|
130
206
|
" ",
|
|
131
|
-
/* @__PURE__ */
|
|
207
|
+
/* @__PURE__ */ jsx4(Text4, { bold: true, children: fit(method, 5) }),
|
|
132
208
|
fit(r.path, pathW),
|
|
133
209
|
" ",
|
|
134
|
-
/* @__PURE__ */
|
|
210
|
+
/* @__PURE__ */ jsx4(Text4, { color: C[statusColor(r.status)], children: status.padStart(3) }),
|
|
135
211
|
" ",
|
|
136
|
-
/* @__PURE__ */
|
|
212
|
+
/* @__PURE__ */ jsx4(Text4, { color: C.muted, children: tail.padStart(7) }),
|
|
137
213
|
" ",
|
|
138
|
-
/* @__PURE__ */
|
|
214
|
+
/* @__PURE__ */ jsx4(Text4, { color: C.muted, children: r.websocket ? "" : formatBytes(r.bytes).padStart(8) }),
|
|
215
|
+
acc ? /* @__PURE__ */ jsx4(Text4, { color: acc.color, children: ` ${acc.text}` }) : null
|
|
139
216
|
] }, r.id);
|
|
140
217
|
})
|
|
141
218
|
] });
|
|
142
219
|
}
|
|
143
220
|
|
|
144
221
|
// src/ui/TunnelTable.tsx
|
|
145
|
-
import { Box as
|
|
146
|
-
import { jsx as
|
|
222
|
+
import { Box as Box4, Text as Text5 } from "ink";
|
|
223
|
+
import { jsx as jsx5, jsxs as jsxs5 } from "react/jsx-runtime";
|
|
147
224
|
function note(t) {
|
|
148
225
|
if (t.status === "failed") return { text: t.error ?? "falhou", color: C.err };
|
|
149
226
|
if (t.status === "no-listener") return { text: `nada escutando na ${t.port}`, color: C.warn };
|
|
@@ -161,12 +238,15 @@ function TunnelTable({ tunnels, selected, focused }) {
|
|
|
161
238
|
const nameW = Math.max(6, ...tunnels.map((t) => t.key.length)) + 1;
|
|
162
239
|
const urlW = Math.max(12, ...tunnels.map((t) => (t.url ?? "").length)) + 1;
|
|
163
240
|
const localW = Math.max(15, ...tunnels.map((t) => localLabel(t).length));
|
|
164
|
-
|
|
165
|
-
|
|
241
|
+
const anyAuth = tunnels.some((t) => t.auth);
|
|
242
|
+
const lock = (t) => !anyAuth ? "" : t.auth ? "\u{1F512}" : " ";
|
|
243
|
+
return /* @__PURE__ */ jsxs5(Box4, { flexDirection: "column", children: [
|
|
244
|
+
/* @__PURE__ */ jsxs5(Text5, { color: C.muted, children: [
|
|
166
245
|
" ",
|
|
167
246
|
fit("T\xDANEL", nameW),
|
|
168
247
|
" ",
|
|
169
248
|
fit("URL", urlW),
|
|
249
|
+
anyAuth ? " " : "",
|
|
170
250
|
" ",
|
|
171
251
|
fit("LOCAL", localW),
|
|
172
252
|
" ",
|
|
@@ -178,33 +258,35 @@ function TunnelTable({ tunnels, selected, focused }) {
|
|
|
178
258
|
const d = dot(t);
|
|
179
259
|
const n = note(t);
|
|
180
260
|
const sel = focused && i === selected;
|
|
181
|
-
return /* @__PURE__ */
|
|
182
|
-
/* @__PURE__ */
|
|
183
|
-
/* @__PURE__ */
|
|
261
|
+
return /* @__PURE__ */ jsxs5(Box4, { flexDirection: "column", children: [
|
|
262
|
+
/* @__PURE__ */ jsxs5(Text5, { inverse: sel, wrap: "truncate-end", children: [
|
|
263
|
+
/* @__PURE__ */ jsx5(Text5, { color: d.color, children: d.char }),
|
|
184
264
|
" ",
|
|
185
|
-
/* @__PURE__ */
|
|
265
|
+
/* @__PURE__ */ jsx5(Text5, { bold: true, children: fit(t.key, nameW) }),
|
|
186
266
|
" ",
|
|
187
267
|
fit(t.url ?? "\u2014", urlW),
|
|
268
|
+
lock(t),
|
|
188
269
|
" ",
|
|
189
|
-
/* @__PURE__ */
|
|
270
|
+
/* @__PURE__ */ jsx5(Text5, { color: C.muted, children: fit(localLabel(t), localW) }),
|
|
190
271
|
" ",
|
|
191
|
-
/* @__PURE__ */
|
|
272
|
+
/* @__PURE__ */ jsx5(Text5, { bold: true, children: String(t.requests).padStart(4) }),
|
|
192
273
|
" ",
|
|
193
|
-
/* @__PURE__ */
|
|
194
|
-
n && t.status !== "failed" ? /* @__PURE__ */
|
|
274
|
+
/* @__PURE__ */ jsx5(Text5, { color: t.errors ? C.err : C.muted, children: String(t.errors).padStart(5) }),
|
|
275
|
+
n && t.status !== "failed" ? /* @__PURE__ */ jsx5(Text5, { color: n.color, children: ` ${n.text}` }) : null
|
|
195
276
|
] }),
|
|
196
|
-
t.status === "failed" && n ? /* @__PURE__ */
|
|
277
|
+
t.status === "failed" && n ? /* @__PURE__ */ jsx5(Text5, { color: n.color, wrap: "wrap", children: ` \u21B3 ${n.text}` }) : null
|
|
197
278
|
] }, t.key);
|
|
198
279
|
})
|
|
199
280
|
] });
|
|
200
281
|
}
|
|
201
282
|
|
|
202
283
|
// src/ui/App.tsx
|
|
203
|
-
import { jsx as
|
|
284
|
+
import { jsx as jsx6, jsxs as jsxs6 } from "react/jsx-runtime";
|
|
204
285
|
var KEYS = [
|
|
205
286
|
["n", "novo t\xFAnel"],
|
|
206
287
|
["c", "copiar URL"],
|
|
207
288
|
["o", "abrir no navegador"],
|
|
289
|
+
["m", "QR no celular"],
|
|
208
290
|
["f", "filtrar"],
|
|
209
291
|
["\u21B5", "detalhes"],
|
|
210
292
|
["tab", "foco"],
|
|
@@ -224,6 +306,7 @@ function App({ session, version, system, onExit }) {
|
|
|
224
306
|
const [selReq, setSelReq] = useState(0);
|
|
225
307
|
const [selTun, setSelTun] = useState(0);
|
|
226
308
|
const [message, setMessage] = useState(null);
|
|
309
|
+
const [now, setNow] = useState(() => Date.now());
|
|
227
310
|
const quitting = useRef(false);
|
|
228
311
|
const records = useMemo(() => {
|
|
229
312
|
const f = filter.trim().toLowerCase();
|
|
@@ -238,6 +321,13 @@ function App({ session, version, system, onExit }) {
|
|
|
238
321
|
const t = setTimeout(() => setMessage(null), 4e3);
|
|
239
322
|
return () => clearTimeout(t);
|
|
240
323
|
}, [message]);
|
|
324
|
+
const inQr = mode.kind === "qr";
|
|
325
|
+
useEffect(() => {
|
|
326
|
+
if (!inQr) return;
|
|
327
|
+
setNow(Date.now());
|
|
328
|
+
const id = setInterval(() => setNow(Date.now()), 1e3);
|
|
329
|
+
return () => clearInterval(id);
|
|
330
|
+
}, [inQr]);
|
|
241
331
|
const order = state.tunnels.map((t) => t.key);
|
|
242
332
|
const tunnelIndex = (key) => Math.max(0, order.indexOf(key));
|
|
243
333
|
function currentTunnel() {
|
|
@@ -263,6 +353,13 @@ function App({ session, version, system, onExit }) {
|
|
|
263
353
|
setMessage(ok ? { text: `Abrindo ${t.url}`, tone: "signal" } : { text: "N\xE3o consegui abrir o navegador", tone: "warn" });
|
|
264
354
|
}
|
|
265
355
|
}
|
|
356
|
+
function showQr() {
|
|
357
|
+
const t = currentTunnel();
|
|
358
|
+
const link = t ? session.qrLink(t.key) : null;
|
|
359
|
+
if (!t || !link) return setMessage({ text: "Esse t\xFAnel ainda n\xE3o tem URL.", tone: "warn" });
|
|
360
|
+
setNow(Date.now());
|
|
361
|
+
setMode({ kind: "qr", key: t.key, link });
|
|
362
|
+
}
|
|
266
363
|
function submitPort(value) {
|
|
267
364
|
const port = Number(value.trim());
|
|
268
365
|
if (!Number.isInteger(port) || port < 1 || port > 65535) return setMessage({ text: `Porta inv\xE1lida: ${value || "(vazia)"} (use 1\u201365535)`, tone: "err" });
|
|
@@ -302,6 +399,18 @@ function App({ session, version, system, onExit }) {
|
|
|
302
399
|
if (key.escape || key.return || input === "q") setMode({ kind: "list" });
|
|
303
400
|
return;
|
|
304
401
|
}
|
|
402
|
+
if (mode.kind === "qr") {
|
|
403
|
+
if (key.escape) return setMode({ kind: "list" });
|
|
404
|
+
if (input === "q") return void quit();
|
|
405
|
+
if (input === "g") {
|
|
406
|
+
const link = session.qrLink(mode.key);
|
|
407
|
+
if (link) {
|
|
408
|
+
setNow(Date.now());
|
|
409
|
+
setMode({ kind: "qr", key: mode.key, link });
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
305
414
|
if (input === "q") return void quit();
|
|
306
415
|
if (key.tab) return setFocus((f) => f === "requests" ? "tunnels" : "requests");
|
|
307
416
|
if (key.upArrow || key.downArrow) {
|
|
@@ -314,6 +423,7 @@ function App({ session, version, system, onExit }) {
|
|
|
314
423
|
if (key.escape && filter) return setFilter("");
|
|
315
424
|
if (input === "c") return void act("copy");
|
|
316
425
|
if (input === "o") return void act("open");
|
|
426
|
+
if (input === "m") return showQr();
|
|
317
427
|
if (input === "f") return setMode({ kind: "filter", value: filter });
|
|
318
428
|
if (input === "n") return setMode({ kind: "port", value: "" });
|
|
319
429
|
});
|
|
@@ -321,14 +431,15 @@ function App({ session, version, system, onExit }) {
|
|
|
321
431
|
const conn = state.connection === "online" ? { text: "conectado \u25CF", color: C.signal } : state.connection === "connecting" ? { text: "conectando\u2026", color: C.muted } : { text: "reconectando\u2026", color: C.warn };
|
|
322
432
|
const listHeight = Math.max(3, rows - state.tunnels.length - 9);
|
|
323
433
|
const selected = records[selReq];
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
434
|
+
const qrTunnel = mode.kind === "qr" ? state.tunnels.find((t) => t.key === mode.key) : void 0;
|
|
435
|
+
return /* @__PURE__ */ jsxs6(Box5, { flexDirection: "column", width, children: [
|
|
436
|
+
/* @__PURE__ */ jsxs6(Box5, { justifyContent: "space-between", children: [
|
|
437
|
+
/* @__PURE__ */ jsxs6(Text6, { children: [
|
|
438
|
+
/* @__PURE__ */ jsx6(Text6, { color: C.signal, children: "\u25B0" }),
|
|
328
439
|
" ",
|
|
329
|
-
/* @__PURE__ */
|
|
440
|
+
/* @__PURE__ */ jsx6(Text6, { bold: true, children: "duto" }),
|
|
330
441
|
" ",
|
|
331
|
-
/* @__PURE__ */
|
|
442
|
+
/* @__PURE__ */ jsxs6(Text6, { color: C.muted, children: [
|
|
332
443
|
version,
|
|
333
444
|
" \xB7 ",
|
|
334
445
|
state.account ?? "\u2026",
|
|
@@ -341,20 +452,20 @@ function App({ session, version, system, onExit }) {
|
|
|
341
452
|
" no ar"
|
|
342
453
|
] })
|
|
343
454
|
] }),
|
|
344
|
-
/* @__PURE__ */
|
|
455
|
+
/* @__PURE__ */ jsx6(Text6, { color: conn.color, children: conn.text })
|
|
345
456
|
] }),
|
|
346
|
-
/* @__PURE__ */
|
|
347
|
-
/* @__PURE__ */
|
|
348
|
-
/* @__PURE__ */
|
|
349
|
-
mode.kind === "detail" && selected ? /* @__PURE__ */
|
|
350
|
-
/* @__PURE__ */
|
|
351
|
-
mode.kind === "filter" && /* @__PURE__ */
|
|
352
|
-
mode.kind === "port" && /* @__PURE__ */
|
|
353
|
-
mode.kind === "name" && /* @__PURE__ */
|
|
354
|
-
mode.kind !== "filter" && mode.kind !== "port" && mode.kind !== "name" && /* @__PURE__ */
|
|
355
|
-
/* @__PURE__ */
|
|
356
|
-
/* @__PURE__ */
|
|
357
|
-
/* @__PURE__ */
|
|
457
|
+
/* @__PURE__ */ jsx6(Text6, { children: " " }),
|
|
458
|
+
/* @__PURE__ */ jsx6(TunnelTable, { tunnels: state.tunnels, selected: selTun, focused: focus === "tunnels" }),
|
|
459
|
+
/* @__PURE__ */ jsx6(Text6, { children: " " }),
|
|
460
|
+
mode.kind === "detail" && selected ? /* @__PURE__ */ jsx6(Detail, { record: selected }) : mode.kind === "qr" && qrTunnel ? /* @__PURE__ */ jsx6(QrView, { tunnel: qrTunnel, link: mode.link, now }) : /* @__PURE__ */ jsx6(RequestList, { records, tunnelIndex, selected: selReq, focused: focus === "requests", height: listHeight, width, filter }),
|
|
461
|
+
/* @__PURE__ */ jsx6(Text6, { children: " " }),
|
|
462
|
+
mode.kind === "filter" && /* @__PURE__ */ jsx6(Prompt, { label: "Filtrar (t\xFAnel, m\xE9todo, caminho ou status):", value: mode.value, hint: "\u21B5 aplica \xB7 esc cancela" }),
|
|
463
|
+
mode.kind === "port" && /* @__PURE__ */ jsx6(Prompt, { label: "Novo t\xFAnel \u2014 porta local:", value: mode.value, hint: "\u21B5 continua \xB7 esc cancela" }),
|
|
464
|
+
mode.kind === "name" && /* @__PURE__ */ jsx6(Prompt, { label: `Nome para localhost:${mode.port} (opcional):`, value: mode.value, hint: "\u21B5 abre \xB7 esc cancela" }),
|
|
465
|
+
mode.kind !== "filter" && mode.kind !== "port" && mode.kind !== "name" && /* @__PURE__ */ jsx6(Text6, { color: message ? C[message.tone] : C.muted, children: message?.text ?? " " }),
|
|
466
|
+
/* @__PURE__ */ jsx6(Text6, { wrap: "truncate-end", children: KEYS.map(([k, label]) => /* @__PURE__ */ jsxs6(Text6, { children: [
|
|
467
|
+
/* @__PURE__ */ jsx6(Text6, { inverse: true, color: C.signal, children: ` ${k} ` }),
|
|
468
|
+
/* @__PURE__ */ jsx6(Text6, { color: C.muted, children: ` ${label} ` })
|
|
358
469
|
] }, k)) })
|
|
359
470
|
] });
|
|
360
471
|
}
|
|
@@ -429,7 +540,7 @@ var realSystem = {
|
|
|
429
540
|
};
|
|
430
541
|
|
|
431
542
|
// src/ui/fullscreen.tsx
|
|
432
|
-
import { jsx as
|
|
543
|
+
import { jsx as jsx7 } from "react/jsx-runtime";
|
|
433
544
|
function runFullscreen(session, specs, version) {
|
|
434
545
|
return withFullscreen(
|
|
435
546
|
process.stdout,
|
|
@@ -441,7 +552,7 @@ function runFullscreen(session, specs, version) {
|
|
|
441
552
|
app.unmount();
|
|
442
553
|
resolve(code);
|
|
443
554
|
};
|
|
444
|
-
const app = render(/* @__PURE__ */
|
|
555
|
+
const app = render(/* @__PURE__ */ jsx7(App, { session, version, system: realSystem, onExit: () => finish(0) }), { exitOnCtrlC: false });
|
|
445
556
|
signal.addEventListener("abort", () => void session.stop().then(() => finish(0)), { once: true });
|
|
446
557
|
session.start(specs).catch(() => void 0);
|
|
447
558
|
})
|