Commit Graph

425 Commits

Author SHA1 Message Date
Brian Krabach e4a4c97eec feat: restore Ctrl+Shift+V via synthetic paste event on xterm textarea
Ctrl+Shift+V does not trigger a native browser paste event (unlike Cmd+V
on macOS). Fix: read clipboard via navigator.clipboard.readText(), then
dispatch a synthetic ClipboardEvent on xterm.js's hidden textarea. This
triggers xterm.js's built-in handlePasteEvent which handles CR/LF
normalization, bracketed paste mode, and correct UTF-8 encoding — the
same code path as Cmd+V.

Also replaces the old regression test that asserted Ctrl+Shift+V was
NOT intercepted, replacing it with the correct test for the synthetic
paste approach.
2026-04-07 02:12:49 -07:00
Brian Krabach ced0c62dd6 fix: decode UTF-8 WebSocket output before writing to xterm.js
xterm.js write(Uint8Array) treats each byte as Latin-1, not UTF-8.
Multi-byte UTF-8 characters like box-drawing ─ (U+2500, bytes E2 94 80)
were rendered as â (Latin-1 interpretation of 0xE2). Fix: decode with
TextDecoder before _term.write(), matching ttyd's official client pattern.

Add module-level _decoder (alongside existing _encoder) and use
_decoder.decode(payload) in the type 0x30 message handler.
2026-04-06 22:16:19 -07:00
Brian Krabach c71a551b06 fix: remove custom Ctrl+Shift+V handler — let xterm.js handle paste natively
Our custom paste handler fought xterm.js's built-in paste pipeline.
Every approach (raw send, _term.paste(), hybrid bracketed+send) broke
either multiline behavior or Unicode encoding. The native xterm.js
paste path (browser paste event → handlePasteEvent → prepareTextForTerminal
→ bracketTextForPaste → triggerDataEvent → onData → WebSocket) handles
both correctly. Fix: stop intercepting Ctrl+Shift+V entirely.
2026-04-06 20:37:12 -07:00
Brian Krabach 0a3a20a1b9 fix: hybrid paste — bracketed paste markers + direct UTF-8 WebSocket send
_term.paste() garbled Unicode box-drawing characters (â instead of ─│┌┐).
Fix: manually apply bracketed paste wrapping (ESC[200~...ESC[201~) for
multiline protection, then send via _encodePayload/TextEncoder for correct
UTF-8 encoding. Combines the multiline fix with the proven encoding path.
2026-04-06 20:04:12 -07:00
Brian Krabach 085379981a fix: use _term.paste() for Ctrl+Shift+V — enables bracketed paste mode
Raw WebSocket send bypassed xterm.js's paste pipeline, so multiline
text was sent without bracketed paste markers (ESC[200~...ESC[201~).
The shell treated each newline as Enter, executing lines separately.
Fix: _term.paste(text) goes through the proper pipeline — converts
CR/LF, wraps in bracketed paste markers, then fires onData → WebSocket.
2026-04-06 19:09:32 -07:00
Brian Krabach ad3c961770 fix: terminal reconnect uses federation connect path for remote sessions
The reconnect logic in connectWebSocket() always called local
/api/sessions/{name}/connect even for remote sessions, causing 404.
Fix: check remoteId (captured from outer scope) and route through
/api/federation/{remoteId}/connect/{name} for remote sessions.
2026-04-06 18:18:21 -07:00
Brian Krabach add9e03718 fix: persist active_remote_id in state for remote session restore
Remote sessions failed to reconnect after page refresh because
restoreState() and renderSheetList() didn't pass remoteId to
openSession(). Fix:

- state.py: add active_remote_id field to empty_state()
- main.py: extend StatePatch model to accept active_session and
  active_remote_id; update PATCH /api/state to selectively update
  only fields explicitly provided (using model_fields_set)
- app.js restoreState(): read state.active_remote_id and pass it
  as remoteId option so page-refresh reconnects remote sessions
- app.js openSession(): fire-and-forget PATCH /api/state to persist
  active_session + active_remote_id after successful connect
- app.js closeSession(): fire-and-forget PATCH /api/state to clear
  both fields so refresh doesn't try to reopen a closed session
- app.js renderSheetList(): add data-remote-id to sheet items and
  pass remoteId in click handler (previously called openSession(name)
  with no remoteId, routing remote sessions to local 404 endpoint)

Fixes: POST /api/sessions/paperclip-adapter/connect 404 when clicking
a remote session from the mobile bottom sheet or after page refresh.
2026-04-06 18:08:19 -07:00
Brian Krabach 50abb4f40b fix: WebSocket federation proxy accepts self-signed TLS certificates
websockets.connect() used default SSL verification, rejecting self-signed
certs on remote instances. Same issue as the httpx fix (2c21dfe) but for
the websockets library. Fix: pass ssl.SSLContext with CERT_NONE when the
remote URL is wss://.
2026-04-06 17:25:25 -07:00
Brian Krabach 89158241c0 fix: prevent double paste on Ctrl+Shift+V
Our custom handler sent clipboard text via WebSocket, but the browser's
native paste event also fired on xterm.js's hidden textarea, causing
xterm's built-in paste handler to send the same text again. Fix:
e.preventDefault() suppresses the browser paste event.
2026-04-06 07:53:56 -07:00
Brian Krabach d10c3b0088 fix: guard Bearer header in WebSocket federation proxy for empty key
The source-inspection test correctly caught one remaining unguarded
Authorization header in the WS proxy additional_headers. Applied the
same 'if remote_key else {}' pattern as the other 4 federation endpoints.
2026-04-06 06:16:10 -07:00
Brian Krabach 4beffc2cf9 fix: guard openSession against empty name from unreachable federation tiles
Clicking an unreachable remote device tile passed empty session name
to openSession(), producing /api/federation/2/connect/ (no session name)
→ 405. Fix: bail early if name is empty or whitespace.

Also added guard in grid click handlers to skip error/status tiles
entirely, preventing clicks on unreachable or auth_failed tiles.
2026-04-06 05:43:09 -07:00
Brian Krabach 39f076502f fix: skip Authorization header when federation key is empty
Empty remote_instances[].key produced 'Bearer ' (illegal header value)
causing httpx to reject the request entirely. Fix: only include the
Authorization header when the key is non-empty. Remotes without keys
still work if their auth allows it (e.g., localhost bypass).

Fixes all 5 sites: fetch_remote GET, connect POST, bell/clear POST,
create-session POST, and WebSocket proxy additional_headers.
2026-04-06 05:25:18 -07:00
Brian Krabach 2c21dfeb3e fix: federation client accepts self-signed TLS certificates
httpx.AsyncClient used default SSL verification, rejecting self-signed
certs from muxplex setup-tls. Remote HTTPS instances (e.g., setup-tls
with self-signed fallback) were unreachable — all federation requests
failed with CERTIFICATE_VERIFY_FAILED. Fix: verify=False on the
federation client. Bearer token auth still protects authorization.
2026-04-06 04:57:23 -07:00
Brian Krabach 7fb803c6b5 fix: new session input stays open when clicking device select dropdown
blur handler on the name input was closing the entire new-session UI
when focus moved to the device select. Fixed: check if activeElement
is the sibling select before running cleanup. Same guard added to the
select's blur handler (check if focus returned to input).

Also adds Escape keydown handler on the select to allow cancelling
without returning focus to the input first.

Applied to both showNewSessionInput() and showFabSessionInput().
2026-04-04 19:33:05 -07:00
Brian Krabach 33b2d52af8 fix: mock _check_dependencies in CLI tests for CI (ttyd not in Ubuntu repos)
11 tests calling main() failed in GitHub Actions because ttyd is not
available in standard Ubuntu packages. _check_dependencies() calls
sys.exit(1) when ttyd is missing. Fixed by adding a mock_check_deps
pytest fixture and applying it to all 11 affected tests.

Affected tests:
- test_main_calls_serve_by_default
- test_main_passes_custom_host_and_port
- test_main_default_host_is_localhost
- test_main_passes_auth_flag
- test_main_passes_session_ttl_flag
- test_main_passes_none_for_unset_flags
- test_main_passes_explicit_host_only
- test_main_serve_subcommand_accepts_flags
- test_main_passes_tls_cert_and_key_flags
- test_main_passes_none_for_unset_tls_flags
- test_serve_subcommand_accepts_tls_flags
2026-04-04 15:41:22 -07:00
Brian Krabach 3494ca6f76 chore: update uv.lock with resolved cryptography transitive deps 2026-04-04 14:59:01 -07:00
Brian Krabach 6294c075bc fix: address code review — publish.yml permissions, test docstrings
- Add contents: read permission to publish.yml (required for actions/checkout)
  when any permission is explicitly set, unlisted permissions default to none
- Add timeout-minutes: 15 to publish.yml for consistency with ci.yml
- Add docstrings to 3 pyproject metadata test functions for consistency
  with the rest of the 2000+ line test file
2026-04-04 14:53:40 -07:00
Brian Krabach 1a4fd4cbed ci: add PyPI publish workflow (OIDC Trusted Publisher on tag push) 2026-04-04 14:45:03 -07:00
Brian Krabach aac5703bc4 docs: clarify checkout step intent in CI workflow 2026-04-04 14:42:05 -07:00
Brian Krabach 96c68915e8 ci: add GitHub Actions test workflow (Python 3.11–3.13)
- Add .github/workflows/ci.yml with correct placement in muxplex repo
- Matrix: Python 3.11, 3.12, 3.13 with fail-fast: false
- Uses uv for Python and dependency management with package caching
- Runs pytest via uv run pytest -v (config in pyproject.toml addopts)
- timeout-minutes: 15 to prevent runaway CI costs
- permissions: contents: read for minimal GITHUB_TOKEN scope

Co-authored-by: Amplifier <amplifier@example.com>
2026-04-04 14:39:29 -07:00
Brian Krabach ee4d0cf708 feat: route upgrade command based on install source (PyPI vs git) 2026-04-04 14:27:51 -07:00
Brian Krabach 94dbdb6a3a chore: exclude test files from wheel 2026-04-04 14:23:56 -07:00
Brian Krabach 8c0b9073d0 fix: use absolute URL for README image (PyPI rendering) 2026-04-04 14:15:16 -07:00
Brian Krabach 23a6dfac31 feat: add PyPI metadata — authors, classifiers, keywords 2026-04-04 14:09:33 -07:00
Brian Krabach 547c89ac6d docs: add search, clickable URLs, image rendering to README 2026-04-04 09:23:24 -07:00
Brian Krabach 1fd8bf46e8 docs: add bell-clear, device selector, activity title, favicon badge to README + move branding writeup 2026-04-04 09:16:08 -07:00
Brian Krabach b333c1fccd fix: update createNewSession regex tests to match 2-param signature
The 5 pre-existing tests used \(\w+\) (single-param regex) but Task 4
changed createNewSession(name) → createNewSession(name, remoteId). Updated
regex to \([\w,\s]+\) to match one or more comma-separated parameters.
2026-04-04 08:34:36 -07:00
Brian Krabach 9aac14212a chore: lint/format fixes 2026-04-04 08:22:36 -07:00
Brian Krabach be1913849e feat: style device select dropdown for new session dialog 2026-04-04 08:16:33 -07:00
Brian Krabach 44e47c5228 feat: route createNewSession through federation proxy for remote devices
- Accept remoteId parameter (defaults to '' via remoteId = remoteId || '')
- Compute endpoint: remoteId ? '/api/federation/{remoteId}/sessions' : '/api/sessions'
- Match sessions in poll loop via sessionKey (with fallback to name)
- Pass { remoteId } option to openSession for remote auto-open
- Add 3 tests verifying federation routing, remoteId passthrough, and sessionKey matching
2026-04-04 08:09:02 -07:00
Brian Krabach c28cb0a7f9 feat: add device select to mobile FAB session input
- Update showFabSessionInput to call _createDeviceSelect() for optional
  device <select> element
- Append select (if present) then input to overlay
- Enter handler reads remoteId from select.value, passes to createNewSession
- Add test: showFabSessionInput creates device select when multi_device_enabled with remotes
2026-04-04 07:59:43 -07:00
Brian Krabach e12a965cdf feat: add device select dropdown to showNewSessionInput
- Add _createDeviceSelect() helper function after _createSessionInput()
- Returns null when multi_device_enabled is false or remote_instances is empty
- Creates <select class='new-session-device-select'> with local + remote options
- Pre-selects based on _activeFilterDevice match
- Update showNewSessionInput() to call _createDeviceSelect() and insert select
- cleanup() removes both select and input; restores btn display
- Enter handler reads remoteId from select.value, calls createNewSession(name, remoteId)
- Export _createDeviceSelect for testing
- Add 3 tests covering select creation, integration, and argument passing
2026-04-04 07:51:21 -07:00
Brian Krabach 97f59fda84 feat: add POST /api/federation/{remote_id}/sessions proxy endpoint
Adds federation_create_session endpoint following the same pattern as
federation_connect and federation_bell_clear. The endpoint:
- Looks up remote by integer index into remote_instances in settings
- Sends POST {remote_url}/api/sessions with Bearer auth header and
  JSON body {name: ...}
- Returns remote's JSON response
- Raises 404 for invalid remote_id, 503 when unreachable, 502 on HTTP error

Adds 4 tests covering:
- Proxy behavior with correct URL, auth header, and JSON body forwarding
- 404 for out-of-range remote_id
- 503 when ConnectError raised
- 502 when remote returns HTTP error status
2026-04-04 07:43:18 -07:00
Brian Krabach 02f1e23f35 style: auto-format test_frontend_js.py with ruff 2026-04-04 07:11:04 -07:00
Brian Krabach 094ccd7d81 feat: fire bell-clear POST when opening remote sessions 2026-04-04 07:06:53 -07:00
Brian Krabach be7663ca57 feat: use sessionKey for bell comparison in pill badges
Update updatePillBell and updateSessionPill to build a viewingKey
that accounts for remote sessions (_viewingRemoteId prefix) and
compare using s.sessionKey || s.name instead of s.name alone.

This prevents false bell suppression when a local session shares a
name with a remote session — the two are now distinguished by their
fully-qualified key (remoteId:name vs plain name).
2026-04-04 06:58:31 -07:00
Brian Krabach 6ee6112744 feat: add federation bell/clear proxy endpoint 2026-04-04 06:52:22 -07:00
Brian Krabach 5f9d6a593e feat: add POST /api/sessions/{name}/bell/clear endpoint 2026-04-04 06:46:31 -07:00
Brian Krabach 42142c0954 feat: inject sessionKey for remote federation sessions
- Remote sessions now include sessionKey field formatted as 'remoteId:name'
- Local sessions are unchanged (no sessionKey) as they never collide
- Prevents name collisions between local and remote sessions with identical names
- Added tests: test_federation_sessions_local_sessions_have_no_session_key
               test_federation_sessions_remote_sessions_have_session_key
2026-04-04 06:36:24 -07:00
Brian Krabach 28aaf3d484 fix: restore favicon activity badge + add activity count and hostname to page title
Favicon badge: removed crossOrigin='anonymous' from _drawFaviconBadge Image
lazy-init. Same-origin favicons don't need CORS credentials; setting it caused
silent cache-miss failures when the browser had the asset cached without CORS
headers, preventing the canvas from drawing.

Page title: new updatePageTitle() centralizes title logic. Format:
'(N) hostname - muxplex' when N sessions have unseen bells, otherwise
'hostname - muxplex'. Hostname uses device_name setting with fallback
to location.hostname. Called from pollSessions on every tick, from
loadServerSettings callback, and immediately when device_name input changes
(updating _serverSettings.device_name in place first so the helper sees the
latest value without waiting for the debounced PATCH).

Replaced 3 scattered document.title assignments with updatePageTitle().
Updated 3 existing source-pattern tests to reflect the new centralised
approach; added 2 new tests for updatePageTitle existence and pollSessions
call site.
2026-04-04 06:21:13 -07:00
Brian Krabach c92689dab2 feat: TLS nudge in doctor and service install output
When host is set to network access (not 127.0.0.1) and TLS is not
configured, doctor shows 'Run: muxplex setup-tls' and service install
shows a tip line. Hidden on localhost-only setups since clipboard
works without HTTPS there.
2026-04-04 03:40:44 -07:00
Brian Krabach 7db56e600f docs: TLS nudge design — contextual hints in doctor + service install 2026-04-04 03:29:36 -07:00
Brian Krabach cb2d425fa7 fix: Tailscale detection reads DNSName from Self + suppress cryptography deprecation
Bug 1: detect_tailscale() looked for DNSName at top level of JSON but
tailscale status --self --json nests it inside Self.DNSName. Fixed to
read from Self first with top-level fallback.

Bug 2: getattr(cert, 'not_valid_after_utc', cert.not_valid_after)
eagerly evaluated the fallback, triggering CryptographyDeprecationWarning.
Replaced with try/except AttributeError pattern in all three occurrences
(generate_self_signed and get_cert_info).
2026-04-04 03:07:13 -07:00
Brian Krabach ddba3baeb1 fix: add cryptography to pyproject.toml dependencies
muxplex/tls.py imports cryptography for self-signed cert generation
but it was missing from the declared dependencies. uv tool installs
would fail with ModuleNotFoundError on setup-tls.
2026-04-04 02:40:19 -07:00
Brian Krabach 4483d879c6 test: fix misleading docstrings and tighten detection-priority assertion in test_readme.py
- test_readme_tls_cert_has_empty_default: docstring now accurately describes
  the assertion (key presence check, not empty-string default verification)
- test_readme_tls_key_has_empty_default: same correction
- test_readme_tls_detection_priority_explanation: replace broad three-way or
  (detection/priority/auto-detect) with tighter check requiring both 'Tailscale'
  and 'mkcert' to appear alongside priority language, so incidental word matches
  can no longer satisfy the assertion
2026-04-03 23:52:53 -07:00
Brian Krabach 1b92340975 docs: update README with complete Phase 2 TLS documentation
- Update HTTPS/TLS feature section: remove (Phase 2) placeholders,
  document Tailscale (tailscale cert), mkcert (zero browser warnings),
  and self-signed (browser warning) methods with accurate descriptions
- Update HTTPS/TLS setup examples: add --method tailscale/mkcert/selfsigned
  and --status commands, detection priority explanation paragraph,
  and 90-day Tailscale cert renewal note
- Update CLI reference: change description to Tailscale/mkcert/self-signed,
  add setup-tls --status entry with 'Show current TLS configuration'
- Update tests: replace Phase-2-placeholder test with 10 new tests
  covering all Phase 2 requirements (no placeholders, all methods,
  status command, detection priority, cert renewal note)
2026-04-03 23:45:25 -07:00
Brian Krabach a4d6e7a348 test: add test_generate_mkcert_includes_tailscale_sans integration test
Verifies that generate_mkcert() correctly passes Tailscale hostnames
(spark-1.tail8f3c4e.ts.net, 100.64.0.1) through to the mkcert command
arguments when extra_hostnames is provided.

- fake_run tracks gen_calls only for '-cert-file' invocations
- Creates fake cert/key files to simulate mkcert success
- Asserts result is not None
- Asserts Tailscale hostname appears in mkcert command args

Part of Phase 2 full test sweep (task-7).
2026-04-03 23:36:46 -07:00
Brian Krabach aa1dadf834 feat: add existing cert detection and regenerate prompt to setup-tls
- Added existing cert detection at the top of setup_tls() in muxplex/cli.py
- After defining cert_path/key_path, checks if tls_cert and tls_key are both set in settings AND the cert file exists
- If so, calls get_cert_info() and prints 'TLS already configured (expires YYYY-MM-DD).'
- Prompts user with input('Regenerate? [y/N] ') with try/except for EOFError and KeyboardInterrupt (defaults to 'n')
- If user does not answer 'y' or 'yes', prints 'Keeping existing certificates.' and returns early
- Added two new tests in test_cli.py: test_setup_tls_prompts_when_certs_exist and test_setup_tls_regenerates_on_eof
- All 866 tests pass

Generated with [Amplifier](https://github.com/microsoft/amplifier)

Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
2026-04-03 23:28:59 -07:00
Brian Krabach 52d0021f06 feat: add setup_tls_status() and --status flag to setup-tls subcommand
- Added setup_tls_status() function to display current TLS configuration status
- Shows cert path, key path, hostnames, expiry, and status when configured
- Shows 'not configured' prompt when TLS is not set up
- Shows 'configured but cert not readable' when cert exists in settings but file isn't accessible
- Added --status flag to setup-tls argparse subparser
- Updated main() dispatch to call setup_tls_status() when --status flag is passed
- Added 4 new tests covering all scenarios

All 95 tests pass.
2026-04-03 23:21:05 -07:00
Brian Krabach 3bba2d44e2 feat: auto-detection chain in setup_tls() — Tailscale → mkcert → self-signed
Rewrite setup_tls() with full auto-detection chain:
1. Tailscale: detect via detect_tailscale(), generate cert with generate_tailscale()
2. mkcert: detect via detect_mkcert(), generate cert with generate_mkcert()
   - Includes optional Tailscale SANs as extra_hostnames when available
3. Self-signed: fallback via generate_self_signed()

For forced methods (--method tailscale/mkcert), exits with code 1 on failure.
Prints cert info (Certificate, Key, Hostnames, Expires) on success.
Method-specific warnings: self-signed (browser warning), tailscale (90-day expiry).
Always prints 'Restart service to apply: muxplex service restart'.

Update argparse --method choices to include 'tailscale' and 'mkcert'.

Add 4 tests:
- test_setup_tls_auto_uses_tailscale_when_available
- test_setup_tls_auto_falls_to_mkcert_when_no_tailscale
- test_setup_tls_auto_falls_to_selfsigned_when_nothing_available
- test_setup_tls_method_choices_expanded

Co-authored-by: Amplifier <amplifier@bkrabach.com>
2026-04-03 23:13:50 -07:00