Commit Graph

343 Commits

Author SHA1 Message Date
Brian Krabach 1f90613030 feat: add 'muxplex generate-federation-key' CLI command 2026-04-01 10:15:38 -07:00
Brian Krabach 8e779b49b9 fix: strengthen load_federation_key() docstring, tests, and env var coverage
- Add docstring to load_federation_key() for module consistency
- Strengthen test_load_federation_key_uses_default_path to call the
  function and assert it returns "" when file absent
- Add test_load_federation_key_uses_env_var_override to cover the
  MUXPLEX_FEDERATION_KEY_FILE env var override path

All 31 tests pass.
2026-04-01 10:12:09 -07:00
Brian Krabach 93a66c0cb9 feat: auto-copy mouse selection + OSC 52 tmux clipboard bridge
Mouse selection: onSelectionChange auto-copies to system clipboard on
every selection change (last fire on mouse-up captures final text).
Matches iTerm2/WezTerm/ttyd native select-to-copy behavior.

OSC 52: Custom parser.registerOscHandler(52, ...) decodes base64
clipboard data from tmux. When tmux copies text (set-clipboard on
in .tmux.conf), it sends OSC 52 to the terminal. We intercept and
write to system clipboard via _copyToClipboard (with execCommand
fallback for HTTP/LAN contexts).

