Unreachable/auth_failed status entries from the federation response
were rendered twice: once as a blank session tile (no name) by
buildTileHTML, and again as an 'Offline' status tile by the separate
status rendering loop. Fix: getVisibleSessions() now filters out
entries with a status field. Status tiles are still rendered separately
from the full sessions array.
The touch scroll handler was only enabled for Android devices,
leaving iOS/iPad users unable to scroll through terminal history.
Changes:
- Extend UA detection to include iPhone, iPad, and iPod
- Add iPadOS 13+ detection (reports as MacIntel with touch points)
- Rename function to initMobileTerminalScroll for clarity
Fixes#3
Bug 1 (auth.py): AuthMiddleware.dispatch() was using self.federation_key
(set once at startup) for Bearer token validation. If the key was generated
or rotated via POST /api/federation/generate-key after startup, the old
(often empty) value caused all federation auth to silently return 401.
Fix: import load_federation_key from muxplex.settings and call it fresh on
every non-exempt, non-cookie request. Also adds a warning log when a Bearer
token is received but no key is configured on this server.
Bug 2 (main.py): _sync_settings_with_remotes() discarded the PUT response,
silently swallowing 401/500 errors from the remote sync endpoint.
Fix: capture the PUT response as put_resp. Handle 409 (Conflict = remote is
newer) with a debug log; raise_for_status() for any other non-2xx so errors
propagate to the outer except and are logged as warnings.
Tests:
- test_dispatch_calls_load_federation_key_live: pattern test confirming
load_federation_key() is called inside dispatch()
- test_dispatch_does_not_use_stale_self_federation_key_for_bearer: pattern
test confirming self.federation_key is gone from the live bearer check
- test_sync_put_response_calls_raise_for_status: pattern test confirming
raise_for_status() is called on the PUT response in the sync function
- Updated existing bearer tests to monkeypatch load_federation_key so they
are isolated from any real key file on disk
- Add SETTINGS_SYNC_INTERVAL = 15 constant (15 poll cycles × 2s ≈ 30s)
- Add _settings_sync_counter module-level counter
- Add _sync_settings_with_remotes() async function that:
- GETs /api/settings/sync from each remote instance
- Adopts remote settings if remote timestamp is newer
- Pushes local settings via PUT if local timestamp is newer
- Silently skips 404/405 (older muxplex without sync endpoints)
- Catches all per-remote errors and logs as warnings
- Wire sync call into _run_poll_cycle() step 13 (runs outside state_lock)
- Add test_settings_sync_poll.py with 10 tests covering all sync behaviours
Captures the exception as 'exc' in the except clause and includes it
as the third argument to the warning log message. This ensures that
the failure reason is observable in logs, improving diagnostics for
federation heartbeat failures.
Generated with Amplifier
Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
- Add module-level _federation_client reference for use in background poll task
- Assign _federation_client in lifespan startup; clear on shutdown
- Add step 12 to _run_poll_cycle: iterate devices viewing a remote session
in fullscreen with recent interaction (<60s), fire POST bell/clear to
the active remote instance with Bearer auth (fire-and-forget, errors logged)
- Add test_poll_cycle_fires_federation_bell_clear_for_remote_session test
Closes task-3 of federation-state-propagation-plan
Fixes falsy-0 bug where sessions with remoteId=0 (first remote instance) were
incorrectly hidden because !s.remoteId treats 0 as falsy. The null check
s.remoteId == null correctly handles:
- remoteId=0 (first remote instance) → truthy, not hidden
- remoteId=null or undefined (local sessions) → falsy, hidden if in hidden list
Changes:
- muxplex/frontend/app.js: Line 533 condition in getVisibleSessions()
- muxplex/frontend/tests/test_app.mjs: Added 2 tests for remoteId=0 behavior
- muxplex/tests/test_frontend_js.py: Added pattern test verifying source code
All 330 JS tests pass, all 200 Python tests pass.
Generated with Amplifier (https://github.com/microsoft/amplifier)
Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
Issue 1 (CRITICAL): terminal.js createTerminal() read fontSize from
localStorage.getItem('muxplex.display'), which always returns null after
the server-settings migration. This caused terminal font size to permanently
default to 14 regardless of the server-side fontSize setting.
Fix: Pass getDisplaySettings().fontSize from app.js to window._openTerminal
as a third argument. Update openTerminal(name, remoteId, fontSize) and
createTerminal(fontSize) to use the parameter, removing all localStorage
reads from terminal.js.
Issue 2: Fix 9 stale test references in test_app.mjs that set
_localStorageStore['muxplex.display'] instead of app._setServerSettings().
Tests now correctly exercise the server-settings path.
Issue 3: Fix stale initSidebar test that used
delete _localStorageStore['muxplex.sidebarOpen'] — replaced with
app._setServerSettings(null) since initSidebar reads from _serverSettings.
Also update 5 Python tests in test_frontend_js.py that were checking the
old localStorage-based createTerminal() behavior:
- Renamed test_create_terminal_reads_font_size_from_localstorage to
test_create_terminal_accepts_font_size_parameter
- Renamed test_create_terminal_parses_json_for_font_size to
test_create_terminal_does_not_parse_json_from_localstorage
- Updated 3 remaining tests to use regex that matches createTerminal(fontSize)
instead of createTerminal()
Verification:
- grep -rn 'localStorage' app.js: only tmux-web-device-id (3 lines)
- grep -rn 'muxplex\.display|muxplex\.sidebarOpen' frontend/: zero matches
- All 372 JS tests pass (44 terminal + 328 app)
- All 199 Python frontend_js tests pass
Part A: DOMContentLoaded now async with await loadServerSettings() before
first render. Removes lazy loadServerSettings() from inside .then() block.
Part B: Fix 18 failing tests in test_app.mjs:
- Sidebar tests: replace _localStorageStore['muxplex.sidebarOpen'] with
app._setServerSettings({sidebarOpen: ...}); update toggleSidebar mocks
to include classList.contains(); assert _serverSettings.sidebarOpen
instead of localStorage values
- loadGridViewMode/saveGridViewMode tests: replace localStorage setup with
_setServerSettings({gridViewMode: ...}); assert _serverSettings results
- cycleViewMode test: replace loadDisplaySettings/saveDisplaySettings with
_setServerSettings/getDisplaySettings
- activityIndicator tests (7): replace _localStorageStore['muxplex.display']
JSON.stringify() with app._setServerSettings({activityIndicator: ...})
- killSession test: increase substring limit from 500 to 600 chars
Part C: Add _getServerSettings test helper to app.js exports so tests can
read back _serverSettings state after sidebar/display mutations.
- Remove SIDEBAR_KEY constant (was an undefined variable bug)
- Rewrite initSidebar() to read sidebarOpen from _serverSettings
- Rewrite toggleSidebar() to derive state from DOM class and persist
to server via patchServerSetting
- Rewrite bindSidebarClickAway() to persist collapsed state via
patchServerSetting instead of localStorage
- Add 7 tests verifying SIDEBAR_KEY removal and _serverSettings usage
Move all display/UX settings from browser localStorage to server-side
settings.json. Flat keys approach (Approach A) - adds 10 new keys to
DEFAULT_SETTINGS, requiring zero changes to existing load/save/patch
functions or API endpoints.
Key decisions:
- No federation settings sync (each server owns its own settings)
- Drop notificationPermission (browser API is source of truth)
- sidebarOpen defaults to None for auto-detect on first load
- No migration needed - users reset preferences
- Fix auto_open vs auto_open_created naming alignment
Verified compatible with muxplex v0.2.0 (1b5207b).
- Add DELETE /api/federation/{remote_id}/sessions/{session_name} endpoint
to proxy session deletion to remote instances with Bearer auth
(follows same pattern as existing create/connect/bell-clear proxies)
- Update killSession() to accept optional remoteId parameter and route
through federation delete proxy when present
- Update delegated click handler to extract data-remote-id from parent
tile/sidebar element and pass to killSession() for remote routing
- Now users can delete sessions on remote devices from their local client
Fixes: https://github.com/bkrabach/muxplex/issues (delete remote sessions)
Generated with Amplifier
Seven bug fixes across five files:
1. auth.py: Add .json to static extension allowlist so /manifest.json
bypasses auth. Previously, unauthenticated requests got redirected to
the login page HTML which the browser tried to parse as JSON.
2. sessions.py: Catch FileNotFoundError in enumerate_sessions() alongside
RuntimeError. If the session command binary is missing from PATH,
asyncio.create_subprocess_exec raises FileNotFoundError, which was
unhandled and broke the poll loop.
3. settings.py: Fix federation key erasure when remote instance URLs are
edited. The key-preservation logic only restored keys by exact URL
match. Editing a URL (e.g. http:// to https://) changed the lookup
key, permanently erasing the real federation key. Added position-based
fallback for same-slot URL edits.
4. main.py: Replace fire-and-forget subprocess.Popen in create_session
with asyncio.create_subprocess_shell that checks the return code and
returns proper HTTP 500 errors. Added shutil.which() pre-flight check.
Commands that exit non-zero but still create the session (e.g.
amplifier-workspace which tries to attach after create) are detected
by checking enumerate_sessions() and treated as success.
5. main.py: Redact sensitive keys in PATCH /api/settings response,
matching the existing GET endpoint behavior.
6. main.py: Include exception type and message in federation 503 error
responses (connect, bell-clear, create-session).
7. app.js: Fix auto_open vs auto_open_created key mismatch. The settings
toggle read/wrote 'auto_open' but the backend key is
'auto_open_created'. The PATCH was silently dropped and the toggle
was completely non-functional.
🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier)
Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
Bump version 0.1.0 → 0.1.1. Add CHANGELOG.md covering all changes
since initial release. Update README clipboard section to reflect
native xterm.js paste handling.
The source-inspection test checks each line individually for an 'if'
guard. The multi-line formatting of the 'if remote_key else {}' guard
split it across 3 lines, making the test see an unguarded header.
Consolidated to match the single-line pattern of the other 4 endpoints.
After 9 clipboard commits that kept breaking each other, COE review
determined: ttyd uses ClipboardAddon, not custom handlers. Ctrl+Shift+V
on Linux and Cmd+V on macOS both trigger native browser paste events
that xterm.js catches via its hidden textarea handler. Zero custom
paste code needed. Deleted synthetic ClipboardEvent dispatch, readText
calls, and all paste-related comments. Kept: Ctrl+Shift+C copy,
auto-copy on selection, OSC 52, Ctrl+F search.
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.
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.
_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.
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.
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.
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.
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://.
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.
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.
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.
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.
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.
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().