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