Together: mouse-select auto-copies, and tmux Ctrl+B [ -> select ->
Enter copies to system clipboard via OSC 52.
2026-04-01 10:09:20 -07:00
Brian Krabach afef15b955 feat: add load_federation_key() with env var override 2026-04-01 10:07:55 -07:00
Brian Krabach b884f7affd feat: add federation_key to DEFAULT_SETTINGS 2026-04-01 10:03:11 -07:00
Brian Krabach ff989ae920 feat: native clipboard support — Ctrl+Shift+C to copy, Ctrl+Shift+V to paste
Uses xterm.js attachCustomKeyEventHandler to intercept Ctrl+Shift+C/V.
Copy: getSelection() → navigator.clipboard.writeText (with execCommand
fallback for non-HTTPS contexts). Paste: navigator.clipboard.readText()
→ send to ttyd via WebSocket binary protocol (0x30 prefix).

Ctrl+C/V continue to work as normal terminal keys (SIGINT / literal paste).
The Shift modifier distinguishes clipboard from terminal operations.

Also hoists encodePayload + TextEncoder to module level (_encodePayload,
_encoder) so the clipboard handler can reuse them without duplication.
2026-04-01 09:51:23 -07:00
Brian Krabach 1dcf1fc2f3 fix: trim trailing blank lines BEFORE slice, not after
Sessions with cursor near the top (e.g. a fresh tunnel/ssh session)
have content at rows 1-2 and rows 3-40 blank in a 40-row terminal.
The previous fix trimmed AFTER slice(-20), but slice(-20) of a 40-line
snapshot grabs the last 20 rows — all blank. Trimming after slice then
removed everything, leaving an empty <pre>.

Fix: trim trailing blank lines from the full snapshot first, then
slice the last N lines of what remains. This way a session with 2
content lines at the top is reduced to those 2 lines before slicing,
and both appear in the preview.

Fixes both buildTileHTML (grid tiles) and buildSidebarHTML (sidebar
cards). Tests updated to cover the 40-row terminal scenario.
2026-04-01 08:59:54 -07:00
Brian Krabach 96da24c30b fix: trim trailing blank lines from tile/sidebar snapshot previews
Sessions with cursor near the top (e.g. just a prompt) have 18+ empty
lines below. slice(-20) grabbed all blanks, making previews appear
empty. Fix: pop trailing blank lines after slicing so the pre element
contains only meaningful content. CSS bottom:0 anchors it correctly.

Tests: 4 new tests cover content-at-top trimming and all-blank edge case.
2026-04-01 08:38:16 -07:00
Brian Krabach be79b45787 feat: edge-bar state indicator, stacked sidebar header, × crossfade on hover 2026-04-01 08:33:56 -07:00
Brian Krabach cb264bbaaa feat: postMessage auth token relay for cross-origin federation login
- Backend: AuthMiddleware.dispatch() checks X-Muxplex-Token header
  after cookie auth, allowing cross-origin federation requests to
  authenticate using a session token sent as a header instead of a
  SameSite=Strict cookie.

- Backend: GET /api/auth/token endpoint returns the current session
  token for authenticated requests. Used by login popups to relay
  the token back to the opener window via postMessage.

- Frontend (app.js): api() injects X-Muxplex-Token header for
  cross-origin requests when a token is stored in localStorage under
  muxplex.federation_tokens keyed by origin.

- Frontend (app.js): storeFederationToken() helper stores tokens.
  window.addEventListener('message') handler catches muxplex-auth-token
  postMessages and stores the token, then retriggers a poll so the
  auth_required source transitions to authenticated immediately.

- Frontend (app.js): _saveRemoteInstances() prunes stale tokens for
  URLs no longer in the remote instances list.

- Frontend (index.html): Popup relay script fetches /api/auth/token
  and sends the token to window.opener via postMessage when loaded
  as a login popup (window.opener present).

Tests added:
  test_auth.py: X-Muxplex-Token header authenticates / invalid rejected
  test_api.py: GET /api/auth/token returns token / 401 when unauthed
  test_app.mjs: api() header injection, storeFederationToken storage,
                index.html popup script presence
2026-04-01 08:17:28 -07:00
Brian Krabach 062a815f83 feat: custom scrollbar styling — thin, dark, brand cyan hover
6px wide, rgba(48,54,61) thumb (--border at 60%), cyan hover/active.
Applied globally via * selector — covers sidebar, grid, settings, and
any future scrollable area. Firefox (scrollbar-width/color) + WebKit
(::-webkit-scrollbar) cross-browser.
2026-04-01 07:20:07 -07:00
Brian Krabach 38e2fc45d5 fix: WS proxy checks ttyd liveness before accepting + reset counter on message
Root cause (4th iteration): The WS proxy called websocket.accept() before
verifying ttyd was alive. The browser 'open' event fired immediately, resetting
_reconnectAttempts to 0. Counter bounced 0→1→0→1 forever — the client-side
/connect POST at >= 2 attempts never fired.

Server fix: _ttyd_is_listening() TCP probe (socket.create_connection, <1ms)
before websocket.accept(). If ttyd is dead, auto-spawns it from active_session
in state (kill_ttyd → spawn_ttyd → sleep 0.8s) THEN accepts. Browser 'open'
only fires when ttyd is confirmed reachable.

Client fix: _reconnectAttempts reset moved from 'open' handler to 'message'
handler. First data message proves ttyd is alive and relaying — not just that
the proxy accepted. Belt-and-suspenders: client-side /connect fetch still fires
at attempt >= 2 as a fallback.

Tests added:
- test_ttyd_is_listening_function_exists: function importable from main
- test_ws_proxy_checks_ttyd_before_accepting: source inspection ensures
  _ttyd_is_listening() call appears before await websocket.accept()
- test_ws_proxy_auto_spawns_ttyd_when_dead: verifies spawn_ttyd called with
  active_session when _ttyd_is_listening returns False
- terminal.mjs: _reconnectAttempts = 0 NOT in open handler, IS in message
2026-04-01 06:56:51 -07:00
Brian Krabach a862327e27 fix: settings item ordering, device name width, activity indicator mode, dot position 2026-04-01 06:52:01 -07:00
Brian Krabach 50ecb1025d fix: WebSocket reconnect awaits /connect POST before creating WS
The /connect POST that respawns ttyd was fire-and-forget — fetch fired
and the next line immediately created a new WebSocket. The WS raced
ttyd's startup and lost every time, staying stuck on 'Reconnecting...'.

Fix: extract WS creation into _connectWebSocket(). When
_reconnectAttempts >= 2, chain via .then() after the /connect fetch
+ 800ms settle delay for ttyd to bind its port, then return early to
prevent fallthrough. When < 2, call _connectWebSocket() directly
(normal reconnect — ttyd is still alive).
2026-04-01 06:38:27 -07:00
Brian Krabach e71ac223a9 feat: reorganize settings tabs, right-align device labels, activity dots, sidebar glow, display config toggles 2026-04-01 06:25:18 -07:00
Brian Krabach 20eee04e46 feat: add muxplex config subcommand — list/get/set/reset settings via CLI
Exposes ~/.config/muxplex/settings.json management without hand-editing:
  config list        — show all settings with (modified) markers
  config get <key>   — print one value
  config set <key> <value> — set with auto type coercion (bool/int/str/list)
  config reset [key] — reset one or all to defaults

Example: muxplex config set host 0.0.0.0 && muxplex service restart
2026-04-01 06:23:45 -07:00
Brian Krabach ddb21c0d5e fix: cache favicon Image object — stop re-fetching favicon-32.png every 2s
updateFaviconBadge() was creating new Image() on every call (every 2s via
poll cycle). Setting img.src triggers a network fetch of favicon-32.png
each time, causing favicon-32.png to appear in network traffic alongside
every heartbeat + sessions poll.

Fix: extract _drawFaviconBadge() helper that owns the Image lifecycle.
It lazily creates _faviconImage once (module-level var), caches it, and
reuses on all subsequent calls. If the image isn't loaded yet it registers
an onload callback to retry. updateFaviconBadge() simply calls
_drawFaviconBadge() — no Image construction in the hot path.
2026-04-01 05:50:58 -07:00
Brian Krabach a40b95efe6 fix: WebSocket reconnect respawns ttyd after 2 failed attempts + exponential backoff
After a service restart ttyd dies. The reconnect loop was retrying the
WebSocket every 2s but never calling POST /api/sessions/{name}/connect
to respawn ttyd. The proxy had nothing to connect to so it looped forever
showing 'Reconnecting...'.

Fix:
- Add _reconnectAttempts counter at module level
- Close handler now uses exponential backoff: 1s, 2s, 4s, 8s, cap 15s + jitter
- After 2 failed WS attempts, fire-and-forget POST to /api/sessions/{name}/connect
  to respawn ttyd before the next WebSocket retry
- Reset _reconnectAttempts to 0 on successful open, openTerminal, closeTerminal

Root cause: open event fires and reconnect loop retried the WS address but
ttyd was dead so the proxy rejected it — POST /connect was only called from
openSession() on page load/restore, never during reconnect.
2026-04-01 05:50:47 -07:00
Brian Krabach 5d76ba491f chore: scrub last install-service references from test comments 2026-04-01 02:15:39 -07:00
Brian Krabach 14b8a33943 refactor: remove install-service entirely — use muxplex service install
No backward compatibility alias. The command is simply gone.
Tests, README, and CLI all cleaned up. Historical plan docs
left as-is for reference.
2026-04-01 02:02:57 -07:00
Brian Krabach e24a5c3834 refactor: hide install-service from CLI help, keep as backward-compat alias
Removed install-service from argparse subparsers so it doesn't appear
in the usage line or help listing. Intercepted before parse_args() for
backward compatibility — prints deprecation warning and forwards to
service install.
2026-04-01 01:55:26 -07:00
Brian Krabach 65b5c3ad20 fix: ttyd dies immediately — add start_new_session=True to subprocess spawn
Without start_new_session=True, ttyd inherits the parent's process group
and gets cleaned up when the asyncio transport is garbage collected after
the HTTP handler completes. start_new_session=True detaches ttyd into
its own process group so it survives independently of the parent.
2026-04-01 01:23:07 -07:00
Brian Krabach 91faab9b94 fix: remove check=True from idempotent service ops; guard input() against EOFError
C1: _systemd_status, _systemd_stop, _systemd_uninstall (stop+disable),
    _launchd_status, _launchd_stop, _launchd_uninstall (bootout) no longer
    pass check=True. systemctl status exits 3 on stopped service; bootout
    exits non-zero on unloaded service — both are informational, not errors.

C2: _prompt_host_if_localhost wraps input() in try/except (EOFError,
    KeyboardInterrupt) so service install doesn't crash in CI or piped
    stdin environments. Also fixes settings["host"] → settings.get("host")
    to prevent KeyError on partial/missing settings files.

Added 9 regression tests in test_service.py:
- test_systemd_status_no_check_true
- test_systemd_stop_no_check_true
- test_systemd_uninstall_stop_and_disable_no_check_true
- test_launchd_status_no_check_true
- test_launchd_stop_no_check_true
- test_launchd_uninstall_no_check_true
- test_prompt_host_eoferror_defaults_to_no_change
- test_prompt_host_keyboard_interrupt_defaults_to_no_change
- test_prompt_host_missing_host_key_no_keyerror

Co-authored-by: Amplifier <amplifier@sourcegraph.com>
2026-03-31 18:12:35 -07:00
Brian Krabach 80a04cb2cc style: ruff format test_readme.py 2026-03-31 17:39:59 -07:00
Brian Krabach fc9376a69a docs: add service subcommand documentation to README 2026-03-31 17:34:56 -07:00
Brian Krabach e1dfe8e10c refactor: remove old install_service/launchd/systemd from cli.py, replaced by service module 2026-03-31 17:24:34 -07:00
Brian Krabach 496c37a32f refactor: install-service deprecated alias now forwards to service_install() 2026-03-31 17:05:43 -07:00
Brian Krabach ff2abb5f77 feat: wire service subcommand group into CLI (install, uninstall, start, stop, restart, status, logs) 2026-03-31 16:58:08 -07:00
Brian Krabach 8637b740c0 fix: launchd quality — use _LAUNCHD_LABEL in plist template, add restart/status tests 2026-03-31 16:53:17 -07:00
Brian Krabach fbfbab8bb8 feat: implement launchd service commands (install, uninstall, start, stop, restart, status, logs) 2026-03-31 16:46:31 -07:00
Brian Krabach 376583bf23 feat: implement systemd service commands (install, uninstall, start, stop, restart, status, logs) 2026-03-31 16:38:46 -07:00
Brian Krabach f360dea767 feat: create service.py module skeleton with platform dispatch 2026-03-31 16:21:49 -07:00
Brian Krabach 103944e318 fix: remove redundant sys import and update upgrade() service install references
- Remove inner 'import sys' inside install-service branch (sys already
  imported at module level on line 8; was triggering PLC0415 smell)
- Update two fallback messages in upgrade() from 'muxplex install-service'
  to 'muxplex service install' — consistent with deprecation warning and
  doctor() messages updated in Task 5
2026-03-31 15:31:47 -07:00
Brian Krabach 1448b70696 style: ruff format test_settings.py 2026-03-31 15:23:41 -07:00
Brian Krabach a1e532cb3f style: reorder devices panel — enable checkbox before device name input
The multi-device enable checkbox is the gating control; device name
only matters when multi-device is on. Place the checkbox first to
reinforce the logical dependency between the two settings.

No behavioral change — no tests enforce element ordering within a panel.
2026-03-31 15:20:32 -07:00
Brian Krabach ae07431ed1 fix: move notifications/device-name elements to correct settings panels
- Move #setting-bell-sound and notification controls from sessions panel
  to a new notifications panel (data-tab="notifications")
- Move #setting-device-name from display panel to devices panel
- Add #setting-view-scope select to devices panel

Fixes 8 test failures in test_frontend_html.py:
- test_html_settings_panels_use_data_tab (now 5 panels)
- test_html_notifications_panel_has_bell_sound_checkbox
- test_html_notifications_panel_has_notification_status_text
- test_html_notifications_panel_has_request_btn
- test_html_sessions_tab_device_name_input
- test_html_devices_panel_has_device_name
- test_html_devices_panel_has_view_scope
2026-03-31 15:15:29 -07:00
Brian Krabach ba1f28b65a docs: update README Usage section for config-driven serve behavior 2026-03-31 15:04:14 -07:00
Brian Krabach 8d465ad8a5 fix: test suite green after Phase 1 config refactor 2026-03-31 15:04:14 -07:00
Brian Krabach 22b85012eb feat: update doctor() to show serve config and fix service install message
- Add serve config display in doctor() showing host, port, auth, and
  session_ttl from settings.json (inserted after Settings file block)
- Update two 'not installed' messages from 'muxplex install-service'
  to 'muxplex service install' (macOS launchd and Linux systemd sections)
- Add test_doctor_shows_serve_config verifying custom settings values
  (0.0.0.0, 9999, password) appear in doctor() output
2026-03-31 15:04:14 -07:00
Brian Krabach 8bb7bcbb67 test: add deprecation warning test for install-service subcommand
- Add test_install_service_subcommand_prints_deprecation_warning to
  verify that 'muxplex install-service' prints a deprecation warning
  to stderr containing 'deprecated' and 'muxplex service install'
- Update the deprecation warning message in main() to include
  'muxplex service install' as the recommended replacement command

Refs: task-4
2026-03-31 15:04:14 -07:00
Brian Krabach 03e9dc66de refactor: argparse None defaults, serve flags on both parsers, upgrade alias
- Add _add_serve_flags() helper to set --host/--port/--auth/--session-ttl
  all with default=None so serve() can distinguish 'not passed' from
  'passed the default value'
- Apply _add_serve_flags() to both root parser and 'serve' subparser so
  'muxplex serve --host X --port Y' is accepted
- Consolidate upgrade/update via aliases=['update'] instead of two
  separate subparsers — help now shows 'upgrade (update)'
- Print deprecation warning to stderr when 'install-service' is used
- Update 5 existing tests to expect None instead of hardcoded defaults
- Add 4 new tests: test_main_passes_none_for_unset_flags,
  test_main_passes_explicit_host_only,
  test_main_serve_subcommand_accepts_flags,
  test_help_shows_single_upgrade_line

All 55 tests pass.
2026-03-31 15:04:14 -07:00
Brian Krabach c057ad6325 feat: refactor serve() to read settings.json with CLI override sentinel (task-2)
- Change serve() signature: all params now default to None (sentinel for 'not passed by CLI')
- Add load_settings() call: resolution order is CLI flag > settings.json > hardcoded default
- Switch from os.environ.setdefault() to os.environ[] hard assignment so settings.json values take effect
- Add 6 new tests: host/port/session_ttl from settings, CLI override, fallback to defaults, session_ttl=0 as valid value
2026-03-31 15:04:14 -07:00
Brian Krabach a4c2a8e3b8 feat: add serve keys (host, port, auth, session_ttl) to DEFAULT_SETTINGS
Add four new server configuration keys to DEFAULT_SETTINGS in settings.py:
- host: '127.0.0.1'
- port: 8088
- auth: 'pam'
- session_ttl: 604800

Serve keys are placed first in the dict as primary server configuration.
Existing keys (default_session, sort_order, etc.) follow unchanged.

Add four tests covering the new keys:
- test_default_settings_include_serve_keys
- test_load_settings_returns_serve_keys_when_file_missing
- test_serve_keys_patchable
- test_old_settings_file_without_serve_keys_loads_correctly
2026-03-31 15:04:14 -07:00
Brian Krabach c745660afe docs: CLI & service management refactor design
- Config file (settings.json) becomes source of truth for serve options
- Replace install-service with muxplex service <command> subgroup
- Clean up CLI structure (upgrade/update alias, serve flag precedence)
- Thin wrappers over systemctl/launchctl for install/uninstall/start/stop/restart/status/logs
- Backward compat: install-service remains as deprecated alias
2026-03-31 15:01:49 -07:00
Brian Krabach 3de9e98de8 refactor: reorganize settings — merge Notifications into Sessions, move device name to Display, remove view scope 2026-03-31 11:53:25 -07:00
Brian Krabach 3707283baf fix: resolve merge test regressions — openSession connect POST and cycleViewMode mock 2026-03-31 09:21:05 -07:00
Brian Krabach 5a67a3eb3e merge: integrate latest upstream (fit view refactor, ttyd kill-by-port, mobile viewport fix) 2026-03-31 09:10:59 -07:00
Brian Krabach b450cf7176 refactor: fit view — pure CSS layout, no DOM measurement in applyFitLayout
Root cause of repeated fit view failures: applyFitLayout() measured
clientHeight/clientWidth/getComputedStyle and set inline tile heights. This
failed in multiple ways:
  - clientHeight = 0 when container is display:none (returning from session)
  - inline tile.style.height destroyed every 2s by innerHTML rebuild in pollSessions
  - getComputedStyle forces style recalc with potentially stale values
  - rAF wrappers couldn't reliably fix timing issues

Fix: pure arithmetic applyFitLayout — no DOM measurement, no inline heights.
The grid already has a definite height from CSS (flex:1 inside height:100dvh).
grid-template-rows: repeat(rows, 1fr) divides that space without JS measurement.

CSS changes:
  - .session-grid--fit { align-content: stretch }
  - .session-grid--fit .session-tile { height: auto } — CSS grid cell controls sizing

JS changes:
  - applyFitLayout() removes all clientHeight/clientWidth/getComputedStyle/style.height
  - applyFitLayout() is now pure arithmetic: count tiles, compute cols×rows, set 1fr templates
  - Removed rAF wrappers from all call sites (renderGrid, closeSession, resize handler,
    applyDisplaySettings) — safe to call synchronously since no layout measurement needed
  - applyDisplaySettings() no longer clears tile heights (never set them anymore)

Tests updated:
  - Added test_fit_view_session_tile_has_height_auto (CSS)
  - Added 'applyFitLayout does NOT measure DOM dimensions' (JS)
  - Updated 'applyFitLayout clears stale...' → 'sets gridTemplateColumns/Rows via arithmetic'
  - Updated rAF-wrapper test to match new direct-call behavior
2026-03-31 07:47:54 -07:00
Brian Krabach 9762098a00 fix: session switching — kill ttyd by port not just PID file (belt-and-suspenders)
kill_ttyd() now uses two strategies:
1. PID file (existing behavior — kept intact)
2. Port-based fallback: lsof -ti :{TTYD_PORT} finds and kills any orphaned
   ttyd process that wasn't tracked in the PID file

spawn_ttyd() also does a pre-spawn port-free guard: if any process is still
occupying TTYD_PORT after kill_ttyd() returns, it sends SIGKILL to force-free
the port before the new ttyd tries to bind.

Root cause: PID file became desynced (pointed to dead process). kill_ttyd()
thought it succeeded but the REAL ttyd (PID not in file) kept running on
port 7682. New spawn_ttyd() failed to bind, died silently. Old ttyd kept
serving the old session — so every session switch showed the same session.

Tests: 2 new tests in test_ttyd.py (RED → GREEN confirmed)
2026-03-31 07:47:30 -07:00
Brian Krabach 0ed03c4e9d fix: fit view — clear stale layout on recalc, revert to position:absolute bottom:0
Bug 1: applyFitLayout now clears grid-template-rows and tile heights
before recalculating. Prevents stale layout from empty-grid calls on
page reload from interfering with subsequent calculations.

Bug 2: Removed flex justify-content:flex-end approach — didn't work
because pre filled 100% of parent (flex-end had no effect). Reverted
to base CSS position:absolute + bottom:0 which anchors content to the
bottom. With 80 lines in fit mode, content always overflows the tile
and excess is clipped at the top by tile-body overflow:hidden.
2026-03-31 06:55:05 -07:00