Files
muxplex/docs/plans/2026-04-08-server-side-settings-design.md
Brian Krabach 3bb3944b52 docs: add design for consolidating settings server-side
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).
2026-04-08 09:24:12 -07:00

9.8 KiB

Consolidate Settings Server-Side Design

Goal

Move all user-configurable settings from browser localStorage to server-side ~/.config/muxplex/settings.json, eliminating the split storage model. After this change, the only remaining localStorage usage is tmux-web-device-id (per-browser device identity for the heartbeat system, not a setting).

Background

Muxplex currently splits settings across two storage layers:

  • Server-side (~/.config/muxplex/settings.json): Infrastructure and session behavior settings (host, port, auth, sort_order, federation config, etc.) -- 17 keys total.
  • Client-side (browser localStorage): Display/UX preferences stored across 3 keys:
    • muxplex.display -- fontSize, gridColumns, bellSound, hoverPreviewDelay, viewMode, showDeviceBadges, showHoverPreview, activityIndicator, gridViewMode, notificationPermission
    • muxplex.sidebarOpen -- sidebar toggle state
    • tmux-web-device-id -- per-browser device identity (not a setting)

This split complicates the mental model and means display preferences don't roam with the server -- if you reinstall your browser or clear localStorage, your preferences are lost even though the server is untouched.

Approach

Flat keys (Approach A) -- add 10 new keys directly as flat top-level entries in DEFAULT_SETTINGS in settings.py.

This requires zero changes to load_settings(), save_settings(), or patch_settings() -- they all iterate DEFAULT_SETTINGS keys, so new keys work automatically through the existing GET/PATCH /api/settings API.

Alternatives Considered

  • Nested display sub-object: Would require deep-merge logic in patch_settings() and frontend changes to patchServerSetting(). More code for the same result. Rejected.
  • Separate display.json file: New file, new API endpoints, new load/save functions. Over-engineered for a single-user tmux UI. Rejected.

Architecture

Multi-Device / Federation Settings Model

Each muxplex server owns its own settings.json. Federation is for session aggregation, not config replication. No settings sync across servers.

When browsing through Server A (the aggregator), all sessions -- local and federated -- render using Server A's display settings. Server B's settings are irrelevant; it only provides raw session data and WebSocket proxying. Server B's settings only matter when someone browses directly to Server B, which is a separate context where different settings may be desirable (e.g., different font size for different monitors).

Syncing would introduce disproportionate complexity (conflict resolution, partial connectivity, auth scope expansion, bootstrap problem) for minimal UX benefit in a single-user tool.

Data Flow

Browser ──GET /api/settings──> Server A (settings.json)
   │                              │
   │  _serverSettings cache       │  fontSize: 16, gridColumns: 3, ...
   │  (in-memory, immediate)      │  + all infra/session settings
   │                              │
   │──PATCH /api/settings──>      │  patch_settings() merges into file
   │  (fire-and-forget)           │
   │                              │
   │                              ├── Local tmux sessions
   │                              ├── Server B sessions (federation proxy)
   │                              └── Server C sessions (federation proxy)
   │
   │  All sessions render using Server A's display settings.
   │  Server B/C settings are irrelevant from this browser.

Components

Server-Side Changes (muxplex/settings.py)

Add 10 new keys to DEFAULT_SETTINGS:

# Display preferences (consumed by frontend)
"fontSize": 14,
"hoverPreviewDelay": 1500,
"gridColumns": "auto",
"bellSound": False,
"viewMode": "auto",
"showDeviceBadges": True,
"showHoverPreview": True,
"activityIndicator": "both",
"gridViewMode": "flat",
# UI state
"sidebarOpen": None,       # None = auto-detect from screen width

sidebarOpen defaults to None (not True/False) so the frontend can distinguish "never set by user" from "explicitly set" and fall back to screen-width detection (open at >= 960px, closed below) on first load.

No changes needed to:

  • load_settings(), save_settings(), patch_settings() -- they iterate DEFAULT_SETTINGS keys automatically.
  • muxplex/main.py -- GET/PATCH /api/settings endpoints already handle any keys in DEFAULT_SETTINGS. None of the new keys need redaction (no sensitive data).

Intentionally excluded: notificationPermission is NOT added. The browser Notification.permission API is the source of truth; the cached localStorage value was never meaningfully consumed.

Frontend Changes (muxplex/frontend/app.js)

Startup Sequence

Move await loadServerSettings() to the top of DOMContentLoaded, before first render. Currently it's called lazily when opening the settings panel. Display settings like fontSize and gridColumns affect rendering, so they must be available before anything renders (~50ms on localhost; page is blank until restoreState anyway).

