feat: switch from ttyd to real kitty via Xpra HTML5
Replaced the tmux+ttyd web terminal with actual kitty running through Xpra's HTML5 streaming. Full GPU rendering, native kitty tabs/splits, persistent sessions, and multi-client support.
This commit is contained in:
@@ -1,137 +1,129 @@
|
||||
# kitty-web
|
||||
|
||||
Mobile-first web terminal powered by [ttyd](https://github.com/tsl0922/ttyd) + [tmux](https://github.com/tmux/tmux). One persistent tmux session, multiple tabs, accessible from any browser. Designed for phones and tablets.
|
||||
Run the real [kitty terminal](https://sw.kovidgoyal.net/kitty/) in your browser. Mobile-friendly, GPU-accelerated, with full tab and split support.
|
||||
|
||||
Powered by [Xpra](https://xpra.org/) — serves kitty as an HTML5 application via its built-in web client. This is not a terminal emulator in JavaScript; it's the actual kitty running on your server, streamed to your browser.
|
||||
|
||||
## Features
|
||||
|
||||
- **Single persistent session** — one tmux session shared across all connected clients
|
||||
- **Mobile touch toolbar** — on-screen buttons for common shortcuts (new tab, ^C, ^D, Esc, arrows, split panes, etc.)
|
||||
- **Text selection mode** — tap `Sel` to enter selection mode, long-press to select and copy text, tap `Done` to resume typing
|
||||
- **Push notifications** — send browser notifications from any terminal command
|
||||
- **PWA installable** — add to home screen for an app-like experience
|
||||
- **Dark theme** — styled for dark-mode terminals with orange accents
|
||||
- **Real kitty** — GPU rendering, ligatures, image protocol, all of it
|
||||
- **Kitty tabs and splits** — native `ctrl+shift+t`, splits, layouts
|
||||
- **Persistent session** — close the browser, reconnect later, everything is still there
|
||||
- **Multi-client** — multiple browsers can view/interact with the same session
|
||||
- **Mobile-friendly** — Xpra's HTML5 client handles touch input, keyboard, and scaling
|
||||
- **Push notifications** — optional notification API for long-running commands
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Browser -> Caddy (HTTPS + Auth) -> ttyd (port 7681) -> tmux session
|
||||
-> notify-server (port 7682) -> /api/notifications
|
||||
Browser -> Caddy (HTTPS + Auth) -> Xpra HTML5 (port 7681) -> kitty
|
||||
```
|
||||
|
||||
- **ttyd** serves the terminal UI with a custom index page that loads `toolbar.js`
|
||||
- **toolbar.js** injects the mobile toolbar (shortcut buttons, selection mode) into the page at runtime
|
||||
- **notify-server.py** provides a simple HTTP endpoint for push notifications
|
||||
- **kitty-notify** is a CLI command to trigger notifications from scripts
|
||||
Xpra runs kitty inside a virtual X display (Xvfb) and streams the rendered output to browsers via WebSocket. The HTML5 client handles input, clipboard, and display scaling.
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Debian/Ubuntu (tested on Debian 13 Trixie)
|
||||
- kitty (`apt install kitty`)
|
||||
- Xpra (`https://xpra.org/` — add their repo for latest version)
|
||||
|
||||
### Install
|
||||
|
||||
```bash
|
||||
sudo ./install.sh
|
||||
# Add Xpra repo (Debian example)
|
||||
curl -sL https://xpra.org/xpra.asc | sudo tee /usr/share/keyrings/xpra.asc
|
||||
echo "deb [signed-by=/usr/share/keyrings/xpra.asc] https://xpra.org/ $(lsb_release -cs) main" | \
|
||||
sudo tee /etc/apt/sources.list.d/xpra.list
|
||||
sudo apt update && sudo apt install -y xpra kitty
|
||||
|
||||
# Create a service user (optional)
|
||||
sudo useradd -m -s /bin/bash rdp
|
||||
|
||||
# Install systemd service
|
||||
sudo cp systemd/kitty-web.service /etc/systemd/system/
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now kitty-web
|
||||
```
|
||||
|
||||
This installs ttyd, tmux, systemd services, and the notification system. The terminal will be available at `http://YOUR_IP:7681`.
|
||||
### Manual Start
|
||||
|
||||
```bash
|
||||
xpra start --bind-ws=0.0.0.0:7681 \
|
||||
--start="kitty" \
|
||||
--html=on \
|
||||
--sharing=yes \
|
||||
--no-daemon
|
||||
```
|
||||
|
||||
Then open `http://YOUR_IP:7681` in a browser.
|
||||
|
||||
### Configuration
|
||||
|
||||
Set environment variables before running the installer:
|
||||
Edit `systemd/kitty-web.service` to customize:
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `KITTY_USER` | `rdp` | System user that owns the tmux session |
|
||||
| `TTYD_PORT` | `7681` | Port for ttyd web terminal |
|
||||
| `NOTIFY_PORT` | `7682` | Port for notification API |
|
||||
| `FONT_SIZE` | `18` | Terminal font size (optimized for mobile) |
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| `--bind-ws=HOST:PORT` | WebSocket listen address |
|
||||
| `--start="CMD"` | Application to launch (default: `kitty`) |
|
||||
| `--sharing=yes` | Allow multiple clients to connect |
|
||||
| `--readonly=no` | Allow keyboard/mouse input |
|
||||
|
||||
### Reverse Proxy
|
||||
### Reverse Proxy (Caddy)
|
||||
|
||||
See `caddy-example.conf` for a complete Caddy v2 configuration with authentication. The setup supports OAuth2 Proxy, Authentik, or Authelia for access control.
|
||||
|
||||
Key requirements for the reverse proxy:
|
||||
- WebSocket support (ttyd uses WebSockets for terminal I/O)
|
||||
- Serve `/toolbar.js`, `/manifest.json`, `/icon-*.png` as static files
|
||||
- Proxy `/api/*` to the notification server (port 7682)
|
||||
- Proxy everything else to ttyd (port 7681)
|
||||
|
||||
## Mobile Toolbar
|
||||
|
||||
The toolbar appears automatically on screens narrower than 900px. Buttons:
|
||||
|
||||
| Button | Action | tmux Key |
|
||||
|--------|--------|----------|
|
||||
| **+Tab** | New tab | `Ctrl-A c` |
|
||||
| **Next** | Next tab | `Ctrl-A n` |
|
||||
| **Prev** | Previous tab | `Ctrl-A p` |
|
||||
| **^C** | Interrupt | `Ctrl-C` |
|
||||
| **^D** | EOF / logout | `Ctrl-D` |
|
||||
| **Clr** | Clear screen | `Ctrl-L` |
|
||||
| **Esc** | Escape key | `Escape` |
|
||||
| **Tab** | Tab completion | `Tab` |
|
||||
| **Up/Down** | History navigation | Arrow keys |
|
||||
| **Sel** | Toggle text selection mode | — |
|
||||
| **Spl** | Split pane vertically | `Ctrl-A %` |
|
||||
| **Pane** | Cycle between panes | `Ctrl-A o` |
|
||||
| **Kill** | Kill current pane/tab | `Ctrl-A x` |
|
||||
|
||||
## Push Notifications
|
||||
|
||||
Send notifications from any terminal session:
|
||||
|
||||
```bash
|
||||
# Direct message
|
||||
kitty-notify "Build complete!"
|
||||
|
||||
# Pipe output
|
||||
echo "Deploy finished" | kitty-notify
|
||||
|
||||
# Use in scripts
|
||||
make build && kitty-notify "Build succeeded" || kitty-notify "Build FAILED"
|
||||
```
|
||||
terminal.example.com {
|
||||
# Add your auth here (OAuth2 Proxy, Authentik, etc.)
|
||||
reverse_proxy YOUR_SERVER:7681
|
||||
}
|
||||
```
|
||||
|
||||
Notifications appear as browser push notifications on mobile. Tap the bell icon in the terminal UI to enable them. Notifications expire after 30 seconds.
|
||||
WebSocket support is required. Caddy handles this automatically. See `caddy-example.conf` for a full example with authentication options.
|
||||
|
||||
## tmux Keybindings
|
||||
### Kitty Config
|
||||
|
||||
The included tmux config uses `Ctrl-A` as the prefix (easier on mobile than the default `Ctrl-B`):
|
||||
Place your kitty config at `~/.config/kitty/kitty.conf` for the service user. See `config/kitty.conf` for a dark-themed example.
|
||||
|
||||
| Key | Action |
|
||||
|-----|--------|
|
||||
| `Ctrl-A c` | New window/tab |
|
||||
| `Ctrl-A n` / `Ctrl-A p` | Next / previous window |
|
||||
| `Ctrl-A %` / `Ctrl-A "` | Split vertical / horizontal |
|
||||
| `Ctrl-A o` | Cycle panes |
|
||||
| `Ctrl-A x` | Kill pane |
|
||||
| `Alt-1` through `Alt-5` | Jump to window 1-5 |
|
||||
| `Alt-Left` / `Alt-Right` | Previous / next window |
|
||||
| `Alt-t` | New window |
|
||||
| Mouse scroll | Scroll through history |
|
||||
## Optional: Push Notifications
|
||||
|
||||
The `notify-server.py` and `kitty-notify` command provide a simple browser notification system:
|
||||
|
||||
```bash
|
||||
# Install
|
||||
sudo cp notify-server.py /opt/kitty-web/
|
||||
sudo cp kitty-notify /usr/local/bin/
|
||||
sudo cp systemd/kitty-notify.service /etc/systemd/system/
|
||||
sudo systemctl enable --now kitty-notify
|
||||
|
||||
# Usage
|
||||
kitty-notify "Build complete!"
|
||||
echo "done" | kitty-notify
|
||||
```
|
||||
|
||||
Requires proxying `/api/*` to port 7682 — see `caddy-example.conf`.
|
||||
|
||||
## Files
|
||||
|
||||
```
|
||||
kitty-web/
|
||||
toolbar.js # Mobile toolbar (injected into ttyd page)
|
||||
notify-server.py # Push notification HTTP API
|
||||
kitty-notify # CLI notification command
|
||||
README.md
|
||||
LICENSE
|
||||
install.sh # Automated installer
|
||||
caddy-example.conf # Reverse proxy config template
|
||||
notify-server.py # Push notification HTTP API (optional)
|
||||
kitty-notify # CLI notification command (optional)
|
||||
manifest.json # PWA manifest
|
||||
icon-192.png # PWA icon (192x192)
|
||||
icon-512.png # PWA icon (512x512)
|
||||
install.sh # Installer script
|
||||
caddy-example.conf # Reverse proxy configuration example
|
||||
icon-192.png # PWA icon
|
||||
icon-512.png # PWA icon
|
||||
config/
|
||||
tmux.conf # tmux configuration (dark theme, mobile-friendly)
|
||||
tmux.conf # tmux config (for optional tmux-inside-kitty usage)
|
||||
kitty.conf # Kitty terminal config (dark theme)
|
||||
systemd/
|
||||
ttyd-kitty.service # ttyd systemd unit
|
||||
kitty-web.service # Xpra + kitty systemd unit
|
||||
kitty-notify.service # Notification API systemd unit
|
||||
```
|
||||
|
||||
## Requirements
|
||||
|
||||
- Linux (Debian/Ubuntu tested)
|
||||
- tmux
|
||||
- Python 3 (for notification server)
|
||||
- [ttyd](https://github.com/tsl0922/ttyd) (installed automatically)
|
||||
- Caddy, nginx, or another reverse proxy (for HTTPS and auth)
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
# Sethian Terminal Theme
|
||||
font_family JetBrains Mono
|
||||
font_size 11.0
|
||||
bold_font auto
|
||||
italic_font auto
|
||||
|
||||
# Sethian colors - dark with orange accents
|
||||
foreground #e0e0e0
|
||||
background #0a0a0a
|
||||
background_opacity 0.95
|
||||
|
||||
cursor #D35400
|
||||
cursor_shape beam
|
||||
|
||||
selection_foreground #0a0a0a
|
||||
selection_background #D35400
|
||||
|
||||
# Tab bar
|
||||
active_tab_foreground #0a0a0a
|
||||
active_tab_background #D35400
|
||||
inactive_tab_foreground #999999
|
||||
inactive_tab_background #1a1a1a
|
||||
tab_bar_style powerline
|
||||
|
||||
# Normal colors
|
||||
color0 #1a1a1a
|
||||
color1 #cc3333
|
||||
color2 #4e9a06
|
||||
color3 #D35400
|
||||
color4 #3465a4
|
||||
color5 #75507b
|
||||
color6 #06989a
|
||||
color7 #d3d7cf
|
||||
|
||||
# Bright colors
|
||||
color8 #555753
|
||||
color9 #ef2929
|
||||
color10 #8ae234
|
||||
color11 #fce94f
|
||||
color12 #729fcf
|
||||
color13 #ad7fa8
|
||||
color14 #34e2e2
|
||||
color15 #eeeeec
|
||||
|
||||
# Window
|
||||
window_padding_width 4
|
||||
confirm_os_window_close 0
|
||||
enable_audio_bell no
|
||||
|
||||
# URL handling
|
||||
url_color #D35400
|
||||
url_style curly
|
||||
@@ -0,0 +1,30 @@
|
||||
[Unit]
|
||||
Description=Kitty terminal via Xpra HTML5 (kitty.sethpc.xyz)
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=rdp
|
||||
Group=rdp
|
||||
WorkingDirectory=/home/rdp
|
||||
Environment=XDG_RUNTIME_DIR=/run/user/1002
|
||||
|
||||
ExecStart=/usr/bin/xpra start \
|
||||
--bind-ws=0.0.0.0:7681 \
|
||||
--start="kitty" \
|
||||
--html=on \
|
||||
--no-notifications \
|
||||
--no-pulseaudio \
|
||||
--no-mdns \
|
||||
--no-printing \
|
||||
--sharing=yes \
|
||||
--readonly=no \
|
||||
--no-daemon
|
||||
|
||||
ExecStop=/usr/bin/xpra stop :0
|
||||
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -1,29 +0,0 @@
|
||||
[Unit]
|
||||
Description=ttyd web terminal (kitty.sethpc.xyz)
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=rdp
|
||||
Group=rdp
|
||||
|
||||
ExecStartPre=/bin/bash -c "/usr/bin/tmux kill-session -t kitty 2>/dev/null; sleep 0.5; /usr/bin/tmux new-session -d -s kitty -x 120 -y 40"
|
||||
ExecStart=/usr/local/bin/ttyd \
|
||||
--port 7681 \
|
||||
--interface 0.0.0.0 \
|
||||
--index /opt/ttyd/index-with-toolbar.html \
|
||||
--writable \
|
||||
--check-origin \
|
||||
--max-clients 5 \
|
||||
--ping-interval 30 \
|
||||
--client-option titleFixed=sethpc.xyz \
|
||||
--client-option fontSize=18 \
|
||||
--client-option fontFamily=monospace \
|
||||
--client-option enableSixel=true \
|
||||
/usr/bin/tmux attach-session -t kitty
|
||||
|
||||
Restart=always
|
||||
RestartSec=3
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
-81
@@ -1,81 +0,0 @@
|
||||
(function(){
|
||||
if(window._toolbar) return;
|
||||
window._toolbar=true;
|
||||
|
||||
var css=document.createElement('style');
|
||||
css.textContent=`
|
||||
#mb{display:none;position:fixed;bottom:0;left:0;right:0;background:#111;
|
||||
border-top:2px solid #D35400;padding:5px 4px;gap:4px;justify-content:center;
|
||||
flex-wrap:wrap;z-index:99999}
|
||||
#mb button{background:#222;color:#ccc;border:1px solid #444;border-radius:5px;
|
||||
padding:10px 12px;font-size:14px;font-family:ui-monospace,monospace;
|
||||
cursor:pointer;touch-action:manipulation;-webkit-tap-highlight-color:transparent;
|
||||
min-width:42px;text-align:center;user-select:none}
|
||||
#mb button:active{background:#D35400;color:#0a0a0a;border-color:#D35400}
|
||||
#mb button.hi{border-color:#D35400;color:#D35400}
|
||||
#mb button.on{background:#D35400;color:#0a0a0a;border-color:#D35400}
|
||||
#mb .sep{width:1px;background:#333;margin:0 2px;align-self:stretch}
|
||||
@media(max-width:900px){#mb{display:flex}}
|
||||
body.selmode .xterm-screen{pointer-events:none!important;
|
||||
user-select:text!important;-webkit-user-select:text!important}
|
||||
`;
|
||||
document.head.appendChild(css);
|
||||
|
||||
var bar=document.createElement('div');
|
||||
bar.id='mb';
|
||||
bar.innerHTML=
|
||||
'<button class="hi" data-k="\\x01c">+Tab</button>'+
|
||||
'<button data-k="\\x01n">Next</button>'+
|
||||
'<button data-k="\\x01p">Prev</button>'+
|
||||
'<div class="sep"></div>'+
|
||||
'<button data-k="\\x03">^C</button>'+
|
||||
'<button data-k="\\x04">^D</button>'+
|
||||
'<button data-k="\\x0c">Clr</button>'+
|
||||
'<div class="sep"></div>'+
|
||||
'<button data-k="\\x1b">Esc</button>'+
|
||||
'<button data-k="\\t">Tab</button>'+
|
||||
'<button data-k="\\x1bOA">\u25B2</button>'+
|
||||
'<button data-k="\\x1bOB">\u25BC</button>'+
|
||||
'<div class="sep"></div>'+
|
||||
'<button class="hi" id="selbtn" data-sel="1">Sel</button>'+
|
||||
'<button data-k="\\x01%">Spl</button>'+
|
||||
'<button data-k="\\x01o">Pane</button>'+
|
||||
'<button data-k="\\x01x">Kill</button>';
|
||||
document.body.appendChild(bar);
|
||||
|
||||
function send(k){
|
||||
if(document.body.classList.contains('selmode')) toggleSel();
|
||||
k=k.replace(/\\x([0-9a-f]{2})/gi,function(_,h){return String.fromCharCode(parseInt(h,16));});
|
||||
k=k.replace(/\\t/g,'\t');
|
||||
if(window.term){window.term.input(k);window.term.focus();}
|
||||
}
|
||||
|
||||
function toggleSel(){
|
||||
var b=document.getElementById('selbtn');
|
||||
document.body.classList.toggle('selmode');
|
||||
if(document.body.classList.contains('selmode')){
|
||||
b.classList.add('on');b.textContent='Done';
|
||||
} else {
|
||||
b.classList.remove('on');b.textContent='Sel';
|
||||
window.getSelection().removeAllRanges();
|
||||
if(window.term) window.term.focus();
|
||||
}
|
||||
}
|
||||
|
||||
bar.addEventListener('click',function(e){
|
||||
var btn=e.target.closest('button');
|
||||
if(!btn) return;
|
||||
if(btn.dataset.sel) return toggleSel();
|
||||
if(btn.dataset.k) send(btn.dataset.k);
|
||||
});
|
||||
|
||||
// Shrink terminal for toolbar on mobile
|
||||
var obs=new MutationObserver(function(){
|
||||
var el=document.querySelector('.xterm');
|
||||
if(el && window.innerWidth<=900){
|
||||
el.style.height='calc(100vh - 54px)';
|
||||
if(window.term && window.term.fit) window.term.fit();
|
||||
}
|
||||
});
|
||||
obs.observe(document.body,{childList:true,subtree:true});
|
||||
})();
|
||||
Reference in New Issue
Block a user