Mode operatoire : session Claude Code persistante dans un navigateur
Stack ttyd + tmux + Caddy, reproduction d'une installation en production. Inclut le frontend personnalise et les correctifs deja valides (FitAddon, refit apres changement de police, double collage Ctrl+V, OSC 52, window.open). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,949 @@
|
|||||||
|
# Mode operatoire : acces web a une session Claude Code persistante
|
||||||
|
|
||||||
|
Document destine a etre donne tel quel a une instance Claude Code disposant d'un acces root
|
||||||
|
sur l'hyperviseur / le serveur cible. Reproduit une stack deja en production.
|
||||||
|
|
||||||
|
Fichier joint indispensable : `ttyd-custom.html` (frontend personnalise, etape 6).
|
||||||
|
Si tu ne l'as pas, la section 6.3 contient son contenu integral.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. Resultat vise et architecture
|
||||||
|
|
||||||
|
Depuis n'importe quel navigateur, on ouvre `https://claude.mondomaine.fr`, on saisit un
|
||||||
|
identifiant, et on retombe sur **la meme session Claude Code**, exactement la ou elle en
|
||||||
|
etait, y compris apres fermeture du navigateur, redemarrage du PC ou changement d'appareil.
|
||||||
|
|
||||||
|
```
|
||||||
|
navigateur
|
||||||
|
-> https://claude.mondomaine.fr (443, TLS Let's Encrypt)
|
||||||
|
-> reverse proxy Caddy (basic_auth + websocket)
|
||||||
|
-> conteneur/VM claude-code : 7681 (jamais expose publiquement)
|
||||||
|
-> ttyd 1.7.7 (frontend xterm.js personnalise)
|
||||||
|
-> tmux attach-session -t claude (persistance de la session)
|
||||||
|
-> claude (Claude Code CLI)
|
||||||
|
```
|
||||||
|
|
||||||
|
Points de conception qui comptent :
|
||||||
|
|
||||||
|
- La persistance vient de **tmux**, pas de ttyd. ttyd n'est qu'un pont websocket vers un PTY.
|
||||||
|
- tmux tourne dans un service systemd **distinct** de ttyd. Redemarrer ttyd ne tue donc jamais
|
||||||
|
la session. C'est ce qui rend les mises a jour du frontend sans risque.
|
||||||
|
- L'authentification est faite par le **reverse proxy**, pas par ttyd. Le port 7681 doit rester
|
||||||
|
inaccessible depuis l'exterieur.
|
||||||
|
|
||||||
|
Reference materielle de l'installation existante (largement suffisant) :
|
||||||
|
LXC Proxmox non privilegie, Ubuntu 24.04, 4 vCPU, 4 Go RAM, disque 20 Go.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Prerequis
|
||||||
|
|
||||||
|
| Element | Detail |
|
||||||
|
|---|---|
|
||||||
|
| Un domaine | ex. `mondomaine.fr`, avec acces a la zone DNS |
|
||||||
|
| Une IP publique | fixe, ou dynamique + client DDNS |
|
||||||
|
| Acces au routeur/box | pour rediriger les ports 80 et 443 |
|
||||||
|
| Un serveur Linux | conteneur LXC, VM ou machine physique, x86_64 |
|
||||||
|
| Un compte Claude | abonnement Claude ou cle API Anthropic |
|
||||||
|
|
||||||
|
Choisir des maintenant :
|
||||||
|
- le sous-domaine : `claude.mondomaine.fr`
|
||||||
|
- l'IP interne du serveur Claude : ex. `192.168.1.123`
|
||||||
|
- l'IP interne du reverse proxy : ex. `192.168.1.2`
|
||||||
|
|
||||||
|
Le reverse proxy et le serveur Claude peuvent etre la meme machine. Le document les separe
|
||||||
|
car c'est plus propre, mais l'etape 7 indique comment tout regrouper.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Etape 1 : creer le conteneur
|
||||||
|
|
||||||
|
Sous Proxmox (adapter les identifiants et le pont reseau) :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pct create 123 local:vztmpl/ubuntu-24.04-standard_24.04-2_amd64.tar.zst \
|
||||||
|
--hostname claude-code \
|
||||||
|
--cores 4 --memory 4096 --swap 512 \
|
||||||
|
--rootfs local-lvm:20 \
|
||||||
|
--net0 name=eth0,bridge=vmbr0,ip=192.168.1.123/24,gw=192.168.1.1 \
|
||||||
|
--unprivileged 1 --features nesting=1 \
|
||||||
|
--onboot 1 --start 1
|
||||||
|
```
|
||||||
|
|
||||||
|
`nesting=1` n'est necessaire que si tu comptes faire tourner Docker ou des outils qui montent
|
||||||
|
des systemes de fichiers dans le conteneur. Sans cela, ce n'est pas requis.
|
||||||
|
|
||||||
|
Sur une VM ou une machine physique : installer Ubuntu 24.04, IP fixe, c'est tout.
|
||||||
|
|
||||||
|
Toute la suite s'execute **dans** ce serveur, en root.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Etape 2 : base systeme
|
||||||
|
|
||||||
|
```bash
|
||||||
|
apt update && apt install -y \
|
||||||
|
curl git tmux ca-certificates locales build-essential \
|
||||||
|
python3 jq unzip
|
||||||
|
|
||||||
|
# Locale UTF-8 : indispensable, sinon les accents et les caracteres de dessin
|
||||||
|
# de l'interface Claude Code s'affichent en carres.
|
||||||
|
sed -i 's/^# *fr_FR.UTF-8/fr_FR.UTF-8/' /etc/locale.gen
|
||||||
|
locale-gen
|
||||||
|
update-locale LANG=fr_FR.UTF-8
|
||||||
|
|
||||||
|
# Node.js 22 LTS
|
||||||
|
curl -fsSL https://deb.nodesource.com/setup_22.x | bash -
|
||||||
|
apt install -y nodejs
|
||||||
|
|
||||||
|
# Claude Code
|
||||||
|
npm install -g @anthropic-ai/claude-code
|
||||||
|
claude --version
|
||||||
|
```
|
||||||
|
|
||||||
|
Configuration tmux minimale mais **obligatoire** pour le confort de l'interface web :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cat > /root/.tmux.conf <<'EOF'
|
||||||
|
set -g mouse on
|
||||||
|
set -g set-clipboard on
|
||||||
|
EOF
|
||||||
|
```
|
||||||
|
|
||||||
|
- `mouse on` : molette = defilement de l'historique, selection a la souris.
|
||||||
|
- `set-clipboard on` : tmux emet des sequences OSC 52, que le frontend convertit en
|
||||||
|
ecriture dans le presse-papier du navigateur. Sans cela, la copie depuis le terminal
|
||||||
|
distant ne remonte pas jusqu'a la machine cliente.
|
||||||
|
|
||||||
|
Authentification Claude : lancer `claude` une premiere fois en SSH et suivre le flux de
|
||||||
|
connexion. Les identifiants sont stockes dans `/root/.claude/.credentials.json`.
|
||||||
|
|
||||||
|
Notification de fin de commande (facultatif, utilise par l'etape 6) :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cat >> /root/.bashrc <<'EOF'
|
||||||
|
|
||||||
|
# Notification terminal : emet OSC 777 a chaque prompt (capte par le JS de ttyd)
|
||||||
|
__ttyd_prompt_cmd() {
|
||||||
|
local code=$?
|
||||||
|
printf '\033]777;%d\007' "$code"
|
||||||
|
}
|
||||||
|
PROMPT_COMMAND="${PROMPT_COMMAND:+$PROMPT_COMMAND; }__ttyd_prompt_cmd"
|
||||||
|
EOF
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Etape 3 : installer ttyd
|
||||||
|
|
||||||
|
Binaire statique, pas de compilation :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fL -o /usr/local/bin/ttyd \
|
||||||
|
https://github.com/tsl0922/ttyd/releases/download/1.7.7/ttyd.x86_64
|
||||||
|
chmod +x /usr/local/bin/ttyd
|
||||||
|
ttyd --version # doit afficher 1.7.7
|
||||||
|
```
|
||||||
|
|
||||||
|
Verifier le nom exact de l'asset sur la page des releases si le telechargement echoue ;
|
||||||
|
les noms de fichiers changent selon les versions. Rester en **1.7.7** : le frontend
|
||||||
|
personnalise de l'etape 6 a ete corrige contre ce bundle xterm.js precis, une autre version
|
||||||
|
peut deplacer les points d'accroche.
|
||||||
|
|
||||||
|
Transfert de fichiers dans le navigateur (facultatif, active par `-t enableTrzsz=true`) :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fL https://github.com/trzsz/trzsz-go/releases/download/v1.1.8/trzsz_1.1.8_linux_x86_64.tar.gz \
|
||||||
|
| tar xz -C /tmp
|
||||||
|
install -m755 /tmp/trzsz_1.1.8_linux_x86_64/tr[sz]* /usr/local/bin/
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Etape 4 : session tmux persistante et service ttyd
|
||||||
|
|
||||||
|
### 5.1 Script de session
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cat > /usr/local/bin/claude-session.sh <<'EOF'
|
||||||
|
#!/bin/bash
|
||||||
|
# Maintient la session tmux "claude" toujours en vie
|
||||||
|
|
||||||
|
tmux has-session -t claude 2>/dev/null || \
|
||||||
|
tmux new-session -d -s claude -c /root -x 220 -y 50
|
||||||
|
|
||||||
|
echo "[claude-session] Session tmux 'claude' active"
|
||||||
|
|
||||||
|
while true; do
|
||||||
|
if ! tmux has-session -t claude 2>/dev/null; then
|
||||||
|
echo "[claude-session] Session perdue, recreation..."
|
||||||
|
tmux new-session -d -s claude -c /root -x 220 -y 50
|
||||||
|
fi
|
||||||
|
sleep 5
|
||||||
|
done
|
||||||
|
EOF
|
||||||
|
chmod +x /usr/local/bin/claude-session.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Les dimensions `-x 220 -y 50` ne sont qu'une valeur de depart : le premier client qui
|
||||||
|
s'attache redimensionne la session.
|
||||||
|
|
||||||
|
### 5.2 Script de lancement ttyd
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cat > /usr/local/bin/claude-start.sh <<'EOF'
|
||||||
|
#!/bin/bash
|
||||||
|
export LANG=fr_FR.UTF-8
|
||||||
|
export LC_ALL=fr_FR.UTF-8
|
||||||
|
exec /usr/local/bin/ttyd --port 7681 --writable --index /etc/ttyd/index.html \
|
||||||
|
-t enableTrzsz=true tmux attach-session -t claude
|
||||||
|
EOF
|
||||||
|
chmod +x /usr/local/bin/claude-start.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Ne pas ajouter `--credential` ici : l'authentification est geree par le reverse proxy.
|
||||||
|
Si le serveur Claude est expose autrement que derriere le proxy, alors il faut la remettre.
|
||||||
|
|
||||||
|
### 5.3 Unites systemd
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cat > /etc/systemd/system/claude-session.service <<'EOF'
|
||||||
|
[Unit]
|
||||||
|
Description=Claude Code - Session tmux persistante
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=root
|
||||||
|
Environment=HOME=/root
|
||||||
|
ExecStart=/usr/local/bin/claude-session.sh
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
EOF
|
||||||
|
|
||||||
|
cat > /etc/systemd/system/ttyd.service <<'EOF'
|
||||||
|
[Unit]
|
||||||
|
Description=Claude Code - Web Terminal (ttyd)
|
||||||
|
After=claude-session.service
|
||||||
|
Requires=claude-session.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=root
|
||||||
|
Environment=HOME=/root
|
||||||
|
ExecStart=/usr/local/bin/claude-start.sh
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
EOF
|
||||||
|
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl enable --now claude-session.service ttyd.service
|
||||||
|
systemctl status ttyd.service --no-pager
|
||||||
|
```
|
||||||
|
|
||||||
|
Le couple `After=` + `Requires=` garantit l'ordre au demarrage. La separation en deux unites
|
||||||
|
est **le point structurant** : `systemctl restart ttyd` ne touche pas au serveur tmux, donc la
|
||||||
|
session Claude survit a toutes les manipulations du frontend.
|
||||||
|
|
||||||
|
Test local avant d'aller plus loin :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -sI http://127.0.0.1:7681/ | head -3
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Etape 5 : frontend personnalise
|
||||||
|
|
||||||
|
C'est ici que se trouve tout le travail d'ergonomie. Le frontend stock de ttyd est un terminal
|
||||||
|
nu : pas de barre d'outils, pas de themes, pas de recherche, copier-coller approximatif.
|
||||||
|
|
||||||
|
### 6.1 Ce que le bloc personnalise apporte
|
||||||
|
|
||||||
|
- barre d'outils fixe de 30 px : indicateur de connexion websocket, taille de police +/-,
|
||||||
|
effacer, recherche, selecteur de theme (5 themes), boutons de split et de navigation tmux
|
||||||
|
- taille de police et theme persistes en localStorage
|
||||||
|
- recherche dans l'historique du buffer, `Ctrl+F`, `Entree` / `Maj+Entree` pour naviguer
|
||||||
|
- menu contextuel au clic droit (copier, coller, rechercher, effacer, police)
|
||||||
|
- `Ctrl+C` copie s'il y a une selection, sinon envoie SIGINT ; `Ctrl+V` colle
|
||||||
|
- `Ctrl+molette` pour zoomer
|
||||||
|
- OSC 52 : le presse-papier de tmux remonte dans celui du navigateur
|
||||||
|
- OSC 777 : notification desktop quand une commande longue se termine, onglet en arriere-plan
|
||||||
|
- les liens cliquables s'ouvrent dans un nouvel onglet au lieu de remplacer la page
|
||||||
|
|
||||||
|
### 6.2 Installation
|
||||||
|
|
||||||
|
Recuperer d'abord le `index.html` stock que ttyd embarque, puis y injecter le bloc :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p /etc/ttyd
|
||||||
|
# instance jetable sur un autre port, uniquement pour recuperer la page embarquee
|
||||||
|
/usr/local/bin/ttyd --port 7699 tmux ls >/dev/null 2>&1 & P=$!
|
||||||
|
sleep 1
|
||||||
|
curl -s http://127.0.0.1:7699/ > /etc/ttyd/index.stock.html
|
||||||
|
kill $P
|
||||||
|
wc -c /etc/ttyd/index.stock.html # 729693 octets attendus en 1.7.7
|
||||||
|
```
|
||||||
|
|
||||||
|
Le service `ttyd` en production n'a pas besoin d'etre arrete : le port est different.
|
||||||
|
|
||||||
|
Placer `ttyd-custom.html` (fourni avec ce document) dans `/root/`, puis :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 - <<'EOF'
|
||||||
|
stock = open('/etc/ttyd/index.stock.html', encoding='utf-8').read()
|
||||||
|
custom = open('/root/ttyd-custom.html', encoding='utf-8').read()
|
||||||
|
tail = '</body></html>'
|
||||||
|
assert stock.rstrip().endswith(tail), "structure du index.html stock inattendue"
|
||||||
|
out = stock.rstrip()[:-len(tail)] + custom + tail + '\n'
|
||||||
|
open('/etc/ttyd/index.html','w',encoding='utf-8').write(out)
|
||||||
|
print(len(out), 'octets ecrits')
|
||||||
|
EOF
|
||||||
|
```
|
||||||
|
|
||||||
|
Verifier la syntaxe avant de recharger la page (une erreur JS ici donne un terminal blanc) :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 -c "
|
||||||
|
s=open('/etc/ttyd/index.html',encoding='utf-8').read()
|
||||||
|
js=s[s.rindex('<script>')+8:s.rindex('</script>')]
|
||||||
|
open('/tmp/check.js','w',encoding='utf-8').write(js)"
|
||||||
|
node --check /tmp/check.js && echo "JS OK"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Aucun redemarrage n'est necessaire.** ttyd relit le fichier `--index` sur le disque a chaque
|
||||||
|
requete HTTP et repond `cache-control: no-store` : un simple rechargement de page suffit, la
|
||||||
|
session n'est jamais coupee. Toujours garder une sauvegarde avant modification :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp /etc/ttyd/index.html /etc/ttyd/index.html.bak-$(date +%Y%m%d)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.3 Contenu de `ttyd-custom.html`
|
||||||
|
|
||||||
|
Si le fichier joint est absent, creer `/root/ttyd-custom.html` avec exactement ce contenu.
|
||||||
|
Les sequences `\x02` (prefixe tmux Ctrl+B) et `\x1b` (ESC) sont des echappements JavaScript
|
||||||
|
volontaires : ne pas les convertir en caracteres bruts.
|
||||||
|
|
||||||
|
```html
|
||||||
|
<style>
|
||||||
|
#ttyd-bar{position:fixed;top:0;left:0;right:0;height:30px;background:#111;display:flex;align-items:center;padding:0 6px;gap:4px;z-index:8000;border-bottom:1px solid #2a2a2a;}
|
||||||
|
#ttyd-bar button{background:#222;color:#aaa;border:1px solid #333;border-radius:3px;padding:1px 7px;cursor:pointer;font:11px monospace;white-space:nowrap;}
|
||||||
|
#ttyd-bar button:hover{background:#333;color:#fff;}
|
||||||
|
#ttyd-bar select{background:#222;color:#aaa;border:1px solid #333;border-radius:3px;padding:1px 4px;font:11px monospace;cursor:pointer;}
|
||||||
|
#ttyd-conn{width:8px;height:8px;border-radius:50%;background:#555;margin:0 4px;flex-shrink:0;}
|
||||||
|
#ttyd-conn.ok{background:#4caf50;}
|
||||||
|
#ttyd-conn.err{background:#f44336;animation:blink 1s infinite;}
|
||||||
|
@keyframes blink{0%,100%{opacity:1}50%{opacity:.3}}
|
||||||
|
#ttyd-spacer{flex:1;}
|
||||||
|
#ttyd-search-bar{position:fixed;top:30px;right:0;background:#1a1a1a;border:1px solid #333;border-top:none;border-right:none;border-radius:0 0 0 6px;display:flex;align-items:center;gap:4px;padding:4px 8px;z-index:8001;display:none;}
|
||||||
|
#ttyd-search-bar input{background:#111;color:#ddd;border:1px solid #333;border-radius:3px;padding:2px 6px;font:12px monospace;width:200px;outline:none;}
|
||||||
|
#ttyd-search-bar span{color:#666;font:11px monospace;min-width:60px;}
|
||||||
|
#ttyd-search-bar button{background:#222;color:#aaa;border:1px solid #333;border-radius:3px;padding:1px 7px;cursor:pointer;font:11px monospace;}
|
||||||
|
#terminal-container{position:absolute !important;top:30px;left:0;right:0;bottom:0;height:auto !important;padding-top:0 !important;}
|
||||||
|
#ttyd-ctx{position:fixed;background:#1e1e1e;border:1px solid #444;border-radius:4px;padding:4px 0;z-index:9999;min-width:150px;box-shadow:0 4px 12px rgba(0,0,0,.5);display:none;}
|
||||||
|
#ttyd-ctx div{padding:5px 14px;cursor:pointer;color:#ccc;font:12px monospace;}
|
||||||
|
#ttyd-ctx div:hover{background:#2d2d2d;color:#fff;}
|
||||||
|
#ttyd-ctx hr{border:none;border-top:1px solid #333;margin:3px 0;}
|
||||||
|
#ttyd-toast{position:fixed;bottom:8px;right:8px;background:rgba(20,20,20,.93);color:#7fff7f;font:12px monospace;padding:4px 10px;border-radius:4px;z-index:9999;pointer-events:none;opacity:0;transition:opacity .2s;}
|
||||||
|
#ttyd-toast.show{opacity:1;}
|
||||||
|
</style>
|
||||||
|
<div id="ttyd-bar">
|
||||||
|
<div id="ttyd-conn" title="Connexion WebSocket"></div>
|
||||||
|
<button onclick="ttydFontSize(-1)" title="Police -">A−</button>
|
||||||
|
<span id="ttyd-fsize" style="color:#555;font:11px monospace;min-width:28px;text-align:center;">13</span>
|
||||||
|
<button onclick="ttydFontSize(+1)" title="Police +">A+</button>
|
||||||
|
<button onclick="ttydClear()" title="Vider">✕ Clear</button>
|
||||||
|
<button onclick="ttydSearch()" title="Rechercher (Ctrl+F)">⌕ Rech.</button>
|
||||||
|
<select id="ttyd-theme-sel" onchange="ttydSetTheme(this.value)" title="Thème"></select>
|
||||||
|
<button onclick="ttydSplit('h')" title="Split vertical (côte à côte)">⬜ ⬜</button>
|
||||||
|
<button onclick="ttydSplit('v')" title="Split horizontal (haut/bas)">⬜<br style='line-height:3px'>⬜</button>
|
||||||
|
<button onclick="ttydPaneClose()" title="Fermer ce panneau">✕ Pan.</button>
|
||||||
|
<button onclick="ttydPaneNav('Left')" title="Panneau gauche">◀</button>
|
||||||
|
<button onclick="ttydPaneNav('Right')" title="Panneau droite">▶</button>
|
||||||
|
<button onclick="ttydPaneNav('Up')" title="Panneau haut">▲</button>
|
||||||
|
<button onclick="ttydPaneNav('Down')" title="Panneau bas">▼</button>
|
||||||
|
<div id="ttyd-spacer"></div>
|
||||||
|
<span style="color:#333;font:10px monospace;">Ctrl+C=copier/inter. Ctrl+V=coller clic droit=menu</span>
|
||||||
|
</div>
|
||||||
|
<div id="ttyd-search-bar">
|
||||||
|
<input id="ttyd-sq" placeholder="Rechercher…" oninput="ttydDoSearch()" onkeydown="ttydSearchKey(event)">
|
||||||
|
<span id="ttyd-sr">—</span>
|
||||||
|
<button onclick="ttydSearchNav(-1)">▲</button>
|
||||||
|
<button onclick="ttydSearchNav(+1)">▼</button>
|
||||||
|
<button onclick="ttydSearchClose()">✕</button>
|
||||||
|
</div>
|
||||||
|
<div id="ttyd-ctx"></div>
|
||||||
|
<div id="ttyd-toast"></div>
|
||||||
|
<script>
|
||||||
|
(function(){
|
||||||
|
var _fontSize = parseInt(localStorage.getItem('ttyd-font') || '13');
|
||||||
|
var _themes = {"Defaut": {"background": "#2b2b2b", "foreground": "#d2d2d2", "cursor": "#adadad", "black": "#000000", "red": "#d81e00", "green": "#5ea702", "yellow": "#cfae00", "blue": "#427ab3", "magenta": "#89658e", "cyan": "#00a7aa", "white": "#dbded8", "brightBlack": "#686a66", "brightRed": "#f54235", "brightGreen": "#99e343", "brightYellow": "#fdeb61", "brightBlue": "#84b0d8", "brightMagenta": "#bc94b7", "brightCyan": "#37e6e8", "brightWhite": "#f1f1f0"}, "Dracula": {"background": "#282a36", "foreground": "#f8f8f2", "cursor": "#f8f8f2", "black": "#21222c", "red": "#ff5555", "green": "#50fa7b", "yellow": "#f1fa8c", "blue": "#bd93f9", "magenta": "#ff79c6", "cyan": "#8be9fd", "white": "#f8f8f2", "brightBlack": "#6272a4", "brightRed": "#ff6e6e", "brightGreen": "#69ff94", "brightYellow": "#ffffa5", "brightBlue": "#d6acff", "brightMagenta": "#ff92df", "brightCyan": "#a4ffff", "brightWhite": "#ffffff"}, "One Dark": {"background": "#282c34", "foreground": "#abb2bf", "cursor": "#528bff", "black": "#3f4451", "red": "#e06c75", "green": "#98c379", "yellow": "#e5c07b", "blue": "#61afef", "magenta": "#c678dd", "cyan": "#56b6c2", "white": "#abb2bf", "brightBlack": "#4f5666", "brightRed": "#ff616e", "brightGreen": "#a5e075", "brightYellow": "#f0a45d", "brightBlue": "#4dc4ff", "brightMagenta": "#de73ff", "brightCyan": "#4cd1e0", "brightWhite": "#ffffff"}, "Solarized": {"background": "#002b36", "foreground": "#839496", "cursor": "#839496", "black": "#073642", "red": "#dc322f", "green": "#859900", "yellow": "#b58900", "blue": "#268bd2", "magenta": "#d33682", "cyan": "#2aa198", "white": "#eee8d5", "brightBlack": "#002b36", "brightRed": "#cb4b16", "brightGreen": "#586e75", "brightYellow": "#657b83", "brightBlue": "#839496", "brightMagenta": "#6c71c4", "brightCyan": "#93a1a1", "brightWhite": "#fdf6e3"}, "Clair": {"background": "#ffffff", "foreground": "#383a42", "cursor": "#526fff", "black": "#383a42", "red": "#e45649", "green": "#50a14f", "yellow": "#c18401", "blue": "#4078f2", "magenta": "#a626a4", "cyan": "#0184bc", "white": "#fafafa", "brightBlack": "#4f525e", "brightRed": "#e06c75", "brightGreen": "#98c379", "brightYellow": "#e5c07b", "brightBlue": "#61afef", "brightMagenta": "#c678dd", "brightCyan": "#56b6c2", "brightWhite": "#ffffff"}};
|
||||||
|
var _themeName = localStorage.getItem('ttyd-theme') || 'Defaut';
|
||||||
|
var _searchMatches = [], _searchIdx = 0;
|
||||||
|
var _notifPerm = false;
|
||||||
|
var _lastCmdStart = 0;
|
||||||
|
|
||||||
|
/* ---- Toast ---- */
|
||||||
|
function toast(msg, dur) {
|
||||||
|
var t = document.getElementById('ttyd-toast');
|
||||||
|
t.textContent = msg; t.classList.add('show');
|
||||||
|
clearTimeout(t._tid);
|
||||||
|
t._tid = setTimeout(function(){ t.classList.remove('show'); }, dur||1800);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Clipboard ---- */
|
||||||
|
function writeClip(text) {
|
||||||
|
if (!text) return;
|
||||||
|
var ok = function(){ toast('Copié : ' + text.slice(0,40).replace(/\n/g,' ') + (text.length>40?'..':'')); };
|
||||||
|
if (navigator.clipboard && navigator.clipboard.writeText)
|
||||||
|
navigator.clipboard.writeText(text).then(ok).catch(function(){ legacyCopy(text); });
|
||||||
|
else legacyCopy(text);
|
||||||
|
}
|
||||||
|
function legacyCopy(text) {
|
||||||
|
var ta = document.createElement('textarea');
|
||||||
|
ta.value = text; ta.style.cssText='position:fixed;top:0;left:0;width:1px;height:1px;opacity:0;';
|
||||||
|
document.body.appendChild(ta); ta.focus(); ta.select();
|
||||||
|
var ok=false; try{ ok=document.execCommand('copy'); }catch(e){}
|
||||||
|
document.body.removeChild(ta);
|
||||||
|
toast(ok ? 'Copié (legacy)' : 'Erreur copie');
|
||||||
|
}
|
||||||
|
function readClip(cb) {
|
||||||
|
if (navigator.clipboard && navigator.clipboard.readText)
|
||||||
|
navigator.clipboard.readText().then(cb).catch(function(){ toast('Accès presse-papier refusé'); });
|
||||||
|
else toast('API clipboard non disponible');
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Police ---- */
|
||||||
|
window.ttydFontSize = function(d) {
|
||||||
|
_fontSize = Math.max(8, Math.min(28, _fontSize + d));
|
||||||
|
localStorage.setItem('ttyd-font', _fontSize);
|
||||||
|
if (window.term) { window.term.options.fontSize = _fontSize; window.dispatchEvent(new Event('resize')); }
|
||||||
|
document.getElementById('ttyd-fsize').textContent = _fontSize;
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ---- Clear ---- */
|
||||||
|
window.ttydClear = function() { if (window.term) window.term.clear(); };
|
||||||
|
|
||||||
|
/* ---- Tmux split & navigation ---- */
|
||||||
|
function sendRaw(data) {
|
||||||
|
// Envoie données brutes au PTY sans bracketed paste
|
||||||
|
var term = window.term;
|
||||||
|
if (!term) return;
|
||||||
|
if (term._core && term._core.coreService)
|
||||||
|
term._core.coreService.triggerDataEvent(data, true);
|
||||||
|
else
|
||||||
|
term.paste(data);
|
||||||
|
}
|
||||||
|
window.ttydSplit = function(dir) {
|
||||||
|
// Ctrl+B (\x02) + % pour vertical, + " pour horizontal
|
||||||
|
sendRaw('\x02' + (dir === 'h' ? '%' : '"'));
|
||||||
|
};
|
||||||
|
window.ttydPaneClose = function() {
|
||||||
|
// Ctrl+B + x, puis y pour confirmer
|
||||||
|
sendRaw('\x02x');
|
||||||
|
setTimeout(function(){ sendRaw('y'); }, 200);
|
||||||
|
};
|
||||||
|
window.ttydPaneNav = function(dir) {
|
||||||
|
var arrows = {Left:'\x1b[D', Right:'\x1b[C', Up:'\x1b[A', Down:'\x1b[B'};
|
||||||
|
if (arrows[dir]) sendRaw('\x02' + arrows[dir]);
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ---- Thèmes ---- */
|
||||||
|
var sel = document.getElementById('ttyd-theme-sel');
|
||||||
|
Object.keys(_themes).forEach(function(n){
|
||||||
|
var o = document.createElement('option'); o.value=n; o.textContent=n;
|
||||||
|
if(n===_themeName) o.selected=true;
|
||||||
|
sel.appendChild(o);
|
||||||
|
});
|
||||||
|
window.ttydSetTheme = function(name) {
|
||||||
|
if (!_themes[name] || !window.term) return;
|
||||||
|
window.term.options.theme = _themes[name];
|
||||||
|
_themeName = name;
|
||||||
|
localStorage.setItem('ttyd-theme', name);
|
||||||
|
toast('Thème : ' + name);
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ---- Recherche ---- */
|
||||||
|
var _searchOpen = false;
|
||||||
|
window.ttydSearch = function() {
|
||||||
|
var bar = document.getElementById('ttyd-search-bar');
|
||||||
|
_searchOpen = !_searchOpen;
|
||||||
|
bar.style.display = _searchOpen ? 'flex' : 'none';
|
||||||
|
if (_searchOpen) setTimeout(function(){ document.getElementById('ttyd-sq').focus(); }, 50);
|
||||||
|
};
|
||||||
|
window.ttydSearchClose = function() {
|
||||||
|
_searchOpen = false;
|
||||||
|
document.getElementById('ttyd-search-bar').style.display = 'none';
|
||||||
|
_searchMatches = []; document.getElementById('ttyd-sr').textContent = '—';
|
||||||
|
};
|
||||||
|
window.ttydDoSearch = function() {
|
||||||
|
var q = document.getElementById('ttyd-sq').value;
|
||||||
|
var sr = document.getElementById('ttyd-sr');
|
||||||
|
if (!q || !window.term) { _searchMatches=[]; sr.textContent='—'; return; }
|
||||||
|
var buf = window.term.buffer.active;
|
||||||
|
var matches = [];
|
||||||
|
var total = buf.length;
|
||||||
|
for (var i = 0; i < total; i++) {
|
||||||
|
var line = buf.getLine(i);
|
||||||
|
if (line && line.translateToString().indexOf(q) >= 0) matches.push(i);
|
||||||
|
}
|
||||||
|
_searchMatches = matches; _searchIdx = matches.length - 1;
|
||||||
|
sr.textContent = matches.length ? ((_searchIdx+1)+'/'+matches.length) : '0 résultat';
|
||||||
|
if (matches.length) window.term.scrollToLine(matches[_searchIdx]);
|
||||||
|
};
|
||||||
|
window.ttydSearchNav = function(dir) {
|
||||||
|
if (!_searchMatches.length) return;
|
||||||
|
_searchIdx = (_searchIdx + dir + _searchMatches.length) % _searchMatches.length;
|
||||||
|
document.getElementById('ttyd-sr').textContent = (_searchIdx+1)+'/'+_searchMatches.length;
|
||||||
|
window.term.scrollToLine(_searchMatches[_searchIdx]);
|
||||||
|
};
|
||||||
|
window.ttydSearchKey = function(e) {
|
||||||
|
if (e.key === 'Enter') ttydSearchNav(e.shiftKey ? -1 : 1);
|
||||||
|
if (e.key === 'Escape') ttydSearchClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ---- Connexion ---- */
|
||||||
|
var _connEl = document.getElementById('ttyd-conn');
|
||||||
|
function checkConn() {
|
||||||
|
fetch('/token', {cache:'no-store'})
|
||||||
|
.then(function(r){ _connEl.className = r.ok ? 'ok' : 'err'; })
|
||||||
|
.catch(function(){ _connEl.className = 'err'; });
|
||||||
|
}
|
||||||
|
checkConn();
|
||||||
|
setInterval(checkConn, 5000);
|
||||||
|
|
||||||
|
/* ---- Patch window.open (liens WebLinksAddon) ---- */
|
||||||
|
var _open = window.open;
|
||||||
|
window.open = function(url, target, features) {
|
||||||
|
if (url === undefined || url === null || url === '') {
|
||||||
|
var loc = {};
|
||||||
|
Object.defineProperty(loc,'href',{set:function(u){
|
||||||
|
var a=document.createElement('a'); a.href=u; a.target='_blank'; a.rel='noopener noreferrer';
|
||||||
|
document.body.appendChild(a); a.click(); document.body.removeChild(a);
|
||||||
|
}});
|
||||||
|
return {opener:null, location:loc};
|
||||||
|
}
|
||||||
|
return _open.apply(this, arguments);
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ---- Menu contextuel ---- */
|
||||||
|
var _ctx = document.getElementById('ttyd-ctx');
|
||||||
|
function hideCtx(){ _ctx.style.display='none'; }
|
||||||
|
document.addEventListener('click', hideCtx);
|
||||||
|
document.addEventListener('keydown', function(e){ if(e.key==='Escape'){ hideCtx(); ttydSearchClose(); }});
|
||||||
|
function showCtx(x,y) {
|
||||||
|
hideCtx();
|
||||||
|
_ctx.innerHTML = '';
|
||||||
|
var items = [
|
||||||
|
['Copier', function(){ if(window.term){var s=window.term.getSelection();if(s)writeClip(s);} }],
|
||||||
|
['Coller', function(){ if(window.term) readClip(function(t){window.term.paste(t);}); }],
|
||||||
|
null,
|
||||||
|
['Rechercher…', ttydSearch],
|
||||||
|
null,
|
||||||
|
['Effacer', ttydClear],
|
||||||
|
['Police +', function(){ttydFontSize(+1);}],
|
||||||
|
['Police −', function(){ttydFontSize(-1);}],
|
||||||
|
];
|
||||||
|
items.forEach(function(item){
|
||||||
|
if(!item){var hr=document.createElement('hr');_ctx.appendChild(hr);return;}
|
||||||
|
var d=document.createElement('div'); d.textContent=item[0];
|
||||||
|
d.addEventListener('click',function(e){e.stopPropagation();hideCtx();item[1]();});
|
||||||
|
_ctx.appendChild(d);
|
||||||
|
});
|
||||||
|
_ctx.style.cssText='display:block;left:'+x+'px;top:'+y+'px;';
|
||||||
|
var r=_ctx.getBoundingClientRect();
|
||||||
|
if(r.right>window.innerWidth) _ctx.style.left=(x-r.width)+'px';
|
||||||
|
if(r.bottom>window.innerHeight) _ctx.style.top=(y-r.height)+'px';
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Notifications ---- */
|
||||||
|
function requestNotifPerm() {
|
||||||
|
if ('Notification' in window && Notification.permission === 'default')
|
||||||
|
Notification.requestPermission().then(function(p){ _notifPerm = p==='granted'; });
|
||||||
|
else _notifPerm = ('Notification' in window && Notification.permission === 'granted');
|
||||||
|
}
|
||||||
|
function desktopNotif(title, body) {
|
||||||
|
if (_notifPerm && 'Notification' in window)
|
||||||
|
new Notification(title, {body:body, icon:''});
|
||||||
|
}
|
||||||
|
requestNotifPerm();
|
||||||
|
|
||||||
|
/* ---- Initialisation terminal ---- */
|
||||||
|
function waitForTerm() {
|
||||||
|
var term = window.term;
|
||||||
|
if (!term || !term.parser) { setTimeout(waitForTerm, 100); return; }
|
||||||
|
|
||||||
|
/* Police initiale */
|
||||||
|
term.options.fontSize = _fontSize;
|
||||||
|
document.getElementById('ttyd-fsize').textContent = _fontSize;
|
||||||
|
|
||||||
|
/* Thème initial */
|
||||||
|
if (_themes[_themeName]) term.options.theme = _themes[_themeName];
|
||||||
|
|
||||||
|
/* Refit : le fit initial a ete calcule avec l'ancienne taille de cellule */
|
||||||
|
setTimeout(function(){ window.dispatchEvent(new Event('resize')); }, 50);
|
||||||
|
|
||||||
|
/* OSC 52 : tmux set-clipboard -> presse-papier */
|
||||||
|
try {
|
||||||
|
term.parser.registerOscHandler(52, function(data) {
|
||||||
|
var parts = data.split(';');
|
||||||
|
if (parts.length>=2 && parts[1] && parts[1]!=='?') {
|
||||||
|
try { writeClip(atob(parts[1])); } catch(e){}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
} catch(e){}
|
||||||
|
|
||||||
|
/* OSC 777 : PROMPT_COMMAND -> notification fin de commande */
|
||||||
|
try {
|
||||||
|
term.parser.registerOscHandler(777, function(data) {
|
||||||
|
var code = parseInt(data) || 0;
|
||||||
|
var elapsed = Date.now() - _lastCmdStart;
|
||||||
|
/* Notifier si onglet caché ET commande > 5s */
|
||||||
|
if (document.hidden && elapsed > 5000) {
|
||||||
|
var msg = code === 0 ? 'Commande terminée (succès)' : 'Commande terminée (code '+code+')';
|
||||||
|
desktopNotif('Terminal', msg);
|
||||||
|
}
|
||||||
|
_lastCmdStart = Date.now();
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
} catch(e){}
|
||||||
|
|
||||||
|
/* Ctrl+C = copie si sélection, sinon SIGINT */
|
||||||
|
/* Ctrl+V = colle */
|
||||||
|
/* Ctrl+F = recherche */
|
||||||
|
term.attachCustomKeyEventHandler(function(e) {
|
||||||
|
if (e.type !== 'keydown') return true;
|
||||||
|
if (e.ctrlKey && !e.shiftKey && !e.altKey && !e.metaKey) {
|
||||||
|
if (e.code==='KeyC') {
|
||||||
|
var s = term.getSelection ? term.getSelection() : '';
|
||||||
|
if (s) { writeClip(s); return false; }
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (e.code==='KeyV') {
|
||||||
|
e.preventDefault();
|
||||||
|
readClip(function(t){ term.paste(t); }); return false;
|
||||||
|
}
|
||||||
|
if (e.code==='KeyF') { ttydSearch(); return false; }
|
||||||
|
}
|
||||||
|
if (e.ctrlKey && e.shiftKey) {
|
||||||
|
if (e.code==='KeyC') { var s=term.getSelection?term.getSelection():''; if(s) writeClip(s); return false; }
|
||||||
|
if (e.code==='KeyV') { e.preventDefault(); readClip(function(t){ term.paste(t); }); return false; }
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
/* Clic droit -> menu contextuel */
|
||||||
|
term.element.addEventListener('contextmenu', function(e){
|
||||||
|
e.preventDefault(); showCtx(e.clientX, e.clientY);
|
||||||
|
});
|
||||||
|
|
||||||
|
/* Zoom souris */
|
||||||
|
term.element.addEventListener('wheel', function(e){
|
||||||
|
if (!e.ctrlKey) return;
|
||||||
|
e.preventDefault();
|
||||||
|
ttydFontSize(e.deltaY < 0 ? 1 : -1);
|
||||||
|
}, {passive:false});
|
||||||
|
}
|
||||||
|
waitForTerm();
|
||||||
|
|
||||||
|
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Etape 6 : reverse proxy, domaine et TLS
|
||||||
|
|
||||||
|
Caddy est le choix retenu : HTTPS automatique par Let's Encrypt, websocket natif sans
|
||||||
|
configuration, fichier de conf de trois lignes. Nginx ou Traefik fonctionnent aussi, mais
|
||||||
|
demandent une configuration websocket explicite (`Upgrade` / `Connection`), source classique
|
||||||
|
de terminal qui ne se connecte pas.
|
||||||
|
|
||||||
|
### 7.1 Installer Caddy
|
||||||
|
|
||||||
|
Sur une machine dediee (ex. `192.168.1.2`) ou sur le serveur Claude lui-meme :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
apt install -y debian-keyring debian-archive-keyring apt-transport-https curl
|
||||||
|
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' \
|
||||||
|
| gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
|
||||||
|
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' \
|
||||||
|
| tee /etc/apt/sources.list.d/caddy-stable.list
|
||||||
|
apt update && apt install -y caddy
|
||||||
|
caddy version
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.2 Generer le hash du mot de passe
|
||||||
|
|
||||||
|
```bash
|
||||||
|
caddy hash-password
|
||||||
|
# saisir le mot de passe deux fois, copier le hash bcrypt affiche ($2a$14$...)
|
||||||
|
```
|
||||||
|
|
||||||
|
Stocker le mot de passe en clair dans un gestionnaire de mots de passe : le hash bcrypt n'est
|
||||||
|
pas reversible, un mot de passe perdu impose d'en regenerer un.
|
||||||
|
|
||||||
|
### 7.3 Caddyfile
|
||||||
|
|
||||||
|
```
|
||||||
|
claude.mondomaine.fr {
|
||||||
|
basic_auth {
|
||||||
|
claude $2a$14$REMPLACER_PAR_LE_HASH_GENERE
|
||||||
|
}
|
||||||
|
reverse_proxy 192.168.1.123:7681
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
caddy validate --config /etc/caddy/Caddyfile
|
||||||
|
systemctl reload caddy
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes :
|
||||||
|
|
||||||
|
- `basic_auth` est la directive de Caddy >= 2.8. Sur une version anterieure, ecrire `basicauth`.
|
||||||
|
- Le hash contient des `$`. Dans un Caddyfile ce n'est pas un probleme, mais si tu generes ce
|
||||||
|
fichier depuis un script shell, protege-le des expansions (guillemets simples).
|
||||||
|
- Les websockets passent seuls : ne surtout pas ajouter de `header_up Upgrade` manuel.
|
||||||
|
- Si le proxy et le serveur Claude sont sur la meme machine, remplacer par
|
||||||
|
`reverse_proxy 127.0.0.1:7681`.
|
||||||
|
|
||||||
|
### 7.4 DNS et ouverture de ports
|
||||||
|
|
||||||
|
1. Enregistrement DNS public : `claude.mondomaine.fr` -> IP publique (type A). Si l'IP est
|
||||||
|
dynamique, utiliser un client DDNS.
|
||||||
|
2. Redirection sur le routeur : **80 -> proxy:80** et **443 -> proxy:443**. Rien d'autre.
|
||||||
|
Le port 80 est requis pour la validation Let's Encrypt en HTTP-01.
|
||||||
|
3. **Ne jamais rediriger le port 7681.** Il donne un shell root sans authentification.
|
||||||
|
4. Si le reseau local resout deja `*.mondomaine.fr`, ajouter une entree interne
|
||||||
|
`claude.mondomaine.fr -> 192.168.1.2` pour eviter de sortir sur internet.
|
||||||
|
5. Pare-feu du serveur Claude : n'autoriser 7681 que depuis l'IP du proxy.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# sur le serveur claude-code, si ufw est utilise
|
||||||
|
ufw allow from 192.168.1.2 to any port 7681 proto tcp
|
||||||
|
ufw deny 7681
|
||||||
|
```
|
||||||
|
|
||||||
|
Le premier acces en HTTPS declenche l'emission du certificat, ce qui prend quelques secondes.
|
||||||
|
En cas d'echec, `journalctl -u caddy -f` donne la raison exacte (port 80 non joignable depuis
|
||||||
|
l'exterieur dans 90 % des cas).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Verification
|
||||||
|
|
||||||
|
Dans l'ordre, en s'arretant a la premiere etape qui echoue :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. ttyd repond en local
|
||||||
|
curl -sI http://127.0.0.1:7681/ | head -1 # 200
|
||||||
|
|
||||||
|
# 2. la session tmux existe
|
||||||
|
tmux ls # claude: 1 windows
|
||||||
|
|
||||||
|
# 3. le proxy demande bien une authentification
|
||||||
|
curl -sI https://claude.mondomaine.fr | head -1 # 401
|
||||||
|
|
||||||
|
# 4. avec les identifiants
|
||||||
|
curl -sI -u claude:MOTDEPASSE https://claude.mondomaine.fr | head -1 # 200
|
||||||
|
```
|
||||||
|
|
||||||
|
Puis dans un navigateur, controler point par point :
|
||||||
|
|
||||||
|
- [ ] le terminal occupe toute la hauteur, la derniere ligne n'est pas coupee
|
||||||
|
- [ ] la pastille de connexion en haut a gauche est verte
|
||||||
|
- [ ] `Ctrl+V` colle **une seule fois** le contenu du presse-papier
|
||||||
|
- [ ] selectionner du texte puis `Ctrl+C` affiche le toast "Copie" et le texte est
|
||||||
|
recuperable dans une autre application
|
||||||
|
- [ ] les boutons A+ / A- changent la taille **et** la grille se recalcule (le nombre de
|
||||||
|
lignes visibles change)
|
||||||
|
- [ ] le clic droit ouvre le menu contextuel
|
||||||
|
- [ ] fermer l'onglet, le rouvrir : la session est retrouvee intacte
|
||||||
|
- [ ] `systemctl restart ttyd` puis recharger : la session est toujours la
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Pieges connus et bugs deja corriges
|
||||||
|
|
||||||
|
Cette section est la raison d'etre du document. Ces defauts ont tous ete rencontres et
|
||||||
|
diagnostiques en production ; les correctifs sont **deja integres** dans `ttyd-custom.html`.
|
||||||
|
Elle sert a ne pas les reintroduire lors d'une modification ulterieure.
|
||||||
|
|
||||||
|
### 9.1 Bas du terminal tronque (deux causes cumulees)
|
||||||
|
|
||||||
|
**Cause A : le padding du parent est compte comme espace disponible par le FitAddon.**
|
||||||
|
Poser `#terminal-container{padding-top:30px}` pour degager la barre d'outils semble naturel.
|
||||||
|
C'est structurellement faux. `proposeDimensions()` du FitAddon calcule :
|
||||||
|
|
||||||
|
```js
|
||||||
|
r = parseInt(getComputedStyle(parentElement).getPropertyValue("height"))
|
||||||
|
a = r - (padding-top + padding-bottom de .terminal) // le padding du PARENT n'est jamais retranche
|
||||||
|
rows = floor(a / cell.height)
|
||||||
|
```
|
||||||
|
|
||||||
|
Il mesure donc 30 px de trop, soit environ deux lignes de trop a chaque fit.
|
||||||
|
|
||||||
|
Regle : **ne jamais poser de padding, de bordure ou de decoration sur `#terminal-container`.**
|
||||||
|
C'est l'element mesure par le FitAddon, il doit contenir exactement l'espace utilisable.
|
||||||
|
Pour degager de la place, positionner le conteneur, ne pas le rembourrer :
|
||||||
|
|
||||||
|
```css
|
||||||
|
#terminal-container{position:absolute !important;top:30px;left:0;right:0;bottom:0;
|
||||||
|
height:auto !important;padding-top:0 !important;}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Cause B : police changee apres l'initialisation, sans refit.** Le frontend applique la
|
||||||
|
taille de police memorisee en localStorage alors que le fit initial a deja tourne avec la
|
||||||
|
cellule d'origine, plus petite, donc avec trop de lignes. De plus, `term.fit()` **n'existe
|
||||||
|
pas** sur un Terminal xterm.js : `fit()` appartient au FitAddon. L'appel est ignore
|
||||||
|
silencieusement et les boutons A+ / A- ne recalculent jamais la grille.
|
||||||
|
|
||||||
|
Regle : **tout changement de `term.options.fontSize` doit etre suivi de**
|
||||||
|
|
||||||
|
```js
|
||||||
|
window.dispatchEvent(new Event('resize'));
|
||||||
|
```
|
||||||
|
|
||||||
|
C'est l'evenement que ttyd ecoute reellement (`g(window,"resize",()=>t.fit())` dans son
|
||||||
|
bundle). Le meme appel est place en differe de 50 ms apres l'application de la police
|
||||||
|
initiale, dans `waitForTerm()`.
|
||||||
|
|
||||||
|
Mesures reelles avant / apres, fenetre de 980 px, police 14, dpr 1.25 :
|
||||||
|
|
||||||
|
| | padding conteneur | rows | bas de .xterm-screen |
|
||||||
|
|---|---|---|---|
|
||||||
|
| avant, au chargement | 30 | 62 | 1027 (deborde de 69 px) |
|
||||||
|
| apres, au chargement | 0 | 61 | 1011 |
|
||||||
|
| apres, refit a +50 ms | 0 | 58 | **963, tient dans 980** |
|
||||||
|
|
||||||
|
### 9.2 Ctrl+V collait en double
|
||||||
|
|
||||||
|
Le gestionnaire de touches faisait `readClip(t => term.paste(t)); return false;` sans
|
||||||
|
`preventDefault()`. `return false` empeche seulement xterm.js de traiter la touche : le
|
||||||
|
navigateur emettait quand meme son evenement `paste` natif vers le textarea cache, ou xterm
|
||||||
|
l'ecoute (`handlePasteEvent` -> `getData("text/plain")` -> `paste`). Deux insertions.
|
||||||
|
|
||||||
|
Correctif : `e.preventDefault()` dans les branches `Ctrl+V` et `Ctrl+Maj+V`. `return false`
|
||||||
|
est conserve pour que xterm n'envoie pas `\x16` au PTY. Le seul chemin de collage restant est
|
||||||
|
`readClip`.
|
||||||
|
|
||||||
|
```js
|
||||||
|
if (e.code==='KeyV') {
|
||||||
|
e.preventDefault();
|
||||||
|
readClip(function(t){ term.paste(t); });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Meme defaut latent sur la branche `Ctrl+C`, laisse tel quel car sans consequence visible :
|
||||||
|
la copie native et `writeClip` copient la meme selection.
|
||||||
|
|
||||||
|
### 9.3 Copier depuis le terminal distant ne remontait pas
|
||||||
|
|
||||||
|
Il faut les deux moities : `set -g set-clipboard on` cote tmux (emission de l'OSC 52) et le
|
||||||
|
handler `registerOscHandler(52, ...)` cote frontend, qui decode le base64 et appelle
|
||||||
|
`navigator.clipboard.writeText`. L'un sans l'autre ne donne rien.
|
||||||
|
|
||||||
|
`navigator.clipboard` n'existe qu'en contexte securise : cela fonctionne en HTTPS ou sur
|
||||||
|
`localhost`, **jamais en HTTP simple sur une IP**. C'est une raison de plus de toujours passer
|
||||||
|
par le proxy TLS, meme pour tester. Un repli `document.execCommand('copy')` est present pour
|
||||||
|
les navigateurs anciens.
|
||||||
|
|
||||||
|
### 9.4 Les liens ouvraient une page blanche
|
||||||
|
|
||||||
|
Le WebLinksAddon appelle `window.open('')` puis assigne `location.href`, comportement que les
|
||||||
|
navigateurs recents bloquent ou traitent mal. Le bloc personnalise remplace `window.open` :
|
||||||
|
quand l'URL est vide, il retourne un objet dont le setter `href` cree un `<a target="_blank"
|
||||||
|
rel="noopener noreferrer">` et le clique. Ne pas retirer ce patch.
|
||||||
|
|
||||||
|
### 9.5 Pieges d'exploitation
|
||||||
|
|
||||||
|
- **Ne pas mettre l'authentification a deux endroits.** ttyd `--credential` **et** basic_auth
|
||||||
|
Caddy donnent une double invite. Une seule, au niveau du proxy.
|
||||||
|
- **Modifier `index.html` ne demande pas de redemarrage.** ttyd relit le fichier a chaque
|
||||||
|
requete. Redemarrer ttyd est neanmoins sans danger pour la session (elle appartient a
|
||||||
|
l'autre unite systemd).
|
||||||
|
- **Une erreur de syntaxe JS donne un terminal blanc et muet.** Toujours passer
|
||||||
|
`node --check` avant de recharger, et garder la sauvegarde datee.
|
||||||
|
- **Ne pas suivre les mises a jour de ttyd sans verifier.** Le bloc personnalise s'accroche a
|
||||||
|
des details du bundle xterm.js de la 1.7.7 (`window.term`, l'ecoute de `resize`,
|
||||||
|
`#terminal-container`). Apres une montee de version, rejouer toute la checklist du 8.
|
||||||
|
- **Methode de debug du frontend, sans rien installer.** ttyd journalise le **chemin** de
|
||||||
|
chaque requete HTTP. Il suffit d'encoder les mesures dans un chemin bidon : ttyd repond 404
|
||||||
|
et la valeur atterrit dans le journal, lisible cote serveur.
|
||||||
|
|
||||||
|
```js
|
||||||
|
fetch('/diag/win'+innerHeight+'/rows'+term.rows+'/sBot'+rect.bottom);
|
||||||
|
```
|
||||||
|
```bash
|
||||||
|
journalctl -u ttyd -f | grep diag
|
||||||
|
```
|
||||||
|
|
||||||
|
Aucun endpoint a creer, aucun port a ouvrir, et le retour est lisible directement au lieu
|
||||||
|
d'etre dicte par l'utilisateur. Penser a retirer l'instrumentation ensuite.
|
||||||
|
- **Lecon de methode.** Sur le bug du bas tronque, la premiere hypothese, purement CSS, etait
|
||||||
|
fausse et n'a rien corrige. Le signal d'alerte a ete une prediction contredite par
|
||||||
|
l'observation. Sur ce frontend, mesurer avant de conclure coute moins cher que corriger au
|
||||||
|
jugé.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Options utiles
|
||||||
|
|
||||||
|
**Redemarrage propre de la session Claude** sans toucher aux services :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tmux send-keys -t claude C-c
|
||||||
|
tmux send-keys -t claude 'claude' Enter
|
||||||
|
```
|
||||||
|
|
||||||
|
**Editeur web** sur le meme serveur, pratique pour editer les fichiers de config :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fsSL https://code-server.dev/install.sh | sh
|
||||||
|
systemctl enable --now code-server@root # port 8080
|
||||||
|
```
|
||||||
|
|
||||||
|
Ajouter un bloc Caddy dedie, avec la meme protection :
|
||||||
|
|
||||||
|
```
|
||||||
|
code.mondomaine.fr {
|
||||||
|
basic_auth { claude $2a$14$LE_HASH }
|
||||||
|
reverse_proxy 192.168.1.123:8080
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Sauvegarde de la configuration** : versionner `/etc/ttyd/index.html`, les unites systemd et
|
||||||
|
les scripts `/usr/local/bin/claude-*.sh` dans un depot git prive. C'est ce qui permet de
|
||||||
|
rejouer l'installation ou de revenir en arriere apres une modification ratee du frontend.
|
||||||
|
|
||||||
|
Attention en versionnant : filtrer tout secret avant le commit. Un fichier d'environnement
|
||||||
|
pousse par erreur reste dans l'historique meme apres correction, et la seule remediation
|
||||||
|
reelle est de **regenerer les identifiants concernes**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Rappel de securite
|
||||||
|
|
||||||
|
`claude.mondomaine.fr` donne un **shell root persistant** sur le serveur, expose sur internet.
|
||||||
|
Le niveau de protection doit etre a la hauteur :
|
||||||
|
|
||||||
|
- mot de passe long et unique, stocke dans un gestionnaire de mots de passe
|
||||||
|
- port 7681 jamais redirige, et filtre au pare-feu sur l'IP du proxy
|
||||||
|
- HTTPS obligatoire, jamais d'acces en HTTP simple
|
||||||
|
- evolution recommandee : remplacer basic_auth par une authentification SSO avec second
|
||||||
|
facteur (forward_auth vers Authentik ou Authelia), ou restreindre l'acces a un VPN
|
||||||
|
- si un service d'upload de fichiers est ajoute a cote, verifier qu'il ne tourne pas avec un
|
||||||
|
mot de passe par defaut et qu'il n'ecoute pas sur `0.0.0.0` sans authentification
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# claude-web-terminal
|
||||||
|
|
||||||
|
Mode operatoire pour exposer une session **Claude Code persistante** dans un navigateur,
|
||||||
|
derriere un nom de domaine et un reverse proxy.
|
||||||
|
|
||||||
|
```
|
||||||
|
navigateur -> https://claude.mondomaine.fr -> Caddy (basic_auth) -> ttyd -> tmux -> claude
|
||||||
|
```
|
||||||
|
|
||||||
|
La session survit a la fermeture du navigateur, au redemarrage du poste client et au
|
||||||
|
changement d'appareil : on retombe toujours sur le meme contexte Claude Code.
|
||||||
|
|
||||||
|
## Contenu
|
||||||
|
|
||||||
|
| Fichier | Role |
|
||||||
|
|---|---|
|
||||||
|
| `MODE-OPERATOIRE.md` | Le document complet, a donner tel quel a une instance Claude Code disposant d'un acces root sur le serveur cible |
|
||||||
|
| `ttyd-custom.html` | Le frontend personnalise a injecter dans la page de ttyd (etape 6) |
|
||||||
|
|
||||||
|
## Utilisation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://gitea.8grangettes.fr/sylfrd/claude-web-terminal.git
|
||||||
|
cd claude-web-terminal
|
||||||
|
claude
|
||||||
|
```
|
||||||
|
|
||||||
|
Puis : `lis MODE-OPERATOIRE.md et deroule l'installation`.
|
||||||
|
|
||||||
|
## Points notables
|
||||||
|
|
||||||
|
Le document ne se limite pas a l'installation. Sa section 9 documente les defauts rencontres
|
||||||
|
en production et deja corriges dans `ttyd-custom.html` :
|
||||||
|
|
||||||
|
- bas du terminal tronque : le padding du parent est compte comme espace disponible par le
|
||||||
|
FitAddon, et tout changement de taille de police exige un refit explicite
|
||||||
|
- `Ctrl+V` qui collait en double, faute de `preventDefault()`
|
||||||
|
- copie depuis le terminal distant qui ne remontait pas (OSC 52, des deux cotes, en HTTPS)
|
||||||
|
- liens cliquables qui ouvraient une page blanche
|
||||||
|
|
||||||
|
Ainsi qu'une methode de debug du frontend sans rien installer, en encodant les mesures dans
|
||||||
|
des chemins HTTP journalises par ttyd.
|
||||||
|
|
||||||
|
## Avertissement
|
||||||
|
|
||||||
|
Cette stack expose un **shell root persistant** sur internet. Lire la section 11 avant de
|
||||||
|
rediriger le moindre port.
|
||||||
@@ -0,0 +1,325 @@
|
|||||||
|
<style>
|
||||||
|
#ttyd-bar{position:fixed;top:0;left:0;right:0;height:30px;background:#111;display:flex;align-items:center;padding:0 6px;gap:4px;z-index:8000;border-bottom:1px solid #2a2a2a;}
|
||||||
|
#ttyd-bar button{background:#222;color:#aaa;border:1px solid #333;border-radius:3px;padding:1px 7px;cursor:pointer;font:11px monospace;white-space:nowrap;}
|
||||||
|
#ttyd-bar button:hover{background:#333;color:#fff;}
|
||||||
|
#ttyd-bar select{background:#222;color:#aaa;border:1px solid #333;border-radius:3px;padding:1px 4px;font:11px monospace;cursor:pointer;}
|
||||||
|
#ttyd-conn{width:8px;height:8px;border-radius:50%;background:#555;margin:0 4px;flex-shrink:0;}
|
||||||
|
#ttyd-conn.ok{background:#4caf50;}
|
||||||
|
#ttyd-conn.err{background:#f44336;animation:blink 1s infinite;}
|
||||||
|
@keyframes blink{0%,100%{opacity:1}50%{opacity:.3}}
|
||||||
|
#ttyd-spacer{flex:1;}
|
||||||
|
#ttyd-search-bar{position:fixed;top:30px;right:0;background:#1a1a1a;border:1px solid #333;border-top:none;border-right:none;border-radius:0 0 0 6px;display:flex;align-items:center;gap:4px;padding:4px 8px;z-index:8001;display:none;}
|
||||||
|
#ttyd-search-bar input{background:#111;color:#ddd;border:1px solid #333;border-radius:3px;padding:2px 6px;font:12px monospace;width:200px;outline:none;}
|
||||||
|
#ttyd-search-bar span{color:#666;font:11px monospace;min-width:60px;}
|
||||||
|
#ttyd-search-bar button{background:#222;color:#aaa;border:1px solid #333;border-radius:3px;padding:1px 7px;cursor:pointer;font:11px monospace;}
|
||||||
|
#terminal-container{position:absolute !important;top:30px;left:0;right:0;bottom:0;height:auto !important;padding-top:0 !important;}
|
||||||
|
#ttyd-ctx{position:fixed;background:#1e1e1e;border:1px solid #444;border-radius:4px;padding:4px 0;z-index:9999;min-width:150px;box-shadow:0 4px 12px rgba(0,0,0,.5);display:none;}
|
||||||
|
#ttyd-ctx div{padding:5px 14px;cursor:pointer;color:#ccc;font:12px monospace;}
|
||||||
|
#ttyd-ctx div:hover{background:#2d2d2d;color:#fff;}
|
||||||
|
#ttyd-ctx hr{border:none;border-top:1px solid #333;margin:3px 0;}
|
||||||
|
#ttyd-toast{position:fixed;bottom:8px;right:8px;background:rgba(20,20,20,.93);color:#7fff7f;font:12px monospace;padding:4px 10px;border-radius:4px;z-index:9999;pointer-events:none;opacity:0;transition:opacity .2s;}
|
||||||
|
#ttyd-toast.show{opacity:1;}
|
||||||
|
</style>
|
||||||
|
<div id="ttyd-bar">
|
||||||
|
<div id="ttyd-conn" title="Connexion WebSocket"></div>
|
||||||
|
<button onclick="ttydFontSize(-1)" title="Police -">A−</button>
|
||||||
|
<span id="ttyd-fsize" style="color:#555;font:11px monospace;min-width:28px;text-align:center;">13</span>
|
||||||
|
<button onclick="ttydFontSize(+1)" title="Police +">A+</button>
|
||||||
|
<button onclick="ttydClear()" title="Vider">✕ Clear</button>
|
||||||
|
<button onclick="ttydSearch()" title="Rechercher (Ctrl+F)">⌕ Rech.</button>
|
||||||
|
<select id="ttyd-theme-sel" onchange="ttydSetTheme(this.value)" title="Thème"></select>
|
||||||
|
<button onclick="ttydSplit('h')" title="Split vertical (côte à côte)">⬜ ⬜</button>
|
||||||
|
<button onclick="ttydSplit('v')" title="Split horizontal (haut/bas)">⬜<br style='line-height:3px'>⬜</button>
|
||||||
|
<button onclick="ttydPaneClose()" title="Fermer ce panneau">✕ Pan.</button>
|
||||||
|
<button onclick="ttydPaneNav('Left')" title="Panneau gauche">◀</button>
|
||||||
|
<button onclick="ttydPaneNav('Right')" title="Panneau droite">▶</button>
|
||||||
|
<button onclick="ttydPaneNav('Up')" title="Panneau haut">▲</button>
|
||||||
|
<button onclick="ttydPaneNav('Down')" title="Panneau bas">▼</button>
|
||||||
|
<div id="ttyd-spacer"></div>
|
||||||
|
<span style="color:#333;font:10px monospace;">Ctrl+C=copier/inter. Ctrl+V=coller clic droit=menu</span>
|
||||||
|
</div>
|
||||||
|
<div id="ttyd-search-bar">
|
||||||
|
<input id="ttyd-sq" placeholder="Rechercher…" oninput="ttydDoSearch()" onkeydown="ttydSearchKey(event)">
|
||||||
|
<span id="ttyd-sr">—</span>
|
||||||
|
<button onclick="ttydSearchNav(-1)">▲</button>
|
||||||
|
<button onclick="ttydSearchNav(+1)">▼</button>
|
||||||
|
<button onclick="ttydSearchClose()">✕</button>
|
||||||
|
</div>
|
||||||
|
<div id="ttyd-ctx"></div>
|
||||||
|
<div id="ttyd-toast"></div>
|
||||||
|
<script>
|
||||||
|
(function(){
|
||||||
|
var _fontSize = parseInt(localStorage.getItem('ttyd-font') || '13');
|
||||||
|
var _themes = {"Defaut": {"background": "#2b2b2b", "foreground": "#d2d2d2", "cursor": "#adadad", "black": "#000000", "red": "#d81e00", "green": "#5ea702", "yellow": "#cfae00", "blue": "#427ab3", "magenta": "#89658e", "cyan": "#00a7aa", "white": "#dbded8", "brightBlack": "#686a66", "brightRed": "#f54235", "brightGreen": "#99e343", "brightYellow": "#fdeb61", "brightBlue": "#84b0d8", "brightMagenta": "#bc94b7", "brightCyan": "#37e6e8", "brightWhite": "#f1f1f0"}, "Dracula": {"background": "#282a36", "foreground": "#f8f8f2", "cursor": "#f8f8f2", "black": "#21222c", "red": "#ff5555", "green": "#50fa7b", "yellow": "#f1fa8c", "blue": "#bd93f9", "magenta": "#ff79c6", "cyan": "#8be9fd", "white": "#f8f8f2", "brightBlack": "#6272a4", "brightRed": "#ff6e6e", "brightGreen": "#69ff94", "brightYellow": "#ffffa5", "brightBlue": "#d6acff", "brightMagenta": "#ff92df", "brightCyan": "#a4ffff", "brightWhite": "#ffffff"}, "One Dark": {"background": "#282c34", "foreground": "#abb2bf", "cursor": "#528bff", "black": "#3f4451", "red": "#e06c75", "green": "#98c379", "yellow": "#e5c07b", "blue": "#61afef", "magenta": "#c678dd", "cyan": "#56b6c2", "white": "#abb2bf", "brightBlack": "#4f5666", "brightRed": "#ff616e", "brightGreen": "#a5e075", "brightYellow": "#f0a45d", "brightBlue": "#4dc4ff", "brightMagenta": "#de73ff", "brightCyan": "#4cd1e0", "brightWhite": "#ffffff"}, "Solarized": {"background": "#002b36", "foreground": "#839496", "cursor": "#839496", "black": "#073642", "red": "#dc322f", "green": "#859900", "yellow": "#b58900", "blue": "#268bd2", "magenta": "#d33682", "cyan": "#2aa198", "white": "#eee8d5", "brightBlack": "#002b36", "brightRed": "#cb4b16", "brightGreen": "#586e75", "brightYellow": "#657b83", "brightBlue": "#839496", "brightMagenta": "#6c71c4", "brightCyan": "#93a1a1", "brightWhite": "#fdf6e3"}, "Clair": {"background": "#ffffff", "foreground": "#383a42", "cursor": "#526fff", "black": "#383a42", "red": "#e45649", "green": "#50a14f", "yellow": "#c18401", "blue": "#4078f2", "magenta": "#a626a4", "cyan": "#0184bc", "white": "#fafafa", "brightBlack": "#4f525e", "brightRed": "#e06c75", "brightGreen": "#98c379", "brightYellow": "#e5c07b", "brightBlue": "#61afef", "brightMagenta": "#c678dd", "brightCyan": "#56b6c2", "brightWhite": "#ffffff"}};
|
||||||
|
var _themeName = localStorage.getItem('ttyd-theme') || 'Defaut';
|
||||||
|
var _searchMatches = [], _searchIdx = 0;
|
||||||
|
var _notifPerm = false;
|
||||||
|
var _lastCmdStart = 0;
|
||||||
|
|
||||||
|
/* ---- Toast ---- */
|
||||||
|
function toast(msg, dur) {
|
||||||
|
var t = document.getElementById('ttyd-toast');
|
||||||
|
t.textContent = msg; t.classList.add('show');
|
||||||
|
clearTimeout(t._tid);
|
||||||
|
t._tid = setTimeout(function(){ t.classList.remove('show'); }, dur||1800);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Clipboard ---- */
|
||||||
|
function writeClip(text) {
|
||||||
|
if (!text) return;
|
||||||
|
var ok = function(){ toast('Copié : ' + text.slice(0,40).replace(/\n/g,' ') + (text.length>40?'..':'')); };
|
||||||
|
if (navigator.clipboard && navigator.clipboard.writeText)
|
||||||
|
navigator.clipboard.writeText(text).then(ok).catch(function(){ legacyCopy(text); });
|
||||||
|
else legacyCopy(text);
|
||||||
|
}
|
||||||
|
function legacyCopy(text) {
|
||||||
|
var ta = document.createElement('textarea');
|
||||||
|
ta.value = text; ta.style.cssText='position:fixed;top:0;left:0;width:1px;height:1px;opacity:0;';
|
||||||
|
document.body.appendChild(ta); ta.focus(); ta.select();
|
||||||
|
var ok=false; try{ ok=document.execCommand('copy'); }catch(e){}
|
||||||
|
document.body.removeChild(ta);
|
||||||
|
toast(ok ? 'Copié (legacy)' : 'Erreur copie');
|
||||||
|
}
|
||||||
|
function readClip(cb) {
|
||||||
|
if (navigator.clipboard && navigator.clipboard.readText)
|
||||||
|
navigator.clipboard.readText().then(cb).catch(function(){ toast('Accès presse-papier refusé'); });
|
||||||
|
else toast('API clipboard non disponible');
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Police ---- */
|
||||||
|
window.ttydFontSize = function(d) {
|
||||||
|
_fontSize = Math.max(8, Math.min(28, _fontSize + d));
|
||||||
|
localStorage.setItem('ttyd-font', _fontSize);
|
||||||
|
if (window.term) { window.term.options.fontSize = _fontSize; window.dispatchEvent(new Event('resize')); }
|
||||||
|
document.getElementById('ttyd-fsize').textContent = _fontSize;
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ---- Clear ---- */
|
||||||
|
window.ttydClear = function() { if (window.term) window.term.clear(); };
|
||||||
|
|
||||||
|
/* ---- Tmux split & navigation ---- */
|
||||||
|
function sendRaw(data) {
|
||||||
|
// Envoie données brutes au PTY sans bracketed paste
|
||||||
|
var term = window.term;
|
||||||
|
if (!term) return;
|
||||||
|
if (term._core && term._core.coreService)
|
||||||
|
term._core.coreService.triggerDataEvent(data, true);
|
||||||
|
else
|
||||||
|
term.paste(data);
|
||||||
|
}
|
||||||
|
window.ttydSplit = function(dir) {
|
||||||
|
// Ctrl+B (\x02) + % pour vertical, + " pour horizontal
|
||||||
|
sendRaw('\x02' + (dir === 'h' ? '%' : '"'));
|
||||||
|
};
|
||||||
|
window.ttydPaneClose = function() {
|
||||||
|
// Ctrl+B + x, puis y pour confirmer
|
||||||
|
sendRaw('\x02x');
|
||||||
|
setTimeout(function(){ sendRaw('y'); }, 200);
|
||||||
|
};
|
||||||
|
window.ttydPaneNav = function(dir) {
|
||||||
|
var arrows = {Left:'\x1b[D', Right:'\x1b[C', Up:'\x1b[A', Down:'\x1b[B'};
|
||||||
|
if (arrows[dir]) sendRaw('\x02' + arrows[dir]);
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ---- Thèmes ---- */
|
||||||
|
var sel = document.getElementById('ttyd-theme-sel');
|
||||||
|
Object.keys(_themes).forEach(function(n){
|
||||||
|
var o = document.createElement('option'); o.value=n; o.textContent=n;
|
||||||
|
if(n===_themeName) o.selected=true;
|
||||||
|
sel.appendChild(o);
|
||||||
|
});
|
||||||
|
window.ttydSetTheme = function(name) {
|
||||||
|
if (!_themes[name] || !window.term) return;
|
||||||
|
window.term.options.theme = _themes[name];
|
||||||
|
_themeName = name;
|
||||||
|
localStorage.setItem('ttyd-theme', name);
|
||||||
|
toast('Thème : ' + name);
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ---- Recherche ---- */
|
||||||
|
var _searchOpen = false;
|
||||||
|
window.ttydSearch = function() {
|
||||||
|
var bar = document.getElementById('ttyd-search-bar');
|
||||||
|
_searchOpen = !_searchOpen;
|
||||||
|
bar.style.display = _searchOpen ? 'flex' : 'none';
|
||||||
|
if (_searchOpen) setTimeout(function(){ document.getElementById('ttyd-sq').focus(); }, 50);
|
||||||
|
};
|
||||||
|
window.ttydSearchClose = function() {
|
||||||
|
_searchOpen = false;
|
||||||
|
document.getElementById('ttyd-search-bar').style.display = 'none';
|
||||||
|
_searchMatches = []; document.getElementById('ttyd-sr').textContent = '—';
|
||||||
|
};
|
||||||
|
window.ttydDoSearch = function() {
|
||||||
|
var q = document.getElementById('ttyd-sq').value;
|
||||||
|
var sr = document.getElementById('ttyd-sr');
|
||||||
|
if (!q || !window.term) { _searchMatches=[]; sr.textContent='—'; return; }
|
||||||
|
var buf = window.term.buffer.active;
|
||||||
|
var matches = [];
|
||||||
|
var total = buf.length;
|
||||||
|
for (var i = 0; i < total; i++) {
|
||||||
|
var line = buf.getLine(i);
|
||||||
|
if (line && line.translateToString().indexOf(q) >= 0) matches.push(i);
|
||||||
|
}
|
||||||
|
_searchMatches = matches; _searchIdx = matches.length - 1;
|
||||||
|
sr.textContent = matches.length ? ((_searchIdx+1)+'/'+matches.length) : '0 résultat';
|
||||||
|
if (matches.length) window.term.scrollToLine(matches[_searchIdx]);
|
||||||
|
};
|
||||||
|
window.ttydSearchNav = function(dir) {
|
||||||
|
if (!_searchMatches.length) return;
|
||||||
|
_searchIdx = (_searchIdx + dir + _searchMatches.length) % _searchMatches.length;
|
||||||
|
document.getElementById('ttyd-sr').textContent = (_searchIdx+1)+'/'+_searchMatches.length;
|
||||||
|
window.term.scrollToLine(_searchMatches[_searchIdx]);
|
||||||
|
};
|
||||||
|
window.ttydSearchKey = function(e) {
|
||||||
|
if (e.key === 'Enter') ttydSearchNav(e.shiftKey ? -1 : 1);
|
||||||
|
if (e.key === 'Escape') ttydSearchClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ---- Connexion ---- */
|
||||||
|
var _connEl = document.getElementById('ttyd-conn');
|
||||||
|
function checkConn() {
|
||||||
|
fetch('/token', {cache:'no-store'})
|
||||||
|
.then(function(r){ _connEl.className = r.ok ? 'ok' : 'err'; })
|
||||||
|
.catch(function(){ _connEl.className = 'err'; });
|
||||||
|
}
|
||||||
|
checkConn();
|
||||||
|
setInterval(checkConn, 5000);
|
||||||
|
|
||||||
|
/* ---- Patch window.open (liens WebLinksAddon) ---- */
|
||||||
|
var _open = window.open;
|
||||||
|
window.open = function(url, target, features) {
|
||||||
|
if (url === undefined || url === null || url === '') {
|
||||||
|
var loc = {};
|
||||||
|
Object.defineProperty(loc,'href',{set:function(u){
|
||||||
|
var a=document.createElement('a'); a.href=u; a.target='_blank'; a.rel='noopener noreferrer';
|
||||||
|
document.body.appendChild(a); a.click(); document.body.removeChild(a);
|
||||||
|
}});
|
||||||
|
return {opener:null, location:loc};
|
||||||
|
}
|
||||||
|
return _open.apply(this, arguments);
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ---- Menu contextuel ---- */
|
||||||
|
var _ctx = document.getElementById('ttyd-ctx');
|
||||||
|
function hideCtx(){ _ctx.style.display='none'; }
|
||||||
|
document.addEventListener('click', hideCtx);
|
||||||
|
document.addEventListener('keydown', function(e){ if(e.key==='Escape'){ hideCtx(); ttydSearchClose(); }});
|
||||||
|
function showCtx(x,y) {
|
||||||
|
hideCtx();
|
||||||
|
_ctx.innerHTML = '';
|
||||||
|
var items = [
|
||||||
|
['Copier', function(){ if(window.term){var s=window.term.getSelection();if(s)writeClip(s);} }],
|
||||||
|
['Coller', function(){ if(window.term) readClip(function(t){window.term.paste(t);}); }],
|
||||||
|
null,
|
||||||
|
['Rechercher…', ttydSearch],
|
||||||
|
null,
|
||||||
|
['Effacer', ttydClear],
|
||||||
|
['Police +', function(){ttydFontSize(+1);}],
|
||||||
|
['Police −', function(){ttydFontSize(-1);}],
|
||||||
|
];
|
||||||
|
items.forEach(function(item){
|
||||||
|
if(!item){var hr=document.createElement('hr');_ctx.appendChild(hr);return;}
|
||||||
|
var d=document.createElement('div'); d.textContent=item[0];
|
||||||
|
d.addEventListener('click',function(e){e.stopPropagation();hideCtx();item[1]();});
|
||||||
|
_ctx.appendChild(d);
|
||||||
|
});
|
||||||
|
_ctx.style.cssText='display:block;left:'+x+'px;top:'+y+'px;';
|
||||||
|
var r=_ctx.getBoundingClientRect();
|
||||||
|
if(r.right>window.innerWidth) _ctx.style.left=(x-r.width)+'px';
|
||||||
|
if(r.bottom>window.innerHeight) _ctx.style.top=(y-r.height)+'px';
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Notifications ---- */
|
||||||
|
function requestNotifPerm() {
|
||||||
|
if ('Notification' in window && Notification.permission === 'default')
|
||||||
|
Notification.requestPermission().then(function(p){ _notifPerm = p==='granted'; });
|
||||||
|
else _notifPerm = ('Notification' in window && Notification.permission === 'granted');
|
||||||
|
}
|
||||||
|
function desktopNotif(title, body) {
|
||||||
|
if (_notifPerm && 'Notification' in window)
|
||||||
|
new Notification(title, {body:body, icon:''});
|
||||||
|
}
|
||||||
|
requestNotifPerm();
|
||||||
|
|
||||||
|
/* ---- Initialisation terminal ---- */
|
||||||
|
function waitForTerm() {
|
||||||
|
var term = window.term;
|
||||||
|
if (!term || !term.parser) { setTimeout(waitForTerm, 100); return; }
|
||||||
|
|
||||||
|
/* Police initiale */
|
||||||
|
term.options.fontSize = _fontSize;
|
||||||
|
document.getElementById('ttyd-fsize').textContent = _fontSize;
|
||||||
|
|
||||||
|
/* Thème initial */
|
||||||
|
if (_themes[_themeName]) term.options.theme = _themes[_themeName];
|
||||||
|
|
||||||
|
/* Refit : le fit initial a ete calcule avec l'ancienne taille de cellule */
|
||||||
|
setTimeout(function(){ window.dispatchEvent(new Event('resize')); }, 50);
|
||||||
|
|
||||||
|
/* OSC 52 : tmux set-clipboard -> presse-papier */
|
||||||
|
try {
|
||||||
|
term.parser.registerOscHandler(52, function(data) {
|
||||||
|
var parts = data.split(';');
|
||||||
|
if (parts.length>=2 && parts[1] && parts[1]!=='?') {
|
||||||
|
try { writeClip(atob(parts[1])); } catch(e){}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
} catch(e){}
|
||||||
|
|
||||||
|
/* OSC 777 : PROMPT_COMMAND -> notification fin de commande */
|
||||||
|
try {
|
||||||
|
term.parser.registerOscHandler(777, function(data) {
|
||||||
|
var code = parseInt(data) || 0;
|
||||||
|
var elapsed = Date.now() - _lastCmdStart;
|
||||||
|
/* Notifier si onglet caché ET commande > 5s */
|
||||||
|
if (document.hidden && elapsed > 5000) {
|
||||||
|
var msg = code === 0 ? 'Commande terminée (succès)' : 'Commande terminée (code '+code+')';
|
||||||
|
desktopNotif('Terminal', msg);
|
||||||
|
}
|
||||||
|
_lastCmdStart = Date.now();
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
} catch(e){}
|
||||||
|
|
||||||
|
/* Ctrl+C = copie si sélection, sinon SIGINT */
|
||||||
|
/* Ctrl+V = colle */
|
||||||
|
/* Ctrl+F = recherche */
|
||||||
|
term.attachCustomKeyEventHandler(function(e) {
|
||||||
|
if (e.type !== 'keydown') return true;
|
||||||
|
if (e.ctrlKey && !e.shiftKey && !e.altKey && !e.metaKey) {
|
||||||
|
if (e.code==='KeyC') {
|
||||||
|
var s = term.getSelection ? term.getSelection() : '';
|
||||||
|
if (s) { writeClip(s); return false; }
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (e.code==='KeyV') {
|
||||||
|
e.preventDefault();
|
||||||
|
readClip(function(t){ term.paste(t); }); return false;
|
||||||
|
}
|
||||||
|
if (e.code==='KeyF') { ttydSearch(); return false; }
|
||||||
|
}
|
||||||
|
if (e.ctrlKey && e.shiftKey) {
|
||||||
|
if (e.code==='KeyC') { var s=term.getSelection?term.getSelection():''; if(s) writeClip(s); return false; }
|
||||||
|
if (e.code==='KeyV') { e.preventDefault(); readClip(function(t){ term.paste(t); }); return false; }
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
/* Clic droit -> menu contextuel */
|
||||||
|
term.element.addEventListener('contextmenu', function(e){
|
||||||
|
e.preventDefault(); showCtx(e.clientX, e.clientY);
|
||||||
|
});
|
||||||
|
|
||||||
|
/* Zoom souris */
|
||||||
|
term.element.addEventListener('wheel', function(e){
|
||||||
|
if (!e.ctrlKey) return;
|
||||||
|
e.preventDefault();
|
||||||
|
ttydFontSize(e.deltaY < 0 ? 1 : -1);
|
||||||
|
}, {passive:false});
|
||||||
|
}
|
||||||
|
waitForTerm();
|
||||||
|
|
||||||
|
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
Reference in New Issue
Block a user