Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b9fe3d7131 | |||
| 2ec6f73843 |
@@ -0,0 +1,123 @@
|
||||
# muxplex
|
||||
|
||||
Web-based tmux session dashboard and terminal multiplexer. Pure vanilla JS frontend (no frameworks, no build step), FastAPI backend, xterm.js terminals via ttyd WebSocket proxy.
|
||||
|
||||
## Repository
|
||||
|
||||
- **Upstream**: https://github.com/bkrabach/muxplex
|
||||
- **Fork (primary working repo)**: git@git.ampbox.io:ken/muxplex.git
|
||||
- **Branch strategy**: Work on `main` directly in the fork. PR upstream when ready.
|
||||
|
||||
## Architecture
|
||||
|
||||
- `muxplex/frontend/app.js` (~4,600 lines) -- dashboard, sidebar, settings, views, mobile UX
|
||||
- `muxplex/frontend/terminal.js` (~700 lines) -- xterm.js terminal, WebSocket, mobile toolbar
|
||||
- `muxplex/frontend/style.css` (~2,600 lines) -- dark theme, responsive breakpoints
|
||||
- `muxplex/frontend/index.html` -- SPA shell
|
||||
- `muxplex/frontend/vendor/` -- vendored xterm.js + addons (fit, search, web-links, image)
|
||||
- `muxplex/main.py` -- FastAPI app, API endpoints, WebSocket proxy, poll loop
|
||||
- `muxplex/sessions.py` -- tmux session enumeration, caching, snapshots
|
||||
- `muxplex/ttyd.py` -- ttyd process lifecycle management
|
||||
- `muxplex/tests/` -- 1300+ tests (Python + frontend structure/behavior tests)
|
||||
|
||||
## Key patterns
|
||||
|
||||
- No build step. JS/CSS served as-is with version-busting query suffixes.
|
||||
- Icons are inline SVGs (replaced unicode entities), stored in `_icons` object in app.js.
|
||||
- Mobile detection: `MOBILE_THRESHOLD = 600`, `isMobile()` function.
|
||||
- Touch devices get a bottom toolbar with Ctrl/Alt modifier toggles.
|
||||
- xterm.js handles touch scrolling natively -- do NOT add custom touch scroll handlers.
|
||||
- Session names can contain spaces -- always use `shlex.quote()` when interpolating into shell commands.
|
||||
- The running service is an editable install from this directory: `systemctl --user restart muxplex` to apply changes.
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
.venv/bin/python -m pytest muxplex/tests/ -x -q --timeout=30
|
||||
```
|
||||
|
||||
The CSS test suite enforces that `@media (max-width: 959px)` is the last `@media` block in style.css. Place new `@media` blocks above it.
|
||||
|
||||
---
|
||||
|
||||
# UX Improvement Roadmap
|
||||
|
||||
Prioritized improvements for a power user on desktop + Android phone + Android tablet.
|
||||
|
||||
## P0 -- High Impact (Grid "catches you off guard")
|
||||
|
||||
### Search/filter bar on dashboard grid
|
||||
With 10+ sessions, there's no way to quickly find one. Add a sticky search input at the top of the grid that filters tiles by name as you type. Critical on mobile where the list view requires scrolling through all sessions linearly.
|
||||
|
||||
### Visual hierarchy for tiles
|
||||
All tiles look identical -- no distinction between a session used 30 seconds ago and one idle for 3 days. Add subtle visual cues: accent border on recently active sessions, dimmed styling for idle ones. Give the user's eyes an anchor point.
|
||||
|
||||
### Readable tile previews
|
||||
Terminal snapshot text is 11px in a 300px tile -- it's decoration, not information. Either make it large enough to read (fewer lines, bigger font), or add a readable "last command" / "working directory" summary line beneath the session name.
|
||||
|
||||
### Default session / quick-resume
|
||||
Auto-open the last-used or default session on page load. The `default_session` setting exists but may not be fully wired. At minimum, put a prominent "Resume" action for the most recently used session.
|
||||
|
||||
## P1 -- High Impact (Mobile Phone)
|
||||
|
||||
### Tap-to-open too easy to trigger accidentally
|
||||
On mobile list view, any tap anywhere on the tile opens the terminal. Need to distinguish scroll gestures from taps, and make the tap target the header area rather than the entire tile body.
|
||||
|
||||
### Session pill is too subtle
|
||||
In expanded terminal view on mobile, the floating pill (75% opacity, 13px, bottom-right) is the only session switcher. Make it more prominent -- persistent thin bar at bottom, or a swipe-to-switch gesture.
|
||||
|
||||
### Swipe gestures
|
||||
- Swipe from left edge to open sidebar (Android convention)
|
||||
- Swipe down on bottom sheet to dismiss
|
||||
- Swipe left/right on session pill to switch sessions
|
||||
|
||||
### Scroll-to-bottom indicator
|
||||
When scrolled up in the terminal buffer, there's no visual cue and no quick way to jump back to live output. Add a floating "Jump to bottom" button that appears when scrolled up.
|
||||
|
||||
## P2 -- Medium Impact (Tablet)
|
||||
|
||||
### Tablet grid wastes space
|
||||
At 600-899px (tablet portrait), grid collapses to a single column with 200px tiles. Too conservative. Use a two-column grid with shorter tiles to show more sessions.
|
||||
|
||||
### Compact sidebar for tablet
|
||||
Tablets have room for a mini session list alongside the terminal. Add a compact sidebar mode (just session names, no preview tiles) that works as a side panel rather than a full overlay.
|
||||
|
||||
## P3 -- Medium Impact (General UX)
|
||||
|
||||
### Keyboard shortcut cheat sheet
|
||||
Shortcuts exist (`,` settings, `` ` `` view dropdown, `1-9` views, `Escape` close) but are undiscoverable. Add `?` shortcut to show a cheat sheet overlay.
|
||||
|
||||
### Quick session creation
|
||||
The current flow requires typing a name. Add "Quick new" that auto-generates a name (e.g. `session-4`) and opens immediately. Name prompt becomes optional.
|
||||
|
||||
### Inline session rename
|
||||
No rename exists today -- typo means kill and recreate. Add long-press (mobile) / double-click (desktop) on tile header to rename. Backend: `tmux rename-session`.
|
||||
|
||||
### Pull-to-refresh on mobile dashboard
|
||||
The 2s poll handles updates, but users expect pull-to-refresh on mobile. Even a triggered immediate poll gives a sense of control.
|
||||
|
||||
### Confirmation before killing sessions
|
||||
Verify the delete confirmation step is present in the mobile bottom sheet (not just desktop flyout). Accidentally killing a session with running work is catastrophic.
|
||||
|
||||
## P4 -- Polish
|
||||
|
||||
### Fix tile metadata fade on hover
|
||||
Timestamp and device badge fade to transparent on hover -- exactly when you're looking at it. Invert: show more detail on hover, not less.
|
||||
|
||||
### Reduce grid flicker on poll
|
||||
The 2s poll rebuilds DOM for all tiles. Diff the DOM instead (only update changed tiles) to eliminate subtle flicker.
|
||||
|
||||
### Long-press context menu on mobile tiles
|
||||
The kebab button is small on mobile. Long-press on the tile itself should open the same action menu -- standard Android convention.
|
||||
|
||||
### Better toast positioning on mobile
|
||||
Toast at `bottom: 80px` may be occluded by the mobile toolbar or FAB. Position above any floating elements.
|
||||
|
||||
## Future Ideas
|
||||
|
||||
- **Session pinning** -- pin 1-3 sessions to always show at top of grid
|
||||
- **Session tags/colors** -- color-code sessions by project
|
||||
- **Command palette** -- `Ctrl+K` / `Cmd+K` to search sessions, switch views, open settings
|
||||
- **Notification badges on tiles** -- "3 new lines" counter on idle tiles
|
||||
- **Multi-select on grid** -- select multiple sessions for bulk kill or bulk-add to view
|
||||
- **Dim vs deep dark toggle** -- for outdoor tablet use in bright light
|
||||
@@ -0,0 +1,217 @@
|
||||
# Cloudflare Tunnel Setup for Incus Host
|
||||
|
||||
## Overview
|
||||
|
||||
Single routing point for all services across Incus instances.
|
||||
Services self-declare by dropping a file into a shared directory.
|
||||
No port forwarding, no OPNsense reverse proxy config, no DDNS.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Domain: ampbox.io (registrar: Namecheap)
|
||||
- Cloudflare account (free tier) with ampbox.io DNS delegated
|
||||
- At Namecheap: set nameservers to the ones Cloudflare assigns
|
||||
- Verify domain is active in Cloudflare dashboard
|
||||
|
||||
## 1. Install cloudflared on the Incus host
|
||||
|
||||
```bash
|
||||
curl -fsSL https://pkg.cloudflare.com/cloudflare-main.gpg \
|
||||
| sudo tee /usr/share/keyrings/cloudflare-main.gpg >/dev/null
|
||||
echo "deb [signed-by=/usr/share/keyrings/cloudflare-main.gpg] \
|
||||
https://pkg.cloudflare.com/cloudflared $(lsb_release -cs) main" \
|
||||
| sudo tee /etc/apt/sources.list.d/cloudflared.list
|
||||
sudo apt update && sudo apt install -y cloudflared
|
||||
```
|
||||
|
||||
## 2. Authenticate and create the tunnel
|
||||
|
||||
```bash
|
||||
cloudflared tunnel login # opens browser, authorize ampbox.io
|
||||
cloudflared tunnel create ampbox # creates tunnel, saves credentials JSON
|
||||
cloudflared tunnel route dns ampbox "*.ampbox.io" # wildcard DNS route
|
||||
```
|
||||
|
||||
Note the credentials file path printed (typically
|
||||
`~/.cloudflared/<TUNNEL_ID>.json`). You need it for step 4.
|
||||
|
||||
## 3. Create the service declaration directory
|
||||
|
||||
```bash
|
||||
sudo mkdir -p /etc/cloudflared/services.d
|
||||
```
|
||||
|
||||
## 4. Write the main tunnel config
|
||||
|
||||
```bash
|
||||
# Replace <TUNNEL_ID> with your actual tunnel ID from step 2
|
||||
sudo tee /etc/cloudflared/config.yml << 'EOF'
|
||||
tunnel: ampbox
|
||||
credentials-file: /root/.cloudflared/<TUNNEL_ID>.json
|
||||
|
||||
ingress:
|
||||
- service: http_status:404
|
||||
EOF
|
||||
```
|
||||
|
||||
## 5. Share the declaration directory into all instances
|
||||
|
||||
```bash
|
||||
incus profile device add default svc-declare disk \
|
||||
source=/etc/cloudflared/services.d \
|
||||
path=/mnt/services
|
||||
```
|
||||
|
||||
## 6. Helper scripts (on the host)
|
||||
|
||||
### /usr/local/bin/svc-rebuild
|
||||
|
||||
Reads all service declarations and rebuilds the cloudflared ingress config.
|
||||
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
CONFIG="/etc/cloudflared/config.yml"
|
||||
SERVICES_DIR="/etc/cloudflared/services.d"
|
||||
TUNNEL_CREDS="/root/.cloudflared/<TUNNEL_ID>.json" # fix this
|
||||
|
||||
cat > "$CONFIG" <<HEADER
|
||||
tunnel: ampbox
|
||||
credentials-file: $TUNNEL_CREDS
|
||||
|
||||
ingress:
|
||||
HEADER
|
||||
|
||||
for f in "$SERVICES_DIR"/*.yml; do
|
||||
[ -f "$f" ] || continue
|
||||
while IFS= read -r line; do
|
||||
echo " $line" >> "$CONFIG"
|
||||
done < "$f"
|
||||
done
|
||||
|
||||
echo " - service: http_status:404" >> "$CONFIG"
|
||||
sudo systemctl restart cloudflared
|
||||
echo "Rebuilt with $(ls "$SERVICES_DIR"/*.yml 2>/dev/null | wc -l) service(s)"
|
||||
```
|
||||
|
||||
```bash
|
||||
sudo install -m 755 /dev/stdin /usr/local/bin/svc-rebuild < svc-rebuild.sh
|
||||
```
|
||||
|
||||
### /usr/local/bin/svc-watch
|
||||
|
||||
Auto-rebuilds on file changes (optional but recommended).
|
||||
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SERVICES_DIR="/etc/cloudflared/services.d"
|
||||
echo "Watching $SERVICES_DIR for changes..."
|
||||
while inotifywait -q -e create,delete,modify "$SERVICES_DIR"; do
|
||||
sleep 1 # debounce
|
||||
/usr/local/bin/svc-rebuild
|
||||
done
|
||||
```
|
||||
|
||||
```bash
|
||||
sudo apt install -y inotify-tools
|
||||
sudo install -m 755 /dev/stdin /usr/local/bin/svc-watch < svc-watch.sh
|
||||
```
|
||||
|
||||
## 7. Systemd services (on the host)
|
||||
|
||||
### /etc/systemd/system/cloudflared.service
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=Cloudflare Tunnel
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
ExecStart=/usr/local/bin/cloudflared tunnel run ampbox
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
### /etc/systemd/system/svc-watch.service
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=Service declaration watcher
|
||||
After=cloudflared.service
|
||||
|
||||
[Service]
|
||||
ExecStart=/usr/local/bin/svc-watch
|
||||
Restart=always
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
```bash
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now cloudflared svc-watch
|
||||
```
|
||||
|
||||
## 8. Declaring services FROM INSIDE an instance
|
||||
|
||||
### Expose an HTTP service
|
||||
|
||||
```bash
|
||||
cat > /mnt/services/myapp.yml << EOF
|
||||
- hostname: myapp.ampbox.io
|
||||
service: http://$(hostname -I | awk '{print $1}'):8080
|
||||
EOF
|
||||
```
|
||||
|
||||
### Expose SSH
|
||||
|
||||
```bash
|
||||
cat > /mnt/services/ssh-$(hostname).yml << EOF
|
||||
- hostname: ssh-$(hostname).ampbox.io
|
||||
service: ssh://$(hostname -I | awk '{print $1}'):22
|
||||
EOF
|
||||
```
|
||||
|
||||
### Remove a service
|
||||
|
||||
```bash
|
||||
rm /mnt/services/myapp.yml
|
||||
# svc-watch picks it up and rebuilds automatically
|
||||
```
|
||||
|
||||
## 9. Client-side SSH config (on laptops / remote machines)
|
||||
|
||||
```
|
||||
# ~/.ssh/config
|
||||
Host *.ampbox.io
|
||||
ProxyCommand cloudflared access ssh --hostname %h
|
||||
```
|
||||
|
||||
Then: `ssh user@ssh-myinstance.ampbox.io` just works.
|
||||
|
||||
## 10. OPNsense cleanup
|
||||
|
||||
Once the tunnel is verified working, remove from OPNsense:
|
||||
- All per-service reverse proxy rules in os-caddy
|
||||
- Port forward / NAT rules for 80 and 443
|
||||
- DDNS configuration (no longer needed)
|
||||
|
||||
OPNsense goes back to just being your firewall.
|
||||
|
||||
## Quick Reference
|
||||
|
||||
| Action | From inside instance |
|
||||
|-------------------------|----------------------------------------------------------|
|
||||
| Expose HTTP | `echo "- hostname: X.ampbox.io\n service: http://IP:PORT" > /mnt/services/X.yml` |
|
||||
| Expose SSH | Same pattern with `service: ssh://IP:22` |
|
||||
| Expose raw TCP | Same pattern with `service: tcp://IP:PORT` |
|
||||
| Remove service | `rm /mnt/services/X.yml` |
|
||||
| List registered | `ls /mnt/services/` |
|
||||
| Check tunnel status | `sudo systemctl status cloudflared` (from host) |
|
||||
+42
-23
@@ -1,5 +1,20 @@
|
||||
// Phase 2b implementation — app.js
|
||||
|
||||
/** Inline SVG icon strings used throughout the UI. */
|
||||
var _icons = {
|
||||
statusOk: '<svg viewBox="0 0 16 16" width="10" height="10" fill="currentColor"><circle cx="8" cy="8" r="4"/></svg>',
|
||||
statusWarn: '<svg viewBox="0 0 16 16" width="10" height="10" fill="none" stroke="currentColor" stroke-width="2" stroke-dasharray="3 2"><circle cx="8" cy="8" r="5"/></svg>',
|
||||
statusErr: '<svg viewBox="0 0 16 16" width="10" height="10" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M4 4l8 8M12 4l-8 8"/></svg>',
|
||||
kebab: '<svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor"><circle cx="8" cy="3" r="1.5"/><circle cx="8" cy="8" r="1.5"/><circle cx="8" cy="13" r="1.5"/></svg>',
|
||||
chevronLeft: '<svg viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M10 3L5 8l5 5"/></svg>',
|
||||
chevronRight: '<svg viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M6 3l5 5-5 5"/></svg>',
|
||||
chevronUp: '<svg viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 10l4-4 4 4"/></svg>',
|
||||
chevronDown: '<svg viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 6l4 4 4-4"/></svg>',
|
||||
check: '<svg viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M3 8l4 4 6-7"/></svg>',
|
||||
bell: '<svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor"><path d="M8 1a1 1 0 0 1 1 1v.3A4.5 4.5 0 0 1 12.5 7v2.5l1 2H2.5l1-2V7A4.5 4.5 0 0 1 7 2.3V2a1 1 0 0 1 1-1zM6.5 13a1.5 1.5 0 0 0 3 0z"/></svg>',
|
||||
close: '<svg viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M4 4l8 8M12 4l-8 8"/></svg>',
|
||||
};
|
||||
|
||||
/**
|
||||
* Format a Unix timestamp (seconds) into a relative time string.
|
||||
* @param {number|null|undefined} ts - Unix timestamp in seconds
|
||||
@@ -324,13 +339,13 @@ function setConnectionStatus(level) {
|
||||
const el = $('connection-status');
|
||||
if (!el) return;
|
||||
const map = {
|
||||
ok: { text: '●', cls: 'connection-status--ok' },
|
||||
warn: { text: '◌ slow', cls: 'connection-status--warn' },
|
||||
err: { text: '✕ offline', cls: 'connection-status--err' },
|
||||
ok: { text: _icons.statusOk, cls: 'connection-status--ok' },
|
||||
warn: { text: _icons.statusWarn + ' slow', cls: 'connection-status--warn' },
|
||||
err: { text: _icons.statusErr + ' offline', cls: 'connection-status--err' },
|
||||
};
|
||||
const s = map[level];
|
||||
if (!s) return;
|
||||
el.textContent = s.text;
|
||||
el.innerHTML = s.text;
|
||||
el.className = s.cls;
|
||||
}
|
||||
|
||||
@@ -543,7 +558,7 @@ function buildTileHTML(session, index, mobile) {
|
||||
`<span class="tile-name">${escapeHtml(name)}</span>` +
|
||||
`${badgeHtml}` +
|
||||
`<span class="tile-meta">${escapeHtml(timeStr)}</span>` +
|
||||
`<button class="tile-options-btn" data-session="${escapedName}" aria-label="Session options" aria-haspopup="true">⋮</button>` +
|
||||
`<button class="tile-options-btn" data-session="${escapedName}" aria-label="Session options" aria-haspopup="true">${_icons.kebab}</button>` +
|
||||
`</div>` +
|
||||
`<div class="tile-body"><pre>${ansiToHtml(lastLines)}</pre></div>` +
|
||||
`</article>`
|
||||
@@ -601,7 +616,7 @@ function buildSidebarHTML(session, currentSession, currentRemoteId) {
|
||||
`<div class="sidebar-item-header">` +
|
||||
`<span class="sidebar-item-name">${escapedName}</span>` +
|
||||
badgeHtml +
|
||||
`<button class="tile-options-btn" data-session="${escapedName}" aria-label="Session options" aria-haspopup="true">⋮</button>` +
|
||||
`<button class="tile-options-btn" data-session="${escapedName}" aria-label="Session options" aria-haspopup="true">${_icons.kebab}</button>` +
|
||||
`</div>` +
|
||||
`<div class="sidebar-item-body"><pre>${ansiToHtml(lastLines)}</pre></div>` +
|
||||
`</article>`
|
||||
@@ -959,7 +974,7 @@ function toggleSidebar() {
|
||||
patchServerSetting('sidebarOpen', isOpen);
|
||||
|
||||
var collapseBtn = $('sidebar-collapse-btn');
|
||||
if (collapseBtn) collapseBtn.textContent = isOpen ? '\u2039' : '\u203a';
|
||||
if (collapseBtn) collapseBtn.innerHTML = isOpen ? _icons.chevronLeft : _icons.chevronRight;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1437,7 +1452,7 @@ function renderViewsSettingsTab() {
|
||||
// Up button
|
||||
var upBtn = document.createElement('button');
|
||||
upBtn.className = 'views-settings-btn';
|
||||
upBtn.textContent = '\u25b2';
|
||||
upBtn.innerHTML = _icons.chevronUp;
|
||||
upBtn.title = 'Move up';
|
||||
upBtn.setAttribute('data-action', 'move-up');
|
||||
upBtn.setAttribute('data-idx', String(idx));
|
||||
@@ -1446,7 +1461,7 @@ function renderViewsSettingsTab() {
|
||||
// Down button
|
||||
var downBtn = document.createElement('button');
|
||||
downBtn.className = 'views-settings-btn';
|
||||
downBtn.textContent = '\u25bc';
|
||||
downBtn.innerHTML = _icons.chevronDown;
|
||||
downBtn.title = 'Move down';
|
||||
downBtn.setAttribute('data-action', 'move-down');
|
||||
downBtn.setAttribute('data-idx', String(idx));
|
||||
@@ -2038,7 +2053,7 @@ function _openMobileViewPicker(sessionKey, sessionName, unhideFirst) {
|
||||
var v = views[i];
|
||||
var isIn = (v.sessions || []).indexOf(sessionKey) !== -1;
|
||||
html += '<button class="flyout-sheet__item" role="menuitem" data-view-index="' + i + '">';
|
||||
html += '<span style="margin-right:8px">' + (isIn ? '\u2713' : '\u00a0\u00a0') + '</span>';
|
||||
html += '<span style="margin-right:8px">' + (isIn ? _icons.check : '\u00a0\u00a0') + '</span>';
|
||||
html += escapeHtml(v.name);
|
||||
html += '</button>';
|
||||
}
|
||||
@@ -2082,7 +2097,7 @@ function _openMobileViewPicker(sessionKey, sessionName, unhideFirst) {
|
||||
|
||||
// Update checkmark immediately for responsiveness
|
||||
var checkEl = viewBtn.querySelector('span');
|
||||
if (checkEl) checkEl.textContent = nowIn ? '\u2713' : '\u00a0\u00a0';
|
||||
if (checkEl) checkEl.innerHTML = nowIn ? _icons.check : '\u00a0\u00a0';
|
||||
|
||||
api('PATCH', '/api/settings', patch)
|
||||
.then(function() {
|
||||
@@ -2095,7 +2110,7 @@ function _openMobileViewPicker(sessionKey, sessionName, unhideFirst) {
|
||||
.catch(function(err) {
|
||||
showToast('Couldn\u2019t save \u2014 try again');
|
||||
// Revert checkmark
|
||||
if (checkEl) checkEl.textContent = nowIn ? '\u00a0\u00a0' : '\u2713';
|
||||
if (checkEl) checkEl.innerHTML = nowIn ? '\u00a0\u00a0' : _icons.check;
|
||||
console.warn('[_openMobileViewPicker] PATCH failed:', err);
|
||||
});
|
||||
});
|
||||
@@ -2170,7 +2185,7 @@ function _openFlyoutSubmenu(triggerItem, unhideFirst) {
|
||||
var v = views[i];
|
||||
var isIn = (v.sessions || []).indexOf(sessionKey) !== -1;
|
||||
html += '<button class="flyout-submenu__item" role="menuitem" data-view-index="' + i + '">';
|
||||
html += '<span class="flyout-submenu__check">' + (isIn ? '\u2713' : '') + '</span>';
|
||||
html += '<span class="flyout-submenu__check">' + (isIn ? _icons.check : '') + '</span>';
|
||||
html += escapeHtml(v.name);
|
||||
html += '</button>';
|
||||
}
|
||||
@@ -2283,7 +2298,7 @@ function _openFlyoutSubmenu(triggerItem, unhideFirst) {
|
||||
var checkEl = checkItems[ci].querySelector('.flyout-submenu__check');
|
||||
var updViews = (_serverSettings && _serverSettings.views) || [];
|
||||
if (checkEl && updViews[vi]) {
|
||||
checkEl.textContent = (updViews[vi].sessions || []).indexOf(sessionKey) !== -1 ? '\u2713' : '';
|
||||
checkEl.innerHTML = (updViews[vi].sessions || []).indexOf(sessionKey) !== -1 ? _icons.check : '';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2474,7 +2489,7 @@ function openManageViewPanel() {
|
||||
nameRow.innerHTML =
|
||||
'<h2 id="manage-view-name" class="manage-view-panel__name">' + escapeHtml(_activeView) + '</h2>' +
|
||||
'<button id="manage-view-delete-btn" class="manage-view-panel__delete-btn" ' +
|
||||
'title="Delete this view" aria-label="Delete view">\u2715</button>';
|
||||
'title="Delete this view" aria-label="Delete view">' + _icons.close + '</button>';
|
||||
}
|
||||
|
||||
renderManageViewList();
|
||||
@@ -3193,7 +3208,7 @@ function _buildRemoteInstanceRow(url, name, key) {
|
||||
keyInput.setAttribute('aria-label', 'Federation key for remote instance');
|
||||
var removeBtn = document.createElement('button');
|
||||
removeBtn.className = 'settings-remote-remove';
|
||||
removeBtn.textContent = '\u00d7';
|
||||
removeBtn.innerHTML = _icons.close;
|
||||
removeBtn.setAttribute('aria-label', 'Remove remote instance');
|
||||
row.appendChild(urlInput);
|
||||
row.appendChild(nameInput);
|
||||
@@ -3731,7 +3746,7 @@ function renderSheetList() {
|
||||
return '<li class="sheet-item' + (isActive ? ' sheet-item--active' : '') + '"' +
|
||||
' data-session="' + escapedName + '"' + remoteIdAttr + ' role="option">' +
|
||||
'<span class="sheet-item__name">' + escapedName + '</span>' +
|
||||
(hasBell ? '<span class="sheet-item__bell">\uD83D\uDD14</span>' : '') +
|
||||
(hasBell ? '<span class="sheet-item__bell">' + _icons.bell + '</span>' : '') +
|
||||
'<span class="sheet-item__time">' + formatTimestamp(s.bell && s.bell.last_fired_at) + '</span>' +
|
||||
'</li>';
|
||||
}).join('');
|
||||
@@ -4013,26 +4028,30 @@ async function createNewSession(name, remoteId) {
|
||||
// Compute expectedKey: for remote sessions, use 'deviceId:sessionName' (sessionKey format)
|
||||
var expectedKey = deviceId ? (deviceId + ':' + sessionName) : sessionName;
|
||||
|
||||
// Poll until the session appears in _currentSessions (max 30s, every 2s)
|
||||
// Poll until the session appears in _currentSessions.
|
||||
// Check immediately first (session may already be in cache), then retry
|
||||
// at short intervals. This eliminates the old 2-second dead-wait.
|
||||
var attempts = 0;
|
||||
var maxAttempts = 15;
|
||||
var pollForSession = setInterval(async function() {
|
||||
var maxAttempts = 30;
|
||||
async function checkForSession() {
|
||||
attempts++;
|
||||
await pollSessions();
|
||||
var found = _currentSessions && _currentSessions.find(function(s) {
|
||||
return (s.sessionKey || s.name) === expectedKey;
|
||||
});
|
||||
if (found) {
|
||||
clearInterval(pollForSession);
|
||||
removeLoadingTile();
|
||||
showToast('Session \'' + sessionName + '\' ready');
|
||||
openSession(sessionName, { remoteId: deviceId });
|
||||
} else if (attempts >= maxAttempts) {
|
||||
clearInterval(pollForSession);
|
||||
removeLoadingTile();
|
||||
showToast('Session \'' + sessionName + '\' is taking longer than expected');
|
||||
} else {
|
||||
// Short retry: 500ms for first few attempts, then 2s
|
||||
setTimeout(checkForSession, attempts <= 5 ? 500 : 2000);
|
||||
}
|
||||
}, 2000);
|
||||
}
|
||||
checkForSession();
|
||||
} catch (err) {
|
||||
showToast(err.message || 'Failed to create session');
|
||||
}
|
||||
|
||||
+32
-15
@@ -22,14 +22,14 @@
|
||||
<div class="view-dropdown" id="view-dropdown">
|
||||
<button id="view-dropdown-trigger" class="view-dropdown__trigger" aria-haspopup="true" aria-expanded="false" aria-controls="view-dropdown-menu">
|
||||
<span id="view-dropdown-label">All Sessions</span>
|
||||
<span class="view-dropdown__caret" aria-hidden="true">▾</span>
|
||||
<svg class="view-dropdown__caret" aria-hidden="true" viewBox="0 0 16 16" width="10" height="10" fill="currentColor"><path d="M4 6l4 4 4-4z"/></svg>
|
||||
</button>
|
||||
<div id="view-dropdown-menu" class="view-dropdown__menu hidden" role="menu" aria-label="Switch view"></div>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<button id="new-session-btn" class="header-btn" aria-label="New session">+</button>
|
||||
<button id="view-mode-btn" class="header-btn" aria-label="Toggle view mode" title="View: auto">▦</button>
|
||||
<button id="settings-btn" class="header-btn" aria-label="Settings">⚙</button>
|
||||
<button id="new-session-btn" class="header-btn" aria-label="New session"><svg viewBox="0 0 16 16" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2"><path d="M8 3v10M3 8h10"/></svg></button>
|
||||
<button id="view-mode-btn" class="header-btn" aria-label="Toggle view mode" title="View: auto"><svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor"><rect x="1" y="1" width="6" height="6" rx="1"/><rect x="9" y="1" width="6" height="6" rx="1"/><rect x="1" y="9" width="6" height="6" rx="1"/><rect x="9" y="9" width="6" height="6" rx="1"/></svg></button>
|
||||
<button id="settings-btn" class="header-btn" aria-label="Settings"><svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="8" cy="8" r="2.5"/><path d="M8 1v2M8 13v2M1 8h2M13 8h2M2.9 2.9l1.4 1.4M11.7 11.7l1.4 1.4M2.9 13.1l1.4-1.4M11.7 4.3l1.4-1.4"/></svg></button>
|
||||
<span id="connection-status"></span>
|
||||
</div>
|
||||
</header>
|
||||
@@ -41,10 +41,10 @@
|
||||
<!-- ── Expanded (terminal) view ──────────────────────────────────────── -->
|
||||
<div id="view-expanded" class="view hidden">
|
||||
<header class="expanded-header">
|
||||
<button id="back-btn" class="back-btn" aria-label="Back">←</button>
|
||||
<button id="sidebar-toggle-btn" class="sidebar-toggle-btn" aria-label="Toggle session list">☰</button>
|
||||
<button id="back-btn" class="back-btn" aria-label="Back"><svg viewBox="0 0 16 16" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M10 3L5 8l5 5"/></svg></button>
|
||||
<button id="sidebar-toggle-btn" class="sidebar-toggle-btn" aria-label="Toggle session list"><svg viewBox="0 0 16 16" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M2 4h12M2 8h12M2 12h12"/></svg></button>
|
||||
<span id="expanded-session-name" class="expanded-session-name"></span>
|
||||
<button id="settings-btn-expanded" class="header-btn" aria-label="Settings">⚙</button>
|
||||
<button id="settings-btn-expanded" class="header-btn" aria-label="Settings"><svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="8" cy="8" r="2.5"/><path d="M8 1v2M8 13v2M1 8h2M13 8h2M2.9 2.9l1.4 1.4M11.7 11.7l1.4 1.4M2.9 13.1l1.4-1.4M11.7 4.3l1.4-1.4"/></svg></button>
|
||||
</header>
|
||||
<div class="view-body">
|
||||
<div id="session-sidebar" class="session-sidebar">
|
||||
@@ -52,11 +52,11 @@
|
||||
<div class="sidebar-view-dropdown" id="sidebar-view-dropdown">
|
||||
<button id="sidebar-view-dropdown-trigger" class="sidebar-view-trigger" aria-haspopup="true" aria-expanded="false" aria-controls="sidebar-view-dropdown-menu">
|
||||
<span id="sidebar-view-label">All Sessions</span>
|
||||
<span class="view-dropdown__caret" aria-hidden="true">▾</span>
|
||||
<svg class="view-dropdown__caret" aria-hidden="true" viewBox="0 0 16 16" width="10" height="10" fill="currentColor"><path d="M4 6l4 4 4-4z"/></svg>
|
||||
</button>
|
||||
<div id="sidebar-view-dropdown-menu" class="view-dropdown__menu hidden" role="menu" aria-label="Switch view"></div>
|
||||
</div>
|
||||
<button id="sidebar-collapse-btn" class="sidebar-collapse-btn" aria-label="Collapse session list">‹</button>
|
||||
<button id="sidebar-collapse-btn" class="sidebar-collapse-btn" aria-label="Collapse session list"><svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M10 3L5 8l5 5"/></svg></button>
|
||||
</div>
|
||||
<div id="sidebar-list" class="sidebar-list"></div>
|
||||
<div class="sidebar-footer">
|
||||
@@ -67,11 +67,28 @@
|
||||
<div id="terminal-search-bar" class="terminal-search-bar hidden">
|
||||
<input id="terminal-search-input" type="text" class="terminal-search-input" placeholder="Find..." />
|
||||
<span id="terminal-search-count" class="terminal-search-count"></span>
|
||||
<button id="terminal-search-prev" class="terminal-search-btn" title="Previous (Shift+Enter)">▲</button>
|
||||
<button id="terminal-search-next" class="terminal-search-btn" title="Next (Enter)">▼</button>
|
||||
<button id="terminal-search-close" class="terminal-search-btn" title="Close (Escape)">×</button>
|
||||
<button id="terminal-search-prev" class="terminal-search-btn" title="Previous (Shift+Enter)"><svg viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 10l4-4 4 4"/></svg></button>
|
||||
<button id="terminal-search-next" class="terminal-search-btn" title="Next (Enter)"><svg viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 6l4 4 4-4"/></svg></button>
|
||||
<button id="terminal-search-close" class="terminal-search-btn" title="Close (Escape)"><svg viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M4 4l8 8M12 4l-8 8"/></svg></button>
|
||||
</div>
|
||||
<div id="terminal-container" class="terminal-container"></div>
|
||||
<div id="mobile-toolbar" class="mobile-toolbar hidden">
|
||||
<div class="mobile-toolbar__scroll">
|
||||
<button class="mobile-toolbar__key" data-key="Escape">Esc</button>
|
||||
<button class="mobile-toolbar__key" data-key="Tab">Tab</button>
|
||||
<button class="mobile-toolbar__key mobile-toolbar__key--modifier" data-modifier="ctrl">Ctrl</button>
|
||||
<button class="mobile-toolbar__key mobile-toolbar__key--modifier" data-modifier="alt">Alt</button>
|
||||
<button class="mobile-toolbar__key" data-key="ArrowUp"><svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2"><path d="M8 12V4M4 7l4-4 4 4"/></svg></button>
|
||||
<button class="mobile-toolbar__key" data-key="ArrowDown"><svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2"><path d="M8 4v8M4 9l4 4 4-4"/></svg></button>
|
||||
<button class="mobile-toolbar__key" data-key="ArrowLeft"><svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 8H4M7 4L3 8l4 4"/></svg></button>
|
||||
<button class="mobile-toolbar__key" data-key="ArrowRight"><svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 8h8M9 4l4 4-4 4"/></svg></button>
|
||||
<button class="mobile-toolbar__key" data-input="|">|</button>
|
||||
<button class="mobile-toolbar__key" data-input="~">~</button>
|
||||
<button class="mobile-toolbar__key" data-input="/">/</button>
|
||||
<button class="mobile-toolbar__key" data-input="-">-</button>
|
||||
<button class="mobile-toolbar__key" data-input="`">`</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="reconnect-overlay" class="reconnect-overlay hidden" aria-live="polite">Reconnecting…</div>
|
||||
@@ -106,11 +123,11 @@
|
||||
<!-- ── Session pill (persistent overlay button) ────────────────────────── -->
|
||||
<button id="session-pill" class="session-pill hidden" aria-label="Switch session">
|
||||
<span id="session-pill-label" class="session-pill__label"></span>
|
||||
<span id="session-pill-bell" class="session-pill__bell hidden" aria-hidden="true">🔔</span>
|
||||
<span id="session-pill-bell" class="session-pill__bell hidden" aria-hidden="true"><svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor"><path d="M8 1a1 1 0 0 1 1 1v.3A4.5 4.5 0 0 1 12.5 7v2.5l1 2H2.5l1-2V7A4.5 4.5 0 0 1 7 2.3V2a1 1 0 0 1 1-1zM6.5 13a1.5 1.5 0 0 0 3 0z"/></svg></span>
|
||||
</button>
|
||||
|
||||
<!-- ── Mobile FAB (new session) ──────────────────────────────────────────── -->
|
||||
<button id="new-session-fab" class="new-session-fab" aria-label="New session">+</button>
|
||||
<button id="new-session-fab" class="new-session-fab" aria-label="New session"><svg viewBox="0 0 16 16" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2"><path d="M8 3v10M3 8h10"/></svg></button>
|
||||
|
||||
<!-- ── Toast notification ──────────────────────────────────────────────── -->
|
||||
<div id="toast" class="toast hidden" role="status" aria-live="polite" aria-atomic="true"></div>
|
||||
@@ -120,7 +137,7 @@
|
||||
<dialog id="settings-dialog" class="settings-dialog">
|
||||
<div class="settings-dialog-header">
|
||||
<h2 class="settings-dialog-title">Settings</h2>
|
||||
<button id="settings-close-btn" class="settings-close-btn" aria-label="Close settings">×</button>
|
||||
<button id="settings-close-btn" class="settings-close-btn" aria-label="Close settings"><svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M4 4l8 8M12 4l-8 8"/></svg></button>
|
||||
</div>
|
||||
<div class="settings-layout">
|
||||
<nav class="settings-tabs">
|
||||
|
||||
+102
-2
@@ -53,6 +53,23 @@
|
||||
--font-mono: 'SF Mono', 'Fira Code', 'Consolas', 'Menlo', monospace;
|
||||
}
|
||||
|
||||
/* Inline SVG icons — ensure consistent vertical alignment */
|
||||
.header-btn svg,
|
||||
.back-btn svg,
|
||||
.sidebar-toggle-btn svg,
|
||||
.sidebar-collapse-btn svg,
|
||||
.terminal-search-btn svg,
|
||||
.settings-close-btn svg,
|
||||
.tile-options-btn svg,
|
||||
.new-session-fab svg {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.view-dropdown__caret {
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* Box-sizing reset */
|
||||
*,
|
||||
*::before,
|
||||
@@ -752,9 +769,13 @@ body {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
/* xterm.js injects overflow-y: scroll on .xterm-viewport — override it */
|
||||
/* xterm.js manages touch scrolling natively via its Viewport — let it work.
|
||||
Hide the visual scrollbar on touch devices but keep overflow-y functional. */
|
||||
.xterm .xterm-viewport {
|
||||
overflow-y: hidden !important;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.xterm .xterm-viewport::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.reconnect-overlay {
|
||||
@@ -2056,6 +2077,13 @@ body {
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
/* Ensure mobile toolbar is hidden on non-touch desktop */
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
.mobile-toolbar {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Responsive overlay sidebar at <960px
|
||||
============================================================ */
|
||||
@@ -2539,3 +2567,75 @@ body {
|
||||
margin: 4px 0;
|
||||
background: var(--border-subtle);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Mobile terminal toolbar — virtual keys for touch devices
|
||||
============================================================ */
|
||||
|
||||
.mobile-toolbar {
|
||||
flex-shrink: 0;
|
||||
background: var(--bg-secondary);
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
padding: 4px 6px;
|
||||
}
|
||||
|
||||
.mobile-toolbar__scroll {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
scrollbar-width: none; /* Firefox */
|
||||
padding-bottom: 2px; /* prevent clipping of active state */
|
||||
}
|
||||
|
||||
.mobile-toolbar__scroll::-webkit-scrollbar {
|
||||
display: none; /* Chrome/Safari */
|
||||
}
|
||||
|
||||
.mobile-toolbar__key {
|
||||
flex-shrink: 0;
|
||||
min-width: 38px;
|
||||
height: 32px;
|
||||
padding: 0 8px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 5px;
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: border-color var(--t-fast), color var(--t-fast), background var(--t-fast);
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.mobile-toolbar__key:active {
|
||||
background: var(--accent-dim);
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.mobile-toolbar__key svg {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Modifier keys (Ctrl, Alt) — slightly different base style */
|
||||
.mobile-toolbar__key--modifier {
|
||||
font-family: var(--font-ui);
|
||||
font-weight: 600;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
/* Active (toggled on) state for modifier keys */
|
||||
.mobile-toolbar__key--active {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: var(--bg);
|
||||
}
|
||||
|
||||
+132
-59
@@ -11,6 +11,8 @@ let _vpHandler = null;
|
||||
let _reconnectAttempts = 0; // tracks consecutive failed reconnect attempts for backoff + ttyd respawn
|
||||
let _searchAddon = null;
|
||||
let _resizeObserver = null;
|
||||
let _ctrlActive = false; // mobile toolbar: Ctrl modifier toggle (one-shot)
|
||||
let _altActive = false; // mobile toolbar: Alt modifier toggle (one-shot)
|
||||
|
||||
// ─── Module-level encoding helpers ──────────────────────────────────────────
|
||||
// Hoisted here so the clipboard key handler (in openTerminal) can also use them.
|
||||
@@ -76,8 +78,24 @@ function connectWebSocket(name, remoteId) {
|
||||
if (_term) {
|
||||
_term.onData(function(data) {
|
||||
if (_ws && _ws.readyState === WebSocket.OPEN) {
|
||||
var outData = data;
|
||||
// Mobile toolbar modifier keys (one-shot: auto-deactivate after use)
|
||||
if (_ctrlActive && data.length === 1) {
|
||||
var code = data.toUpperCase().charCodeAt(0);
|
||||
if (code >= 65 && code <= 90) { // A-Z → Ctrl+letter
|
||||
outData = String.fromCharCode(code - 64);
|
||||
}
|
||||
_ctrlActive = false;
|
||||
var cb = document.querySelector('[data-modifier="ctrl"]');
|
||||
if (cb) cb.classList.remove('mobile-toolbar__key--active');
|
||||
} else if (_altActive && data.length === 1) {
|
||||
outData = '\x1b' + data; // Alt = ESC prefix
|
||||
_altActive = false;
|
||||
var ab = document.querySelector('[data-modifier="alt"]');
|
||||
if (ab) ab.classList.remove('mobile-toolbar__key--active');
|
||||
}
|
||||
// ttyd protocol: input is type 0x30 ('0') + UTF-8 keystroke bytes
|
||||
_ws.send(encodePayload(0x30, data));
|
||||
_ws.send(encodePayload(0x30, outData));
|
||||
}
|
||||
});
|
||||
_term.onResize(function(size) {
|
||||
@@ -206,12 +224,19 @@ function initVisualViewport() {
|
||||
var container = document.getElementById('terminal-container');
|
||||
if (!container) return;
|
||||
|
||||
// Resize container to fill visual viewport above keyboard
|
||||
// Resize container to fill visual viewport above keyboard.
|
||||
// Account for header + mobile toolbar (if visible).
|
||||
var headerHeight = 44; // matches --header-height CSS custom property
|
||||
var toolbar = document.getElementById('mobile-toolbar');
|
||||
var toolbarHeight = (toolbar && !toolbar.classList.contains('hidden')) ? toolbar.offsetHeight : 0;
|
||||
var vvh = window.visualViewport.height;
|
||||
var termHeight = Math.max(100, vvh - headerHeight);
|
||||
var termHeight = Math.max(100, vvh - headerHeight - toolbarHeight);
|
||||
container.style.height = termHeight + 'px';
|
||||
|
||||
// Ensure terminal viewport stays pinned — prevent the page from
|
||||
// shifting upward when the soft keyboard opens on mobile.
|
||||
window.scrollTo(0, 0);
|
||||
|
||||
// Refit xterm.js to new container size
|
||||
try { _fitAddon.fit(); } catch (_) {}
|
||||
};
|
||||
@@ -501,6 +526,10 @@ function openTerminal(sessionName, remoteId, fontSize) {
|
||||
|
||||
connectWebSocket(sessionName, remoteId);
|
||||
initVisualViewport(); /* defined in Task 14 */
|
||||
|
||||
// --- Mobile enhancements ---
|
||||
_initAndroidIMEFix(container);
|
||||
_initMobileToolbar();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -524,6 +553,12 @@ function closeTerminal() {
|
||||
|
||||
if (_resizeObserver) { _resizeObserver.disconnect(); _resizeObserver = null; }
|
||||
|
||||
// Clean up mobile enhancements
|
||||
_ctrlActive = false;
|
||||
_altActive = false;
|
||||
var mobileToolbar = document.getElementById('mobile-toolbar');
|
||||
if (mobileToolbar) mobileToolbar.classList.add('hidden');
|
||||
|
||||
if (_term) {
|
||||
_term.dispose();
|
||||
_term = null;
|
||||
@@ -563,71 +598,109 @@ function setTerminalFontSize(size) {
|
||||
window._setTerminalFontSize = setTerminalFontSize;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mobile touch scroll — rAF-batched WheelEvent dispatch
|
||||
// Mobile devices batch touchmove events irregularly; dispatching one WheelEvent
|
||||
// per frame (via requestAnimationFrame) smooths over burst delivery.
|
||||
// Applies to Android, iOS, and iPadOS touch devices.
|
||||
// Android IME fix — prevent double-space-to-period from duplicating input
|
||||
// Android keyboards use insertReplacementText beforeinput events to perform
|
||||
// auto-corrections (e.g. " " → ". "). xterm.js's hidden textarea gets confused
|
||||
// by these replacements, causing the entire composition buffer to replay.
|
||||
// ---------------------------------------------------------------------------
|
||||
;(function initMobileTerminalScroll() {
|
||||
var isTouchDevice = /Android|iPhone|iPad|iPod/i.test(navigator.userAgent) ||
|
||||
(navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
|
||||
|
||||
function _initAndroidIMEFix(container) {
|
||||
if (!/Android/i.test(navigator.userAgent)) return;
|
||||
|
||||
// Wait a tick for xterm.js to create its hidden textarea
|
||||
setTimeout(function() {
|
||||
var ta = container.querySelector('.xterm-helper-textarea');
|
||||
if (!ta) return;
|
||||
|
||||
ta.addEventListener('beforeinput', function(e) {
|
||||
if (e.inputType === 'insertReplacementText') {
|
||||
e.preventDefault();
|
||||
e.stopImmediatePropagation();
|
||||
var data = e.data || '';
|
||||
if (data && _ws && _ws.readyState === WebSocket.OPEN) {
|
||||
// Send backspace (delete the char being replaced) + replacement text
|
||||
_ws.send(_encodePayload(0x30, '\x08' + data));
|
||||
}
|
||||
// Clear textarea to prevent stale IME state
|
||||
ta.value = '';
|
||||
}
|
||||
}, true); // capture phase — run before xterm.js handlers
|
||||
}, 100);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mobile toolbar — virtual keys for Esc, Tab, Ctrl, Alt, arrows, specials
|
||||
// Ctrl and Alt are one-shot modifier toggles: tap Ctrl, then type a letter
|
||||
// on the soft keyboard → sends Ctrl+letter, modifier auto-deactivates.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function _initMobileToolbar() {
|
||||
var isTouchDevice = 'ontouchstart' in window || navigator.maxTouchPoints > 0;
|
||||
if (!isTouchDevice) return;
|
||||
|
||||
var container = document.getElementById('terminal-container');
|
||||
if (!container) return;
|
||||
var toolbar = document.getElementById('mobile-toolbar');
|
||||
if (!toolbar) return;
|
||||
|
||||
var _lastY = 0;
|
||||
var _accumulated = 0; // pixel debt between rAF ticks
|
||||
var _rafId = null;
|
||||
var SCROLL_PX = 20; // pixels of touch movement = one WheelEvent dispatch
|
||||
toolbar.classList.remove('hidden');
|
||||
_ctrlActive = false;
|
||||
_altActive = false;
|
||||
|
||||
function flushScroll() {
|
||||
_rafId = null;
|
||||
if (!_term || Math.abs(_accumulated) < SCROLL_PX) return;
|
||||
var ctrlBtn = toolbar.querySelector('[data-modifier="ctrl"]');
|
||||
var altBtn = toolbar.querySelector('[data-modifier="alt"]');
|
||||
|
||||
var viewport = container.querySelector('.xterm-viewport');
|
||||
if (!viewport) { _accumulated = 0; return; }
|
||||
// Single delegated handler for all toolbar keys.
|
||||
// Use 'pointerdown' + preventDefault to avoid focusing xterm's hidden textarea
|
||||
// (which brings up the soft keyboard). Modifier toggles (Ctrl/Alt) DO focus
|
||||
// intentionally so the user can type the next character on the soft keyboard.
|
||||
toolbar.addEventListener('pointerdown', function(e) {
|
||||
var btn = e.target.closest('.mobile-toolbar__key');
|
||||
if (!btn) return;
|
||||
|
||||
// One WheelEvent per frame — dir * 120 = one standard scroll click
|
||||
var dir = _accumulated > 0 ? 1 : -1;
|
||||
viewport.dispatchEvent(new WheelEvent('wheel', {
|
||||
deltaY: dir * 120,
|
||||
deltaMode: WheelEvent.DOM_DELTA_PIXEL,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}));
|
||||
_accumulated -= dir * SCROLL_PX;
|
||||
|
||||
// Self-schedule until remainder is consumed
|
||||
if (Math.abs(_accumulated) >= SCROLL_PX) {
|
||||
_rafId = requestAnimationFrame(flushScroll);
|
||||
// --- Modifier toggles: focus terminal to bring up keyboard ---
|
||||
var modifier = btn.dataset.modifier;
|
||||
if (modifier === 'ctrl') {
|
||||
e.preventDefault();
|
||||
_ctrlActive = !_ctrlActive;
|
||||
btn.classList.toggle('mobile-toolbar__key--active', _ctrlActive);
|
||||
if (_altActive && altBtn) { _altActive = false; altBtn.classList.remove('mobile-toolbar__key--active'); }
|
||||
// Focus terminal so soft keyboard appears for the next character
|
||||
if (_ctrlActive && _term) _term.focus();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
container.addEventListener('touchstart', function (e) {
|
||||
_lastY = e.touches[0].clientY;
|
||||
_accumulated = 0;
|
||||
if (_rafId) { cancelAnimationFrame(_rafId); _rafId = null; }
|
||||
}, { passive: true });
|
||||
|
||||
container.addEventListener('touchmove', function (e) {
|
||||
if (!_term) return;
|
||||
e.preventDefault(); // block outer-container scroll
|
||||
|
||||
var y = e.touches[0].clientY;
|
||||
_accumulated += _lastY - y; // positive = swipe up = newer content
|
||||
_lastY = y;
|
||||
|
||||
if (!_rafId) {
|
||||
_rafId = requestAnimationFrame(flushScroll);
|
||||
if (modifier === 'alt') {
|
||||
e.preventDefault();
|
||||
_altActive = !_altActive;
|
||||
btn.classList.toggle('mobile-toolbar__key--active', _altActive);
|
||||
if (_ctrlActive && ctrlBtn) { _ctrlActive = false; ctrlBtn.classList.remove('mobile-toolbar__key--active'); }
|
||||
// Focus terminal so soft keyboard appears for the next character
|
||||
if (_altActive && _term) _term.focus();
|
||||
return;
|
||||
}
|
||||
}, { passive: false }); // passive:false required for preventDefault
|
||||
|
||||
container.addEventListener('touchend', function () {
|
||||
_lastY = 0;
|
||||
_accumulated = 0;
|
||||
if (_rafId) { cancelAnimationFrame(_rafId); _rafId = null; }
|
||||
}, { passive: true });
|
||||
})();
|
||||
// --- Direct key presses: send sequence WITHOUT focusing (no keyboard popup) ---
|
||||
e.preventDefault(); // prevent focus transfer to xterm textarea
|
||||
var key = btn.dataset.key;
|
||||
var input = btn.dataset.input;
|
||||
var seq = '';
|
||||
|
||||
if (key) {
|
||||
switch (key) {
|
||||
case 'Escape': seq = '\x1b'; break;
|
||||
case 'Tab': seq = '\t'; break;
|
||||
case 'ArrowUp': seq = '\x1b[A'; break;
|
||||
case 'ArrowDown': seq = '\x1b[B'; break;
|
||||
case 'ArrowRight': seq = '\x1b[C'; break;
|
||||
case 'ArrowLeft': seq = '\x1b[D'; break;
|
||||
}
|
||||
} else if (input) {
|
||||
seq = input;
|
||||
}
|
||||
|
||||
if (seq && _ws && _ws.readyState === WebSocket.OPEN) {
|
||||
_ws.send(_encodePayload(0x30, seq));
|
||||
}
|
||||
// Do NOT focus terminal — keeps soft keyboard hidden
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
+27
-4
@@ -20,6 +20,7 @@ import pwd
|
||||
import re
|
||||
import socket
|
||||
import ssl
|
||||
import shlex
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
@@ -374,7 +375,9 @@ async def lifespan(app: FastAPI):
|
||||
"set-hook",
|
||||
"-g",
|
||||
"alert-bell",
|
||||
f"run-shell 'curl -sfo /dev/null -X POST http://localhost:{SERVER_PORT}/api/sessions/#{{session_name}}/bell || true'",
|
||||
"run-shell '"
|
||||
f'name=$(printf "%s" "#{{session_name}}" | sed "s/ /%20/g"); '
|
||||
f"curl -sfo /dev/null -X POST http://localhost:{SERVER_PORT}/api/sessions/$name/bell || true'",
|
||||
)
|
||||
except Exception:
|
||||
pass # tmux not running at startup is OK; hook will be set on first poll
|
||||
@@ -635,7 +638,7 @@ async def create_session(payload: CreateSessionPayload) -> dict:
|
||||
"Ensure it is installed and in the server's PATH.",
|
||||
)
|
||||
|
||||
command = template.replace("{name}", name)
|
||||
command = template.replace("{name}", shlex.quote(name))
|
||||
_log.info("Creating session '%s' with command: %s", name, command)
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_shell(
|
||||
@@ -692,6 +695,16 @@ async def create_session(payload: CreateSessionPayload) -> dict:
|
||||
status_code=500,
|
||||
detail=f"Failed to launch command: {exc}",
|
||||
)
|
||||
|
||||
# Eagerly refresh the session cache so the next GET /api/sessions
|
||||
# reflects the newly-created session without waiting for the poll loop.
|
||||
try:
|
||||
fresh_names = await enumerate_sessions()
|
||||
fresh_snapshots = await snapshot_all(fresh_names)
|
||||
update_session_cache(fresh_names, fresh_snapshots)
|
||||
except Exception:
|
||||
pass # non-fatal; poll loop will catch up
|
||||
|
||||
return {"name": name, "ok": True}
|
||||
|
||||
|
||||
@@ -713,6 +726,14 @@ async def connect_session(name: str) -> dict:
|
||||
await kill_ttyd()
|
||||
await spawn_ttyd(name)
|
||||
|
||||
# Wait for ttyd to actually bind its port before returning.
|
||||
# This eliminates the 0.8s blind sleep in the WebSocket proxy path —
|
||||
# the client can connect immediately when this endpoint responds.
|
||||
for _attempt in range(20): # up to ~1s (20 × 50ms)
|
||||
if _ttyd_is_listening():
|
||||
break
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
async with state_lock:
|
||||
state = load_state()
|
||||
state["active_session"] = name
|
||||
@@ -758,7 +779,7 @@ async def delete_session(name: str) -> dict:
|
||||
settings = load_settings()
|
||||
command = settings.get(
|
||||
"delete_session_template", "tmux kill-session -t {name}"
|
||||
).replace("{name}", name)
|
||||
).replace("{name}", shlex.quote(name))
|
||||
|
||||
_log.info("Deleting session '%s' with command: %s", name, command)
|
||||
try:
|
||||
@@ -858,7 +879,9 @@ async def setup_hooks() -> dict:
|
||||
"set-hook",
|
||||
"-g",
|
||||
"alert-bell",
|
||||
f"run-shell 'curl -sfo /dev/null -X POST http://localhost:{SERVER_PORT}/api/sessions/#{{session_name}}/bell || true'",
|
||||
"run-shell '"
|
||||
f'name=$(printf "%s" "#{{session_name}}" | sed "s/ /%20/g"); '
|
||||
f"curl -sfo /dev/null -X POST http://localhost:{SERVER_PORT}/api/sessions/$name/bell || true'",
|
||||
)
|
||||
return {"ok": True}
|
||||
except Exception as e:
|
||||
|
||||
@@ -963,8 +963,9 @@ def test_html_fab_exists() -> None:
|
||||
assert fab.get("aria-label") == "New session", (
|
||||
f"#new-session-fab must have aria-label='New session', got: {fab.get('aria-label')!r}"
|
||||
)
|
||||
text = fab.get_text(strip=True)
|
||||
assert text == "+", f"#new-session-fab text must be '+', got: {text!r}"
|
||||
# FAB uses an inline SVG icon instead of text
|
||||
svg = fab.find("svg")
|
||||
assert svg is not None, "#new-session-fab must contain an SVG icon"
|
||||
|
||||
|
||||
def test_html_fab_before_toast() -> None:
|
||||
|
||||
@@ -1734,9 +1734,9 @@ def test_create_new_session_polls_before_open() -> None:
|
||||
assert "setTimeout(() => openSession" not in body, (
|
||||
"createNewSession must not use immediate setTimeout(() => openSession) — should poll instead"
|
||||
)
|
||||
# New polling pattern must be present
|
||||
assert "setInterval" in body, (
|
||||
"createNewSession must use setInterval to poll for session readiness"
|
||||
# Polling pattern must be present (recursive setTimeout or setInterval)
|
||||
assert "setTimeout" in body or "setInterval" in body, (
|
||||
"createNewSession must poll for session readiness (setTimeout or setInterval)"
|
||||
)
|
||||
|
||||
|
||||
|
||||
+122
-2
@@ -1,6 +1,8 @@
|
||||
"""
|
||||
Tests for coordinator/ttyd.py — ttyd process lifecycle management.
|
||||
All 11 acceptance-criteria tests are defined here.
|
||||
All 11 acceptance-criteria tests are defined here, plus 5 tests for the
|
||||
_pids_on_port() lsof→fuser→ss fallback chain (the root-cause guard for systems
|
||||
where lsof is not installed).
|
||||
"""
|
||||
|
||||
import signal
|
||||
@@ -9,7 +11,13 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
||||
import pytest
|
||||
|
||||
import muxplex.ttyd as ttyd_mod
|
||||
from muxplex.ttyd import kill_orphan_ttyd, kill_ttyd, spawn_ttyd
|
||||
from muxplex.ttyd import (
|
||||
_kill_pids_on_port,
|
||||
_pids_on_port,
|
||||
kill_orphan_ttyd,
|
||||
kill_ttyd,
|
||||
spawn_ttyd,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -311,6 +319,118 @@ async def test_spawn_ttyd_force_kills_process_on_port_before_binding():
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _pids_on_port fallback chain tests
|
||||
#
|
||||
# These cover the exact production failure: lsof not installed → all kill
|
||||
# strategies silently returned False → stale ttyd survived restarts.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_subprocess_result(returncode: int, stdout: str) -> MagicMock:
|
||||
r = MagicMock()
|
||||
r.returncode = returncode
|
||||
r.stdout = stdout
|
||||
return r
|
||||
|
||||
|
||||
def test_pids_on_port_uses_lsof_when_available():
|
||||
"""_pids_on_port() returns PID list from lsof output when lsof is available."""
|
||||
|
||||
def mock_run(cmd, **kwargs): # noqa: ANN001
|
||||
if cmd[0] == "lsof":
|
||||
return _make_subprocess_result(0, "3555095\n")
|
||||
return _make_subprocess_result(1, "")
|
||||
|
||||
with patch("muxplex.ttyd._subprocess.run", side_effect=mock_run):
|
||||
result = _pids_on_port(7682)
|
||||
|
||||
assert result == [3555095], "Should return PID from lsof"
|
||||
|
||||
|
||||
def test_pids_on_port_falls_back_to_fuser_when_lsof_missing():
|
||||
"""_pids_on_port() uses fuser when lsof is not installed (FileNotFoundError).
|
||||
|
||||
This is the exact failure mode that caused the yazi-stuck-ttyd bug:
|
||||
lsof was absent, _kill_pids_on_port silently returned False, and the
|
||||
stale ttyd was never killed.
|
||||
"""
|
||||
|
||||
def mock_run(cmd, **kwargs): # noqa: ANN001
|
||||
if cmd[0] == "lsof":
|
||||
raise FileNotFoundError("lsof not found")
|
||||
if cmd[0] == "fuser":
|
||||
return _make_subprocess_result(0, " 3555095")
|
||||
return _make_subprocess_result(1, "")
|
||||
|
||||
with patch("muxplex.ttyd._subprocess.run", side_effect=mock_run):
|
||||
result = _pids_on_port(7682)
|
||||
|
||||
assert result == [3555095], "Should fall back to fuser when lsof is missing"
|
||||
|
||||
|
||||
def test_pids_on_port_falls_back_to_ss_when_lsof_and_fuser_missing():
|
||||
"""_pids_on_port() uses ss when both lsof and fuser are unavailable."""
|
||||
ss_output = (
|
||||
'LISTEN 0 128 0.0.0.0:7682 0.0.0.0:* users:(("ttyd",pid=3555095,fd=12))\n'
|
||||
)
|
||||
|
||||
def mock_run(cmd, **kwargs): # noqa: ANN001
|
||||
if cmd[0] in ("lsof", "fuser"):
|
||||
raise FileNotFoundError(f"{cmd[0]} not found")
|
||||
if cmd[0] == "ss":
|
||||
return _make_subprocess_result(0, ss_output)
|
||||
return _make_subprocess_result(1, "")
|
||||
|
||||
with patch("muxplex.ttyd._subprocess.run", side_effect=mock_run):
|
||||
result = _pids_on_port(7682)
|
||||
|
||||
assert result == [3555095], "Should fall back to ss when lsof and fuser missing"
|
||||
|
||||
|
||||
def test_pids_on_port_returns_empty_when_all_tools_fail():
|
||||
"""_pids_on_port() returns [] when no discovery tool is available."""
|
||||
|
||||
def mock_run(cmd, **kwargs): # noqa: ANN001
|
||||
raise FileNotFoundError(f"{cmd[0]} not found")
|
||||
|
||||
with patch("muxplex.ttyd._subprocess.run", side_effect=mock_run):
|
||||
result = _pids_on_port(7682)
|
||||
|
||||
assert result == [], "Should return empty list when all tools fail"
|
||||
|
||||
|
||||
def test_kill_pids_on_port_sends_signal_via_fuser_fallback():
|
||||
"""_kill_pids_on_port() sends the correct signal using the fuser fallback.
|
||||
|
||||
End-to-end regression guard: verifies that the full chain from
|
||||
_kill_pids_on_port → _pids_on_port → fuser → os.kill works correctly
|
||||
so that stale ttyd processes are killed even when lsof is absent.
|
||||
"""
|
||||
killed: list[tuple[int, int]] = []
|
||||
|
||||
def mock_run(cmd, **kwargs): # noqa: ANN001
|
||||
if cmd[0] == "lsof":
|
||||
raise FileNotFoundError("lsof not found")
|
||||
if cmd[0] == "fuser":
|
||||
return _make_subprocess_result(0, " 3555095")
|
||||
return _make_subprocess_result(1, "")
|
||||
|
||||
def mock_kill(pid: int, sig: int) -> None:
|
||||
killed.append((pid, sig))
|
||||
|
||||
with (
|
||||
patch("muxplex.ttyd._subprocess.run", side_effect=mock_run),
|
||||
patch("os.kill", side_effect=mock_kill),
|
||||
):
|
||||
result = _kill_pids_on_port(7682, signal.SIGKILL)
|
||||
|
||||
assert result is True, "_kill_pids_on_port must return True when fuser finds a PID"
|
||||
assert (3555095, signal.SIGKILL) in killed, (
|
||||
"Must send SIGKILL to PID 3555095 discovered via fuser fallback"
|
||||
)
|
||||
|
||||
|
||||
async def test_kill_orphan_ttyd_handles_invalid_pid_file_content():
|
||||
"""kill_orphan_ttyd() gracefully handles a PID file with non-integer content."""
|
||||
pid_path = ttyd_mod.TTYD_PID_PATH
|
||||
|
||||
+89
-22
@@ -17,6 +17,7 @@ Public API:
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import re as _re
|
||||
import signal
|
||||
import subprocess as _subprocess
|
||||
import time
|
||||
@@ -43,12 +44,21 @@ _active_process: asyncio.subprocess.Process | None = None
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _kill_pids_on_port(port: int, sig: int) -> bool:
|
||||
"""Find and signal all processes listening on *port* via lsof.
|
||||
def _pids_on_port(port: int) -> list[int]:
|
||||
"""Return PIDs of all processes listening on *port*.
|
||||
|
||||
Returns True if at least one PID was found and signalled.
|
||||
Silently ignores lsof unavailability and already-dead processes.
|
||||
Tries three tools in order, stopping at the first that returns results:
|
||||
|
||||
1. ``lsof -ti :<port>`` — one PID per line, most widely available.
|
||||
2. ``fuser <port>/tcp`` — space-separated PIDs on stdout (psmisc).
|
||||
3. ``ss -Hnltp`` — parses ``pid=N`` from users field (iproute2).
|
||||
|
||||
Returns an empty list if none of the tools are available or find anything.
|
||||
Silently swallows all errors so callers always get a list.
|
||||
"""
|
||||
pids: list[int] = []
|
||||
|
||||
# --- Tool 1: lsof ---
|
||||
try:
|
||||
result = _subprocess.run(
|
||||
["lsof", "-ti", f":{port}"],
|
||||
@@ -56,23 +66,79 @@ def _kill_pids_on_port(port: int, sig: int) -> bool:
|
||||
text=True,
|
||||
timeout=5,
|
||||
)
|
||||
if result.returncode != 0 or not result.stdout.strip():
|
||||
return False
|
||||
sent = False
|
||||
for pid_str in result.stdout.strip().split("\n"):
|
||||
pid_str = pid_str.strip()
|
||||
if not pid_str:
|
||||
continue
|
||||
try:
|
||||
orphan_pid = int(pid_str)
|
||||
os.kill(orphan_pid, sig)
|
||||
sent = True
|
||||
except (ValueError, ProcessLookupError, PermissionError):
|
||||
pass
|
||||
return sent
|
||||
if result.returncode == 0 and result.stdout.strip():
|
||||
for tok in result.stdout.split():
|
||||
try:
|
||||
pids.append(int(tok))
|
||||
except ValueError:
|
||||
pass
|
||||
except Exception: # noqa: BLE001
|
||||
# lsof not available, timed out, or other unexpected failure
|
||||
pass
|
||||
|
||||
if pids:
|
||||
return pids
|
||||
|
||||
# --- Tool 2: fuser (psmisc) ---
|
||||
try:
|
||||
result = _subprocess.run(
|
||||
["fuser", f"{port}/tcp"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
)
|
||||
# fuser writes the port label to stderr and PIDs to stdout.
|
||||
if result.stdout.strip():
|
||||
for tok in result.stdout.split():
|
||||
try:
|
||||
pids.append(int(tok))
|
||||
except ValueError:
|
||||
pass
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
if pids:
|
||||
return pids
|
||||
|
||||
# --- Tool 3: ss (iproute2) ---
|
||||
try:
|
||||
result = _subprocess.run(
|
||||
["ss", "-Hnltp", f"sport = :{port}"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
)
|
||||
if result.returncode == 0 and result.stdout.strip():
|
||||
for match in _re.finditer(r"pid=(\d+)", result.stdout):
|
||||
try:
|
||||
pids.append(int(match.group(1)))
|
||||
except ValueError:
|
||||
pass
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
return pids
|
||||
|
||||
|
||||
def _kill_pids_on_port(port: int, sig: int) -> bool:
|
||||
"""Find and signal all processes listening on *port*.
|
||||
|
||||
Uses :func:`_pids_on_port` (lsof → fuser → ss) to locate PIDs, then
|
||||
signals each with *sig*.
|
||||
|
||||
Returns True if at least one PID was found and signalled.
|
||||
Silently ignores unavailable tools and already-dead processes.
|
||||
"""
|
||||
pids = _pids_on_port(port)
|
||||
if not pids:
|
||||
return False
|
||||
sent = False
|
||||
for pid in pids:
|
||||
try:
|
||||
os.kill(pid, sig)
|
||||
sent = True
|
||||
except (ProcessLookupError, PermissionError):
|
||||
pass
|
||||
return sent
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -94,9 +160,10 @@ async def kill_ttyd() -> bool:
|
||||
|
||||
Strategy 2 — port-based fallback:
|
||||
After the PID-file kill, finds and kills any process still listening on
|
||||
TTYD_PORT via ``lsof -ti :<port>``. This catches orphaned ttyd processes
|
||||
whose PID was never recorded in the file (e.g. after a coordinator crash).
|
||||
A brief 0.3 s wait is added to let the OS release the port.
|
||||
TTYD_PORT via ``_pids_on_port()`` (lsof → fuser → ss). This catches
|
||||
orphaned ttyd processes whose PID was never recorded in the file (e.g.
|
||||
after a coordinator crash). A brief 0.3 s wait is added to let the OS
|
||||
release the port.
|
||||
|
||||
The PID file and ``_active_process`` are cleared in all cases before
|
||||
returning.
|
||||
|
||||
Reference in New Issue
Block a user