commit 5f1beb4d4d271ccec53aa219d6f8cadac21870f3 Author: Mortdecai Date: Thu Mar 26 18:59:37 2026 -0400 feat: initial release - mobile-first web terminal with tmux touch 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..49bb47a --- /dev/null +++ b/README.md @@ -0,0 +1,113 @@ +# sethmux + +Mobile-first web terminal powered by [ttyd](https://github.com/tsl0922/ttyd) + [tmux](https://github.com/tmux/tmux). One persistent session, multiple tabs, accessible from any browser. + +## Features + +- **Single persistent tmux session** — shared across all connected clients +- **Mobile touch toolbar** — on-screen buttons for tabs, signals, navigation, splits, and text selection +- **Text selection mode** — tap `Sel` to select and copy text on touch devices +- **Push notifications** — `sethmux-notify "Build done!"` sends browser notifications +- **PWA installable** — add to home screen for app-like experience +- **Dark theme** — Sethian dark + orange (#D35400) accent + +## Architecture + +``` +Browser -> Caddy (HTTPS + Auth) -> ttyd (port 7683) -> tmux session "sethmux" + -> notify-server (port 7684) -> /api/notifications +``` + +## Quick Start + +```bash +# Dependencies +apt install -y tmux +curl -sL https://github.com/tsl0922/ttyd/releases/latest/download/ttyd.x86_64 -o /usr/local/bin/ttyd +chmod +x /usr/local/bin/ttyd + +# Deploy +sudo mkdir -p /opt/sethmux +sudo cp static/* /opt/sethmux/ +sudo cp notify-server.py /opt/sethmux/ +sudo cp sethmux-notify /usr/local/bin/ +sudo cp config/tmux.conf ~/.tmux.conf +sudo cp systemd/*.service /etc/systemd/system/ +sudo systemctl daemon-reload +sudo systemctl enable --now sethmux sethmux-notify +``` + +Open `http://YOUR_IP:7683` in a browser. + +## Mobile Toolbar + +Appears on screens < 900px. Buttons: + +| Button | Action | tmux Key | +|--------|--------|----------| +| **+Tab** | New tab | `Ctrl-A c` | +| **Next/Prev** | Switch tabs | `Ctrl-A n/p` | +| **^C / ^D / Clr** | Interrupt / EOF / Clear | | +| **Esc / Tab / Up / Down** | Navigation | | +| **Sel** | Text selection mode | | +| **Spl** | Split vertical | `Ctrl-A %` | +| **Pane** | Cycle panes | `Ctrl-A o` | +| **Kill** | Kill pane/tab | `Ctrl-A x` | + +## Push Notifications + +```bash +sethmux-notify "Deploy complete!" +echo "done" | sethmux-notify +make build && sethmux-notify "OK" || sethmux-notify "FAIL" +``` + +## tmux Keybindings + +Prefix: `Ctrl-A` (not the default Ctrl-B — easier on mobile). + +| Key | Action | +|-----|--------| +| `Ctrl-A c` | New window | +| `Ctrl-A n / p` | Next / previous | +| `Ctrl-A % / "` | Split vertical / horizontal | +| `Ctrl-A o` | Cycle panes | +| `Ctrl-A x` | Kill pane | +| `Alt-1` to `Alt-5` | Jump to window | +| Mouse scroll | History | + +## Reverse Proxy (Caddy) + +``` +mux.example.com { + # your auth here + handle /toolbar.js { root * /opt/sethmux; file_server } + handle /manifest.json { root * /opt/sethmux; file_server } + handle /icon-*.png { root * /opt/sethmux; file_server } + handle /api/* { uri strip_prefix /api; reverse_proxy localhost:7684 } + handle { reverse_proxy localhost:7683 } +} +``` + +## Files + +``` +sethmux/ + static/ + index.html # Custom ttyd page with toolbar injection + toolbar.js # Mobile touch toolbar + manifest.json # PWA manifest + icon-192.png # PWA icon + icon-512.png # PWA icon + config/ + tmux.conf # Sethian-themed tmux config + systemd/ + sethmux.service # ttyd + tmux systemd unit + sethmux-notify.service # Notification API unit + notify-server.py # Push notification HTTP API + sethmux-notify # CLI notification command +``` + +## License + +MIT 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/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/sethmux-notify b/sethmux-notify new file mode 100755 index 0000000..39dfca4 --- /dev/null +++ b/sethmux-notify @@ -0,0 +1,8 @@ +#!/bin/bash +# Send a push notification to kitty.sethpc.xyz +# Usage: sethmux-notify "Build complete!" or echo "done" | sethmux-notify +if [ -n "$1" ]; then + echo "$*" > /tmp/sethmux-notify +else + cat > /tmp/sethmux-notify +fi diff --git a/static/icon-192.png b/static/icon-192.png new file mode 100644 index 0000000..7cb0875 Binary files /dev/null and b/static/icon-192.png differ diff --git a/static/icon-512.png b/static/icon-512.png new file mode 100644 index 0000000..ebffc7e Binary files /dev/null and b/static/icon-512.png differ diff --git a/static/index.html b/static/index.html new file mode 100644 index 0000000..12ff163 --- /dev/null +++ b/static/index.html @@ -0,0 +1,3 @@ +sethmux \ No newline at end of file diff --git a/static/manifest.json b/static/manifest.json new file mode 100644 index 0000000..3509b06 --- /dev/null +++ b/static/manifest.json @@ -0,0 +1,12 @@ +{ + "name": "sethmux", + "short_name": "sethmux", + "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/static/toolbar.js b/static/toolbar.js new file mode 100644 index 0000000..ed90a7f --- /dev/null +++ b/static/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}); +})(); diff --git a/systemd/sethmux-notify.service b/systemd/sethmux-notify.service new file mode 100644 index 0000000..7cfb43d --- /dev/null +++ b/systemd/sethmux-notify.service @@ -0,0 +1,13 @@ +[Unit] +Description=sethmux notification API +After=network.target + +[Service] +Type=simple +User=rdp +ExecStart=/usr/bin/python3 /opt/sethmux/notify-server.py +Restart=always +RestartSec=3 + +[Install] +WantedBy=multi-user.target diff --git a/systemd/sethmux.service b/systemd/sethmux.service new file mode 100644 index 0000000..254bcb4 --- /dev/null +++ b/systemd/sethmux.service @@ -0,0 +1,29 @@ +[Unit] +Description=sethmux - web terminal (mux.sethpc.xyz) +After=network.target + +[Service] +Type=simple +User=rdp +Group=rdp + +ExecStartPre=/bin/bash -c "/usr/bin/tmux kill-session -t sethmux 2>/dev/null; sleep 0.5; /usr/bin/tmux new-session -d -s sethmux -x 120 -y 40" +ExecStart=/usr/local/bin/ttyd \ + --port 7683 \ + --interface 0.0.0.0 \ + --index /opt/sethmux/index.html \ + --writable \ + --check-origin \ + --max-clients 5 \ + --ping-interval 30 \ + --client-option titleFixed=sethmux \ + --client-option fontSize=18 \ + --client-option fontFamily=monospace \ + --client-option enableSixel=true \ + /usr/bin/tmux attach-session -t sethmux + +Restart=always +RestartSec=3 + +[Install] +WantedBy=multi-user.target