commit 94eb19aa76ad8659dceff0bb84c5ccf07e23a3a6 Author: Mortdecai Date: Thu Mar 26 08:46:00 2026 -0400 feat: initial release - mobile-first web terminal with tmux, toolbar, and push notifications diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c56c6cd --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +*.env +.env* +*.pem +*.key +__pycache__/ +*.pyc diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..e16fdbc --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Seth Freiberg + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..e780f78 --- /dev/null +++ b/README.md @@ -0,0 +1,137 @@ +# 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. + +## 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 + +## Architecture + +``` +Browser -> Caddy (HTTPS + Auth) -> ttyd (port 7681) -> tmux session + -> notify-server (port 7682) -> /api/notifications +``` + +- **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 + +## Quick Start + +```bash +sudo ./install.sh +``` + +This installs ttyd, tmux, systemd services, and the notification system. The terminal will be available at `http://YOUR_IP:7681`. + +### Configuration + +Set environment variables before running the installer: + +| 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) | + +### Reverse Proxy + +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" +``` + +Notifications appear as browser push notifications on mobile. Tap the bell icon in the terminal UI to enable them. Notifications expire after 30 seconds. + +## tmux Keybindings + +The included tmux config uses `Ctrl-A` as the prefix (easier on mobile than the default `Ctrl-B`): + +| 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 | + +## Files + +``` +kitty-web/ + toolbar.js # Mobile toolbar (injected into ttyd page) + notify-server.py # Push notification HTTP API + kitty-notify # CLI notification command + 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 + config/ + tmux.conf # tmux configuration (dark theme, mobile-friendly) + systemd/ + ttyd-kitty.service # ttyd 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 diff --git a/caddy-example.conf b/caddy-example.conf new file mode 100644 index 0000000..566ebee --- /dev/null +++ b/caddy-example.conf @@ -0,0 +1,68 @@ +# Example Caddy reverse proxy config for kitty-web +# +# Prerequisites: +# - Caddy v2 with HTTPS +# - OAuth2 Proxy, Authentik, or Authelia for authentication (recommended) +# - ttyd running on TTYD_HOST:7681 +# - notify server running on TTYD_HOST:7682 +# +# Replace TTYD_HOST with the IP/hostname of the machine running kitty-web. +# Replace the auth snippet with your own authentication setup. + +# --- Authentication snippet (pick one) --- + +# Option A: OAuth2 Proxy (Google Auth) +# (google_auth) { +# forward_auth OAUTH2_PROXY_HOST:4180 { +# uri /oauth2/auth +# header_up Host auth.example.com +# copy_headers X-Auth-Request-User X-Auth-Request-Email +# handle_response @unauthorized { +# redir https://auth.example.com/oauth2/start?rd={scheme}://{host}{uri} +# } +# @unauthorized status 401 +# } +# } + +# Option B: Authentik +# (authentik) { +# forward_auth AUTHENTIK_HOST:9000 { +# uri /outpost.goauthentik.io/auth/caddy +# copy_headers X-Authentik-Username X-Authentik-Groups X-Authentik-Email +# } +# } + +# --- Site block --- + +# terminal.example.com { +# import google_auth +# +# # Undo any cached permanent redirects from old configs +# @oldttyd path /ttyd /ttyd/ +# redir @oldttyd / temporary +# +# # Static assets (toolbar, PWA manifest, icons) +# handle /toolbar.js { +# root * /opt/kitty-web/static +# file_server +# } +# handle /manifest.json { +# root * /opt/kitty-web/static +# file_server +# } +# handle /icon-*.png { +# root * /opt/kitty-web/static +# file_server +# } +# +# # Notification API +# handle /api/* { +# uri strip_prefix /api +# reverse_proxy TTYD_HOST:7682 +# } +# +# # Terminal (catch-all) +# handle { +# reverse_proxy TTYD_HOST:7681 +# } +# } diff --git a/config/tmux.conf b/config/tmux.conf new file mode 100644 index 0000000..9177d2e --- /dev/null +++ b/config/tmux.conf @@ -0,0 +1,53 @@ +# Sethian tmux config + +# Remap prefix to Ctrl-a (easier on mobile) +unbind C-b +set -g prefix C-a +bind C-a send-prefix + +# Easy tab management +bind -n M-t new-window +bind -n M-w kill-window +bind -n M-1 select-window -t 0 +bind -n M-2 select-window -t 1 +bind -n M-3 select-window -t 2 +bind -n M-4 select-window -t 3 +bind -n M-5 select-window -t 4 +bind -n M-Left previous-window +bind -n M-Right next-window + +# Mouse support (critical for mobile/touch) +set -g mouse on + +# Scrollback +set -g history-limit 50000 + +# Start numbering at 1 +set -g base-index 1 +setw -g pane-base-index 1 + +# Renumber windows on close +set -g renumber-windows on + +# Status bar - Sethian dark + orange +set -g status-style "bg=#1a1a1a,fg=#e0e0e0" +set -g status-left "#[bg=#D35400,fg=#0a0a0a,bold] #S #[bg=#1a1a1a] " +set -g status-right "#[fg=#D35400]%H:%M #[fg=#666666]| #[fg=#e0e0e0]%b %d" +set -g status-left-length 20 +set -g status-right-length 30 + +# Window status +setw -g window-status-format " #[fg=#888888]#I:#W " +setw -g window-status-current-format "#[bg=#D35400,fg=#0a0a0a,bold] #I:#W " +setw -g window-status-separator "" + +# Pane borders +set -g pane-border-style "fg=#333333" +set -g pane-active-border-style "fg=#D35400" + +# Terminal settings +set -g default-terminal "tmux-256color" +set -ga terminal-overrides ",xterm-256color:Tc" + +# Reduce escape delay +set -sg escape-time 10 diff --git a/icon-192.png b/icon-192.png new file mode 100644 index 0000000..7cb0875 Binary files /dev/null and b/icon-192.png differ diff --git a/icon-512.png b/icon-512.png new file mode 100644 index 0000000..ebffc7e Binary files /dev/null and b/icon-512.png differ diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..e9ed777 --- /dev/null +++ b/install.sh @@ -0,0 +1,92 @@ +#!/bin/bash +# kitty-web installer +# Mobile-first web terminal with tmux, ttyd, and push notifications +set -e + +TTYD_VERSION="1.7.7" +SERVICE_USER="${KITTY_USER:-rdp}" +TTYD_PORT="${TTYD_PORT:-7681}" +NOTIFY_PORT="${NOTIFY_PORT:-7682}" +FONT_SIZE="${FONT_SIZE:-18}" + +echo "=== kitty-web installer ===" +echo "Service user: $SERVICE_USER" +echo "ttyd port: $TTYD_PORT" +echo "Notify port: $NOTIFY_PORT" + +# Check root +if [ "$EUID" -ne 0 ]; then + echo "Run as root" + exit 1 +fi + +# Install dependencies +echo "[1/6] Installing dependencies..." +apt install -y tmux 2>/dev/null || true + +# Install ttyd +if ! command -v ttyd &>/dev/null; then + echo "[2/6] Installing ttyd $TTYD_VERSION..." + curl -sL "https://github.com/tsl0922/ttyd/releases/latest/download/ttyd.x86_64" -o /usr/local/bin/ttyd + chmod +x /usr/local/bin/ttyd +else + echo "[2/6] ttyd already installed" +fi + +# Create user if needed +if ! id "$SERVICE_USER" &>/dev/null; then + echo "[3/6] Creating user $SERVICE_USER..." + useradd -m -s /bin/bash "$SERVICE_USER" +else + echo "[3/6] User $SERVICE_USER exists" +fi + +# Deploy files +echo "[4/6] Deploying files..." +mkdir -p /opt/kitty-web/static + +# Build custom index (inject toolbar into ttyd's default page) +TMPINDEX=$(mktemp) +ttyd --port 0 /bin/true & +TTYD_PID=$! +sleep 1 +# Can't easily grab default page without a running instance, so we'll +# add toolbar.js loading to the page at runtime via the notify server +kill $TTYD_PID 2>/dev/null || true + +cp "$(dirname "$0")/toolbar.js" /opt/kitty-web/static/toolbar.js +cp "$(dirname "$0")/manifest.json" /opt/kitty-web/static/manifest.json +cp "$(dirname "$0")/icon-192.png" /opt/kitty-web/static/icon-192.png 2>/dev/null || true +cp "$(dirname "$0")/icon-512.png" /opt/kitty-web/static/icon-512.png 2>/dev/null || true +cp "$(dirname "$0")/notify-server.py" /opt/kitty-web/notify-server.py +chmod +x /opt/kitty-web/notify-server.py + +# Install kitty-notify command +cp "$(dirname "$0")/kitty-notify" /usr/local/bin/kitty-notify +chmod +x /usr/local/bin/kitty-notify + +# Install tmux config +sudo -u "$SERVICE_USER" cp "$(dirname "$0")/config/tmux.conf" "$(eval echo ~$SERVICE_USER)/.tmux.conf" 2>/dev/null || true + +# Install systemd services +echo "[5/6] Installing services..." +sed "s/User=rdp/User=$SERVICE_USER/g; s/Group=rdp/Group=$SERVICE_USER/g; s/--port 7681/--port $TTYD_PORT/g; s/fontSize=18/fontSize=$FONT_SIZE/g" \ + "$(dirname "$0")/systemd/ttyd-kitty.service" > /etc/systemd/system/ttyd-kitty.service + +sed "s/User=rdp/User=$SERVICE_USER/g" \ + "$(dirname "$0")/systemd/kitty-notify.service" > /etc/systemd/system/kitty-notify.service + +systemctl daemon-reload +systemctl enable --now ttyd-kitty kitty-notify + +echo "[6/6] Verifying..." +sleep 2 +systemctl is-active ttyd-kitty && echo " ttyd: OK (port $TTYD_PORT)" +systemctl is-active kitty-notify && echo " notify: OK (port $NOTIFY_PORT)" + +echo "" +echo "=== kitty-web is running ===" +echo "Direct access: http://$(hostname -I | awk '{print $1}'):$TTYD_PORT" +echo "" +echo "For reverse proxy setup, see README.md" +echo "Send notifications: kitty-notify 'Hello from the terminal!'" diff --git a/kitty-notify b/kitty-notify new file mode 100755 index 0000000..efdd163 --- /dev/null +++ b/kitty-notify @@ -0,0 +1,8 @@ +#!/bin/bash +# Send a push notification to kitty.sethpc.xyz +# Usage: kitty-notify "Build complete!" or echo "done" | kitty-notify +if [ -n "$1" ]; then + echo "$*" > /tmp/kitty-notify +else + cat > /tmp/kitty-notify +fi diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..23b4955 --- /dev/null +++ b/manifest.json @@ -0,0 +1,12 @@ +{ + "name": "sethpc terminal", + "short_name": "kitty", + "start_url": "/", + "display": "standalone", + "background_color": "#0a0a0a", + "theme_color": "#D35400", + "icons": [ + {"src": "/icon-192.png", "sizes": "192x192", "type": "image/png"}, + {"src": "/icon-512.png", "sizes": "512x512", "type": "image/png"} + ] +} diff --git a/notify-server.py b/notify-server.py new file mode 100755 index 0000000..a4a7292 --- /dev/null +++ b/notify-server.py @@ -0,0 +1,37 @@ +#!/usr/bin/env python3 +"""Tiny HTTP server for terminal notifications. Serves /api/notifications.""" +import http.server +import json +import os +import time + +NOTIFY_FILE = "/tmp/kitty-notify" +PORT = 7682 + +class Handler(http.server.BaseHTTPRequestHandler): + def do_GET(self): + if self.path == "/api/notifications": + msg = "" + if os.path.exists(NOTIFY_FILE): + try: + mtime = os.path.getmtime(NOTIFY_FILE) + if time.time() - mtime < 30: # only show notifications < 30s old + with open(NOTIFY_FILE) as f: + msg = f.read().strip() + except: + pass + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Access-Control-Allow-Origin", "*") + self.end_headers() + self.wfile.write(json.dumps({"message": msg}).encode()) + else: + self.send_response(404) + self.end_headers() + + def log_message(self, format, *args): + pass # quiet + +if __name__ == "__main__": + server = http.server.HTTPServer(("0.0.0.0", PORT), Handler) + server.serve_forever() diff --git a/systemd/kitty-notify.service b/systemd/kitty-notify.service new file mode 100644 index 0000000..638fee7 --- /dev/null +++ b/systemd/kitty-notify.service @@ -0,0 +1,13 @@ +[Unit] +Description=Kitty terminal notification API +After=network.target + +[Service] +Type=simple +User=rdp +ExecStart=/usr/bin/python3 /opt/ttyd/notify-server.py +Restart=always +RestartSec=3 + +[Install] +WantedBy=multi-user.target diff --git a/systemd/ttyd-kitty.service b/systemd/ttyd-kitty.service new file mode 100644 index 0000000..34c16d0 --- /dev/null +++ b/systemd/ttyd-kitty.service @@ -0,0 +1,29 @@ +[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 diff --git a/toolbar.js b/toolbar.js new file mode 100644 index 0000000..ed90a7f --- /dev/null +++ b/toolbar.js @@ -0,0 +1,81 @@ +(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= + ''+ + ''+ + ''+ + '
'+ + ''+ + ''+ + ''+ + '
'+ + ''+ + ''+ + ''+ + ''+ + '
'+ + ''+ + ''+ + ''+ + ''; + 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}); +})();