merge: integrate upstream changes with multi-device federation
This commit is contained in:
+22
-3
@@ -136,6 +136,20 @@ def authenticate_pam(username: str, password: str) -> bool:
|
|||||||
# Paths that bypass auth (login page itself, static assets it needs)
|
# Paths that bypass auth (login page itself, static assets it needs)
|
||||||
_AUTH_EXEMPT_PATHS = {"/login", "/auth/mode", "/auth/logout", "/api/instance-info"}
|
_AUTH_EXEMPT_PATHS = {"/login", "/auth/mode", "/auth/logout", "/api/instance-info"}
|
||||||
|
|
||||||
|
# File extensions that are always served without auth — the login page needs
|
||||||
|
# its own CSS, JS, images, and fonts before the user has a session cookie.
|
||||||
|
_STATIC_EXTENSIONS = {
|
||||||
|
".css",
|
||||||
|
".js",
|
||||||
|
".svg",
|
||||||
|
".png",
|
||||||
|
".ico",
|
||||||
|
".woff",
|
||||||
|
".woff2",
|
||||||
|
".ttf",
|
||||||
|
".map",
|
||||||
|
}
|
||||||
|
|
||||||
# Socket-level localhost addresses — cannot be forged via HTTP headers
|
# Socket-level localhost addresses — cannot be forged via HTTP headers
|
||||||
_LOCALHOST_ADDRS = {"127.0.0.1", "::1"}
|
_LOCALHOST_ADDRS = {"127.0.0.1", "::1"}
|
||||||
|
|
||||||
@@ -168,12 +182,17 @@ class AuthMiddleware(BaseHTTPMiddleware):
|
|||||||
if request.url.path in _AUTH_EXEMPT_PATHS:
|
if request.url.path in _AUTH_EXEMPT_PATHS:
|
||||||
return await call_next(request)
|
return await call_next(request)
|
||||||
|
|
||||||
# 3. Valid session cookie
|
# 3. Static assets — login page needs its CSS/JS/images before auth
|
||||||
|
path = request.url.path
|
||||||
|
if any(path.endswith(ext) for ext in _STATIC_EXTENSIONS):
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
|
# 4. Valid session cookie
|
||||||
cookie = request.cookies.get("muxplex_session")
|
cookie = request.cookies.get("muxplex_session")
|
||||||
if cookie and verify_session_cookie(self.secret, cookie, self.ttl_seconds):
|
if cookie and verify_session_cookie(self.secret, cookie, self.ttl_seconds):
|
||||||
return await call_next(request)
|
return await call_next(request)
|
||||||
|
|
||||||
# 4. Authorization: Basic header
|
# 5. Authorization: Basic header
|
||||||
auth_header = request.headers.get("authorization", "")
|
auth_header = request.headers.get("authorization", "")
|
||||||
if auth_header.lower().startswith("basic "):
|
if auth_header.lower().startswith("basic "):
|
||||||
try:
|
try:
|
||||||
@@ -186,7 +205,7 @@ class AuthMiddleware(BaseHTTPMiddleware):
|
|||||||
pass
|
pass
|
||||||
return JSONResponse({"detail": "Invalid credentials"}, status_code=401)
|
return JSONResponse({"detail": "Invalid credentials"}, status_code=401)
|
||||||
|
|
||||||
# 5. No auth — redirect browsers, 401 for API clients
|
# 6. No auth — redirect browsers, 401 for API clients
|
||||||
accept = request.headers.get("accept", "")
|
accept = request.headers.get("accept", "")
|
||||||
if "application/json" in accept:
|
if "application/json" in accept:
|
||||||
return JSONResponse({"detail": "Authentication required"}, status_code=401)
|
return JSONResponse({"detail": "Authentication required"}, status_code=401)
|
||||||
|
|||||||
+345
-9
@@ -21,6 +21,110 @@ from muxplex.auth import (
|
|||||||
_system_service_path = Path("/etc/systemd/system/muxplex.service")
|
_system_service_path = Path("/etc/systemd/system/muxplex.service")
|
||||||
|
|
||||||
|
|
||||||
|
def _get_install_info() -> dict:
|
||||||
|
"""Detect how muxplex was installed using PEP 610 direct_url.json.
|
||||||
|
|
||||||
|
Returns dict with keys:
|
||||||
|
source: 'git' | 'editable' | 'pypi' | 'unknown'
|
||||||
|
version: installed version string
|
||||||
|
commit: installed commit sha (git only)
|
||||||
|
url: git repo URL (git only)
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
from importlib.metadata import PackageNotFoundError, distribution
|
||||||
|
|
||||||
|
info: dict = {
|
||||||
|
"source": "unknown",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"commit": None,
|
||||||
|
"url": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
dist = distribution("muxplex")
|
||||||
|
info["version"] = dist.metadata["Version"]
|
||||||
|
|
||||||
|
du_text = dist.read_text("direct_url.json")
|
||||||
|
if du_text:
|
||||||
|
du = json.loads(du_text)
|
||||||
|
|
||||||
|
if "vcs_info" in du:
|
||||||
|
info["source"] = "git"
|
||||||
|
info["commit"] = du["vcs_info"].get("commit_id", "")
|
||||||
|
info["url"] = du.get("url", "")
|
||||||
|
elif "dir_info" in du and du["dir_info"].get("editable"):
|
||||||
|
info["source"] = "editable"
|
||||||
|
else:
|
||||||
|
info["source"] = "unknown"
|
||||||
|
else:
|
||||||
|
# No direct_url.json → probably PyPI
|
||||||
|
info["source"] = "pypi"
|
||||||
|
except PackageNotFoundError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return info
|
||||||
|
|
||||||
|
|
||||||
|
def _check_for_update(info: dict) -> tuple[bool, str]:
|
||||||
|
"""Check if an update is available. Returns (update_available, message).
|
||||||
|
|
||||||
|
For git: compares installed commit_id against remote HEAD sha.
|
||||||
|
For pypi: compares installed version against latest PyPI version.
|
||||||
|
For editable: always returns (False, "editable install").
|
||||||
|
For unknown: always returns (True, "unknown install source").
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
if info["source"] == "editable":
|
||||||
|
return False, "editable install — manage updates manually"
|
||||||
|
|
||||||
|
if info["source"] == "git":
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["git", "ls-remote", info["url"], "HEAD"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
return True, "could not check remote — upgrading to be safe"
|
||||||
|
|
||||||
|
remote_sha = (
|
||||||
|
result.stdout.strip().split()[0] if result.stdout.strip() else ""
|
||||||
|
)
|
||||||
|
local_sha = info["commit"] or ""
|
||||||
|
|
||||||
|
if not remote_sha:
|
||||||
|
return True, "could not read remote sha — upgrading to be safe"
|
||||||
|
|
||||||
|
if local_sha == remote_sha:
|
||||||
|
return False, f"up to date (commit {local_sha[:8]})"
|
||||||
|
else:
|
||||||
|
return True, f"update available ({local_sha[:8]} → {remote_sha[:8]})"
|
||||||
|
except Exception:
|
||||||
|
return True, "check failed — upgrading to be safe"
|
||||||
|
|
||||||
|
if info["source"] == "pypi":
|
||||||
|
try:
|
||||||
|
req = urllib.request.Request(
|
||||||
|
"https://pypi.org/pypi/muxplex/json",
|
||||||
|
headers={"Accept": "application/json"},
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||||
|
data = json.loads(resp.read())
|
||||||
|
latest = data["info"]["version"]
|
||||||
|
if latest == info["version"]:
|
||||||
|
return False, f"up to date (v{info['version']})"
|
||||||
|
else:
|
||||||
|
return True, f"update available (v{info['version']} → v{latest})"
|
||||||
|
except Exception:
|
||||||
|
return True, "could not check PyPI — upgrading to be safe"
|
||||||
|
|
||||||
|
# Unknown source
|
||||||
|
return True, "unknown install source — upgrading to be safe"
|
||||||
|
|
||||||
|
|
||||||
def reset_secret() -> None:
|
def reset_secret() -> None:
|
||||||
"""Regenerate the signing secret and warn that all sessions are now invalid."""
|
"""Regenerate the signing secret and warn that all sessions are now invalid."""
|
||||||
path = get_secret_path()
|
path = get_secret_path()
|
||||||
@@ -116,14 +220,26 @@ def doctor() -> None:
|
|||||||
else:
|
else:
|
||||||
print(" Install: sudo apt install ttyd")
|
print(" Install: sudo apt install ttyd")
|
||||||
|
|
||||||
# muxplex version
|
# muxplex version + install source + update check
|
||||||
try:
|
try:
|
||||||
from importlib.metadata import version as pkg_version # noqa: PLC0415
|
from importlib.metadata import version as pkg_version # noqa: PLC0415
|
||||||
|
|
||||||
muxplex_version = pkg_version("muxplex")
|
muxplex_version = pkg_version("muxplex")
|
||||||
except Exception:
|
except Exception:
|
||||||
muxplex_version = "dev"
|
muxplex_version = "dev"
|
||||||
print(f" {ok_mark} muxplex {muxplex_version}")
|
|
||||||
|
info = _get_install_info()
|
||||||
|
source_label = info["source"]
|
||||||
|
if info["commit"]:
|
||||||
|
source_label += f" @ {info['commit'][:8]}"
|
||||||
|
print(f" {ok_mark} muxplex {muxplex_version} (installed via {source_label})")
|
||||||
|
|
||||||
|
update_available, update_msg = _check_for_update(info)
|
||||||
|
if update_available:
|
||||||
|
print(f" {warn_mark} Update: {update_msg}")
|
||||||
|
print(" Run: muxplex upgrade")
|
||||||
|
else:
|
||||||
|
print(f" {ok_mark} {update_msg}")
|
||||||
|
|
||||||
# Settings file
|
# Settings file
|
||||||
from muxplex.settings import SETTINGS_PATH # noqa: PLC0415
|
from muxplex.settings import SETTINGS_PATH # noqa: PLC0415
|
||||||
@@ -171,7 +287,18 @@ def doctor() -> None:
|
|||||||
if sys.platform == "darwin":
|
if sys.platform == "darwin":
|
||||||
plist = Path.home() / "Library" / "LaunchAgents" / "com.muxplex.plist"
|
plist = Path.home() / "Library" / "LaunchAgents" / "com.muxplex.plist"
|
||||||
if plist.exists():
|
if plist.exists():
|
||||||
print(f" {ok_mark} Service: launchd agent installed ({plist})")
|
uid = os.getuid()
|
||||||
|
result = subprocess.run(
|
||||||
|
["launchctl", "print", f"gui/{uid}/com.muxplex"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
if result.returncode == 0:
|
||||||
|
print(f" {ok_mark} Service: launchd agent running")
|
||||||
|
else:
|
||||||
|
print(
|
||||||
|
f" {warn_mark} Service: launchd agent installed but not running ({plist})"
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
print(
|
print(
|
||||||
f" {warn_mark} Service: not installed (run: muxplex install-service)"
|
f" {warn_mark} Service: not installed (run: muxplex install-service)"
|
||||||
@@ -220,21 +347,39 @@ def _install_launchd(executable: str) -> None:
|
|||||||
import shutil
|
import shutil
|
||||||
|
|
||||||
# Prefer the entry point script ('muxplex') so macOS shows the correct
|
# Prefer the entry point script ('muxplex') so macOS shows the correct
|
||||||
# process name in Activity Monitor, launchctl list, and login items.
|
# process name in Activity Monitor and launchctl list.
|
||||||
# Fall back to 'python -m muxplex' if the entry point isn't on PATH.
|
|
||||||
muxplex_bin = shutil.which("muxplex")
|
muxplex_bin = shutil.which("muxplex")
|
||||||
if muxplex_bin:
|
if muxplex_bin:
|
||||||
program_args = f""" <array>
|
program_args = f""" <array>
|
||||||
<string>{muxplex_bin}</string>
|
<string>{muxplex_bin}</string>
|
||||||
|
<string>--host</string>
|
||||||
|
<string>0.0.0.0</string>
|
||||||
</array>"""
|
</array>"""
|
||||||
else:
|
else:
|
||||||
program_args = f""" <array>
|
program_args = f""" <array>
|
||||||
<string>{executable}</string>
|
<string>{executable}</string>
|
||||||
<string>-m</string>
|
<string>-m</string>
|
||||||
<string>muxplex</string>
|
<string>muxplex</string>
|
||||||
|
<string>--host</string>
|
||||||
|
<string>0.0.0.0</string>
|
||||||
</array>"""
|
</array>"""
|
||||||
|
|
||||||
|
# Build PATH that includes Homebrew and common tool locations.
|
||||||
|
# launchd agents run with a minimal PATH (/usr/bin:/bin:/usr/sbin:/sbin).
|
||||||
|
# Homebrew binaries (tmux, ttyd, uv, git) won't be found without this.
|
||||||
|
_homebrew_paths = [
|
||||||
|
"/opt/homebrew/bin", # Apple Silicon Homebrew
|
||||||
|
"/usr/local/bin", # Intel Homebrew + system extras
|
||||||
|
"/opt/homebrew/sbin",
|
||||||
|
"/usr/local/sbin",
|
||||||
|
]
|
||||||
|
_user_local = str(Path.home() / ".local" / "bin") # uv/pip tool installs
|
||||||
|
_extra = [_user_local] + _homebrew_paths
|
||||||
|
_base = "/usr/bin:/bin:/usr/sbin:/sbin"
|
||||||
|
_service_path = ":".join(_extra) + ":" + _base
|
||||||
|
|
||||||
label = "com.muxplex"
|
label = "com.muxplex"
|
||||||
|
uid = os.getuid()
|
||||||
plist = f"""<?xml version="1.0" encoding="UTF-8"?>
|
plist = f"""<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
@@ -243,6 +388,11 @@ def _install_launchd(executable: str) -> None:
|
|||||||
<string>{label}</string>
|
<string>{label}</string>
|
||||||
<key>ProgramArguments</key>
|
<key>ProgramArguments</key>
|
||||||
{program_args}
|
{program_args}
|
||||||
|
<key>EnvironmentVariables</key>
|
||||||
|
<dict>
|
||||||
|
<key>PATH</key>
|
||||||
|
<string>{_service_path}</string>
|
||||||
|
</dict>
|
||||||
<key>RunAtLoad</key>
|
<key>RunAtLoad</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>KeepAlive</key>
|
<key>KeepAlive</key>
|
||||||
@@ -256,12 +406,44 @@ def _install_launchd(executable: str) -> None:
|
|||||||
"""
|
"""
|
||||||
path = Path.home() / "Library" / "LaunchAgents" / f"{label}.plist"
|
path = Path.home() / "Library" / "LaunchAgents" / f"{label}.plist"
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Unload existing service first (ignore errors if not loaded)
|
||||||
|
subprocess.run(
|
||||||
|
["launchctl", "bootout", f"gui/{uid}/{label}"],
|
||||||
|
capture_output=True,
|
||||||
|
)
|
||||||
|
|
||||||
path.write_text(plist)
|
path.write_text(plist)
|
||||||
print(f"Launch agent written to {path}")
|
print(f"Launch agent written to {path}")
|
||||||
print("Enable with:")
|
print()
|
||||||
print(f" launchctl load {path}")
|
|
||||||
print("Disable with:")
|
# Load the service using the modern bootstrap API
|
||||||
print(f" launchctl unload {path}")
|
result = subprocess.run(
|
||||||
|
["launchctl", "bootstrap", f"gui/{uid}", str(path)],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
if result.returncode == 0:
|
||||||
|
print(f" Service started (launchctl bootstrap gui/{uid})")
|
||||||
|
else:
|
||||||
|
# Fallback to legacy load for older macOS
|
||||||
|
result2 = subprocess.run(
|
||||||
|
["launchctl", "load", str(path)],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
if result2.returncode == 0:
|
||||||
|
print(" Service started (launchctl load)")
|
||||||
|
else:
|
||||||
|
print(" Could not auto-start. Try manually:")
|
||||||
|
print(f" launchctl bootstrap gui/{uid} {path}")
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("Management commands:")
|
||||||
|
print(f" Stop: launchctl bootout gui/{uid}/{label}")
|
||||||
|
print(f" Start: launchctl bootstrap gui/{uid} {path}")
|
||||||
|
print(f" Status: launchctl print gui/{uid}/{label}")
|
||||||
|
print(" Logs: tail -f /tmp/muxplex.log")
|
||||||
|
|
||||||
|
|
||||||
def _install_systemd(executable: str, *, system: bool = False) -> None:
|
def _install_systemd(executable: str, *, system: bool = False) -> None:
|
||||||
@@ -313,6 +495,143 @@ def install_service(*, system: bool = False) -> None:
|
|||||||
_install_systemd(executable, system=system)
|
_install_systemd(executable, system=system)
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade(*, force: bool = False) -> None:
|
||||||
|
"""Upgrade muxplex to the latest version and restart the service."""
|
||||||
|
print("\nmuxplex upgrade\n")
|
||||||
|
|
||||||
|
# Show current install info
|
||||||
|
info = _get_install_info()
|
||||||
|
commit_suffix = f" (commit {info['commit'][:8]})" if info["commit"] else ""
|
||||||
|
print(f" Installed: v{info['version']}{commit_suffix} via {info['source']}")
|
||||||
|
|
||||||
|
if not force:
|
||||||
|
update_available, message = _check_for_update(info)
|
||||||
|
print(f" Status: {message}")
|
||||||
|
|
||||||
|
if not update_available:
|
||||||
|
print(
|
||||||
|
"\n Already up to date."
|
||||||
|
" Use 'muxplex upgrade --force' to reinstall anyway.\n"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
print(" Status: --force specified — skipping version check")
|
||||||
|
|
||||||
|
# 1. Detect platform and stop service
|
||||||
|
if sys.platform == "darwin":
|
||||||
|
label = "com.muxplex"
|
||||||
|
uid = os.getuid()
|
||||||
|
plist = Path.home() / "Library" / "LaunchAgents" / f"{label}.plist"
|
||||||
|
if plist.exists():
|
||||||
|
print(" Stopping launchd service...")
|
||||||
|
subprocess.run(
|
||||||
|
["launchctl", "bootout", f"gui/{uid}/{label}"], capture_output=True
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
print(" No launchd service found (skipping stop)")
|
||||||
|
else:
|
||||||
|
# Linux/WSL — check systemd
|
||||||
|
result = subprocess.run(
|
||||||
|
["systemctl", "--user", "is-active", "muxplex"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
if result.returncode == 0:
|
||||||
|
print(" Stopping systemd service...")
|
||||||
|
subprocess.run(
|
||||||
|
["systemctl", "--user", "stop", "muxplex"], capture_output=True
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
print(" No active systemd service found (skipping stop)")
|
||||||
|
|
||||||
|
# 2. Reinstall via uv tool install
|
||||||
|
print(" Installing latest version...")
|
||||||
|
uv_path = shutil.which("uv")
|
||||||
|
if uv_path:
|
||||||
|
result = subprocess.run(
|
||||||
|
[
|
||||||
|
uv_path,
|
||||||
|
"tool",
|
||||||
|
"install",
|
||||||
|
"git+https://github.com/bkrabach/muxplex",
|
||||||
|
"--force",
|
||||||
|
],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
print(f" ERROR: uv tool install failed:\n{result.stderr}")
|
||||||
|
return
|
||||||
|
print(" Installed successfully")
|
||||||
|
else:
|
||||||
|
# Fallback: pip
|
||||||
|
pip_path = shutil.which("pip") or shutil.which("pip3")
|
||||||
|
if pip_path:
|
||||||
|
result = subprocess.run(
|
||||||
|
[
|
||||||
|
pip_path,
|
||||||
|
"install",
|
||||||
|
"--upgrade",
|
||||||
|
"git+https://github.com/bkrabach/muxplex",
|
||||||
|
],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
print(f" ERROR: pip install failed:\n{result.stderr}")
|
||||||
|
return
|
||||||
|
print(" Installed successfully")
|
||||||
|
else:
|
||||||
|
print(" ERROR: neither uv nor pip found — cannot upgrade")
|
||||||
|
return
|
||||||
|
|
||||||
|
# 3. Regenerate service file (picks up any plist/unit changes)
|
||||||
|
print(" Regenerating service file...")
|
||||||
|
install_service(system=False)
|
||||||
|
|
||||||
|
# 4. Restart service
|
||||||
|
if sys.platform == "darwin":
|
||||||
|
label = "com.muxplex"
|
||||||
|
uid = os.getuid()
|
||||||
|
plist = Path.home() / "Library" / "LaunchAgents" / f"{label}.plist"
|
||||||
|
if plist.exists():
|
||||||
|
print(" Starting launchd service...")
|
||||||
|
result = subprocess.run(
|
||||||
|
["launchctl", "bootstrap", f"gui/{uid}", str(plist)],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
if result.returncode == 0:
|
||||||
|
print(" Service started")
|
||||||
|
else:
|
||||||
|
# Fallback to legacy load for older macOS
|
||||||
|
subprocess.run(["launchctl", "load", str(plist)], capture_output=True)
|
||||||
|
print(" Service started (legacy)")
|
||||||
|
else:
|
||||||
|
print(" Service file not found — run: muxplex install-service")
|
||||||
|
else:
|
||||||
|
result = subprocess.run(
|
||||||
|
["systemctl", "--user", "is-enabled", "muxplex"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
if result.returncode == 0:
|
||||||
|
print(" Restarting systemd service...")
|
||||||
|
subprocess.run(
|
||||||
|
["systemctl", "--user", "daemon-reload"], capture_output=True
|
||||||
|
)
|
||||||
|
subprocess.run(
|
||||||
|
["systemctl", "--user", "start", "muxplex"], capture_output=True
|
||||||
|
)
|
||||||
|
print(" Service started")
|
||||||
|
else:
|
||||||
|
print(" Service not enabled — run: muxplex install-service")
|
||||||
|
|
||||||
|
# 5. Doctor check
|
||||||
|
print("\n Verifying...")
|
||||||
|
doctor()
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
"""CLI entry point."""
|
"""CLI entry point."""
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
@@ -356,6 +675,21 @@ def main() -> None:
|
|||||||
|
|
||||||
sub.add_parser("doctor", help="Check dependencies and system status")
|
sub.add_parser("doctor", help="Check dependencies and system status")
|
||||||
|
|
||||||
|
upgrade_parser = sub.add_parser(
|
||||||
|
"upgrade", help="Upgrade muxplex to latest version and restart service"
|
||||||
|
)
|
||||||
|
upgrade_parser.add_argument(
|
||||||
|
"--force",
|
||||||
|
action="store_true",
|
||||||
|
help="Force reinstall even if already up to date",
|
||||||
|
)
|
||||||
|
update_parser = sub.add_parser("update", help="Alias for upgrade")
|
||||||
|
update_parser.add_argument(
|
||||||
|
"--force",
|
||||||
|
action="store_true",
|
||||||
|
help="Force reinstall even if already up to date",
|
||||||
|
)
|
||||||
|
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
if args.command == "install-service":
|
if args.command == "install-service":
|
||||||
@@ -366,6 +700,8 @@ def main() -> None:
|
|||||||
reset_secret()
|
reset_secret()
|
||||||
elif args.command == "doctor":
|
elif args.command == "doctor":
|
||||||
doctor()
|
doctor()
|
||||||
|
elif args.command in ("upgrade", "update"):
|
||||||
|
upgrade(force=getattr(args, "force", False))
|
||||||
else:
|
else:
|
||||||
_check_dependencies()
|
_check_dependencies()
|
||||||
serve(
|
serve(
|
||||||
|
|||||||
+278
-21
@@ -145,8 +145,12 @@ const DISPLAY_DEFAULTS = {
|
|||||||
gridColumns: 'auto',
|
gridColumns: 'auto',
|
||||||
bellSound: false,
|
bellSound: false,
|
||||||
notificationPermission: 'default',
|
notificationPermission: 'default',
|
||||||
|
viewMode: 'auto',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
var VIEW_MODES = ['auto', 'fit'];
|
||||||
const NEW_SESSION_DEFAULT_TEMPLATE = 'tmux new-session -d -s {name}';
|
const NEW_SESSION_DEFAULT_TEMPLATE = 'tmux new-session -d -s {name}';
|
||||||
|
const DELETE_SESSION_DEFAULT_TEMPLATE = 'tmux kill-session -t {name}';
|
||||||
|
|
||||||
// ─── DOM helpers ──────────────────────────────────────────────────────────────
|
// ─── DOM helpers ──────────────────────────────────────────────────────────────
|
||||||
function $(id) {
|
function $(id) {
|
||||||
@@ -207,8 +211,8 @@ function trackInteraction() {
|
|||||||
// ─── State restoration ───────────────────────────────────────────────────────
|
// ─── State restoration ───────────────────────────────────────────────────────
|
||||||
/**
|
/**
|
||||||
* Restore application state from the server on page load.
|
* Restore application state from the server on page load.
|
||||||
* Calls GET /api/state and, if an active session exists, re-opens it
|
* Calls GET /api/state and, if an active session exists, re-opens it,
|
||||||
* without POSTing to /connect (ttyd is already running).
|
* skipping only the zoom animation (ttyd is re-spawned to handle service restarts).
|
||||||
* Always resolves — errors are logged as warnings so the app can start normally.
|
* Always resolves — errors are logged as warnings so the app can start normally.
|
||||||
* @returns {Promise<void>}
|
* @returns {Promise<void>}
|
||||||
*/
|
*/
|
||||||
@@ -217,7 +221,7 @@ async function restoreState() {
|
|||||||
const res = await api('GET', '/api/state');
|
const res = await api('GET', '/api/state');
|
||||||
const state = await res.json();
|
const state = await res.json();
|
||||||
if (state.active_session) {
|
if (state.active_session) {
|
||||||
await openSession(state.active_session, { skipConnect: true });
|
await openSession(state.active_session, { skipAnimation: true });
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('[restoreState] could not restore previous session:', err);
|
console.warn('[restoreState] could not restore previous session:', err);
|
||||||
@@ -322,6 +326,7 @@ async function pollSessions() {
|
|||||||
renderSidebar(merged, _viewingSession);
|
renderSidebar(merged, _viewingSession);
|
||||||
handleBellTransitions(prev, merged);
|
handleBellTransitions(prev, merged);
|
||||||
updateSessionPill(merged);
|
updateSessionPill(merged);
|
||||||
|
updateFaviconBadge();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -470,9 +475,11 @@ function buildTileHTML(session, index, mobile) {
|
|||||||
? `<span class="device-badge">${escapeHtml(session.deviceName)}</span>`
|
? `<span class="device-badge">${escapeHtml(session.deviceName)}</span>`
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
// Last 20 lines of snapshot
|
// Last N lines of snapshot — show more in fit mode so tall tiles fill
|
||||||
const snapshot = session.snapshot || '';
|
const snapshot = session.snapshot || '';
|
||||||
const lastLines = snapshot.split('\n').slice(-20).join('\n');
|
var _tileDs = loadDisplaySettings();
|
||||||
|
var _lineCount = (_tileDs.viewMode === 'fit') ? -80 : -20;
|
||||||
|
const lastLines = snapshot.split('\n').slice(_lineCount).join('\n');
|
||||||
|
|
||||||
const sourceUrlAttr = session.sourceUrl ? ` data-source-url="${escapeHtml(session.sourceUrl)}"` : '';
|
const sourceUrlAttr = session.sourceUrl ? ` data-source-url="${escapeHtml(session.sourceUrl)}"` : '';
|
||||||
return (
|
return (
|
||||||
@@ -920,6 +927,20 @@ function renderGrid(sessions) {
|
|||||||
updatePillBell();
|
updatePillBell();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reapply view mode layout after grid HTML is rebuilt
|
||||||
|
var currentDs = loadDisplaySettings();
|
||||||
|
var currentMode = currentDs.viewMode || 'auto';
|
||||||
|
if (currentMode === 'fit' && grid) {
|
||||||
|
grid.classList.add('session-grid--fit');
|
||||||
|
requestAnimationFrame(function() {
|
||||||
|
applyFitLayout(grid);
|
||||||
|
// Scroll each tile's pre to the bottom so content anchors at the bottom (like a real terminal)
|
||||||
|
grid.querySelectorAll('.tile-body pre').forEach(function(pre) {
|
||||||
|
pre.scrollTop = pre.scrollHeight;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -1043,7 +1064,14 @@ function handleBellTransitions(prevSessions, nextSessions) {
|
|||||||
*/
|
*/
|
||||||
async function sendHeartbeat() {
|
async function sendHeartbeat() {
|
||||||
try {
|
try {
|
||||||
const payload = buildHeartbeatPayload(_deviceId, _viewingSession, _viewMode, _lastInteractionAt);
|
// When the browser tab is hidden (user switched tabs or minimized), report
|
||||||
|
// viewing_session as null. This prevents the server from clearing bells on
|
||||||
|
// the session — the user isn't actually looking at it, so activity should
|
||||||
|
// accumulate and show in the favicon badge / tab indicators.
|
||||||
|
var effectiveSession = (typeof document !== 'undefined' && document.hidden)
|
||||||
|
? null
|
||||||
|
: _viewingSession;
|
||||||
|
const payload = buildHeartbeatPayload(_deviceId, effectiveSession, _viewMode, _lastInteractionAt);
|
||||||
await api('POST', '/api/heartbeat', payload);
|
await api('POST', '/api/heartbeat', payload);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('[sendHeartbeat] heartbeat failed:', err);
|
console.warn('[sendHeartbeat] heartbeat failed:', err);
|
||||||
@@ -1096,13 +1124,68 @@ function updatePillBell() {
|
|||||||
if (hasBell) el.classList.remove('hidden'); else el.classList.add('hidden');
|
if (hasBell) el.classList.remove('hidden'); else el.classList.add('hidden');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Dynamic favicon — activity dot overlay
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
var _originalFavicon = null; // cached original favicon href
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the favicon with an activity dot if any session has unseen bells.
|
||||||
|
* Uses a 32x32 canvas to draw the original favicon + a colored circle overlay.
|
||||||
|
* Restores the original favicon when there are no unseen bells.
|
||||||
|
*/
|
||||||
|
function updateFaviconBadge() {
|
||||||
|
var hasActivity = _currentSessions && _currentSessions.some(function (s) {
|
||||||
|
return s.bell && s.bell.unseen_count > 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
var link = document.querySelector('link[rel="icon"][sizes="32x32"]') ||
|
||||||
|
document.querySelector('link[rel="icon"]');
|
||||||
|
if (!link) return;
|
||||||
|
|
||||||
|
// Cache the original favicon on first call
|
||||||
|
if (!_originalFavicon) _originalFavicon = link.href;
|
||||||
|
|
||||||
|
if (!hasActivity) {
|
||||||
|
// Restore original favicon when no activity
|
||||||
|
if (link.href !== _originalFavicon) link.href = _originalFavicon;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw favicon + activity dot on canvas
|
||||||
|
var canvas = document.createElement('canvas');
|
||||||
|
canvas.width = 32;
|
||||||
|
canvas.height = 32;
|
||||||
|
var ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
var img = new Image();
|
||||||
|
img.crossOrigin = 'anonymous';
|
||||||
|
img.onload = function () {
|
||||||
|
ctx.drawImage(img, 0, 0, 32, 32);
|
||||||
|
|
||||||
|
// Activity dot — brand amber (same as bell indicator)
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(24, 8, 7, 0, 2 * Math.PI); // top-right area
|
||||||
|
ctx.fillStyle = '#F1A640'; // var(--bell-color)
|
||||||
|
ctx.fill();
|
||||||
|
ctx.strokeStyle = '#0D1117'; // var(--bg) — border for contrast
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
link.href = canvas.toDataURL('image/png');
|
||||||
|
};
|
||||||
|
img.src = _originalFavicon;
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Session open / close ────────────────────────────────────────────────────
|
// ─── Session open / close ────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Open a session in fullscreen view with a zoom transition.
|
* Open a session in fullscreen view with a zoom transition.
|
||||||
* @param {string} name - session name
|
* @param {string} name - session name
|
||||||
* @param {object} [opts]
|
* @param {object} [opts]
|
||||||
* @param {boolean} [opts.skipConnect] - if true, skip the /connect POST (ttyd already running)
|
* @param {boolean} [opts.skipAnimation] - if true, skip the zoom animation (e.g. on page restore)
|
||||||
* @returns {Promise<void>}
|
* @returns {Promise<void>}
|
||||||
*/
|
*/
|
||||||
async function openSession(name, opts = {}) {
|
async function openSession(name, opts = {}) {
|
||||||
@@ -1120,7 +1203,8 @@ async function openSession(name, opts = {}) {
|
|||||||
if (nameEl) nameEl.textContent = name;
|
if (nameEl) nameEl.textContent = name;
|
||||||
|
|
||||||
// Zoom animation: pin tile at current position, then animate to full viewport
|
// Zoom animation: pin tile at current position, then animate to full viewport
|
||||||
const tile = document.querySelector(`[data-session="${name}"]`);
|
// Skipped on restore (skipAnimation:true) — no tile DOM element to zoom from
|
||||||
|
const tile = opts.skipAnimation ? null : document.querySelector(`[data-session="${name}"]`);
|
||||||
if (tile) {
|
if (tile) {
|
||||||
const rect = tile.getBoundingClientRect();
|
const rect = tile.getBoundingClientRect();
|
||||||
tile.style.position = 'fixed';
|
tile.style.position = 'fixed';
|
||||||
@@ -1152,7 +1236,7 @@ async function openSession(name, opts = {}) {
|
|||||||
initSidebar();
|
initSidebar();
|
||||||
renderSidebar(_currentSessions, name);
|
renderSidebar(_currentSessions, name);
|
||||||
resolve();
|
resolve();
|
||||||
}, 260);
|
}, opts.skipAnimation ? 0 : 260);
|
||||||
// If setTimeout is stubbed (e.g. in test env), resolve immediately so we don't hang
|
// If setTimeout is stubbed (e.g. in test env), resolve immediately so we don't hang
|
||||||
if (timerId == null) resolve();
|
if (timerId == null) resolve();
|
||||||
});
|
});
|
||||||
@@ -1173,7 +1257,7 @@ async function openSession(name, opts = {}) {
|
|||||||
const fab = $('new-session-fab');
|
const fab = $('new-session-fab');
|
||||||
if (fab) fab.classList.add('hidden');
|
if (fab) fab.classList.add('hidden');
|
||||||
|
|
||||||
// Connect to session (kill old ttyd, spawn new one for this session)
|
// Always spawn ttyd for this session — ensures correct session after service restart or page restore
|
||||||
var _sourceUrl = opts.sourceUrl || '';
|
var _sourceUrl = opts.sourceUrl || '';
|
||||||
try {
|
try {
|
||||||
if (!opts.skipConnect) {
|
if (!opts.skipConnect) {
|
||||||
@@ -1220,6 +1304,16 @@ function closeSession() {
|
|||||||
}
|
}
|
||||||
if (overview) overview.style.display = ''; // overview uses view--active (no !important), style.display clears fine
|
if (overview) overview.style.display = ''; // overview uses view--active (no !important), style.display clears fine
|
||||||
|
|
||||||
|
// Reapply fit layout after overview becomes visible again
|
||||||
|
var _closDs = loadDisplaySettings();
|
||||||
|
if ((_closDs.viewMode || 'auto') === 'fit') {
|
||||||
|
var _closGrid = document.getElementById('session-grid');
|
||||||
|
if (_closGrid) {
|
||||||
|
_closGrid.classList.add('session-grid--fit');
|
||||||
|
requestAnimationFrame(function() { applyFitLayout(_closGrid); });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const pill = $('session-pill');
|
const pill = $('session-pill');
|
||||||
if (pill) pill.classList.add('hidden');
|
if (pill) pill.classList.add('hidden');
|
||||||
|
|
||||||
@@ -1383,24 +1477,116 @@ function saveDisplaySettings(settings) {
|
|||||||
} catch (_) { /* blocked — ok */ }
|
} catch (_) { /* blocked — ok */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate and apply grid layout to fill the viewport exactly (Fit mode).
|
||||||
|
* Determines optimal cols × rows based on tile count and available space.
|
||||||
|
* @param {Element} grid - The session grid element
|
||||||
|
*/
|
||||||
|
function applyFitLayout(grid) {
|
||||||
|
var count = grid.querySelectorAll('.session-tile').length;
|
||||||
|
if (count === 0) return;
|
||||||
|
|
||||||
|
// Available space — use grid's parent container
|
||||||
|
var parent = grid.parentElement;
|
||||||
|
var availH = parent ? parent.clientHeight : window.innerHeight;
|
||||||
|
var availW = grid.clientWidth;
|
||||||
|
|
||||||
|
// Subtract padding and gap
|
||||||
|
var style = getComputedStyle(grid);
|
||||||
|
var padT = parseFloat(style.paddingTop) || 0;
|
||||||
|
var padB = parseFloat(style.paddingBottom) || 0;
|
||||||
|
var padL = parseFloat(style.paddingLeft) || 0;
|
||||||
|
var padR = parseFloat(style.paddingRight) || 0;
|
||||||
|
var gap = parseFloat(style.gap) || 8;
|
||||||
|
|
||||||
|
var innerW = availW - padL - padR;
|
||||||
|
var innerH = availH - padT - padB;
|
||||||
|
|
||||||
|
// Calculate optimal cols/rows — start with square root
|
||||||
|
var cols = Math.ceil(Math.sqrt(count));
|
||||||
|
var rows = Math.ceil(count / cols);
|
||||||
|
|
||||||
|
// Prefer wider layouts (more cols, fewer rows) since tiles are landscape
|
||||||
|
if (rows > 1 && cols < count) {
|
||||||
|
var altCols = cols + 1;
|
||||||
|
var altRows = Math.ceil(count / altCols);
|
||||||
|
if (altRows < rows) {
|
||||||
|
cols = altCols;
|
||||||
|
rows = altRows;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tile height from available space
|
||||||
|
var tileH = (innerH - gap * (rows - 1)) / rows;
|
||||||
|
|
||||||
|
grid.style.gridTemplateColumns = 'repeat(' + cols + ', 1fr)';
|
||||||
|
grid.style.gridTemplateRows = 'repeat(' + rows + ', 1fr)';
|
||||||
|
|
||||||
|
// Override tile height so tiles fill the grid rows
|
||||||
|
grid.querySelectorAll('.session-tile').forEach(function(t) {
|
||||||
|
t.style.height = tileH + 'px';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cycle the dashboard view mode: auto → fit → auto.
|
||||||
|
* Persists to localStorage and reapplies display settings.
|
||||||
|
*/
|
||||||
|
function cycleViewMode() {
|
||||||
|
var ds = loadDisplaySettings();
|
||||||
|
var idx = VIEW_MODES.indexOf(ds.viewMode || 'auto');
|
||||||
|
ds.viewMode = VIEW_MODES[(idx + 1) % VIEW_MODES.length];
|
||||||
|
saveDisplaySettings(ds);
|
||||||
|
applyDisplaySettings(ds);
|
||||||
|
|
||||||
|
// Update button label
|
||||||
|
var btn = document.getElementById('view-mode-btn');
|
||||||
|
if (btn) btn.title = 'View: ' + ds.viewMode;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Apply display settings to the live DOM.
|
* Apply display settings to the live DOM.
|
||||||
* Sets --preview-font-size CSS custom property and updates #session-grid
|
* Sets --preview-font-size CSS custom property and updates #session-grid
|
||||||
* grid-template-columns based on the gridColumns setting.
|
* grid-template-columns based on the gridColumns setting and viewMode.
|
||||||
* @param {object} ds - display settings object
|
* @param {object} ds - display settings object
|
||||||
*/
|
*/
|
||||||
function applyDisplaySettings(ds) {
|
function applyDisplaySettings(ds) {
|
||||||
// Apply font size as CSS custom property
|
// Apply font size as CSS custom property (tile previews)
|
||||||
|
if (document.documentElement) {
|
||||||
document.documentElement.style.setProperty('--preview-font-size', ds.fontSize + 'px');
|
document.documentElement.style.setProperty('--preview-font-size', ds.fontSize + 'px');
|
||||||
|
}
|
||||||
|
|
||||||
// Apply grid columns
|
// Apply font size to the live xterm.js terminal without reconnecting
|
||||||
|
if (window._setTerminalFontSize) {
|
||||||
|
window._setTerminalFontSize(ds.fontSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply view mode to grid
|
||||||
var grid = document.getElementById('session-grid');
|
var grid = document.getElementById('session-grid');
|
||||||
if (grid) {
|
if (!grid) return;
|
||||||
if (ds.gridColumns === 'auto') {
|
|
||||||
|
var mode = ds.viewMode || 'auto';
|
||||||
|
|
||||||
|
// Remove all mode classes
|
||||||
|
grid.classList.remove('session-grid--fit');
|
||||||
|
|
||||||
|
// Reset any inline styles from previous fit calculation
|
||||||
|
grid.style.removeProperty('grid-template-rows');
|
||||||
|
grid.querySelectorAll('.session-tile').forEach(function(t) {
|
||||||
|
t.style.removeProperty('height');
|
||||||
|
});
|
||||||
|
|
||||||
|
if (mode === 'auto') {
|
||||||
|
// Restore grid columns setting
|
||||||
|
if (ds.gridColumns === 'auto' || !ds.gridColumns) {
|
||||||
grid.style.removeProperty('grid-template-columns');
|
grid.style.removeProperty('grid-template-columns');
|
||||||
} else {
|
} else {
|
||||||
grid.style.gridTemplateColumns = 'repeat(' + ds.gridColumns + ', 1fr)';
|
grid.style.gridTemplateColumns = 'repeat(' + ds.gridColumns + ', 1fr)';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
} else if (mode === 'fit') {
|
||||||
|
grid.classList.add('session-grid--fit');
|
||||||
|
requestAnimationFrame(function() { applyFitLayout(grid); });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1590,11 +1776,17 @@ function openSettings() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// New Session tab - populate template textarea
|
// Commands tab - populate create template textarea
|
||||||
const templateEl = $('setting-template');
|
const templateEl = $('setting-template');
|
||||||
if (templateEl) {
|
if (templateEl) {
|
||||||
templateEl.value = (ss && ss.new_session_template) || NEW_SESSION_DEFAULT_TEMPLATE;
|
templateEl.value = (ss && ss.new_session_template) || NEW_SESSION_DEFAULT_TEMPLATE;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Commands tab - populate delete template textarea
|
||||||
|
const deleteTemplateEl = $('setting-delete-template');
|
||||||
|
if (deleteTemplateEl) {
|
||||||
|
deleteTemplateEl.value = (ss && ss.delete_session_template) || DELETE_SESSION_DEFAULT_TEMPLATE;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1847,10 +2039,30 @@ async function createNewSession(name) {
|
|||||||
const sessionName = data.name || name;
|
const sessionName = data.name || name;
|
||||||
showToast('Creating session \'' + sessionName + '\'…');
|
showToast('Creating session \'' + sessionName + '\'…');
|
||||||
|
|
||||||
|
// Inject a loading placeholder tile so the user sees feedback immediately
|
||||||
|
var loadingTile = null;
|
||||||
|
var grid = document.getElementById('session-grid');
|
||||||
|
if (grid) {
|
||||||
|
loadingTile = document.createElement('div');
|
||||||
|
loadingTile.className = 'session-tile tile--loading';
|
||||||
|
loadingTile.id = 'loading-tile-' + sessionName;
|
||||||
|
loadingTile.innerHTML =
|
||||||
|
'<div class="tile-header"><span class="tile-name">' + escapeHtml(sessionName) + '</span>' +
|
||||||
|
'<span class="tile-meta">Creating...</span></div>' +
|
||||||
|
'<div class="tile-body"><pre class="loading-pulse"></pre></div>';
|
||||||
|
grid.appendChild(loadingTile);
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeLoadingTile() {
|
||||||
|
var tile = document.getElementById('loading-tile-' + sessionName);
|
||||||
|
if (tile) tile.remove();
|
||||||
|
}
|
||||||
|
|
||||||
const ss = _serverSettings || {};
|
const ss = _serverSettings || {};
|
||||||
if (ss.auto_open_created === false) {
|
if (ss.auto_open_created === false) {
|
||||||
// Auto-open disabled — just do one refresh
|
// Auto-open disabled — just do one refresh
|
||||||
await pollSessions();
|
await pollSessions();
|
||||||
|
removeLoadingTile();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1865,10 +2077,12 @@ async function createNewSession(name) {
|
|||||||
});
|
});
|
||||||
if (found) {
|
if (found) {
|
||||||
clearInterval(pollForSession);
|
clearInterval(pollForSession);
|
||||||
|
removeLoadingTile();
|
||||||
showToast('Session \'' + sessionName + '\' ready');
|
showToast('Session \'' + sessionName + '\' ready');
|
||||||
openSession(sessionName);
|
openSession(sessionName);
|
||||||
} else if (attempts >= maxAttempts) {
|
} else if (attempts >= maxAttempts) {
|
||||||
clearInterval(pollForSession);
|
clearInterval(pollForSession);
|
||||||
|
removeLoadingTile();
|
||||||
showToast('Session \'' + sessionName + '\' is taking longer than expected');
|
showToast('Session \'' + sessionName + '\' is taking longer than expected');
|
||||||
}
|
}
|
||||||
}, 2000);
|
}, 2000);
|
||||||
@@ -1931,12 +2145,19 @@ function bindStaticEventListeners() {
|
|||||||
on($('sheet-backdrop'), 'click', closeBottomSheet);
|
on($('sheet-backdrop'), 'click', closeBottomSheet);
|
||||||
|
|
||||||
// Settings dialog bindings
|
// Settings dialog bindings
|
||||||
|
on($('view-mode-btn'), 'click', cycleViewMode);
|
||||||
on($('settings-btn'), 'click', openSettings);
|
on($('settings-btn'), 'click', openSettings);
|
||||||
on($('settings-btn-expanded'), 'click', openSettings);
|
on($('settings-btn-expanded'), 'click', openSettings);
|
||||||
on($('settings-close-btn'), 'click', closeSettings);
|
on($('settings-close-btn'), 'click', closeSettings);
|
||||||
on($('settings-backdrop'), 'click', closeSettings);
|
on($('settings-backdrop'), 'click', closeSettings);
|
||||||
const settingsDialog = $('settings-dialog');
|
const settingsDialog = $('settings-dialog');
|
||||||
if (settingsDialog) settingsDialog.addEventListener('cancel', closeSettings);
|
if (settingsDialog) {
|
||||||
|
settingsDialog.addEventListener('cancel', closeSettings);
|
||||||
|
// Click on the ::backdrop area (outside dialog content) dismisses settings
|
||||||
|
settingsDialog.addEventListener('click', function(e) {
|
||||||
|
if (e.target === settingsDialog) closeSettings();
|
||||||
|
});
|
||||||
|
}
|
||||||
document.querySelectorAll('.settings-tab').forEach(function(tab) {
|
document.querySelectorAll('.settings-tab').forEach(function(tab) {
|
||||||
on(tab, 'click', function() { switchSettingsTab(tab.dataset.tab); });
|
on(tab, 'click', function() { switchSettingsTab(tab.dataset.tab); });
|
||||||
});
|
});
|
||||||
@@ -2060,7 +2281,7 @@ function bindStaticEventListeners() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// New Session tab — template textarea with 500ms debounce
|
// Commands tab — create template textarea with 500ms debounce
|
||||||
var _templateDebounceTimer;
|
var _templateDebounceTimer;
|
||||||
on($('setting-template'), 'input', function() {
|
on($('setting-template'), 'input', function() {
|
||||||
clearTimeout(_templateDebounceTimer);
|
clearTimeout(_templateDebounceTimer);
|
||||||
@@ -2070,7 +2291,7 @@ function bindStaticEventListeners() {
|
|||||||
}, 500);
|
}, 500);
|
||||||
});
|
});
|
||||||
|
|
||||||
// New Session tab — reset button restores default template
|
// Commands tab — create template reset button restores default
|
||||||
on($('setting-template-reset'), 'click', function() {
|
on($('setting-template-reset'), 'click', function() {
|
||||||
var el = $('setting-template');
|
var el = $('setting-template');
|
||||||
if (el) el.value = NEW_SESSION_DEFAULT_TEMPLATE;
|
if (el) el.value = NEW_SESSION_DEFAULT_TEMPLATE;
|
||||||
@@ -2132,6 +2353,23 @@ function bindStaticEventListeners() {
|
|||||||
renderGrid(_currentSessions || []);
|
renderGrid(_currentSessions || []);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Commands tab — delete template textarea with 500ms debounce
|
||||||
|
var _deleteTemplateDebounceTimer;
|
||||||
|
on($('setting-delete-template'), 'input', function() {
|
||||||
|
clearTimeout(_deleteTemplateDebounceTimer);
|
||||||
|
var val = this.value;
|
||||||
|
_deleteTemplateDebounceTimer = setTimeout(function() {
|
||||||
|
patchServerSetting('delete_session_template', val);
|
||||||
|
}, 500);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Commands tab — delete template reset button restores default
|
||||||
|
on($('setting-delete-template-reset'), 'click', function() {
|
||||||
|
var el = $('setting-delete-template');
|
||||||
|
if (el) el.value = DELETE_SESSION_DEFAULT_TEMPLATE;
|
||||||
|
patchServerSetting('delete_session_template', DELETE_SESSION_DEFAULT_TEMPLATE);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Test-only helpers ────────────────────────────────────────────────────────
|
// ─── Test-only helpers ────────────────────────────────────────────────────────
|
||||||
@@ -2206,10 +2444,25 @@ function _setActiveFilterDevice(device) {
|
|||||||
_activeFilterDevice = device;
|
_activeFilterDevice = device;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Recalculate fit layout on window resize
|
||||||
|
window.addEventListener('resize', function() {
|
||||||
|
var ds = loadDisplaySettings();
|
||||||
|
if ((ds.viewMode || 'auto') === 'fit') {
|
||||||
|
var grid = document.getElementById('session-grid');
|
||||||
|
if (grid) requestAnimationFrame(function() { applyFitLayout(grid); });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
initDeviceId();
|
initDeviceId();
|
||||||
applyDisplaySettings(loadDisplaySettings());
|
var _initDs = loadDisplaySettings();
|
||||||
|
applyDisplaySettings(_initDs);
|
||||||
_gridViewMode = loadGridViewMode();
|
_gridViewMode = loadGridViewMode();
|
||||||
|
|
||||||
|
// Initialize view mode button title
|
||||||
|
var vmBtn = document.getElementById('view-mode-btn');
|
||||||
|
if (vmBtn) vmBtn.title = 'View: ' + (_initDs.viewMode || 'auto');
|
||||||
|
|
||||||
document.addEventListener('keydown', trackInteraction);
|
document.addEventListener('keydown', trackInteraction);
|
||||||
document.addEventListener('click', trackInteraction);
|
document.addEventListener('click', trackInteraction);
|
||||||
document.addEventListener('touchstart', trackInteraction);
|
document.addEventListener('touchstart', trackInteraction);
|
||||||
@@ -2219,7 +2472,6 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
startPolling();
|
startPolling();
|
||||||
loadServerSettings();
|
loadServerSettings();
|
||||||
startHeartbeat();
|
startHeartbeat();
|
||||||
requestNotificationPermission();
|
|
||||||
bindStaticEventListeners();
|
bindStaticEventListeners();
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
@@ -2280,6 +2532,8 @@ if (typeof module !== 'undefined' && module.exports) {
|
|||||||
applyDisplaySettings,
|
applyDisplaySettings,
|
||||||
loadGridViewMode,
|
loadGridViewMode,
|
||||||
saveGridViewMode,
|
saveGridViewMode,
|
||||||
|
applyFitLayout,
|
||||||
|
cycleViewMode,
|
||||||
onDisplaySettingChange,
|
onDisplaySettingChange,
|
||||||
openSettings,
|
openSettings,
|
||||||
closeSettings,
|
closeSettings,
|
||||||
@@ -2306,6 +2560,9 @@ if (typeof module !== 'undefined' && module.exports) {
|
|||||||
buildOfflineTileHTML,
|
buildOfflineTileHTML,
|
||||||
openLoginPopup,
|
openLoginPopup,
|
||||||
formatLastSeen,
|
formatLastSeen,
|
||||||
|
// Constants
|
||||||
|
NEW_SESSION_DEFAULT_TEMPLATE,
|
||||||
|
DELETE_SESSION_DEFAULT_TEMPLATE,
|
||||||
// Test-only helpers
|
// Test-only helpers
|
||||||
_setCurrentSessions,
|
_setCurrentSessions,
|
||||||
_setViewMode,
|
_setViewMode,
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
<h1 class="app-wordmark"><img src="/wordmark-on-dark.svg" alt="muxplex" height="24" /></h1>
|
<h1 class="app-wordmark"><img src="/wordmark-on-dark.svg" alt="muxplex" height="24" /></h1>
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
<button id="new-session-btn" class="header-btn" aria-label="New session">+</button>
|
<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="settings-btn" class="header-btn" aria-label="Settings">⚙</button>
|
||||||
<span id="connection-status"></span>
|
<span id="connection-status"></span>
|
||||||
</div>
|
</div>
|
||||||
@@ -88,7 +89,7 @@
|
|||||||
<button class="settings-tab settings-tab--active" data-tab="display">Display</button>
|
<button class="settings-tab settings-tab--active" data-tab="display">Display</button>
|
||||||
<button class="settings-tab" data-tab="sessions">Sessions</button>
|
<button class="settings-tab" data-tab="sessions">Sessions</button>
|
||||||
<button class="settings-tab" data-tab="notifications">Notifications</button>
|
<button class="settings-tab" data-tab="notifications">Notifications</button>
|
||||||
<button class="settings-tab" data-tab="new-session">New Session</button>
|
<button class="settings-tab" data-tab="new-session">Commands</button>
|
||||||
</nav>
|
</nav>
|
||||||
<div class="settings-content">
|
<div class="settings-content">
|
||||||
<div class="settings-panel" data-tab="display">
|
<div class="settings-panel" data-tab="display">
|
||||||
@@ -100,6 +101,7 @@
|
|||||||
<option value="13">13</option>
|
<option value="13">13</option>
|
||||||
<option value="14" selected>14</option>
|
<option value="14" selected>14</option>
|
||||||
<option value="16">16</option>
|
<option value="16">16</option>
|
||||||
|
<option value="18">18</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="settings-field">
|
<div class="settings-field">
|
||||||
@@ -194,6 +196,12 @@
|
|||||||
<span class="settings-helper">{name} is replaced with the session name</span>
|
<span class="settings-helper">{name} is replaced with the session name</span>
|
||||||
<button id="setting-template-reset" class="settings-action-btn">Reset to default</button>
|
<button id="setting-template-reset" class="settings-action-btn">Reset to default</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="settings-field settings-field--column">
|
||||||
|
<label class="settings-label" for="setting-delete-template">Delete session command</label>
|
||||||
|
<textarea id="setting-delete-template" class="settings-textarea" rows="3" placeholder="tmux kill-session -t {name}"></textarea>
|
||||||
|
<span class="settings-helper">{name} is replaced with the session name</span>
|
||||||
|
<button id="setting-delete-template-reset" class="settings-action-btn">Reset to default</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -239,6 +239,30 @@ body {
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Loading placeholder tile — shown while a new session is being created */
|
||||||
|
.tile--loading {
|
||||||
|
opacity: 0.6;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tile--loading .tile-body pre {
|
||||||
|
background: repeating-linear-gradient(
|
||||||
|
90deg,
|
||||||
|
var(--border) 0px,
|
||||||
|
var(--bg-surface) 30px,
|
||||||
|
var(--border) 60px
|
||||||
|
);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
animation: shimmer 1.5s ease-in-out infinite;
|
||||||
|
min-height: 60px;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes shimmer {
|
||||||
|
0% { background-position: 200% 0; }
|
||||||
|
100% { background-position: -200% 0; }
|
||||||
|
}
|
||||||
|
|
||||||
.tile-pre {
|
.tile-pre {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
@@ -1492,6 +1516,27 @@ body {
|
|||||||
padding: 8px 12px 4px;
|
padding: 8px 12px 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
Dashboard view modes — Fit
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
/* Fit view — tiles fill viewport, no scroll */
|
||||||
|
.session-grid--fit {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* In fit mode, stretch pre to fill the full tile body height and anchor content to bottom */
|
||||||
|
.session-grid--fit .tile-body pre {
|
||||||
|
top: 0; /* stretch to fill full tile body height in fit mode */
|
||||||
|
overflow-y: scroll; /* enable scrollTop positioning (content anchored via JS scrollTop=scrollHeight) */
|
||||||
|
scrollbar-width: none; /* Firefox: hide scrollbar */
|
||||||
|
-ms-overflow-style: none; /* IE/Edge: hide scrollbar */
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-grid--fit .tile-body pre::-webkit-scrollbar {
|
||||||
|
display: none; /* Chrome/Safari: hide scrollbar */
|
||||||
|
}
|
||||||
|
|
||||||
/* ============================================================
|
/* ============================================================
|
||||||
Responsive overlay sidebar at <960px
|
Responsive overlay sidebar at <960px
|
||||||
============================================================ */
|
============================================================ */
|
||||||
|
|||||||
@@ -268,6 +268,26 @@ function closeTerminal() {
|
|||||||
window._openTerminal = openTerminal;
|
window._openTerminal = openTerminal;
|
||||||
window._closeTerminal = closeTerminal;
|
window._closeTerminal = closeTerminal;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// setTerminalFontSize — live font-size update without reconnecting
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the terminal font size at runtime without reconnecting.
|
||||||
|
* Modifies _term.options.fontSize and refits the terminal to recalculate dimensions.
|
||||||
|
* No-op when no terminal is open.
|
||||||
|
* @param {number} size - font size in pixels
|
||||||
|
*/
|
||||||
|
function setTerminalFontSize(size) {
|
||||||
|
if (!_term) return;
|
||||||
|
_term.options.fontSize = size;
|
||||||
|
if (_fitAddon) {
|
||||||
|
try { _fitAddon.fit(); } catch (_) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window._setTerminalFontSize = setTerminalFontSize;
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Android touch scroll — rAF-batched WheelEvent dispatch
|
// Android touch scroll — rAF-batched WheelEvent dispatch
|
||||||
// Android batches touchmove events irregularly; dispatching one WheelEvent
|
// Android batches touchmove events irregularly; dispatching one WheelEvent
|
||||||
|
|||||||
@@ -1049,20 +1049,24 @@ test('openSession returns a Promise', () => {
|
|||||||
globalThis.setTimeout = origSetTimeout;
|
globalThis.setTimeout = origSetTimeout;
|
||||||
});
|
});
|
||||||
|
|
||||||
test('openSession with skipConnect calls window._openTerminal inside setTimeout callback', async () => {
|
test('openSession with skipAnimation calls window._openTerminal after connect POST', async () => {
|
||||||
|
// After the fix: skipAnimation only skips the zoom animation, connect always fires.
|
||||||
let openTerminalCalledWith = null;
|
let openTerminalCalledWith = null;
|
||||||
|
const origFetch = globalThis.fetch;
|
||||||
const origGetById = globalThis.document.getElementById;
|
const origGetById = globalThis.document.getElementById;
|
||||||
const origQS = globalThis.document.querySelector;
|
const origQS = globalThis.document.querySelector;
|
||||||
const origSetTimeout = globalThis.setTimeout;
|
const origSetTimeout = globalThis.setTimeout;
|
||||||
|
globalThis.fetch = async (url, opts) => ({ ok: true });
|
||||||
globalThis.document.getElementById = () => ({ textContent: '', style: {}, classList: { remove: () => {}, add: () => {} } });
|
globalThis.document.getElementById = () => ({ textContent: '', style: {}, classList: { remove: () => {}, add: () => {} } });
|
||||||
globalThis.document.querySelector = () => null;
|
globalThis.document.querySelector = () => null;
|
||||||
// Use a synchronous mock so setTimeout callbacks run immediately — _openTerminal is now called inside setTimeout
|
// Use a synchronous mock so setTimeout callbacks run immediately
|
||||||
globalThis.setTimeout = (fn) => { fn(); };
|
globalThis.setTimeout = (fn) => { fn(); };
|
||||||
globalThis.window._openTerminal = (name) => { openTerminalCalledWith = name; };
|
globalThis.window._openTerminal = (name) => { openTerminalCalledWith = name; };
|
||||||
|
|
||||||
await app.openSession('my-session', { skipConnect: true });
|
await app.openSession('my-session', { skipAnimation: true });
|
||||||
|
|
||||||
assert.strictEqual(openTerminalCalledWith, 'my-session', '_openTerminal should be called with session name');
|
assert.strictEqual(openTerminalCalledWith, 'my-session', '_openTerminal should be called with session name');
|
||||||
|
globalThis.fetch = origFetch;
|
||||||
globalThis.document.getElementById = origGetById;
|
globalThis.document.getElementById = origGetById;
|
||||||
globalThis.document.querySelector = origQS;
|
globalThis.document.querySelector = origQS;
|
||||||
globalThis.setTimeout = origSetTimeout;
|
globalThis.setTimeout = origSetTimeout;
|
||||||
@@ -3273,4 +3277,288 @@ test('formatLastSeen returns Never for null', () => {
|
|||||||
test('formatLastSeen returns Never for undefined', () => {
|
test('formatLastSeen returns Never for undefined', () => {
|
||||||
assert.strictEqual(app.formatLastSeen(undefined), 'Never');
|
assert.strictEqual(app.formatLastSeen(undefined), 'Never');
|
||||||
});
|
});
|
||||||
|
// --- Issue 1: Loading placeholder tile ---
|
||||||
|
|
||||||
|
test('createNewSession injects tile--loading placeholder after POST succeeds', () => {
|
||||||
|
const source = fs.readFileSync(new URL('../app.js', import.meta.url), 'utf8');
|
||||||
|
const start = source.indexOf('async function createNewSession(');
|
||||||
|
assert.ok(start !== -1, 'createNewSession must exist');
|
||||||
|
const snippet = source.slice(start, start + 2500);
|
||||||
|
assert.ok(snippet.includes('tile--loading'), 'createNewSession must inject tile--loading placeholder class');
|
||||||
|
assert.ok(snippet.includes('loading-tile-'), 'createNewSession must use loading-tile- id prefix for the placeholder');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('createNewSession removes loading placeholder when session is found', () => {
|
||||||
|
const source = fs.readFileSync(new URL('../app.js', import.meta.url), 'utf8');
|
||||||
|
const start = source.indexOf('async function createNewSession(');
|
||||||
|
const snippet = source.slice(start, start + 2500);
|
||||||
|
assert.ok(
|
||||||
|
snippet.includes('loadingTile') && snippet.includes('.remove()'),
|
||||||
|
'createNewSession must remove the loading tile (loadingTile.remove()) when session is found'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('CSS style.css has tile--loading and shimmer animation', () => {
|
||||||
|
const source = fs.readFileSync(new URL('../style.css', import.meta.url), 'utf8');
|
||||||
|
assert.ok(source.includes('tile--loading'), 'style.css must have .tile--loading rule');
|
||||||
|
assert.ok(source.includes('shimmer'), 'style.css must have shimmer animation');
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Issue 2: Always call connect on restore ---
|
||||||
|
|
||||||
|
test('openSession always POSTs to connect even when skipConnect option is passed', async () => {
|
||||||
|
// Before the fix, skipConnect:true skipped the connect POST entirely.
|
||||||
|
// After the fix, connect is always called; the option is renamed to skipAnimation.
|
||||||
|
const fetchCalls = [];
|
||||||
|
const origFetch = globalThis.fetch;
|
||||||
|
const origGetById = globalThis.document.getElementById;
|
||||||
|
const origQS = globalThis.document.querySelector;
|
||||||
|
const origSetTimeout = globalThis.setTimeout;
|
||||||
|
globalThis.fetch = async (url, opts) => { fetchCalls.push({ url, opts }); return { ok: true }; };
|
||||||
|
globalThis.document.getElementById = () => ({ textContent: '', style: {}, classList: { remove: () => {}, add: () => {} } });
|
||||||
|
globalThis.document.querySelector = () => null;
|
||||||
|
globalThis.setTimeout = () => {};
|
||||||
|
globalThis.window._openTerminal = () => {};
|
||||||
|
|
||||||
|
await app.openSession('work', { skipConnect: true });
|
||||||
|
|
||||||
|
const connectCall = fetchCalls.find((c) => c.url === '/api/sessions/work/connect');
|
||||||
|
assert.ok(connectCall, 'skipConnect:true must NOT prevent connect POST — connect always fires after fix');
|
||||||
|
assert.strictEqual(connectCall.opts.method, 'POST');
|
||||||
|
globalThis.fetch = origFetch;
|
||||||
|
globalThis.document.getElementById = origGetById;
|
||||||
|
globalThis.document.querySelector = origQS;
|
||||||
|
globalThis.setTimeout = origSetTimeout;
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Issue 3: Notification permission on user click only ---
|
||||||
|
|
||||||
|
test('DOMContentLoaded handler does NOT call requestNotificationPermission at startup', () => {
|
||||||
|
// Browsers require notification permission to be requested in response to a user gesture.
|
||||||
|
// Auto-calling at startup is silently blocked, leaving the permission in a broken state.
|
||||||
|
const source = fs.readFileSync(new URL('../app.js', import.meta.url), 'utf8');
|
||||||
|
const domContentLoadedIdx = source.indexOf("document.addEventListener('DOMContentLoaded'");
|
||||||
|
assert.ok(domContentLoadedIdx !== -1, 'DOMContentLoaded handler must exist');
|
||||||
|
// Extract the DOMContentLoaded handler body (next ~600 chars covers the entire handler)
|
||||||
|
const handlerBody = source.substring(domContentLoadedIdx, domContentLoadedIdx + 600);
|
||||||
|
assert.ok(
|
||||||
|
!handlerBody.includes('requestNotificationPermission'),
|
||||||
|
'requestNotificationPermission must NOT be called automatically in DOMContentLoaded — only on user click'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Issue 4: Apply font size to live terminal without reconnecting ---
|
||||||
|
|
||||||
|
test('applyDisplaySettings calls window._setTerminalFontSize when available', () => {
|
||||||
|
const source = fs.readFileSync(new URL('../app.js', import.meta.url), 'utf8');
|
||||||
|
const fnStart = source.indexOf('function applyDisplaySettings(');
|
||||||
|
assert.ok(fnStart !== -1, 'applyDisplaySettings must exist');
|
||||||
|
const fnBody = source.substring(fnStart, fnStart + 600);
|
||||||
|
assert.ok(
|
||||||
|
fnBody.includes('_setTerminalFontSize'),
|
||||||
|
'applyDisplaySettings must call window._setTerminalFontSize to update live terminal font size'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// --- Issue: Dynamic favicon badge with activity dot ---
|
||||||
|
|
||||||
|
test('updateFaviconBadge function exists in app.js', () => {
|
||||||
|
const source = fs.readFileSync(new URL('../app.js', import.meta.url), 'utf8');
|
||||||
|
assert.ok(
|
||||||
|
source.includes('function updateFaviconBadge'),
|
||||||
|
'app.js must define updateFaviconBadge function'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pollSessions calls updateFaviconBadge', () => {
|
||||||
|
const source = fs.readFileSync(new URL('../app.js', import.meta.url), 'utf8');
|
||||||
|
const pollStart = source.indexOf('async function pollSessions()');
|
||||||
|
assert.ok(pollStart !== -1, 'pollSessions must exist');
|
||||||
|
// Find the closing brace of pollSessions (next line starting with "}")
|
||||||
|
const pollEnd = source.indexOf('\n}', pollStart);
|
||||||
|
const pollBody = source.substring(pollStart, pollEnd + 2);
|
||||||
|
assert.ok(
|
||||||
|
pollBody.includes('updateFaviconBadge'),
|
||||||
|
'pollSessions must call updateFaviconBadge — update favicon on every poll cycle'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// --- Delete session template (task: customizable delete command) ---
|
||||||
|
|
||||||
|
test('app.js defines DELETE_SESSION_DEFAULT_TEMPLATE constant', () => {
|
||||||
|
const source = fs.readFileSync(new URL('../app.js', import.meta.url), 'utf8');
|
||||||
|
assert.ok(
|
||||||
|
source.includes('DELETE_SESSION_DEFAULT_TEMPLATE'),
|
||||||
|
'app.js must define DELETE_SESSION_DEFAULT_TEMPLATE constant'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('DELETE_SESSION_DEFAULT_TEMPLATE value is tmux kill-session -t {name}', () => {
|
||||||
|
const source = fs.readFileSync(new URL('../app.js', import.meta.url), 'utf8');
|
||||||
|
assert.ok(
|
||||||
|
source.includes("'tmux kill-session -t {name}'") || source.includes('"tmux kill-session -t {name}"'),
|
||||||
|
"DELETE_SESSION_DEFAULT_TEMPLATE must be set to 'tmux kill-session -t {name}'"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('openSettings loads delete_session_template from server settings', () => {
|
||||||
|
const source = fs.readFileSync(new URL('../app.js', import.meta.url), 'utf8');
|
||||||
|
const fnStart = source.indexOf('function openSettings(');
|
||||||
|
assert.ok(fnStart !== -1, 'openSettings must exist');
|
||||||
|
const fnEnd = source.indexOf('\nfunction ', fnStart + 1);
|
||||||
|
const fnBody = source.substring(fnStart, fnEnd > fnStart ? fnEnd : fnStart + 3000);
|
||||||
|
assert.ok(
|
||||||
|
fnBody.includes('setting-delete-template'),
|
||||||
|
'openSettings must populate #setting-delete-template from server settings'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('bindStaticEventListeners wires delete template input to save', () => {
|
||||||
|
const source = fs.readFileSync(new URL('../app.js', import.meta.url), 'utf8');
|
||||||
|
const fnStart = source.indexOf('function bindStaticEventListeners(');
|
||||||
|
assert.ok(fnStart !== -1, 'bindStaticEventListeners must exist');
|
||||||
|
const fnEnd = source.indexOf('\nfunction ', fnStart + 1);
|
||||||
|
const fnBody = source.substring(fnStart, fnEnd > fnStart ? fnEnd : fnStart + 6000);
|
||||||
|
assert.ok(
|
||||||
|
fnBody.includes('setting-delete-template'),
|
||||||
|
'bindStaticEventListeners must wire #setting-delete-template input event to save'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('bindStaticEventListeners wires delete template reset button', () => {
|
||||||
|
const source = fs.readFileSync(new URL('../app.js', import.meta.url), 'utf8');
|
||||||
|
const fnStart = source.indexOf('function bindStaticEventListeners(');
|
||||||
|
assert.ok(fnStart !== -1, 'bindStaticEventListeners must exist');
|
||||||
|
const fnEnd = source.indexOf('\nfunction ', fnStart + 1);
|
||||||
|
const fnBody = source.substring(fnStart, fnEnd > fnStart ? fnEnd : fnStart + 6000);
|
||||||
|
assert.ok(
|
||||||
|
fnBody.includes('setting-delete-template-reset'),
|
||||||
|
'bindStaticEventListeners must wire #setting-delete-template-reset click handler'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- View mode cycling (Auto / Fit / Compact) ---
|
||||||
|
|
||||||
|
test('app.js has VIEW_MODES array with auto and fit only (no compact)', () => {
|
||||||
|
const source = fs.readFileSync(new URL('../app.js', import.meta.url), 'utf8');
|
||||||
|
assert.ok(source.includes("'auto'"), "must include 'auto' mode");
|
||||||
|
assert.ok(source.includes("'fit'"), "must include 'fit' mode");
|
||||||
|
assert.ok(source.includes('VIEW_MODES'), 'must define VIEW_MODES');
|
||||||
|
// Compact mode was removed — VIEW_MODES must only have two entries
|
||||||
|
const viewModesMatch = source.match(/var VIEW_MODES\s*=\s*\[([^\]]+)\]/);
|
||||||
|
assert.ok(viewModesMatch, 'VIEW_MODES array must be defined');
|
||||||
|
assert.ok(!viewModesMatch[1].includes("'compact'"), "VIEW_MODES must NOT include 'compact'");
|
||||||
|
});
|
||||||
|
|
||||||
|
test('app.js exports cycleViewMode function', () => {
|
||||||
|
assert.ok('cycleViewMode' in app, 'app.js must export cycleViewMode');
|
||||||
|
assert.strictEqual(typeof app.cycleViewMode, 'function', 'cycleViewMode must be a function');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('app.js exports applyFitLayout function', () => {
|
||||||
|
assert.ok('applyFitLayout' in app, 'app.js must export applyFitLayout');
|
||||||
|
assert.strictEqual(typeof app.applyFitLayout, 'function', 'applyFitLayout must be a function');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('DISPLAY_DEFAULTS includes viewMode: auto', () => {
|
||||||
|
const source = fs.readFileSync(new URL('../app.js', import.meta.url), 'utf8');
|
||||||
|
// DISPLAY_DEFAULTS should define viewMode
|
||||||
|
const defaultsStart = source.indexOf('DISPLAY_DEFAULTS');
|
||||||
|
assert.ok(defaultsStart !== -1, 'DISPLAY_DEFAULTS must exist');
|
||||||
|
const defaultsEnd = source.indexOf('};', defaultsStart);
|
||||||
|
const defaultsBody = source.substring(defaultsStart, defaultsEnd + 2);
|
||||||
|
assert.ok(defaultsBody.includes('viewMode'), 'DISPLAY_DEFAULTS must include viewMode');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('applyDisplaySettings handles fit mode by adding session-grid--fit class', () => {
|
||||||
|
const source = fs.readFileSync(new URL('../app.js', import.meta.url), 'utf8');
|
||||||
|
const fnStart = source.indexOf('function applyDisplaySettings(');
|
||||||
|
assert.ok(fnStart !== -1, 'applyDisplaySettings must exist');
|
||||||
|
const fnEnd = source.indexOf('\nfunction ', fnStart + 1);
|
||||||
|
const fnBody = source.substring(fnStart, fnEnd > fnStart ? fnEnd : fnStart + 2000);
|
||||||
|
assert.ok(
|
||||||
|
fnBody.includes('session-grid--fit'),
|
||||||
|
'applyDisplaySettings must apply session-grid--fit class for fit mode'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('applyDisplaySettings does NOT handle compact mode (compact was removed)', () => {
|
||||||
|
const source = fs.readFileSync(new URL('../app.js', import.meta.url), 'utf8');
|
||||||
|
const fnStart = source.indexOf('function applyDisplaySettings(');
|
||||||
|
assert.ok(fnStart !== -1, 'applyDisplaySettings must exist');
|
||||||
|
const fnEnd = source.indexOf('\nfunction ', fnStart + 1);
|
||||||
|
const fnBody = source.substring(fnStart, fnEnd > fnStart ? fnEnd : fnStart + 2000);
|
||||||
|
assert.ok(
|
||||||
|
!fnBody.includes("'compact'"),
|
||||||
|
"applyDisplaySettings must NOT reference 'compact' mode — compact was removed"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cycleViewMode cycles through auto -> fit -> auto (two modes, compact removed)', () => {
|
||||||
|
// Reset display settings to auto
|
||||||
|
const ds = app.loadDisplaySettings();
|
||||||
|
ds.viewMode = 'auto';
|
||||||
|
app.saveDisplaySettings(ds);
|
||||||
|
|
||||||
|
// First cycle: auto -> fit
|
||||||
|
app.cycleViewMode();
|
||||||
|
const ds1 = app.loadDisplaySettings();
|
||||||
|
assert.strictEqual(ds1.viewMode, 'fit', 'first cycle should go auto -> fit');
|
||||||
|
|
||||||
|
// Second cycle: fit -> auto (wraps, compact is gone)
|
||||||
|
app.cycleViewMode();
|
||||||
|
const ds2 = app.loadDisplaySettings();
|
||||||
|
assert.strictEqual(ds2.viewMode, 'auto', 'second cycle should wrap fit -> auto (only two modes)');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('bindStaticEventListeners wires view-mode-btn click to cycleViewMode', () => {
|
||||||
|
const source = fs.readFileSync(new URL('../app.js', import.meta.url), 'utf8');
|
||||||
|
const fnStart = source.indexOf('function bindStaticEventListeners(');
|
||||||
|
assert.ok(fnStart !== -1, 'bindStaticEventListeners must exist');
|
||||||
|
const fnEnd = source.indexOf('\nfunction ', fnStart + 1);
|
||||||
|
const fnBody = source.substring(fnStart, fnEnd > fnStart ? fnEnd : fnStart + 6000);
|
||||||
|
assert.ok(
|
||||||
|
fnBody.includes('view-mode-btn'),
|
||||||
|
'bindStaticEventListeners must wire #view-mode-btn click handler'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('applyFitLayout is called via requestAnimationFrame for correct timing', () => {
|
||||||
|
const source = fs.readFileSync(new URL('../app.js', import.meta.url), 'utf8');
|
||||||
|
assert.ok(
|
||||||
|
source.includes('requestAnimationFrame') && source.includes('applyFitLayout'),
|
||||||
|
'applyFitLayout must be deferred via requestAnimationFrame'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Fit view bug fixes: closeSession reapply, more lines, bottom-anchor ---
|
||||||
|
|
||||||
|
test('closeSession reapplies fit layout when returning to dashboard', () => {
|
||||||
|
const source = fs.readFileSync(new URL('../app.js', import.meta.url), 'utf8');
|
||||||
|
const fnStart = source.indexOf('function closeSession');
|
||||||
|
assert.ok(fnStart !== -1, 'closeSession function must exist');
|
||||||
|
const fnBody = source.substring(fnStart, fnStart + 1500);
|
||||||
|
assert.ok(
|
||||||
|
fnBody.includes('applyFitLayout'),
|
||||||
|
'closeSession must call applyFitLayout for fit mode when returning to dashboard'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildTileHTML shows up to 80 lines in fit mode', () => {
|
||||||
|
const source = fs.readFileSync(new URL('../app.js', import.meta.url), 'utf8');
|
||||||
|
assert.ok(
|
||||||
|
source.includes('-80') || source.includes('(-80)'),
|
||||||
|
'app.js must use -80 slice for fit mode to show up to 80 lines'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('CSS style.css has scrollbar-width none for fit mode pre to hide scrollbar', () => {
|
||||||
|
const source = fs.readFileSync(new URL('../style.css', import.meta.url), 'utf8');
|
||||||
|
assert.ok(
|
||||||
|
source.includes('scrollbar-width: none'),
|
||||||
|
'style.css must have scrollbar-width: none for hidden scrollbar in fit mode pre'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
@@ -807,4 +807,28 @@ test('terminal.js Android touch scroll is UA-gated', () => {
|
|||||||
assert.ok(!source.includes('scrollLines'), 'must NOT use scrollLines (scrolls local buffer not PTY)');
|
assert.ok(!source.includes('scrollLines'), 'must NOT use scrollLines (scrolls local buffer not PTY)');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- Issue 4: setTerminalFontSize ---
|
||||||
|
|
||||||
|
test('terminal.js exposes window._setTerminalFontSize function', () => {
|
||||||
|
const source = fs.readFileSync(new URL('../terminal.js', import.meta.url), 'utf8');
|
||||||
|
assert.ok(
|
||||||
|
source.includes('window._setTerminalFontSize'),
|
||||||
|
'terminal.js must expose window._setTerminalFontSize for live font size updates'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('_setTerminalFontSize sets _term.options.fontSize and calls _fitAddon.fit()', () => {
|
||||||
|
const source = fs.readFileSync(new URL('../terminal.js', import.meta.url), 'utf8');
|
||||||
|
// The function body must update _term.options.fontSize
|
||||||
|
assert.ok(
|
||||||
|
source.includes('_term.options.fontSize = size'),
|
||||||
|
'_setTerminalFontSize must assign _term.options.fontSize = size'
|
||||||
|
);
|
||||||
|
// And call _fitAddon.fit()
|
||||||
|
assert.ok(
|
||||||
|
source.includes('_fitAddon.fit()'),
|
||||||
|
'_setTerminalFontSize must call _fitAddon.fit() to reflow the terminal'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+50
-6
@@ -14,6 +14,7 @@ import logging
|
|||||||
import os
|
import os
|
||||||
import pathlib
|
import pathlib
|
||||||
import pwd
|
import pwd
|
||||||
|
import socket
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
@@ -296,11 +297,15 @@ class CreateSessionPayload(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Frontend directory
|
# Frontend directory + hostname
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
_FRONTEND_DIR = pathlib.Path(__file__).parent / "frontend"
|
_FRONTEND_DIR = pathlib.Path(__file__).parent / "frontend"
|
||||||
|
|
||||||
|
# Short hostname (no domain) injected into page titles so browser tabs show
|
||||||
|
# which machine each muxplex instance is running on.
|
||||||
|
_HOSTNAME = socket.gethostname().split(".")[0]
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Routes
|
# Routes
|
||||||
@@ -420,9 +425,13 @@ async def delete_current_session() -> dict:
|
|||||||
|
|
||||||
@app.delete("/api/sessions/{name}")
|
@app.delete("/api/sessions/{name}")
|
||||||
async def delete_session(name: str) -> dict:
|
async def delete_session(name: str) -> dict:
|
||||||
"""Kill a tmux session by name.
|
"""Kill/destroy a tmux session using the delete_session_template from settings.
|
||||||
|
|
||||||
Runs `tmux kill-session -t {name}`. Returns {ok: True, name: name}.
|
Reads delete_session_template, substitutes {name}, and runs it synchronously
|
||||||
|
(30s timeout) so the caller can rely on the session being gone on return.
|
||||||
|
|
||||||
|
Returns {ok: True, name: name}. Errors are logged as warnings — the endpoint
|
||||||
|
always returns 200 so the UI can refresh and reflect the gone session.
|
||||||
404 if session is not in the known session list (when non-empty).
|
404 if session is not in the known session list (when non-empty).
|
||||||
Must be declared after DELETE /api/sessions/current so "current" routes correctly.
|
Must be declared after DELETE /api/sessions/current so "current" routes correctly.
|
||||||
"""
|
"""
|
||||||
@@ -430,10 +439,29 @@ async def delete_session(name: str) -> dict:
|
|||||||
if known and name not in known:
|
if known and name not in known:
|
||||||
raise HTTPException(status_code=404, detail=f"Session '{name}' not found")
|
raise HTTPException(status_code=404, detail=f"Session '{name}' not found")
|
||||||
|
|
||||||
|
settings = load_settings()
|
||||||
|
command = settings.get(
|
||||||
|
"delete_session_template", "tmux kill-session -t {name}"
|
||||||
|
).replace("{name}", name)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await run_tmux("kill-session", "-t", name)
|
result = subprocess.run(
|
||||||
except RuntimeError:
|
command,
|
||||||
raise HTTPException(status_code=500, detail=f"Failed to kill session '{name}'")
|
shell=True,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
_log.warning(
|
||||||
|
"Delete command failed (rc=%d): %s",
|
||||||
|
result.returncode,
|
||||||
|
result.stderr.strip(),
|
||||||
|
)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
_log.warning("Delete command timed out after 30s: %r", command)
|
||||||
|
except Exception:
|
||||||
|
_log.warning("Delete command failed: %r", command)
|
||||||
|
|
||||||
return {"ok": True, "name": name}
|
return {"ok": True, "name": name}
|
||||||
|
|
||||||
@@ -589,6 +617,18 @@ async def terminal_ws_proxy(websocket: WebSocket) -> None:
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/", response_class=HTMLResponse)
|
||||||
|
@app.get("/index.html", response_class=HTMLResponse)
|
||||||
|
async def index_page():
|
||||||
|
"""Serve index.html with hostname injected into the page title."""
|
||||||
|
html = (_FRONTEND_DIR / "index.html").read_text()
|
||||||
|
html = html.replace(
|
||||||
|
"<title>muxplex</title>",
|
||||||
|
f"<title>{_HOSTNAME} \u2014 muxplex</title>",
|
||||||
|
)
|
||||||
|
return HTMLResponse(html)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/login", response_class=HTMLResponse)
|
@app.get("/login", response_class=HTMLResponse)
|
||||||
async def login_page():
|
async def login_page():
|
||||||
"""Serve branded login.html with injected window.MUXPLEX_AUTH containing auth mode and username."""
|
"""Serve branded login.html with injected window.MUXPLEX_AUTH containing auth mode and username."""
|
||||||
@@ -598,6 +638,10 @@ async def login_page():
|
|||||||
html = html.replace(
|
html = html.replace(
|
||||||
"</head>", f"<script>window.MUXPLEX_AUTH = {mode_data};</script></head>"
|
"</head>", f"<script>window.MUXPLEX_AUTH = {mode_data};</script></head>"
|
||||||
)
|
)
|
||||||
|
html = html.replace(
|
||||||
|
"<title>Sign in \u2014 muxplex</title>",
|
||||||
|
f"<title>Sign in \u2014 {_HOSTNAME} \u2014 muxplex</title>",
|
||||||
|
)
|
||||||
return HTMLResponse(html)
|
return HTMLResponse(html)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ DEFAULT_SETTINGS: dict = {
|
|||||||
"new_session_template": "tmux new-session -d -s {name}",
|
"new_session_template": "tmux new-session -d -s {name}",
|
||||||
"remote_instances": [],
|
"remote_instances": [],
|
||||||
"device_name": "",
|
"device_name": "",
|
||||||
|
"delete_session_template": "tmux kill-session -t {name}",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+187
-9
@@ -1159,20 +1159,34 @@ def test_delete_session_success(client, monkeypatch):
|
|||||||
|
|
||||||
|
|
||||||
def test_delete_session_calls_kill_session(client, monkeypatch):
|
def test_delete_session_calls_kill_session(client, monkeypatch):
|
||||||
"""DELETE /api/sessions/{name} calls tmux kill-session -t {name}."""
|
"""DELETE /api/sessions/{name} runs 'tmux kill-session -t {name}' via subprocess (default template)."""
|
||||||
from unittest.mock import AsyncMock
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
monkeypatch.setattr("muxplex.main.get_session_list", lambda: ["my-session"])
|
monkeypatch.setattr("muxplex.main.get_session_list", lambda: ["my-session"])
|
||||||
mock_run_tmux = AsyncMock(return_value="")
|
|
||||||
monkeypatch.setattr("muxplex.main.run_tmux", mock_run_tmux)
|
|
||||||
|
|
||||||
|
captured = []
|
||||||
|
|
||||||
|
def mock_run(cmd, **kwargs):
|
||||||
|
captured.append(cmd)
|
||||||
|
result = MagicMock()
|
||||||
|
result.returncode = 0
|
||||||
|
result.stderr = ""
|
||||||
|
return result
|
||||||
|
|
||||||
|
with patch("muxplex.main.subprocess.run", side_effect=mock_run):
|
||||||
client.delete("/api/sessions/my-session")
|
client.delete("/api/sessions/my-session")
|
||||||
|
|
||||||
assert mock_run_tmux.called
|
assert len(captured) == 1, "subprocess.run must be called exactly once"
|
||||||
args = mock_run_tmux.call_args[0]
|
executed_cmd = captured[0]
|
||||||
assert args[0] == "kill-session"
|
assert "kill-session" in executed_cmd, (
|
||||||
assert "-t" in args
|
f"Default command must include 'kill-session', got: {executed_cmd!r}"
|
||||||
assert "my-session" in args
|
)
|
||||||
|
assert "-t" in executed_cmd, (
|
||||||
|
f"Default command must include '-t', got: {executed_cmd!r}"
|
||||||
|
)
|
||||||
|
assert "my-session" in executed_cmd, (
|
||||||
|
f"Command must include session name 'my-session', got: {executed_cmd!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_delete_session_not_found(client, monkeypatch):
|
def test_delete_session_not_found(client, monkeypatch):
|
||||||
@@ -1181,3 +1195,167 @@ def test_delete_session_not_found(client, monkeypatch):
|
|||||||
|
|
||||||
response = client.delete("/api/sessions/nonexistent")
|
response = client.delete("/api/sessions/nonexistent")
|
||||||
assert response.status_code == 404
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Issue 1: Static assets exempt from auth middleware
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_static_asset_accessible_from_non_localhost_without_auth(monkeypatch):
|
||||||
|
"""Static assets (.svg, .css, .js etc.) are served without auth from non-localhost.
|
||||||
|
|
||||||
|
The login page needs its own CSS/JS/images to render before the user has
|
||||||
|
authenticated. The auth middleware must exempt static file extensions.
|
||||||
|
"""
|
||||||
|
monkeypatch.setenv("MUXPLEX_PASSWORD", "test-pw")
|
||||||
|
with TestClient(app, base_url="http://192.168.1.1", follow_redirects=False) as c:
|
||||||
|
response = c.get("/wordmark-on-dark.svg")
|
||||||
|
assert response.status_code == 200, (
|
||||||
|
f"Expected 200 for static asset from non-localhost, got {response.status_code}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_css_asset_accessible_from_non_localhost_without_auth(monkeypatch):
|
||||||
|
"""CSS files are served without auth from non-localhost."""
|
||||||
|
monkeypatch.setenv("MUXPLEX_PASSWORD", "test-pw")
|
||||||
|
with TestClient(app, base_url="http://192.168.1.1", follow_redirects=False) as c:
|
||||||
|
response = c.get("/style.css")
|
||||||
|
assert response.status_code == 200, (
|
||||||
|
f"Expected 200 for CSS from non-localhost, got {response.status_code}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Issue 2: Hostname in page title
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_index_page_title_contains_hostname(client):
|
||||||
|
"""GET / returns HTML with hostname in page title (e.g. 'myhost — muxplex')."""
|
||||||
|
import socket
|
||||||
|
|
||||||
|
hostname = socket.gethostname().split(".")[0]
|
||||||
|
response = client.get("/")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert hostname in response.text, (
|
||||||
|
f"Expected hostname '{hostname}' in title of index page"
|
||||||
|
)
|
||||||
|
assert "muxplex" in response.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_login_page_title_contains_hostname(client):
|
||||||
|
"""GET /login returns HTML with hostname in page title (e.g. 'Sign in — myhost — muxplex')."""
|
||||||
|
import socket
|
||||||
|
|
||||||
|
hostname = socket.gethostname().split(".")[0]
|
||||||
|
response = client.get("/login")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert hostname in response.text, (
|
||||||
|
f"Expected hostname '{hostname}' in title of login page"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# DELETE /api/sessions/{name} — custom template (task: customizable delete command)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_session_uses_template_command(client, monkeypatch, tmp_path):
|
||||||
|
"""DELETE /api/sessions/{name} must execute the delete_session_template from settings.
|
||||||
|
|
||||||
|
The template {name} placeholder must be substituted with the session name.
|
||||||
|
The command must be run synchronously via subprocess.run (not run_tmux).
|
||||||
|
"""
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
# Make the session appear to exist so the 404 guard passes
|
||||||
|
monkeypatch.setattr("muxplex.main.get_session_list", lambda: ["myworkspace"])
|
||||||
|
|
||||||
|
# Redirect settings to a temp path so we can write a custom template
|
||||||
|
import muxplex.settings as settings_mod
|
||||||
|
|
||||||
|
fake_settings_path = tmp_path / "settings.json"
|
||||||
|
monkeypatch.setattr(settings_mod, "SETTINGS_PATH", fake_settings_path)
|
||||||
|
|
||||||
|
# Write a custom template
|
||||||
|
import json
|
||||||
|
|
||||||
|
fake_settings_path.write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"delete_session_template": "echo destroy {name}",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Capture subprocess.run calls
|
||||||
|
captured_commands = []
|
||||||
|
|
||||||
|
def mock_run(cmd, **kwargs):
|
||||||
|
captured_commands.append(cmd)
|
||||||
|
result = MagicMock()
|
||||||
|
result.returncode = 0
|
||||||
|
result.stderr = ""
|
||||||
|
return result
|
||||||
|
|
||||||
|
with patch("muxplex.main.subprocess.run", side_effect=mock_run):
|
||||||
|
response = client.delete("/api/sessions/myworkspace")
|
||||||
|
|
||||||
|
assert response.status_code == 200, (
|
||||||
|
f"DELETE /api/sessions/myworkspace must return 200, got {response.status_code}"
|
||||||
|
)
|
||||||
|
data = response.json()
|
||||||
|
assert data.get("ok") is True, f"Response must have ok=True, got: {data}"
|
||||||
|
assert data.get("name") == "myworkspace", (
|
||||||
|
f"Response must have name='myworkspace', got: {data}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify template substitution happened
|
||||||
|
assert len(captured_commands) == 1, (
|
||||||
|
f"subprocess.run must be called exactly once, called {len(captured_commands)} times"
|
||||||
|
)
|
||||||
|
executed_cmd = captured_commands[0]
|
||||||
|
assert "myworkspace" in executed_cmd, (
|
||||||
|
f"Executed command must contain session name 'myworkspace', got: {executed_cmd!r}"
|
||||||
|
)
|
||||||
|
assert "echo destroy" in executed_cmd, (
|
||||||
|
f"Executed command must use the custom template, got: {executed_cmd!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_session_default_template_is_tmux_kill(client, monkeypatch, tmp_path):
|
||||||
|
"""DELETE /api/sessions/{name} uses 'tmux kill-session -t {name}' when no custom template is set."""
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
monkeypatch.setattr("muxplex.main.get_session_list", lambda: ["mysession"])
|
||||||
|
|
||||||
|
# Redirect settings to empty temp file (no settings file = use defaults)
|
||||||
|
import muxplex.settings as settings_mod
|
||||||
|
|
||||||
|
fake_settings_path = tmp_path / "settings.json"
|
||||||
|
monkeypatch.setattr(settings_mod, "SETTINGS_PATH", fake_settings_path)
|
||||||
|
# Don't write any settings — defaults should be used
|
||||||
|
|
||||||
|
captured_commands = []
|
||||||
|
|
||||||
|
def mock_run(cmd, **kwargs):
|
||||||
|
captured_commands.append(cmd)
|
||||||
|
result = MagicMock()
|
||||||
|
result.returncode = 0
|
||||||
|
result.stderr = ""
|
||||||
|
return result
|
||||||
|
|
||||||
|
with patch("muxplex.main.subprocess.run", side_effect=mock_run):
|
||||||
|
response = client.delete("/api/sessions/mysession")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert len(captured_commands) == 1
|
||||||
|
executed_cmd = captured_commands[0]
|
||||||
|
# Default template substituted
|
||||||
|
assert "mysession" in executed_cmd, (
|
||||||
|
f"Default template must substitute session name, got: {executed_cmd!r}"
|
||||||
|
)
|
||||||
|
assert "kill-session" in executed_cmd, (
|
||||||
|
f"Default template must contain 'kill-session', got: {executed_cmd!r}"
|
||||||
|
)
|
||||||
|
|||||||
@@ -263,12 +263,18 @@ def test_reset_secret_prints_warning(tmp_path, monkeypatch, capsys):
|
|||||||
|
|
||||||
def test_install_service_writes_launchd_plist_on_macos(tmp_path, monkeypatch):
|
def test_install_service_writes_launchd_plist_on_macos(tmp_path, monkeypatch):
|
||||||
"""install_service() on macOS writes a launchd plist to ~/Library/LaunchAgents/."""
|
"""install_service() on macOS writes a launchd plist to ~/Library/LaunchAgents/."""
|
||||||
|
import subprocess
|
||||||
from muxplex.cli import install_service
|
from muxplex.cli import install_service
|
||||||
|
|
||||||
fake_home = tmp_path / "home"
|
fake_home = tmp_path / "home"
|
||||||
fake_home.mkdir()
|
fake_home.mkdir()
|
||||||
monkeypatch.setattr(Path, "home", staticmethod(lambda: fake_home))
|
monkeypatch.setattr(Path, "home", staticmethod(lambda: fake_home))
|
||||||
monkeypatch.setattr("sys.platform", "darwin")
|
monkeypatch.setattr("sys.platform", "darwin")
|
||||||
|
monkeypatch.setattr(
|
||||||
|
subprocess,
|
||||||
|
"run",
|
||||||
|
lambda *a, **kw: type("R", (), {"returncode": 0, "stdout": "", "stderr": ""})(),
|
||||||
|
)
|
||||||
|
|
||||||
install_service(system=False)
|
install_service(system=False)
|
||||||
|
|
||||||
@@ -283,12 +289,18 @@ def test_install_service_writes_launchd_plist_on_macos(tmp_path, monkeypatch):
|
|||||||
|
|
||||||
def test_install_service_does_not_write_systemd_on_macos(tmp_path, monkeypatch):
|
def test_install_service_does_not_write_systemd_on_macos(tmp_path, monkeypatch):
|
||||||
"""install_service() on macOS must NOT write a systemd unit file."""
|
"""install_service() on macOS must NOT write a systemd unit file."""
|
||||||
|
import subprocess
|
||||||
from muxplex.cli import install_service
|
from muxplex.cli import install_service
|
||||||
|
|
||||||
fake_home = tmp_path / "home"
|
fake_home = tmp_path / "home"
|
||||||
fake_home.mkdir()
|
fake_home.mkdir()
|
||||||
monkeypatch.setattr(Path, "home", staticmethod(lambda: fake_home))
|
monkeypatch.setattr(Path, "home", staticmethod(lambda: fake_home))
|
||||||
monkeypatch.setattr("sys.platform", "darwin")
|
monkeypatch.setattr("sys.platform", "darwin")
|
||||||
|
monkeypatch.setattr(
|
||||||
|
subprocess,
|
||||||
|
"run",
|
||||||
|
lambda *a, **kw: type("R", (), {"returncode": 0, "stdout": "", "stderr": ""})(),
|
||||||
|
)
|
||||||
|
|
||||||
install_service(system=False)
|
install_service(system=False)
|
||||||
|
|
||||||
@@ -296,6 +308,64 @@ def test_install_service_does_not_write_systemd_on_macos(tmp_path, monkeypatch):
|
|||||||
assert not systemd_path.exists(), "No systemd unit file should be written on macOS"
|
assert not systemd_path.exists(), "No systemd unit file should be written on macOS"
|
||||||
|
|
||||||
|
|
||||||
|
def test_install_service_uses_modern_launchctl_on_macos(tmp_path, monkeypatch, capsys):
|
||||||
|
"""install-service on macOS must use launchctl bootstrap (not load)."""
|
||||||
|
import subprocess
|
||||||
|
from muxplex.cli import install_service
|
||||||
|
|
||||||
|
fake_home = tmp_path / "home"
|
||||||
|
fake_home.mkdir()
|
||||||
|
monkeypatch.setattr(Path, "home", staticmethod(lambda: fake_home))
|
||||||
|
monkeypatch.setattr("sys.platform", "darwin")
|
||||||
|
monkeypatch.setattr(shutil, "which", lambda name: f"/usr/local/bin/{name}")
|
||||||
|
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def mock_run(cmd, **kwargs):
|
||||||
|
calls.append(cmd)
|
||||||
|
return type("R", (), {"returncode": 0, "stdout": "", "stderr": ""})()
|
||||||
|
|
||||||
|
monkeypatch.setattr(subprocess, "run", mock_run)
|
||||||
|
|
||||||
|
install_service(system=False)
|
||||||
|
|
||||||
|
launchctl_cmds = [c for c in calls if isinstance(c, list) and "launchctl" in str(c)]
|
||||||
|
assert any(
|
||||||
|
"bootout" in str(c) for c in launchctl_cmds
|
||||||
|
), "must bootout old service before loading"
|
||||||
|
assert any(
|
||||||
|
"bootstrap" in str(c) for c in launchctl_cmds
|
||||||
|
), "must use launchctl bootstrap (modern API)"
|
||||||
|
# Must NOT use deprecated load
|
||||||
|
assert not any(
|
||||||
|
c == ["launchctl", "load"] or (isinstance(c, list) and c[:2] == ["launchctl", "load"])
|
||||||
|
for c in launchctl_cmds
|
||||||
|
), "must NOT use deprecated launchctl load"
|
||||||
|
|
||||||
|
|
||||||
|
def test_install_service_plist_includes_host_flag(tmp_path, monkeypatch):
|
||||||
|
"""The generated plist must include --host 0.0.0.0 for network access."""
|
||||||
|
import subprocess
|
||||||
|
from muxplex.cli import install_service
|
||||||
|
|
||||||
|
fake_home = tmp_path / "home"
|
||||||
|
fake_home.mkdir()
|
||||||
|
monkeypatch.setattr(Path, "home", staticmethod(lambda: fake_home))
|
||||||
|
monkeypatch.setattr("sys.platform", "darwin")
|
||||||
|
monkeypatch.setattr(shutil, "which", lambda name: f"/usr/local/bin/{name}")
|
||||||
|
monkeypatch.setattr(
|
||||||
|
subprocess,
|
||||||
|
"run",
|
||||||
|
lambda *a, **kw: type("R", (), {"returncode": 0, "stdout": "", "stderr": ""})(),
|
||||||
|
)
|
||||||
|
|
||||||
|
install_service(system=False)
|
||||||
|
|
||||||
|
plist_path = fake_home / "Library" / "LaunchAgents" / "com.muxplex.plist"
|
||||||
|
content = plist_path.read_text()
|
||||||
|
assert "0.0.0.0" in content, "plist must bind to 0.0.0.0 for network access"
|
||||||
|
|
||||||
|
|
||||||
def test_install_service_writes_systemd_on_linux(tmp_path, monkeypatch):
|
def test_install_service_writes_systemd_on_linux(tmp_path, monkeypatch):
|
||||||
"""install_service() on Linux writes a systemd unit to ~/.config/systemd/user/."""
|
"""install_service() on Linux writes a systemd unit to ~/.config/systemd/user/."""
|
||||||
from muxplex.cli import install_service
|
from muxplex.cli import install_service
|
||||||
@@ -516,3 +586,209 @@ def test_main_dispatches_to_doctor(monkeypatch):
|
|||||||
assert len(calls) == 1, (
|
assert len(calls) == 1, (
|
||||||
"doctor() must be called once when 'doctor' subcommand is used"
|
"doctor() must be called once when 'doctor' subcommand is used"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# upgrade / update subcommand tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_upgrade_subcommand_registered():
|
||||||
|
"""upgrade must be a valid subcommand."""
|
||||||
|
import io
|
||||||
|
|
||||||
|
from muxplex.cli import main
|
||||||
|
|
||||||
|
buf = io.StringIO()
|
||||||
|
with patch("sys.argv", ["muxplex", "--help"]):
|
||||||
|
try:
|
||||||
|
with patch("sys.stdout", buf):
|
||||||
|
main()
|
||||||
|
except SystemExit:
|
||||||
|
pass
|
||||||
|
|
||||||
|
help_text = buf.getvalue().lower()
|
||||||
|
assert "upgrade" in help_text
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_alias_registered():
|
||||||
|
"""update must be a valid subcommand (alias for upgrade)."""
|
||||||
|
import io
|
||||||
|
|
||||||
|
from muxplex.cli import main
|
||||||
|
|
||||||
|
buf = io.StringIO()
|
||||||
|
with patch("sys.argv", ["muxplex", "--help"]):
|
||||||
|
try:
|
||||||
|
with patch("sys.stdout", buf):
|
||||||
|
main()
|
||||||
|
except SystemExit:
|
||||||
|
pass
|
||||||
|
|
||||||
|
help_text = buf.getvalue().lower()
|
||||||
|
assert "update" in help_text
|
||||||
|
|
||||||
|
|
||||||
|
def test_upgrade_calls_uv_tool_install(monkeypatch, capsys):
|
||||||
|
"""upgrade must attempt uv tool install when update is available."""
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
import muxplex.cli as cli_mod
|
||||||
|
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def mock_run(cmd, **kwargs):
|
||||||
|
calls.append(cmd)
|
||||||
|
return type("R", (), {"returncode": 0, "stdout": "", "stderr": ""})()
|
||||||
|
|
||||||
|
monkeypatch.setattr(subprocess, "run", mock_run)
|
||||||
|
monkeypatch.setattr(shutil, "which", lambda name: f"/usr/bin/{name}")
|
||||||
|
monkeypatch.setattr(cli_mod, "install_service", lambda system=False: None)
|
||||||
|
monkeypatch.setattr(cli_mod, "doctor", lambda: None)
|
||||||
|
# Mock version check so upgrade proceeds regardless of local install type
|
||||||
|
monkeypatch.setattr(
|
||||||
|
cli_mod,
|
||||||
|
"_check_for_update",
|
||||||
|
lambda info: (True, "update available (abc12345 → def67890)"),
|
||||||
|
)
|
||||||
|
|
||||||
|
cli_mod.upgrade()
|
||||||
|
|
||||||
|
# Should have called uv tool install
|
||||||
|
uv_calls = [c for c in calls if isinstance(c, list) and "uv" in str(c)]
|
||||||
|
assert len(uv_calls) > 0, "upgrade must call uv tool install"
|
||||||
|
|
||||||
|
|
||||||
|
def test_main_dispatches_to_upgrade(monkeypatch):
|
||||||
|
"""main() with 'upgrade' subcommand must invoke upgrade()."""
|
||||||
|
from muxplex.cli import main
|
||||||
|
|
||||||
|
calls = []
|
||||||
|
monkeypatch.setattr("muxplex.cli.upgrade", lambda force=False: calls.append(True))
|
||||||
|
|
||||||
|
with patch("sys.argv", ["muxplex", "upgrade"]):
|
||||||
|
main()
|
||||||
|
|
||||||
|
assert len(calls) == 1, "upgrade() must be called once for 'upgrade' subcommand"
|
||||||
|
|
||||||
|
|
||||||
|
def test_main_dispatches_update_to_upgrade(monkeypatch):
|
||||||
|
"""main() with 'update' subcommand must also invoke upgrade()."""
|
||||||
|
from muxplex.cli import main
|
||||||
|
|
||||||
|
calls = []
|
||||||
|
monkeypatch.setattr("muxplex.cli.upgrade", lambda force=False: calls.append(True))
|
||||||
|
|
||||||
|
with patch("sys.argv", ["muxplex", "update"]):
|
||||||
|
main()
|
||||||
|
|
||||||
|
assert len(calls) == 1, "upgrade() must be called once for 'update' subcommand"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Smart version-check tests (_get_install_info / _check_for_update)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_install_info_returns_dict():
|
||||||
|
"""_get_install_info must return a dict with all required keys."""
|
||||||
|
from muxplex.cli import _get_install_info
|
||||||
|
|
||||||
|
info = _get_install_info()
|
||||||
|
assert "source" in info
|
||||||
|
assert "version" in info
|
||||||
|
assert "commit" in info
|
||||||
|
assert "url" in info
|
||||||
|
assert info["source"] in ("git", "editable", "pypi", "unknown")
|
||||||
|
|
||||||
|
|
||||||
|
def test_check_for_update_editable_returns_false():
|
||||||
|
"""Editable installs must never suggest an update."""
|
||||||
|
from muxplex.cli import _check_for_update
|
||||||
|
|
||||||
|
info = {"source": "editable", "version": "0.1.0", "commit": None, "url": None}
|
||||||
|
available, msg = _check_for_update(info)
|
||||||
|
assert available is False
|
||||||
|
assert "editable" in msg
|
||||||
|
|
||||||
|
|
||||||
|
def test_upgrade_force_skips_version_check(monkeypatch, capsys):
|
||||||
|
"""upgrade(force=True) must skip the version check and proceed to install."""
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
import muxplex.cli as cli_mod
|
||||||
|
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def mock_run(cmd, **kwargs):
|
||||||
|
calls.append(cmd)
|
||||||
|
return type("R", (), {"returncode": 0, "stdout": "", "stderr": ""})()
|
||||||
|
|
||||||
|
monkeypatch.setattr(subprocess, "run", mock_run)
|
||||||
|
monkeypatch.setattr(shutil, "which", lambda name: f"/usr/bin/{name}")
|
||||||
|
monkeypatch.setattr(cli_mod, "install_service", lambda system=False: None)
|
||||||
|
monkeypatch.setattr(cli_mod, "doctor", lambda: None)
|
||||||
|
# With force=True the version check must be bypassed entirely
|
||||||
|
check_calls = []
|
||||||
|
monkeypatch.setattr(
|
||||||
|
cli_mod,
|
||||||
|
"_check_for_update",
|
||||||
|
lambda info: check_calls.append(info) or (True, "should not be reached"),
|
||||||
|
)
|
||||||
|
|
||||||
|
cli_mod.upgrade(force=True)
|
||||||
|
|
||||||
|
# _check_for_update must NOT have been called when force=True
|
||||||
|
assert len(check_calls) == 0, "Version check must be skipped when force=True"
|
||||||
|
# uv install must still be attempted
|
||||||
|
uv_calls = [c for c in calls if isinstance(c, list) and "uv" in str(c)]
|
||||||
|
assert len(uv_calls) > 0, "upgrade(force=True) must still call uv tool install"
|
||||||
|
|
||||||
|
|
||||||
|
def test_upgrade_already_up_to_date_skips_install(monkeypatch, capsys):
|
||||||
|
"""upgrade() must print 'up to date' and NOT call uv when version check says current."""
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
import muxplex.cli as cli_mod
|
||||||
|
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def mock_run(cmd, **kwargs):
|
||||||
|
calls.append(cmd)
|
||||||
|
return type("R", (), {"returncode": 0, "stdout": "", "stderr": ""})()
|
||||||
|
|
||||||
|
monkeypatch.setattr(subprocess, "run", mock_run)
|
||||||
|
monkeypatch.setattr(shutil, "which", lambda name: f"/usr/bin/{name}")
|
||||||
|
monkeypatch.setattr(cli_mod, "install_service", lambda system=False: None)
|
||||||
|
monkeypatch.setattr(cli_mod, "doctor", lambda: None)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
cli_mod,
|
||||||
|
"_check_for_update",
|
||||||
|
lambda info: (False, "up to date (commit abcd1234)"),
|
||||||
|
)
|
||||||
|
|
||||||
|
cli_mod.upgrade()
|
||||||
|
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
assert "up to date" in out.lower() or "already" in out.lower()
|
||||||
|
# uv install must NOT have been called
|
||||||
|
uv_calls = [c for c in calls if isinstance(c, list) and "uv" in str(c)]
|
||||||
|
assert len(uv_calls) == 0, "uv must NOT be called when already up to date"
|
||||||
|
|
||||||
|
|
||||||
|
def test_upgrade_force_flag_registered():
|
||||||
|
"""upgrade --force must be accepted by argparse without error."""
|
||||||
|
import io
|
||||||
|
|
||||||
|
from muxplex.cli import main
|
||||||
|
|
||||||
|
buf = io.StringIO()
|
||||||
|
with patch("sys.argv", ["muxplex", "upgrade", "--help"]):
|
||||||
|
try:
|
||||||
|
with patch("sys.stdout", buf):
|
||||||
|
main()
|
||||||
|
except SystemExit:
|
||||||
|
pass
|
||||||
|
|
||||||
|
help_text = buf.getvalue()
|
||||||
|
assert "--force" in help_text
|
||||||
|
|||||||
@@ -1940,3 +1940,50 @@ def test_css_no_unclosed_braces() -> None:
|
|||||||
assert open_count == close_count, (
|
assert open_count == close_count, (
|
||||||
f"CSS file has unbalanced braces: {open_count} open vs {close_count} close"
|
f"CSS file has unbalanced braces: {open_count} open vs {close_count} close"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_css_no_compact_view() -> None:
|
||||||
|
""".session-grid--compact CSS modifier must NOT exist — compact view was removed."""
|
||||||
|
css = read_css()
|
||||||
|
assert ".session-grid--compact" not in css, (
|
||||||
|
".session-grid--compact must be removed — compact view mode was removed, only Auto and Fit remain"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_css_fit_view_exists() -> None:
|
||||||
|
""".session-grid--fit CSS modifier must exist for fit view mode."""
|
||||||
|
css = read_css()
|
||||||
|
assert ".session-grid--fit" in css, (
|
||||||
|
"Missing .session-grid--fit CSS selector for fit view mode"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_css_no_compact_tile_height() -> None:
|
||||||
|
""".session-grid--compact .session-tile must NOT exist — compact view was removed."""
|
||||||
|
css = read_css()
|
||||||
|
assert ".session-grid--compact .session-tile" not in css, (
|
||||||
|
".session-grid--compact .session-tile must be removed — compact view mode was removed"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Fit view bug fixes
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
|
||||||
|
def test_fit_view_pre_has_top_zero() -> None:
|
||||||
|
"""In fit mode, .tile-body pre must have top:0 to fill the full tile height.
|
||||||
|
|
||||||
|
Bug: .tile-body pre uses position:absolute with bottom:0 but no top:0.
|
||||||
|
In fit mode where tiles are taller than auto mode, the pre is anchored
|
||||||
|
to the bottom but only takes natural content height, leaving a black gap above.
|
||||||
|
Fix: .session-grid--fit .tile-body pre { top: 0 } stretches it to fill the tile.
|
||||||
|
"""
|
||||||
|
css = read_css()
|
||||||
|
assert ".session-grid--fit .tile-body pre" in css, (
|
||||||
|
"Missing .session-grid--fit .tile-body pre rule — needed to fix pre height in fit mode"
|
||||||
|
)
|
||||||
|
block = _extract_rule_block(css, ".session-grid--fit .tile-body pre {")
|
||||||
|
assert "top: 0" in block or "top:0" in block, (
|
||||||
|
".session-grid--fit .tile-body pre must have top: 0 to fill full tile body height"
|
||||||
|
)
|
||||||
|
|||||||
@@ -1109,3 +1109,107 @@ def test_html_sessions_tab_add_remote_instance_btn() -> None:
|
|||||||
assert sessions_panel.find(id="add-remote-instance-btn") is not None, (
|
assert sessions_panel.find(id="add-remote-instance-btn") is not None, (
|
||||||
"#add-remote-instance-btn must be inside the sessions settings panel"
|
"#add-remote-instance-btn must be inside the sessions settings panel"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Delete session template (task: customizable delete command)
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
|
||||||
|
def test_html_delete_template_textarea_exists() -> None:
|
||||||
|
"""New Session (Commands) panel must contain #setting-delete-template textarea."""
|
||||||
|
soup = _SOUP
|
||||||
|
dialog = soup.find(id="settings-dialog")
|
||||||
|
assert dialog is not None, "Missing #settings-dialog"
|
||||||
|
new_session_panel = dialog.find(
|
||||||
|
class_="settings-panel", attrs={"data-tab": "new-session"}
|
||||||
|
)
|
||||||
|
assert new_session_panel is not None, "Missing new-session settings-panel"
|
||||||
|
textarea = new_session_panel.find("textarea", id="setting-delete-template")
|
||||||
|
assert textarea is not None, (
|
||||||
|
"Missing <textarea id='setting-delete-template'> inside new-session panel"
|
||||||
|
)
|
||||||
|
classes = textarea.get("class") or []
|
||||||
|
assert "settings-textarea" in classes, (
|
||||||
|
f"#setting-delete-template must have class 'settings-textarea', has: {classes}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_html_delete_template_textarea_placeholder() -> None:
|
||||||
|
"""#setting-delete-template must have placeholder 'tmux kill-session -t {name}'."""
|
||||||
|
soup = _SOUP
|
||||||
|
textarea = soup.find("textarea", id="setting-delete-template")
|
||||||
|
assert textarea is not None, "Missing #setting-delete-template textarea"
|
||||||
|
placeholder = textarea.get("placeholder")
|
||||||
|
assert placeholder == "tmux kill-session -t {name}", (
|
||||||
|
f"#setting-delete-template placeholder must be 'tmux kill-session -t {{name}}', "
|
||||||
|
f"got: {placeholder!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_html_delete_template_textarea_rows() -> None:
|
||||||
|
"""#setting-delete-template textarea must have rows=3."""
|
||||||
|
soup = _SOUP
|
||||||
|
textarea = soup.find("textarea", id="setting-delete-template")
|
||||||
|
assert textarea is not None, "Missing #setting-delete-template textarea"
|
||||||
|
rows = textarea.get("rows")
|
||||||
|
assert rows == "3", f"#setting-delete-template must have rows='3', got: {rows!r}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_html_delete_template_reset_button_exists() -> None:
|
||||||
|
"""New Session (Commands) panel must contain #setting-delete-template-reset button."""
|
||||||
|
soup = _SOUP
|
||||||
|
dialog = soup.find(id="settings-dialog")
|
||||||
|
assert dialog is not None, "Missing #settings-dialog"
|
||||||
|
new_session_panel = dialog.find(
|
||||||
|
class_="settings-panel", attrs={"data-tab": "new-session"}
|
||||||
|
)
|
||||||
|
assert new_session_panel is not None, "Missing new-session settings-panel"
|
||||||
|
reset_btn = new_session_panel.find(id="setting-delete-template-reset")
|
||||||
|
assert reset_btn is not None, (
|
||||||
|
"Missing #setting-delete-template-reset inside new-session panel"
|
||||||
|
)
|
||||||
|
classes = reset_btn.get("class") or []
|
||||||
|
assert "settings-action-btn" in classes, (
|
||||||
|
f"#setting-delete-template-reset must have class 'settings-action-btn', has: {classes}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_html_commands_tab_label() -> None:
|
||||||
|
"""The new-session tab button must be labeled 'Commands' (not 'New Session')."""
|
||||||
|
soup = _SOUP
|
||||||
|
dialog = soup.find(id="settings-dialog")
|
||||||
|
assert dialog is not None, "Missing #settings-dialog"
|
||||||
|
tabs_container = dialog.find("nav", class_="settings-tabs")
|
||||||
|
assert tabs_container is not None, "Missing nav.settings-tabs"
|
||||||
|
tab_btn = tabs_container.find("button", attrs={"data-tab": "new-session"})
|
||||||
|
assert tab_btn is not None, "Missing tab button with data-tab='new-session'"
|
||||||
|
label = tab_btn.get_text(strip=True)
|
||||||
|
assert label == "Commands", (
|
||||||
|
f"Tab button data-tab='new-session' must be labeled 'Commands', got: {label!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_html_new_session_tab_controls_with_delete() -> None:
|
||||||
|
"""New Session (Commands) tab must contain create template, delete template, and both reset buttons."""
|
||||||
|
soup = _SOUP
|
||||||
|
for id_ in (
|
||||||
|
"setting-template",
|
||||||
|
"setting-template-reset",
|
||||||
|
"setting-delete-template",
|
||||||
|
"setting-delete-template-reset",
|
||||||
|
):
|
||||||
|
assert soup.find(id=id_), f"Missing element with id='{id_}'"
|
||||||
|
|
||||||
|
|
||||||
|
def test_html_view_mode_button_exists() -> None:
|
||||||
|
"""#view-mode-btn must exist in the overview header for cycling view modes."""
|
||||||
|
soup = _SOUP
|
||||||
|
btn = soup.find(id="view-mode-btn")
|
||||||
|
assert btn is not None, "Missing element with id='view-mode-btn' (view mode toggle button)"
|
||||||
|
# Must be inside the overview header area
|
||||||
|
overview = soup.find(id="view-overview")
|
||||||
|
assert overview is not None, "Missing #view-overview"
|
||||||
|
assert overview.find(id="view-mode-btn") is not None, (
|
||||||
|
"#view-mode-btn must be inside #view-overview header"
|
||||||
|
)
|
||||||
|
|||||||
@@ -228,3 +228,45 @@ def test_load_does_not_mutate_default_remote_instances():
|
|||||||
# A second load must still return the clean default
|
# A second load must still return the clean default
|
||||||
result2 = load_settings()
|
result2 = load_settings()
|
||||||
assert result2["remote_instances"] == []
|
assert result2["remote_instances"] == []
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Delete session template (task: customizable delete command)
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
|
||||||
|
def test_default_settings_include_delete_template():
|
||||||
|
"""DEFAULT_SETTINGS must include delete_session_template with default tmux kill-session value."""
|
||||||
|
assert "delete_session_template" in DEFAULT_SETTINGS, (
|
||||||
|
"DEFAULT_SETTINGS must include 'delete_session_template'"
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
DEFAULT_SETTINGS["delete_session_template"] == "tmux kill-session -t {name}"
|
||||||
|
), (
|
||||||
|
f"delete_session_template default must be 'tmux kill-session -t {{name}}', "
|
||||||
|
f"got: {DEFAULT_SETTINGS['delete_session_template']!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_session_template_returned_by_load_settings():
|
||||||
|
"""load_settings() must return delete_session_template with default value."""
|
||||||
|
result = load_settings()
|
||||||
|
assert "delete_session_template" in result, (
|
||||||
|
"load_settings() must include 'delete_session_template'"
|
||||||
|
)
|
||||||
|
assert result["delete_session_template"] == "tmux kill-session -t {name}", (
|
||||||
|
f"load_settings() delete_session_template must default to 'tmux kill-session -t {{name}}', "
|
||||||
|
f"got: {result['delete_session_template']!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_session_template_patchable():
|
||||||
|
"""patch_settings() must accept and persist delete_session_template."""
|
||||||
|
custom = "amplifier-dev ~/dev/{name} --destroy"
|
||||||
|
result = patch_settings({"delete_session_template": custom})
|
||||||
|
assert result["delete_session_template"] == custom, (
|
||||||
|
f"patch_settings() must accept delete_session_template, got: {result['delete_session_template']!r}"
|
||||||
|
)
|
||||||
|
# Verify it was persisted
|
||||||
|
loaded = load_settings()
|
||||||
|
assert loaded["delete_session_template"] == custom
|
||||||
|
|||||||
Reference in New Issue
Block a user