feat: initial release - mobile-first web terminal with tmux touch toolbar and push notifications
This commit is contained in:
@@ -0,0 +1,6 @@
|
|||||||
|
*.env
|
||||||
|
.env*
|
||||||
|
*.pem
|
||||||
|
*.key
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
Executable
+37
@@ -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()
|
||||||
Executable
+8
@@ -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
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 3.7 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
File diff suppressed because one or more lines are too long
@@ -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"}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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=
|
||||||
|
'<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});
|
||||||
|
})();
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user