Replace the Storage Layer

  • Delete DISPLAY_SETTINGS_KEY constant ('muxplex.display') and SIDEBAR_KEY constant ('muxplex.sidebarOpen').
  • Delete loadDisplaySettings() function (reads from localStorage).
  • Delete saveDisplaySettings() function (writes to localStorage).
  • Add getDisplaySettings() function that reads from _serverSettings cache, falls back to DISPLAY_DEFAULTS for anything missing or before the initial fetch.
  • Remove notificationPermission from DISPLAY_DEFAULTS (keep the runtime _notificationPermission variable for the browser API state).

Update All Callsites

  • All ~16 loadDisplaySettings() calls become getDisplaySettings() (pure rename).
  • All ~6 saveDisplaySettings(ds) calls become patchServerSetting(key, value) calls with an in-memory _serverSettings cache update so the UI stays responsive without waiting for the server round-trip.

Sidebar Functions

Rewrite initSidebar(), toggleSidebar(), and bindSidebarClickAway():

  • Read _serverSettings.sidebarOpen instead of localStorage.
  • Write via patchServerSetting('sidebarOpen', isOpen) (fire-and-forget, non-blocking).
  • Keep the None -> screen-width auto-detect logic for first-time users.

Bonus Fix: auto_open -> auto_open_created

The openSettings() function reads the server setting as ss.auto_open but the canonical server-side key is auto_open_created. Fix this alignment as part of this work since we're already touching that code path.

Only Remaining localStorage Usage

tmux-web-device-id in initDeviceId() (3 occurrences) -- untouched. This is per-browser device identity for the heartbeat/bell system, not a user setting.

Error Handling

Race on Startup

loadServerSettings() is awaited before first render, so display settings are always available. If the fetch fails (server down, network issue), getDisplaySettings() falls back to DISPLAY_DEFAULTS -- the app still loads with sensible defaults, just not personalized.

Fire-and-Forget Writes

Setting changes call patchServerSetting() which updates the in-memory _serverSettings cache immediately (so the UI is responsive), then fires the PATCH /api/settings request. If the PATCH fails, a "Failed to save setting" toast appears. The in-memory cache stays updated for the current session, but the change won't persist across page refreshes. This matches the current behavior for existing server settings.

sidebarOpen: None Auto-Detect

On first load (or after a settings reset), sidebarOpen is None. The frontend detects this and uses screen-width heuristic (open at >= 960px, closed below). Once the user explicitly toggles the sidebar, it writes true/false and the auto-detect is bypassed from then on.

No Migration

No migration of existing localStorage values. Users get defaults and set preferences again. Old localStorage keys (muxplex.display, muxplex.sidebarOpen) become orphans -- harmless, ignored by the new code.

Testing Strategy

Server-Side Tests (muxplex/tests/test_settings.py)

  • One test verifying the 10 new keys exist in DEFAULT_SETTINGS with correct default values.
  • One test verifying display keys round-trip through patch_settings() (write then read back).
  • Existing test infrastructure for load_settings/save_settings already covers the mechanics.

Frontend Tests (muxplex/frontend/tests/test_app.mjs)

  • Update any existing tests that reference loadDisplaySettings or saveDisplaySettings to use getDisplaySettings.
  • Mock _serverSettings instead of localStorage for display settings tests.

Verification Checklist (manual, post-implementation)

  1. grep -r 'localStorage' app.js returns only tmux-web-device-id references.
  2. grep -r 'DISPLAY_SETTINGS_KEY\|SIDEBAR_KEY\|muxplex\.display\|muxplex\.sidebarOpen' app.js returns zero matches.
  3. grep -r 'loadDisplaySettings\|saveDisplaySettings' app.js returns zero matches.
  4. pytest muxplex/tests/test_settings.py passes.
  5. Manual: change font size -> refresh -> persists.
  6. Manual: toggle sidebar -> refresh -> persists.
  7. cat ~/.config/muxplex/settings.json shows the new display keys.

Open Questions

None -- all design decisions have been validated.

Future Work

  • Federation settings sync: Investigate syncing user preferences (fontSize, sort_order, etc.) across federated servers. Deferred -- each server owns its own settings for now. Standard sysadmin solutions (copy the file, ansible, dotfiles repo) work fine in the meantime.

Compatibility

Verified against muxplex v0.2.0 (commit 1b5207b). The v0.2.0 release added a DELETE /api/federation/{remote_id}/sessions/{session_name} endpoint and updated killSession() -- neither affects settings storage. DEFAULT_SETTINGS in settings.py is stable at 17 keys; none of the new display keys conflict with existing keys.