* work * moar work * notes * work * separate to terminal and control channels * stdin working * serve html web client initial * serve static assets loaded with include_dir * merge * enable_web_server config parameter * compile time flag to disable web server capability * rustfmt * add license to all xterm.js assets * mouse working except copy/paste * helpful comment * web client improvements - move script to js file - add favicon - add nerd font - change title TODO: investigate if font license embedded in otf is sufficient * get mouse to work properly * kitty keyboard support initial * fix wrong type in preload link * wip axum websocket handlers - upgrade axum to v0.8.1, enable ws feature - begin setup of websocket handlers - tidy up imports * replace control listener * handle terminal websocket with axum * cleanup Cargo.toml * kitty fixes and bracketed paste * fix(mouse): pane not found crash * initial session switching infra * add `web_client_font` option * session switching, creation and resurrection working through the session manager * move session module to zellij-utils and share logic with web-client * some cleanups * require restart for enable-web-server * use session name from router * write config to disk and watch for config changes * rename session name to ipc path * add basic panic handler, make render_to_client exit on channel close * use while let instead of loop * handle websocket close * add mouse motions * make clipboard work * add weblink handling and webgl rendering * add todo * fix: use session name instead of patch on session switch * use "default" layout for new sessions * ui indication for session being shared * share this session ui * plugin assets * Fix process crash on mac with notify watcher. Use poll watcher instead of recommended as a workaround. * make url session switching and creation work * start welcome screen on root url * scaffold control messages, set font from config * set dimensions on session start * bring back session name from url * send bytes on terminal websocket instead of json - create web client os input and id before websocket connection * draft ui * work * refactor ui * remove otf font, remove margins to avoid scrollbar * version query endpoint for server status * web session info query endpoint * refactor: move stuff around * add web client info to session metadata * make tests pass * populate real data in session list * remove unnecessary endpoint * add web_client node to config, add font option * remove web_client_font * allow disabling the web session through the config - WIP * formalize sharing/not-sharing configuration * fix tests * allow shutting down web server * display error when web clients are forbidden to attach * only show sessions that allow web clients if this is a web client * style(fmt): rustfmt * fix: query web server from Zellij rather than from each plugin * remove log spam * handle some error paths better in the web client * allow controlling the web server through the cli * allow configuring the web server's ip/port * fix tests and format code * use direct WebServerStatus event instead of piggy-backing on SessionInfo * plugin revamp initial * make plugin responsive * adjust plugin title * refactor: share plugin * refactor: share plugin * add cors middleware * some fixes for running without a compiled web server capability * display error when starting the share plugin without web server support * clarify config * add pipelines to compile zellij without web support * display error when unable to start web server * only query web server when share plugin is running * refactor(web-client): connection table * give zellij_server_listener access to the control channel * fixes and clarifications * refactor: consolidate generate_unique_session_name * give proper error when trying to attach to a forbidden session * change browser URL when switching sessions * add keyboard shortcut * enforce https when bound to non-loopback ip * initial authentication token implementation * background color from theme * initial web client theme config * basic token generation ui * refactor set config message creation * also set body background * allow editing scrollback for plugins too * set scrollback to 0 * properly parse colors in config * generate token from plugin * nice login modals * initial token management screen * implement token authentication * refactor(share): token management screen * style(fmt): rustfmt * fix(plugin): some minor bugs * refactor(share): main screen * refactor(share): token screen * refactor(share): main * refactor(share): ui components * fix(responsiveness): properly send usage_width to the render function * fix cli commands and add some verbosity * add support for settings ansi and selection colors * add cursor and cursor accent * basic web client tests * fix tests * refactor: web client * use session tokens for authentication * improve modals * move shutdown to ipc * refactor: ipc logic * serialize theme config for web client * update tests * refactor: move some stuff around to prepare for config hot reload * config live reloading for the web clients * change remember-me UI wording * improve xterm.js link handling * make sure terminal is focused on mousemove * remove deprecated sharing indication from compact-bar * gate deps and functionality behind the web_server_compatibility feature * feat(build): add --no-web flag in all the places * fix some other build flows * add new assets * update CI for no-web (untested) * make more dependencies optional * update axum-extra * add web client configuration options * gracefully close connections on server exit * tests for graceful connection closing * handle client-side reconnect when server is down * fix: make sure ipc bus folder exists before starting * add commands to manage login tokens from the cli * style(fmt): rustfmt * some cleanups * fix(ux): allow alt-right-click on the web client without opening the context menu * fix: prevent attaching to welcome screen * fix: reload config issues * fix long socket path on macos * normalize config conversion and fix color gap in browser * revoke session_token cookie if it is not valid * fix: visual bug with multiple clients in extremely small screen sizes * fix: only include rusqlite for the web server capability builds * update e2e snapshots * refactor(web): client side js * some cleanups * moar cleanups * fix(tests): wait for server instead of using a fixed timeout * debug CI * fix(tests): use spawn_blocking for running the test web server * fix(tests): wait for http rather than tcp port * fix(tests): properly pass config path - hopefully this is the issue... * success! bring back the rest of the tests * attempt to fix the macos CI issue * docs(changelog): add PR --------- Co-authored-by: Thomas Linford <linford.t@gmail.com>
556 lines
15 KiB
JavaScript
556 lines
15 KiB
JavaScript
function createModalStyles() {
|
|
if (document.querySelector('#modal-styles')) return;
|
|
|
|
const zellijGreen = '#A3BD8D';
|
|
const zellijGreenDark = '#7A9B6A';
|
|
const zellijBlue = '#7E9FBE';
|
|
const zellijBlueDark = '#5A7EA0';
|
|
const zellijYellow = '#EACB8B';
|
|
const errorRed = '#BE616B';
|
|
const errorRedDark = '#A04E57';
|
|
|
|
const terminalDark = '#000000';
|
|
const terminalMedium = '#1C1C1C';
|
|
const terminalLight = '#3A3A3A';
|
|
const terminalText = '#FFFFFF';
|
|
const terminalTextDim = '#CCCCCC';
|
|
|
|
const terminalLightBg = '#FFFFFF';
|
|
const terminalLightMedium = '#F0F0F0';
|
|
const terminalLightText = '#000000';
|
|
const terminalLightTextDim = '#666666';
|
|
|
|
const style = document.createElement('style');
|
|
style.id = 'modal-styles';
|
|
style.textContent = `
|
|
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&display=swap');
|
|
|
|
.security-modal {
|
|
position: fixed;
|
|
top: 0;
|
|
left: 0;
|
|
width: 100%;
|
|
height: 100%;
|
|
background: rgba(28, 28, 28, 0.95);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
z-index: 9999;
|
|
font-family: 'JetBrains Mono', 'Consolas', 'Monaco', 'Courier New', monospace;
|
|
}
|
|
|
|
.security-modal-content {
|
|
background: ${terminalDark};
|
|
color: ${terminalText};
|
|
padding: 24px;
|
|
border-radius: 0;
|
|
border: 2px solid ${zellijGreen};
|
|
box-shadow: 0 0 20px rgba(127, 176, 105, 0.3);
|
|
max-width: 420px;
|
|
width: 90%;
|
|
position: relative;
|
|
}
|
|
|
|
.security-modal-content::before {
|
|
content: '';
|
|
position: absolute;
|
|
top: -2px;
|
|
left: -2px;
|
|
right: -2px;
|
|
bottom: -2px;
|
|
background: ${zellijGreen};
|
|
border-radius: 0;
|
|
z-index: -1;
|
|
}
|
|
|
|
.security-modal h3 {
|
|
margin: 0 0 20px 0;
|
|
color: ${zellijBlue};
|
|
font-size: 16px;
|
|
font-weight: 600;
|
|
text-transform: uppercase;
|
|
letter-spacing: 1px;
|
|
border-bottom: 1px solid ${terminalLight};
|
|
padding-bottom: 8px;
|
|
}
|
|
|
|
.security-modal.error .security-modal-content {
|
|
border-color: ${errorRed};
|
|
box-shadow: 0 0 20px rgba(190, 97, 107, 0.3);
|
|
}
|
|
|
|
.security-modal.error .security-modal-content::before {
|
|
background: ${errorRed};
|
|
}
|
|
|
|
.security-modal.error h3 {
|
|
color: ${errorRed};
|
|
}
|
|
|
|
.security-modal input[type="password"] {
|
|
width: 100%;
|
|
padding: 12px 16px;
|
|
margin-bottom: 16px;
|
|
border: 1px solid ${terminalLight};
|
|
border-radius: 0;
|
|
box-sizing: border-box;
|
|
background: ${terminalMedium};
|
|
color: ${terminalText};
|
|
font-family: inherit;
|
|
font-size: 14px;
|
|
}
|
|
|
|
.security-modal input[type="password"]:focus {
|
|
outline: none;
|
|
border-color: ${zellijBlue};
|
|
box-shadow: 0 0 0 1px ${zellijBlue};
|
|
background: ${terminalLight};
|
|
}
|
|
|
|
.security-modal label {
|
|
display: flex;
|
|
align-items: center;
|
|
margin-bottom: 20px;
|
|
cursor: pointer;
|
|
color: ${terminalTextDim};
|
|
font-size: 13px;
|
|
user-select: none;
|
|
}
|
|
|
|
.security-modal input[type="checkbox"] {
|
|
appearance: none;
|
|
width: 16px;
|
|
height: 16px;
|
|
border: 1px solid ${terminalLight};
|
|
margin-right: 10px;
|
|
background: ${terminalMedium};
|
|
position: relative;
|
|
cursor: pointer;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
|
|
.security-modal input[type="checkbox"]:checked {
|
|
background: ${zellijGreen};
|
|
border-color: ${zellijGreen};
|
|
}
|
|
|
|
.security-modal input[type="checkbox"]:checked::after {
|
|
content: '✓';
|
|
color: ${terminalDark};
|
|
font-size: 12px;
|
|
font-weight: bold;
|
|
line-height: 1;
|
|
}
|
|
|
|
.security-modal .button-row {
|
|
display: flex;
|
|
gap: 12px;
|
|
justify-content: flex-end;
|
|
margin-top: 24px;
|
|
}
|
|
|
|
.security-modal button {
|
|
padding: 10px 20px;
|
|
border: 1px solid;
|
|
border-radius: 0;
|
|
cursor: pointer;
|
|
font-family: inherit;
|
|
font-size: 13px;
|
|
font-weight: 500;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.5px;
|
|
min-width: 80px;
|
|
}
|
|
|
|
.security-modal .cancel-btn {
|
|
background: transparent;
|
|
color: ${terminalTextDim};
|
|
border-color: ${terminalLight};
|
|
}
|
|
|
|
.security-modal .cancel-btn:hover {
|
|
background: ${terminalLight};
|
|
color: ${terminalText};
|
|
}
|
|
|
|
.security-modal .submit-btn {
|
|
background: ${zellijGreen};
|
|
color: ${terminalDark};
|
|
border-color: ${zellijGreen};
|
|
}
|
|
|
|
.security-modal .submit-btn:hover {
|
|
background: ${zellijGreenDark};
|
|
border-color: ${zellijGreenDark};
|
|
color: white;
|
|
}
|
|
|
|
.security-modal .dismiss-btn {
|
|
background: transparent;
|
|
color: ${terminalText};
|
|
border-color: ${terminalLight};
|
|
}
|
|
|
|
.security-modal .dismiss-btn:hover {
|
|
background: ${terminalLight};
|
|
}
|
|
|
|
.security-modal.error .dismiss-btn {
|
|
border-color: ${errorRed};
|
|
color: ${errorRed};
|
|
}
|
|
|
|
.security-modal.error .dismiss-btn:hover {
|
|
background: rgba(190, 97, 107, 0.2);
|
|
}
|
|
|
|
.security-modal .error-description {
|
|
margin: 16px 0 20px 0;
|
|
color: ${terminalTextDim};
|
|
line-height: 1.5;
|
|
font-size: 14px;
|
|
padding: 12px;
|
|
background: rgba(190, 97, 107, 0.1);
|
|
border-left: 3px solid ${errorRed};
|
|
}
|
|
|
|
.security-modal .status-bar {
|
|
position: absolute;
|
|
bottom: -2px;
|
|
left: -2px;
|
|
right: -2px;
|
|
height: 3px;
|
|
background: ${zellijGreen};
|
|
}
|
|
|
|
.security-modal.error .status-bar {
|
|
background: ${errorRed};
|
|
}
|
|
|
|
@media (prefers-color-scheme: light) {
|
|
.security-modal {
|
|
background: rgba(255, 255, 255, 0.95);
|
|
}
|
|
|
|
.security-modal-content {
|
|
background: ${terminalLightBg};
|
|
color: ${terminalLightText};
|
|
border-color: ${zellijBlueDark};
|
|
box-shadow: 0 0 20px rgba(90, 126, 160, 0.3);
|
|
}
|
|
|
|
.security-modal-content::before {
|
|
background: ${zellijBlueDark};
|
|
}
|
|
|
|
.security-modal h3 {
|
|
color: ${zellijBlueDark};
|
|
border-bottom-color: ${terminalLightMedium};
|
|
}
|
|
|
|
.security-modal input[type="password"] {
|
|
background: white;
|
|
border-color: ${zellijBlueDark};
|
|
color: ${terminalLightText};
|
|
}
|
|
|
|
.security-modal input[type="password"]:focus {
|
|
border-color: ${zellijBlueDark};
|
|
box-shadow: 0 0 0 1px ${zellijBlueDark};
|
|
background: ${terminalLightBg};
|
|
}
|
|
|
|
.security-modal label {
|
|
color: ${terminalLightTextDim};
|
|
}
|
|
|
|
.security-modal input[type="checkbox"] {
|
|
background: white;
|
|
border-color: ${zellijBlueDark};
|
|
}
|
|
|
|
.security-modal input[type="checkbox"]:checked {
|
|
background: ${zellijGreenDark};
|
|
border-color: ${zellijGreenDark};
|
|
}
|
|
|
|
.security-modal input[type="checkbox"]:checked::after {
|
|
color: white;
|
|
}
|
|
|
|
.security-modal .cancel-btn {
|
|
background: ${terminalLightBg};
|
|
color: ${terminalLightTextDim};
|
|
border-color: ${zellijBlueDark};
|
|
}
|
|
|
|
.security-modal .cancel-btn:hover {
|
|
background: ${terminalLightMedium};
|
|
color: ${terminalLightText};
|
|
}
|
|
|
|
.security-modal .submit-btn {
|
|
background: ${zellijGreenDark};
|
|
border-color: ${zellijGreenDark};
|
|
color: white;
|
|
}
|
|
|
|
.security-modal .submit-btn:hover {
|
|
background: ${zellijGreen};
|
|
border-color: ${zellijGreen};
|
|
color: ${terminalDark};
|
|
}
|
|
|
|
.security-modal .dismiss-btn {
|
|
background: ${terminalLightBg};
|
|
color: ${terminalLightText};
|
|
border-color: ${terminalLightMedium};
|
|
}
|
|
|
|
.security-modal .dismiss-btn:hover {
|
|
background: ${terminalLightMedium};
|
|
}
|
|
|
|
.security-modal.error .dismiss-btn {
|
|
border-color: ${errorRedDark};
|
|
color: ${errorRedDark};
|
|
background: ${terminalLightBg};
|
|
}
|
|
|
|
.security-modal.error .dismiss-btn:hover {
|
|
background: rgba(160, 78, 87, 0.2);
|
|
color: ${errorRedDark};
|
|
border-color: ${errorRedDark};
|
|
}
|
|
|
|
.security-modal .error-description {
|
|
color: ${terminalLightTextDim};
|
|
background: rgba(160, 78, 87, 0.05);
|
|
}
|
|
|
|
.security-modal .status-bar {
|
|
background: ${zellijBlueDark};
|
|
}
|
|
}
|
|
`;
|
|
document.head.appendChild(style);
|
|
}
|
|
|
|
function getSecurityToken() {
|
|
return new Promise((resolve) => {
|
|
createModalStyles();
|
|
|
|
const modal = document.createElement('div');
|
|
modal.className = 'security-modal';
|
|
|
|
modal.innerHTML = `
|
|
<div class="security-modal-content">
|
|
<h3>Security Token Required</h3>
|
|
<input type="password" id="token" placeholder="Enter your security token">
|
|
<label>
|
|
<input type="checkbox" id="remember">
|
|
Remember me
|
|
</label>
|
|
<div class="button-row">
|
|
<button id="cancel" class="cancel-btn">Cancel</button>
|
|
<button id="submit" class="submit-btn">Authenticate</button>
|
|
</div>
|
|
<div class="status-bar"></div>
|
|
</div>
|
|
`;
|
|
|
|
document.body.appendChild(modal);
|
|
modal.querySelector('#token').focus();
|
|
|
|
const handleKeydown = (e) => {
|
|
if (e.key === 'Enter') {
|
|
e.preventDefault();
|
|
handleSubmit();
|
|
} else if (e.key === 'Escape') {
|
|
e.preventDefault();
|
|
handleCancel();
|
|
}
|
|
};
|
|
|
|
modal.addEventListener('keydown', handleKeydown);
|
|
|
|
const cleanup = () => {
|
|
modal.removeEventListener('keydown', handleKeydown);
|
|
document.body.removeChild(modal);
|
|
};
|
|
|
|
const handleSubmit = () => {
|
|
const token = modal.querySelector('#token').value;
|
|
const remember = modal.querySelector('#remember').checked;
|
|
cleanup();
|
|
resolve({ token, remember });
|
|
};
|
|
|
|
const handleCancel = () => {
|
|
cleanup();
|
|
resolve(null);
|
|
};
|
|
|
|
modal.querySelector('#submit').onclick = handleSubmit;
|
|
modal.querySelector('#cancel').onclick = handleCancel;
|
|
|
|
modal.onclick = (e) => {
|
|
if (e.target === modal) {
|
|
handleCancel();
|
|
}
|
|
};
|
|
});
|
|
}
|
|
|
|
function showErrorModal(title, description) {
|
|
return new Promise((resolve) => {
|
|
createModalStyles();
|
|
|
|
const modal = document.createElement('div');
|
|
modal.className = 'security-modal error';
|
|
|
|
modal.innerHTML = `
|
|
<div class="security-modal-content">
|
|
<h3>${title}</h3>
|
|
<div class="error-description">${description}</div>
|
|
<div class="button-row">
|
|
<button id="dismiss" class="dismiss-btn">Acknowledge</button>
|
|
</div>
|
|
<div class="status-bar"></div>
|
|
</div>
|
|
`;
|
|
|
|
document.body.appendChild(modal);
|
|
modal.querySelector('#dismiss').focus();
|
|
|
|
const handleKeydown = (e) => {
|
|
if (e.key === 'Enter' || e.key === 'Escape') {
|
|
e.preventDefault();
|
|
cleanup();
|
|
}
|
|
};
|
|
|
|
modal.addEventListener('keydown', handleKeydown);
|
|
|
|
const cleanup = () => {
|
|
modal.removeEventListener('keydown', handleKeydown);
|
|
document.body.removeChild(modal);
|
|
resolve();
|
|
};
|
|
|
|
modal.querySelector('#dismiss').onclick = cleanup;
|
|
|
|
modal.onclick = (e) => {
|
|
if (e.target === modal) {
|
|
cleanup();
|
|
}
|
|
};
|
|
});
|
|
}
|
|
|
|
function showReconnectionModal(attemptNumber, delaySeconds) {
|
|
return new Promise((resolve) => {
|
|
createModalStyles();
|
|
|
|
const modal = document.createElement('div');
|
|
modal.className = 'security-modal';
|
|
modal.style.background = 'rgba(28, 28, 28, 0.85)'; // More transparent to show terminal
|
|
|
|
const isFirstAttempt = attemptNumber === 1;
|
|
const title = isFirstAttempt ? 'Connection Lost' : 'Reconnection Failed';
|
|
const message = isFirstAttempt
|
|
? `Reconnecting in <span id="countdown">${delaySeconds}</span> second${delaySeconds > 1 ? 's' : ''}...`
|
|
: `Retrying in <span id="countdown">${delaySeconds}</span> second${delaySeconds > 1 ? 's' : ''}... (Attempt ${attemptNumber})`;
|
|
|
|
modal.innerHTML = `
|
|
<div class="security-modal-content">
|
|
<h3 id="modal-title">${title}</h3>
|
|
<div class="error-description" id="modal-message">${message}</div>
|
|
<div class="button-row" id="button-row">
|
|
<button id="cancel" class="cancel-btn">Cancel</button>
|
|
<button id="reconnect" class="submit-btn">Reconnect Now</button>
|
|
</div>
|
|
<div class="status-bar"></div>
|
|
</div>
|
|
`;
|
|
|
|
document.body.appendChild(modal);
|
|
modal.querySelector('#reconnect').focus();
|
|
|
|
let countdownInterval;
|
|
let remainingSeconds = delaySeconds;
|
|
let isCheckingConnection = false;
|
|
|
|
const updateCountdown = () => {
|
|
const countdownElement = modal.querySelector('#countdown');
|
|
if (countdownElement && !isCheckingConnection) {
|
|
countdownElement.textContent = remainingSeconds;
|
|
}
|
|
remainingSeconds--;
|
|
|
|
if (remainingSeconds < 0 && !isCheckingConnection) {
|
|
clearInterval(countdownInterval);
|
|
handleReconnect();
|
|
}
|
|
};
|
|
|
|
const showConnectionCheck = () => {
|
|
isCheckingConnection = true;
|
|
if (countdownInterval) {
|
|
clearInterval(countdownInterval);
|
|
}
|
|
|
|
const messageElement = modal.querySelector('#modal-message');
|
|
messageElement.innerHTML = 'Connecting...';
|
|
};
|
|
|
|
countdownInterval = setInterval(updateCountdown, 1000);
|
|
|
|
const handleKeydown = (e) => {
|
|
if (isCheckingConnection) return;
|
|
|
|
if (e.key === 'Enter') {
|
|
e.preventDefault();
|
|
handleReconnect();
|
|
} else if (e.key === 'Escape') {
|
|
e.preventDefault();
|
|
handleCancel();
|
|
}
|
|
};
|
|
|
|
modal.addEventListener('keydown', handleKeydown);
|
|
|
|
const cleanup = () => {
|
|
if (countdownInterval) {
|
|
clearInterval(countdownInterval);
|
|
}
|
|
modal.removeEventListener('keydown', handleKeydown);
|
|
if (document.body.contains(modal)) {
|
|
document.body.removeChild(modal);
|
|
}
|
|
};
|
|
|
|
const handleReconnect = () => {
|
|
showConnectionCheck();
|
|
// Don't cleanup here - let the parent handle it
|
|
resolve({ action: 'reconnect', cleanup, modal });
|
|
};
|
|
|
|
const handleCancel = () => {
|
|
if (isCheckingConnection) return;
|
|
cleanup();
|
|
resolve({ action: 'cancel' });
|
|
};
|
|
|
|
modal.querySelector('#reconnect').onclick = handleReconnect;
|
|
modal.querySelector('#cancel').onclick = handleCancel;
|
|
|
|
modal.onclick = (e) => {
|
|
if (e.target === modal && !isCheckingConnection) {
|
|
handleCancel();
|
|
}
|
|
};
|
|
});
|
|
}
|