feat: initial commit with UX improvements
Add full project including 12 UX improvements: wider gesture edge zone (50px), prefers-reduced-motion support, additional shortcut keys (^A ^E ^R ^W ^U) and PageDown, Escape to close sidebar, haptic feedback, key repeat for arrow keys, sidebar loading/empty state, login loading state, reconnect status indicator, terminal dim on disconnect, QR modal focus management, and desktop keyboard toggle. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>main
commit
8bcd7eacd8
@ -0,0 +1,4 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.claude/
|
||||||
|
*.env
|
||||||
@ -0,0 +1,2 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
import '../dist/index.js';
|
||||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"name": "cmux-remote",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "Browser-based remote terminal control for cmux",
|
||||||
|
"type": "module",
|
||||||
|
"bin": {
|
||||||
|
"cmux-remote": "./bin/cmux-remote.js"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc",
|
||||||
|
"dev": "tsx src/index.ts",
|
||||||
|
"dev:local": "tsx src/index.ts --no-tunnel",
|
||||||
|
"start": "node dist/index.js",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"bcrypt": "^5.1.0",
|
||||||
|
"commander": "^13.0.0",
|
||||||
|
"express": "^5.0.0",
|
||||||
|
"jsonwebtoken": "^9.0.0",
|
||||||
|
"nanoid": "^5.0.0",
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
|
"qrcode-terminal": "^0.12.0",
|
||||||
|
"ws": "^8.18.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/bcrypt": "^5.0.0",
|
||||||
|
"@types/express": "^5.0.0",
|
||||||
|
"@types/jsonwebtoken": "^9.0.0",
|
||||||
|
"@types/node": "^22.0.0",
|
||||||
|
"@types/qrcode": "^1.5.6",
|
||||||
|
"@types/ws": "^8.5.0",
|
||||||
|
"tsx": "^4.19.0",
|
||||||
|
"typescript": "^5.7.0",
|
||||||
|
"vitest": "^3.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,204 @@
|
|||||||
|
.virtual-keyboard {
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
backdrop-filter: blur(var(--blur-amount));
|
||||||
|
-webkit-backdrop-filter: blur(var(--blur-amount));
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
padding: 8px 8px;
|
||||||
|
padding-bottom: max(8px, env(safe-area-inset-bottom));
|
||||||
|
}
|
||||||
|
|
||||||
|
.key-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 5px;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.key-row:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Physical key style ──────────────────────────── */
|
||||||
|
.key {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
min-width: 40px;
|
||||||
|
height: 36px;
|
||||||
|
border: 1px solid var(--key-border);
|
||||||
|
border-bottom: 2px solid var(--key-depth);
|
||||||
|
border-radius: var(--radius-xs);
|
||||||
|
background: var(--key-bg);
|
||||||
|
background-image: linear-gradient(180deg, var(--key-bg-highlight) 0%, transparent 60%);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 0.77rem;
|
||||||
|
font-weight: 500;
|
||||||
|
font-family: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0 7px;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
user-select: none;
|
||||||
|
transition: background var(--transition-fast), transform var(--transition-fast), border-color var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.key:active {
|
||||||
|
background: var(--key-active-bg);
|
||||||
|
background-image: none;
|
||||||
|
border-bottom-width: 1px;
|
||||||
|
transform: translateY(1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.key.modifier {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.key.modifier.active {
|
||||||
|
background: var(--accent);
|
||||||
|
background-image: var(--accent-gradient);
|
||||||
|
color: #fff;
|
||||||
|
border-color: transparent;
|
||||||
|
border-bottom-color: var(--accent-dim);
|
||||||
|
box-shadow: 0 0 14px var(--accent-glow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.key.modifier.locked {
|
||||||
|
background: var(--accent);
|
||||||
|
background-image: var(--accent-gradient);
|
||||||
|
color: #fff;
|
||||||
|
border-color: transparent;
|
||||||
|
border-bottom-color: var(--accent-dim);
|
||||||
|
box-shadow: 0 0 0 2px var(--accent-hover), 0 0 20px var(--accent-glow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.key.shortcut {
|
||||||
|
font-family: 'SF Mono', 'JetBrains Mono', 'Menlo', monospace;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
min-width: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.key-spacer {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Input row — pill style ──────────────────────── */
|
||||||
|
.input-row {
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-input {
|
||||||
|
flex: 1;
|
||||||
|
height: 38px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
padding: 0 16px;
|
||||||
|
outline: none;
|
||||||
|
font-family: 'SF Mono', 'JetBrains Mono', 'Menlo', monospace;
|
||||||
|
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-input:focus {
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 0 0 3px var(--accent-glow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-input::placeholder {
|
||||||
|
color: var(--text-dimmed);
|
||||||
|
}
|
||||||
|
|
||||||
|
.send-key {
|
||||||
|
width: 38px;
|
||||||
|
height: 38px;
|
||||||
|
min-width: unset;
|
||||||
|
padding: 0;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
background: var(--accent);
|
||||||
|
background-image: var(--accent-gradient);
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-bottom: none;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
font-weight: 700;
|
||||||
|
box-shadow: 0 2px 10px var(--accent-glow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.send-key:active {
|
||||||
|
background-image: none;
|
||||||
|
background: var(--accent-dim);
|
||||||
|
transform: scale(0.92);
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Landscape ───────────────────────────────────── */
|
||||||
|
@media (max-height: 500px) {
|
||||||
|
.key {
|
||||||
|
height: 30px;
|
||||||
|
min-width: 34px;
|
||||||
|
font-size: 0.68rem;
|
||||||
|
}
|
||||||
|
.text-input, .send-key {
|
||||||
|
height: 30px;
|
||||||
|
}
|
||||||
|
.virtual-keyboard {
|
||||||
|
padding: 4px;
|
||||||
|
}
|
||||||
|
.key-row {
|
||||||
|
margin-bottom: 3px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Desktop ─────────────────────────────────────── */
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.virtual-keyboard {
|
||||||
|
padding: 10px 20px;
|
||||||
|
padding-bottom: max(10px, env(safe-area-inset-bottom));
|
||||||
|
}
|
||||||
|
|
||||||
|
.key {
|
||||||
|
min-width: 46px;
|
||||||
|
height: 34px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.key:hover {
|
||||||
|
background-image: none;
|
||||||
|
background: var(--key-active-bg);
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-input {
|
||||||
|
max-width: 640px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.send-key:hover {
|
||||||
|
filter: brightness(1.12);
|
||||||
|
box-shadow: 0 4px 16px var(--accent-glow);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.key-row.shortcut-keys { flex-wrap: wrap; }
|
||||||
|
.key-row.shortcut-keys .key.shortcut { min-width: 32px; padding: 0 5px; }
|
||||||
|
|
||||||
|
.keyboard-toggle { display: none; }
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.keyboard-toggle {
|
||||||
|
display: flex; align-items: center; justify-content: center; gap: 6px;
|
||||||
|
width: 100%; height: 28px;
|
||||||
|
background: var(--bg-secondary); border: none;
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
color: var(--text-dimmed); font-size: 0.72rem;
|
||||||
|
cursor: pointer; flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.keyboard-toggle:hover { color: var(--text-secondary); background: var(--bg-tertiary); }
|
||||||
|
.virtual-keyboard.collapsed { display: none; }
|
||||||
|
}
|
||||||
@ -0,0 +1,322 @@
|
|||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
html, body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Inter', system-ui, sans-serif;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
height: 100dvh;
|
||||||
|
padding-top: env(safe-area-inset-top);
|
||||||
|
padding-bottom: env(safe-area-inset-bottom);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Top bar ─────────────────────────────────────── */
|
||||||
|
.top-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
height: 48px;
|
||||||
|
padding: 0 10px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
backdrop-filter: blur(var(--blur-amount));
|
||||||
|
-webkit-backdrop-filter: blur(var(--blur-amount));
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
flex-shrink: 0;
|
||||||
|
gap: 8px;
|
||||||
|
z-index: 10;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Accent line at top */
|
||||||
|
.top-bar::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 1px;
|
||||||
|
background: var(--accent-gradient);
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 1.15rem;
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
transition: background var(--transition-fast), color var(--transition-fast);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-btn:hover {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-title {
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
color: var(--text-primary);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-bar-right {
|
||||||
|
margin-left: auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Connection status pill */
|
||||||
|
.connection-status {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
padding: 3px 8px 3px 6px;
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
background: rgba(248, 113, 113, 0.08);
|
||||||
|
border: 1px solid rgba(248, 113, 113, 0.15);
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--red);
|
||||||
|
transition: all var(--transition-normal);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection-status::before {
|
||||||
|
content: '';
|
||||||
|
width: 5px;
|
||||||
|
height: 5px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--red);
|
||||||
|
flex-shrink: 0;
|
||||||
|
box-shadow: 0 0 5px var(--red);
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection-status.connected {
|
||||||
|
background: rgba(52, 211, 153, 0.08);
|
||||||
|
border-color: rgba(52, 211, 153, 0.15);
|
||||||
|
color: var(--green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection-status.connected::before {
|
||||||
|
background: var(--green);
|
||||||
|
box-shadow: 0 0 6px var(--green);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes status-pulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.45; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection-status:not(.connected) {
|
||||||
|
animation: status-pulse 2.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-toggle {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 1rem;
|
||||||
|
padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
transition: background var(--transition-fast), color var(--transition-fast);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-toggle:hover {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Terminal container ──────────────────────────── */
|
||||||
|
.terminal-container {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
background: var(--terminal-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-container::-webkit-scrollbar {
|
||||||
|
width: 4px;
|
||||||
|
}
|
||||||
|
.terminal-container::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
.terminal-container::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--scrollbar-thumb);
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── QR button ───────────────────────────────────── */
|
||||||
|
.qr-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 1.1rem;
|
||||||
|
padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
transition: background var(--transition-fast), color var(--transition-fast);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-btn:hover {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── QR modal ────────────────────────────────────── */
|
||||||
|
.qr-modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.65);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
-webkit-backdrop-filter: blur(4px);
|
||||||
|
z-index: 200;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-modal-overlay[hidden] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-modal {
|
||||||
|
position: relative;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-lg, 12px);
|
||||||
|
padding: 24px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
max-width: min(360px, 90vw);
|
||||||
|
width: 100%;
|
||||||
|
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-modal-close {
|
||||||
|
position: absolute;
|
||||||
|
top: 10px;
|
||||||
|
right: 10px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 1.4rem;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
transition: background var(--transition-fast), color var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-modal-close:hover {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-modal-image {
|
||||||
|
width: min(260px, 80vw);
|
||||||
|
height: min(260px, 80vw);
|
||||||
|
display: block;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fff;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-modal-url {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
word-break: break-all;
|
||||||
|
text-align: center;
|
||||||
|
max-width: 100%;
|
||||||
|
font-family: 'SF Mono', 'Menlo', 'Monaco', 'Consolas', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Desktop layout ──────────────────────────────── */
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-overlay {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-container {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-bar {
|
||||||
|
height: 50px;
|
||||||
|
padding: 0 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-btn {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.connection-status:not(.connected) { animation: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection-status.reconnecting {
|
||||||
|
background: rgba(251, 191, 36, 0.08);
|
||||||
|
border-color: rgba(251, 191, 36, 0.15);
|
||||||
|
color: var(--yellow);
|
||||||
|
}
|
||||||
|
.connection-status.reconnecting::before {
|
||||||
|
background: var(--yellow);
|
||||||
|
box-shadow: 0 0 5px var(--yellow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-container.stale { position: relative; }
|
||||||
|
.terminal-container.stale::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
opacity: 0.4;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
@ -0,0 +1,227 @@
|
|||||||
|
.sidebar {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 272px;
|
||||||
|
height: 100%;
|
||||||
|
background: var(--sidebar-bg);
|
||||||
|
backdrop-filter: blur(24px);
|
||||||
|
-webkit-backdrop-filter: blur(24px);
|
||||||
|
border-right: 1px solid var(--sidebar-border);
|
||||||
|
z-index: 100;
|
||||||
|
transform: translateX(-100%);
|
||||||
|
transition: transform var(--transition-smooth);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding-top: env(safe-area-inset-top);
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar.open {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.45);
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
-webkit-backdrop-filter: blur(6px);
|
||||||
|
z-index: 99;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity var(--transition-smooth);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-overlay.visible {
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Header ──────────────────────────────────────── */
|
||||||
|
.sidebar-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 14px 14px 12px;
|
||||||
|
border-bottom: 1px solid var(--sidebar-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-header h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.68rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
color: var(--text-dimmed);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-close {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-dimmed);
|
||||||
|
font-size: 1.25rem;
|
||||||
|
cursor: pointer;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
transition: background var(--transition-fast), color var(--transition-fast);
|
||||||
|
line-height: 1;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-close:hover {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Content ─────────────────────────────────────── */
|
||||||
|
.sidebar-content {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 10px 0 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-content::-webkit-scrollbar {
|
||||||
|
width: 3px;
|
||||||
|
}
|
||||||
|
.sidebar-content::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--scrollbar-thumb);
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Group label ─────────────────────────────────── */
|
||||||
|
.ws-group-label {
|
||||||
|
padding: 14px 16px 5px;
|
||||||
|
font-size: 0.65rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
color: var(--text-dimmed);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Workspace item ──────────────────────────────── */
|
||||||
|
.ws-item {
|
||||||
|
padding: 9px 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 9px;
|
||||||
|
font-size: 0.86rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
transition: background var(--transition-fast), color var(--transition-fast);
|
||||||
|
margin: 1px 8px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ws-item:hover {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ws-item.active {
|
||||||
|
background: var(--accent-soft);
|
||||||
|
color: var(--accent-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ws-item .ws-icon {
|
||||||
|
color: var(--text-dimmed);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
width: 18px;
|
||||||
|
text-align: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ws-item .ws-label {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Surface item ────────────────────────────────── */
|
||||||
|
.surface-item {
|
||||||
|
padding: 8px 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.83rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
transition: background var(--transition-fast), color var(--transition-fast);
|
||||||
|
margin: 1px 8px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.surface-item:hover {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.surface-item.active {
|
||||||
|
background: linear-gradient(90deg, var(--accent-soft) 0%, transparent 100%);
|
||||||
|
color: var(--accent-hover);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Left accent bar for active item */
|
||||||
|
.surface-item.active::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: -8px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
width: 3px;
|
||||||
|
height: 60%;
|
||||||
|
background: var(--accent-gradient);
|
||||||
|
border-radius: 0 var(--radius-pill) var(--radius-pill) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.surface-item .surface-icon {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
width: 16px;
|
||||||
|
text-align: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.surface-item.active .surface-icon {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Desktop ─────────────────────────────────────── */
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.sidebar {
|
||||||
|
position: relative;
|
||||||
|
transform: none;
|
||||||
|
width: 252px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
box-shadow: none;
|
||||||
|
height: 100vh;
|
||||||
|
height: 100dvh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar.open {
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-overlay {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-close {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-empty {
|
||||||
|
padding: 24px 16px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-dimmed);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
@ -0,0 +1,101 @@
|
|||||||
|
@font-face {
|
||||||
|
font-family: 'SymbolsNerdFont';
|
||||||
|
src: url('/fonts/SymbolsNerdFontMono-Regular.ttf') format('truetype');
|
||||||
|
font-weight: normal;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: block;
|
||||||
|
unicode-range: U+23FB-23FE, U+2665, U+26A1, U+2B58, U+E000-E00A, U+E0A0-E0A2, U+E0A3, U+E0B0-E0C8, U+E0CA, U+E0CC-E0D7, U+E200-E2A9, U+E300-E3E3, U+E5FA-E6B7, U+E700-E7C5, U+EA60-EBEB, U+F000-F2FF, U+F400-F533, U+F0001-F1AF0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-output {
|
||||||
|
margin: 0;
|
||||||
|
padding: 12px 16px;
|
||||||
|
font-family: 'SF Mono', 'JetBrains Mono', 'Cascadia Code', 'Fira Code', 'Menlo', 'Monaco', 'Consolas', 'SymbolsNerdFont', 'Symbols Nerd Font Mono', monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.0;
|
||||||
|
white-space: pre;
|
||||||
|
word-wrap: normal;
|
||||||
|
color: var(--text-primary);
|
||||||
|
contain: content;
|
||||||
|
min-height: 100%;
|
||||||
|
-webkit-user-select: text;
|
||||||
|
user-select: text;
|
||||||
|
font-feature-settings: "liga" 0, "calt" 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-output::selection,
|
||||||
|
.terminal-output *::selection {
|
||||||
|
background: var(--accent-glow);
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Claude Code pattern highlighting */
|
||||||
|
|
||||||
|
.hl-tool {
|
||||||
|
color: var(--blue);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hl-thinking {
|
||||||
|
color: var(--purple);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hl-output {
|
||||||
|
color: var(--gray);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hl-prompt {
|
||||||
|
color: var(--green);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hl-success {
|
||||||
|
color: var(--green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hl-error {
|
||||||
|
color: var(--red);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hl-path {
|
||||||
|
color: var(--cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hl-box {
|
||||||
|
color: var(--text-dimmed);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hl-dim {
|
||||||
|
color: var(--text-dimmed);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hl-header {
|
||||||
|
color: var(--yellow);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hl-linenum {
|
||||||
|
color: var(--text-dimmed);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hl-added {
|
||||||
|
color: var(--green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hl-removed {
|
||||||
|
color: var(--red);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* QR code lines — font-size set dynamically in terminal-view.js to fit width */
|
||||||
|
.qr-line {
|
||||||
|
display: block;
|
||||||
|
line-height: 1.0;
|
||||||
|
letter-spacing: -0.03em;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.terminal-output {
|
||||||
|
padding: 16px 24px;
|
||||||
|
font-size: 13.5px;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,153 @@
|
|||||||
|
:root,
|
||||||
|
[data-theme="dark"] {
|
||||||
|
--bg-primary: #0b0b13;
|
||||||
|
--bg-secondary: rgba(14, 14, 24, 0.9);
|
||||||
|
--bg-tertiary: rgba(255, 255, 255, 0.04);
|
||||||
|
--bg-elevated: rgba(20, 20, 36, 0.95);
|
||||||
|
--bg-glass: rgba(14, 14, 24, 0.75);
|
||||||
|
--terminal-bg: #080810;
|
||||||
|
--text-primary: #e2e2ec;
|
||||||
|
--text-secondary: #7878a0;
|
||||||
|
--text-dimmed: #3e3e58;
|
||||||
|
--border-color: rgba(255, 255, 255, 0.055);
|
||||||
|
--border-subtle: rgba(255, 255, 255, 0.025);
|
||||||
|
--border-strong: rgba(255, 255, 255, 0.1);
|
||||||
|
--accent: #6366f1;
|
||||||
|
--accent-hover: #818cf8;
|
||||||
|
--accent-dim: #4f52cc;
|
||||||
|
--accent-glow: rgba(99, 102, 241, 0.18);
|
||||||
|
--accent-soft: rgba(99, 102, 241, 0.08);
|
||||||
|
--accent-gradient: linear-gradient(135deg, #6366f1, #a78bfa);
|
||||||
|
--green: #34d399;
|
||||||
|
--green-glow: rgba(52, 211, 153, 0.2);
|
||||||
|
--red: #f87171;
|
||||||
|
--red-glow: rgba(248, 113, 113, 0.15);
|
||||||
|
--blue: #60a5fa;
|
||||||
|
--purple: #a78bfa;
|
||||||
|
--cyan: #22d3ee;
|
||||||
|
--yellow: #fbbf24;
|
||||||
|
--orange: #fb923c;
|
||||||
|
--gray: #6b7280;
|
||||||
|
--key-bg: rgba(26, 26, 46, 0.9);
|
||||||
|
--key-bg-highlight: rgba(255, 255, 255, 0.035);
|
||||||
|
--key-border: rgba(255, 255, 255, 0.07);
|
||||||
|
--key-depth: rgba(0, 0, 0, 0.5);
|
||||||
|
--key-active-bg: rgba(99, 102, 241, 0.15);
|
||||||
|
--sidebar-bg: #09090f;
|
||||||
|
--sidebar-border: rgba(255, 255, 255, 0.04);
|
||||||
|
--scrollbar-thumb: rgba(255, 255, 255, 0.07);
|
||||||
|
--scrollbar-track: transparent;
|
||||||
|
--shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.4);
|
||||||
|
--shadow-sm: 0 2px 4px rgba(0, 0, 0, 0.4);
|
||||||
|
--shadow-md: 0 4px 16px rgba(0, 0, 0, 0.5);
|
||||||
|
--shadow-lg: 0 8px 40px rgba(0, 0, 0, 0.6);
|
||||||
|
--blur-amount: 20px;
|
||||||
|
--transition-fast: 120ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
--transition-normal: 220ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
--transition-smooth: 320ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
--radius-xs: 5px;
|
||||||
|
--radius-sm: 8px;
|
||||||
|
--radius-md: 12px;
|
||||||
|
--radius-lg: 18px;
|
||||||
|
--radius-pill: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="light"] {
|
||||||
|
--bg-primary: #f4f4f8;
|
||||||
|
--bg-secondary: rgba(255, 255, 255, 0.9);
|
||||||
|
--bg-tertiary: rgba(0, 0, 0, 0.035);
|
||||||
|
--bg-elevated: rgba(255, 255, 255, 0.95);
|
||||||
|
--bg-glass: rgba(255, 255, 255, 0.75);
|
||||||
|
--terminal-bg: #fafafa;
|
||||||
|
--text-primary: #0f0f1a;
|
||||||
|
--text-secondary: #60607a;
|
||||||
|
--text-dimmed: #aaaabb;
|
||||||
|
--border-color: rgba(0, 0, 0, 0.07);
|
||||||
|
--border-subtle: rgba(0, 0, 0, 0.04);
|
||||||
|
--border-strong: rgba(0, 0, 0, 0.12);
|
||||||
|
--accent: #5558eb;
|
||||||
|
--accent-hover: #4347d4;
|
||||||
|
--accent-dim: #7b7ef5;
|
||||||
|
--accent-glow: rgba(85, 88, 235, 0.12);
|
||||||
|
--accent-soft: rgba(85, 88, 235, 0.07);
|
||||||
|
--accent-gradient: linear-gradient(135deg, #5558eb, #9b59f5);
|
||||||
|
--green: #059669;
|
||||||
|
--green-glow: rgba(5, 150, 105, 0.12);
|
||||||
|
--red: #dc2626;
|
||||||
|
--red-glow: rgba(220, 38, 38, 0.1);
|
||||||
|
--blue: #2563eb;
|
||||||
|
--purple: #7c3aed;
|
||||||
|
--cyan: #0891b2;
|
||||||
|
--yellow: #d97706;
|
||||||
|
--orange: #ea580c;
|
||||||
|
--gray: #9ca3af;
|
||||||
|
--key-bg: rgba(255, 255, 255, 0.95);
|
||||||
|
--key-bg-highlight: rgba(255, 255, 255, 0.6);
|
||||||
|
--key-border: rgba(0, 0, 0, 0.09);
|
||||||
|
--key-depth: rgba(0, 0, 0, 0.12);
|
||||||
|
--key-active-bg: rgba(85, 88, 235, 0.08);
|
||||||
|
--sidebar-bg: #eeeef4;
|
||||||
|
--sidebar-border: rgba(0, 0, 0, 0.05);
|
||||||
|
--scrollbar-thumb: rgba(0, 0, 0, 0.1);
|
||||||
|
--scrollbar-track: transparent;
|
||||||
|
--shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.06);
|
||||||
|
--shadow-sm: 0 2px 4px rgba(0, 0, 0, 0.07);
|
||||||
|
--shadow-md: 0 4px 16px rgba(0, 0, 0, 0.08);
|
||||||
|
--shadow-lg: 0 8px 40px rgba(0, 0, 0, 0.1);
|
||||||
|
--blur-amount: 20px;
|
||||||
|
--transition-fast: 120ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
--transition-normal: 220ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
--transition-smooth: 320ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
--radius-xs: 5px;
|
||||||
|
--radius-sm: 8px;
|
||||||
|
--radius-md: 12px;
|
||||||
|
--radius-lg: 18px;
|
||||||
|
--radius-pill: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: light) {
|
||||||
|
:root:not([data-theme="dark"]) {
|
||||||
|
--bg-primary: #f4f4f8;
|
||||||
|
--bg-secondary: rgba(255, 255, 255, 0.9);
|
||||||
|
--bg-tertiary: rgba(0, 0, 0, 0.035);
|
||||||
|
--bg-elevated: rgba(255, 255, 255, 0.95);
|
||||||
|
--bg-glass: rgba(255, 255, 255, 0.75);
|
||||||
|
--terminal-bg: #fafafa;
|
||||||
|
--text-primary: #0f0f1a;
|
||||||
|
--text-secondary: #60607a;
|
||||||
|
--text-dimmed: #aaaabb;
|
||||||
|
--border-color: rgba(0, 0, 0, 0.07);
|
||||||
|
--border-subtle: rgba(0, 0, 0, 0.04);
|
||||||
|
--border-strong: rgba(0, 0, 0, 0.12);
|
||||||
|
--accent: #5558eb;
|
||||||
|
--accent-hover: #4347d4;
|
||||||
|
--accent-dim: #7b7ef5;
|
||||||
|
--accent-glow: rgba(85, 88, 235, 0.12);
|
||||||
|
--accent-soft: rgba(85, 88, 235, 0.07);
|
||||||
|
--accent-gradient: linear-gradient(135deg, #5558eb, #9b59f5);
|
||||||
|
--green: #059669;
|
||||||
|
--green-glow: rgba(5, 150, 105, 0.12);
|
||||||
|
--red: #dc2626;
|
||||||
|
--red-glow: rgba(220, 38, 38, 0.1);
|
||||||
|
--blue: #2563eb;
|
||||||
|
--purple: #7c3aed;
|
||||||
|
--cyan: #0891b2;
|
||||||
|
--yellow: #d97706;
|
||||||
|
--orange: #ea580c;
|
||||||
|
--gray: #9ca3af;
|
||||||
|
--key-bg: rgba(255, 255, 255, 0.95);
|
||||||
|
--key-bg-highlight: rgba(255, 255, 255, 0.6);
|
||||||
|
--key-border: rgba(0, 0, 0, 0.09);
|
||||||
|
--key-depth: rgba(0, 0, 0, 0.12);
|
||||||
|
--key-active-bg: rgba(85, 88, 235, 0.08);
|
||||||
|
--sidebar-bg: #eeeef4;
|
||||||
|
--sidebar-border: rgba(0, 0, 0, 0.05);
|
||||||
|
--scrollbar-thumb: rgba(0, 0, 0, 0.1);
|
||||||
|
--scrollbar-track: transparent;
|
||||||
|
--shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.06);
|
||||||
|
--shadow-sm: 0 2px 4px rgba(0, 0, 0, 0.07);
|
||||||
|
--shadow-md: 0 4px 16px rgba(0, 0, 0, 0.08);
|
||||||
|
--shadow-lg: 0 8px 40px rgba(0, 0, 0, 0.1);
|
||||||
|
--blur-amount: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
Binary file not shown.
@ -0,0 +1,107 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
|
||||||
|
<title>cmux-remote</title>
|
||||||
|
<link rel="manifest" href="/manifest.json">
|
||||||
|
<meta name="theme-color" content="#0c0c14">
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||||
|
<link rel="stylesheet" href="/css/themes.css">
|
||||||
|
<link rel="stylesheet" href="/css/main.css">
|
||||||
|
<link rel="stylesheet" href="/css/terminal.css">
|
||||||
|
<link rel="stylesheet" href="/css/keyboard.css">
|
||||||
|
<link rel="stylesheet" href="/css/sidebar.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<!-- Sidebar -->
|
||||||
|
<div class="sidebar-overlay" id="sidebar-overlay"></div>
|
||||||
|
<aside class="sidebar" id="sidebar">
|
||||||
|
<div class="sidebar-header">
|
||||||
|
<h2>Workspaces</h2>
|
||||||
|
<button class="sidebar-close" id="sidebar-close" aria-label="Close sidebar">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="sidebar-content" id="sidebar-tree"></div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- Main layout -->
|
||||||
|
<div class="app-container">
|
||||||
|
<!-- Top bar -->
|
||||||
|
<header class="top-bar">
|
||||||
|
<button class="menu-btn" id="menu-btn" aria-label="Open sidebar">☰</button>
|
||||||
|
<span class="app-title">cmux-remote</span>
|
||||||
|
<div class="top-bar-right">
|
||||||
|
<span class="connection-status" id="connection-status">Offline</span>
|
||||||
|
<button class="qr-btn" id="qr-btn" aria-label="Show QR code">⌷</button>
|
||||||
|
<button class="theme-toggle" id="theme-toggle" aria-label="Toggle theme"></button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Terminal area -->
|
||||||
|
<main class="terminal-container" id="terminal-container">
|
||||||
|
<pre class="terminal-output" id="terminal-output"></pre>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- Virtual keyboard -->
|
||||||
|
<button class="keyboard-toggle" id="keyboard-toggle" aria-label="Toggle keyboard" hidden>⌨ Keyboard</button>
|
||||||
|
<footer class="virtual-keyboard" id="virtual-keyboard">
|
||||||
|
<div class="key-row special-keys">
|
||||||
|
<button class="key" data-key="Escape">Esc</button>
|
||||||
|
<button class="key" data-key="Tab">Tab</button>
|
||||||
|
<button class="key modifier" data-modifier="ctrl" id="ctrl-key">Ctrl</button>
|
||||||
|
<button class="key modifier" data-modifier="alt" id="alt-key">Alt</button>
|
||||||
|
<span class="key-spacer"></span>
|
||||||
|
<button class="key" data-key="ArrowUp">↑</button>
|
||||||
|
<button class="key" data-key="PageUp">PgUp</button>
|
||||||
|
<button class="key" data-key="PageDown">PgDn</button>
|
||||||
|
</div>
|
||||||
|
<div class="key-row shortcut-keys">
|
||||||
|
<button class="key shortcut" data-key="Ctrl-c">^C</button>
|
||||||
|
<button class="key shortcut" data-key="Ctrl-d">^D</button>
|
||||||
|
<button class="key shortcut" data-key="Ctrl-z">^Z</button>
|
||||||
|
<button class="key shortcut" data-key="Ctrl-l">^L</button>
|
||||||
|
<button class="key shortcut" data-key="Ctrl-a">^A</button>
|
||||||
|
<button class="key shortcut" data-key="Ctrl-e">^E</button>
|
||||||
|
<button class="key shortcut" data-key="Ctrl-r">^R</button>
|
||||||
|
<button class="key shortcut" data-key="Ctrl-w">^W</button>
|
||||||
|
<button class="key shortcut" data-key="Ctrl-u">^U</button>
|
||||||
|
<span class="key-spacer"></span>
|
||||||
|
<button class="key" data-key="ArrowLeft">←</button>
|
||||||
|
<button class="key" data-key="ArrowDown">↓</button>
|
||||||
|
<button class="key" data-key="ArrowRight">→</button>
|
||||||
|
</div>
|
||||||
|
<div class="key-row input-row">
|
||||||
|
<input type="text" class="text-input" id="text-input" placeholder="Type here..."
|
||||||
|
autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false">
|
||||||
|
<button class="key send-key" id="send-btn">↵</button>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- QR modal -->
|
||||||
|
<div class="qr-modal-overlay" id="qr-modal-overlay" hidden>
|
||||||
|
<div class="qr-modal" role="dialog" aria-modal="true" aria-label="QR Code">
|
||||||
|
<button class="qr-modal-close" id="qr-modal-close" aria-label="Close">×</button>
|
||||||
|
<img class="qr-modal-image" src="/api/qr.svg" alt="QR code for access URL" id="qr-modal-image">
|
||||||
|
<div class="qr-modal-url" id="qr-modal-url"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="/js/websocket-client.js"></script>
|
||||||
|
<script src="/js/terminal-view.js"></script>
|
||||||
|
<script src="/js/virtual-keyboard.js"></script>
|
||||||
|
<script src="/js/sidebar.js"></script>
|
||||||
|
<script src="/js/gestures.js"></script>
|
||||||
|
<script src="/js/theme.js"></script>
|
||||||
|
<script src="/js/app.js"></script>
|
||||||
|
<script>
|
||||||
|
// Unregister any cached service workers so CSS/JS changes are always fresh
|
||||||
|
if ('serviceWorker' in navigator) {
|
||||||
|
navigator.serviceWorker.getRegistrations().then(regs => {
|
||||||
|
regs.forEach(r => r.unregister());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@ -0,0 +1,197 @@
|
|||||||
|
// Main application orchestrator
|
||||||
|
(async function () {
|
||||||
|
// Check if auth is required
|
||||||
|
const authStatus = await fetch('/api/auth/status').then((r) => r.json()).catch(() => ({ authRequired: true }));
|
||||||
|
if (authStatus.authRequired) {
|
||||||
|
const token = localStorage.getItem('cmux-remote-token');
|
||||||
|
if (!token) {
|
||||||
|
window.location.href = '/login.html';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// State
|
||||||
|
let currentWorkspace = null;
|
||||||
|
let currentSurface = null;
|
||||||
|
|
||||||
|
// Components
|
||||||
|
const ws = new WebSocketClient();
|
||||||
|
const terminal = new TerminalView(document.getElementById('terminal-output'));
|
||||||
|
const statusDot = document.getElementById('connection-status');
|
||||||
|
const terminalContainer = document.getElementById('terminal-container');
|
||||||
|
const sidebar = new Sidebar((wsRef, surfaceRef) => {
|
||||||
|
switchSurface(wsRef, surfaceRef);
|
||||||
|
});
|
||||||
|
|
||||||
|
const gestures = new GestureHandler(sidebar);
|
||||||
|
const theme = new ThemeSwitcher();
|
||||||
|
|
||||||
|
// QR modal
|
||||||
|
const qrOverlay = document.getElementById('qr-modal-overlay');
|
||||||
|
const qrBtn = document.getElementById('qr-btn');
|
||||||
|
const qrCloseBtn = document.getElementById('qr-modal-close');
|
||||||
|
const qrUrlEl = document.getElementById('qr-modal-url');
|
||||||
|
|
||||||
|
function _qrKeyHandler(e) {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
closeQrModal();
|
||||||
|
} else if (e.key === 'Tab') {
|
||||||
|
e.preventDefault();
|
||||||
|
qrCloseBtn.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openQrModal() {
|
||||||
|
try {
|
||||||
|
const { url } = await fetch('/api/access-url').then(r => r.json());
|
||||||
|
qrUrlEl.textContent = url;
|
||||||
|
} catch {
|
||||||
|
qrUrlEl.textContent = window.location.origin;
|
||||||
|
}
|
||||||
|
// Reload QR image so it's always fresh
|
||||||
|
document.getElementById('qr-modal-image').src = '/api/qr.svg?' + Date.now();
|
||||||
|
qrOverlay.hidden = false;
|
||||||
|
qrCloseBtn.focus();
|
||||||
|
document.addEventListener('keydown', _qrKeyHandler);
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeQrModal() {
|
||||||
|
qrOverlay.hidden = true;
|
||||||
|
document.removeEventListener('keydown', _qrKeyHandler);
|
||||||
|
qrBtn.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
qrBtn.addEventListener('click', openQrModal);
|
||||||
|
qrCloseBtn.addEventListener('click', closeQrModal);
|
||||||
|
qrOverlay.addEventListener('click', (e) => {
|
||||||
|
if (e.target === qrOverlay) closeQrModal();
|
||||||
|
});
|
||||||
|
|
||||||
|
const keyboard = new VirtualKeyboard(
|
||||||
|
(text) => {
|
||||||
|
if (currentWorkspace && currentSurface) {
|
||||||
|
ws.sendText(currentWorkspace, currentSurface, text);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
(key) => {
|
||||||
|
if (currentWorkspace && currentSurface) {
|
||||||
|
ws.sendKey(currentWorkspace, currentSurface, key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Keyboard toggle (desktop)
|
||||||
|
const keyboardToggle = document.getElementById('keyboard-toggle');
|
||||||
|
const virtualKeyboardEl = document.getElementById('virtual-keyboard');
|
||||||
|
|
||||||
|
if (window.innerWidth >= 1024) {
|
||||||
|
virtualKeyboardEl.classList.add('collapsed');
|
||||||
|
keyboardToggle.hidden = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
keyboardToggle.addEventListener('click', () => {
|
||||||
|
virtualKeyboardEl.classList.toggle('collapsed');
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('resize', () => {
|
||||||
|
if (window.innerWidth < 1024) {
|
||||||
|
keyboardToggle.hidden = true;
|
||||||
|
virtualKeyboardEl.classList.remove('collapsed');
|
||||||
|
} else {
|
||||||
|
keyboardToggle.hidden = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Connection events
|
||||||
|
ws.on('connected', () => {
|
||||||
|
statusDot.classList.add('connected');
|
||||||
|
statusDot.classList.remove('reconnecting');
|
||||||
|
statusDot.textContent = 'Connected';
|
||||||
|
terminalContainer.classList.remove('stale');
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.on('disconnected', () => {
|
||||||
|
statusDot.classList.remove('connected');
|
||||||
|
statusDot.classList.add('reconnecting');
|
||||||
|
statusDot.textContent = 'Reconnecting...';
|
||||||
|
terminalContainer.classList.add('stale');
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.on('reconnecting', ({ attempt }) => {
|
||||||
|
statusDot.textContent = `Reconnecting (${attempt})...`;
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.on('auth-ok', () => {
|
||||||
|
// Request workspace list after auth
|
||||||
|
ws.listWorkspaces();
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.on('workspaces', (msg) => {
|
||||||
|
sidebar.setWorkspaces(msg.workspaces);
|
||||||
|
|
||||||
|
// Auto-subscribe to first terminal surface if none selected
|
||||||
|
if (!currentSurface && msg.workspaces.length > 0) {
|
||||||
|
const firstWs = msg.workspaces[0];
|
||||||
|
for (const pane of firstWs.panes) {
|
||||||
|
for (const surface of pane.surfaces) {
|
||||||
|
if (surface.type === 'terminal') {
|
||||||
|
switchSurface(firstWs.ref, surface.ref);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Fallback: first surface of any type
|
||||||
|
if (firstWs.panes.length > 0 && firstWs.panes[0].surfaces.length > 0) {
|
||||||
|
const s = firstWs.panes[0].surfaces[0];
|
||||||
|
switchSurface(firstWs.ref, s.ref);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.on('screen', (msg) => {
|
||||||
|
if (msg.surface === currentSurface) {
|
||||||
|
terminal.setContent(msg.lines);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.on('screen-diff', (msg) => {
|
||||||
|
if (msg.surface === currentSurface) {
|
||||||
|
terminal.applyDiff(msg.patches);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function switchSurface(wsRef, surfaceRef) {
|
||||||
|
// Unsubscribe from current
|
||||||
|
if (currentSurface) {
|
||||||
|
ws.unsubscribe(currentSurface);
|
||||||
|
}
|
||||||
|
|
||||||
|
currentWorkspace = wsRef;
|
||||||
|
currentSurface = surfaceRef;
|
||||||
|
|
||||||
|
terminal.clear();
|
||||||
|
ws.subscribe(wsRef, surfaceRef);
|
||||||
|
sidebar.setActiveSurface(surfaceRef);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Visibility API: pause polling when hidden
|
||||||
|
document.addEventListener('visibilitychange', () => {
|
||||||
|
if (document.hidden) {
|
||||||
|
if (currentSurface) ws.unsubscribe(currentSurface);
|
||||||
|
} else {
|
||||||
|
if (currentWorkspace && currentSurface) {
|
||||||
|
ws.subscribe(currentWorkspace, currentSurface);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Periodic workspace refresh
|
||||||
|
setInterval(() => {
|
||||||
|
if (ws.isConnected()) {
|
||||||
|
ws.listWorkspaces();
|
||||||
|
}
|
||||||
|
}, 10000);
|
||||||
|
|
||||||
|
// Start
|
||||||
|
ws.connect(authStatus.authRequired);
|
||||||
|
})();
|
||||||
@ -0,0 +1,58 @@
|
|||||||
|
// Login page handler
|
||||||
|
(function () {
|
||||||
|
const form = document.getElementById('login-form');
|
||||||
|
const passwordInput = document.getElementById('password');
|
||||||
|
const errorEl = document.getElementById('error');
|
||||||
|
|
||||||
|
// If already authenticated, redirect
|
||||||
|
const token = localStorage.getItem('cmux-remote-token');
|
||||||
|
if (token) {
|
||||||
|
fetch('/api/auth/verify', {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
})
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((data) => {
|
||||||
|
if (data.valid) window.location.href = '/';
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
form.addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
errorEl.classList.remove('visible');
|
||||||
|
|
||||||
|
const password = passwordInput.value.trim();
|
||||||
|
if (!password) return;
|
||||||
|
|
||||||
|
const submitBtn = form.querySelector('button[type="submit"]');
|
||||||
|
submitBtn.disabled = true;
|
||||||
|
submitBtn.textContent = 'Connecting...';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ password }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (res.ok && data.token) {
|
||||||
|
localStorage.setItem('cmux-remote-token', data.token);
|
||||||
|
window.location.href = '/';
|
||||||
|
} else {
|
||||||
|
errorEl.textContent = data.error || 'Login failed';
|
||||||
|
errorEl.classList.add('visible');
|
||||||
|
passwordInput.value = '';
|
||||||
|
passwordInput.focus();
|
||||||
|
submitBtn.disabled = false;
|
||||||
|
submitBtn.textContent = 'Connect';
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
errorEl.textContent = 'Connection failed';
|
||||||
|
errorEl.classList.add('visible');
|
||||||
|
submitBtn.disabled = false;
|
||||||
|
submitBtn.textContent = 'Connect';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
@ -0,0 +1,63 @@
|
|||||||
|
class GestureHandler {
|
||||||
|
constructor(sidebar) {
|
||||||
|
this.sidebar = sidebar;
|
||||||
|
this.startX = 0;
|
||||||
|
this.startY = 0;
|
||||||
|
this.tracking = false;
|
||||||
|
|
||||||
|
document.addEventListener('touchstart', (e) => this.onTouchStart(e), { passive: true });
|
||||||
|
document.addEventListener('touchmove', (e) => this.onTouchMove(e), { passive: false });
|
||||||
|
document.addEventListener('touchend', (e) => this.onTouchEnd(e), { passive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
onTouchStart(e) {
|
||||||
|
const touch = e.touches[0];
|
||||||
|
this.startX = touch.clientX;
|
||||||
|
this.startY = touch.clientY;
|
||||||
|
this.tracking = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
onTouchMove(e) {
|
||||||
|
if (!this.tracking) return;
|
||||||
|
|
||||||
|
const touch = e.touches[0];
|
||||||
|
const dx = touch.clientX - this.startX;
|
||||||
|
const dy = touch.clientY - this.startY;
|
||||||
|
|
||||||
|
// Only track horizontal swipes from edge
|
||||||
|
if (Math.abs(dx) > Math.abs(dy) && Math.abs(dx) > 10) {
|
||||||
|
// Swipe right from left edge to open sidebar
|
||||||
|
if (dx > 0 && this.startX < 50 && !this.sidebar.isOpen()) {
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
// Swipe left to close sidebar
|
||||||
|
if (dx < 0 && this.sidebar.isOpen()) {
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onTouchEnd(e) {
|
||||||
|
if (!this.tracking) return;
|
||||||
|
this.tracking = false;
|
||||||
|
|
||||||
|
const touch = e.changedTouches[0];
|
||||||
|
const dx = touch.clientX - this.startX;
|
||||||
|
const dy = touch.clientY - this.startY;
|
||||||
|
|
||||||
|
// Minimum swipe distance
|
||||||
|
if (Math.abs(dx) < 50 || Math.abs(dy) > Math.abs(dx)) return;
|
||||||
|
|
||||||
|
// Swipe right from left edge = open sidebar
|
||||||
|
if (dx > 0 && this.startX < 50) {
|
||||||
|
this.sidebar.open();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Swipe left = close sidebar
|
||||||
|
if (dx < 0 && this.sidebar.isOpen()) {
|
||||||
|
this.sidebar.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.GestureHandler = GestureHandler;
|
||||||
@ -0,0 +1,114 @@
|
|||||||
|
class Sidebar {
|
||||||
|
constructor(onSelectSurface) {
|
||||||
|
this.onSelectSurface = onSelectSurface;
|
||||||
|
this.sidebar = document.getElementById('sidebar');
|
||||||
|
this.overlay = document.getElementById('sidebar-overlay');
|
||||||
|
this.tree = document.getElementById('sidebar-tree');
|
||||||
|
this.menuBtn = document.getElementById('menu-btn');
|
||||||
|
this.closeBtn = document.getElementById('sidebar-close');
|
||||||
|
|
||||||
|
this.workspaces = [];
|
||||||
|
this.activeSurface = null;
|
||||||
|
this._loaded = false;
|
||||||
|
|
||||||
|
this.menuBtn.addEventListener('click', () => this.open());
|
||||||
|
this.closeBtn.addEventListener('click', () => this.close());
|
||||||
|
this.overlay.addEventListener('click', () => this.close());
|
||||||
|
|
||||||
|
// On desktop, sidebar is always visible via CSS (position: relative)
|
||||||
|
// Check on resize to close overlay if switching to desktop
|
||||||
|
this._onResize = () => {
|
||||||
|
if (window.innerWidth >= 1024) {
|
||||||
|
this.overlay.classList.remove('visible');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('resize', this._onResize);
|
||||||
|
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Escape' && this.isOpen() && !this.isDesktop()) this.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
this.render();
|
||||||
|
}
|
||||||
|
|
||||||
|
open() {
|
||||||
|
this.sidebar.classList.add('open');
|
||||||
|
this.overlay.classList.add('visible');
|
||||||
|
}
|
||||||
|
|
||||||
|
close() {
|
||||||
|
this.sidebar.classList.remove('open');
|
||||||
|
this.overlay.classList.remove('visible');
|
||||||
|
}
|
||||||
|
|
||||||
|
isDesktop() {
|
||||||
|
return window.innerWidth >= 1024;
|
||||||
|
}
|
||||||
|
|
||||||
|
isOpen() {
|
||||||
|
return this.sidebar.classList.contains('open');
|
||||||
|
}
|
||||||
|
|
||||||
|
setWorkspaces(workspaces) {
|
||||||
|
this.workspaces = workspaces;
|
||||||
|
this._loaded = true;
|
||||||
|
this.render();
|
||||||
|
}
|
||||||
|
|
||||||
|
setActiveSurface(surfaceRef) {
|
||||||
|
this.activeSurface = surfaceRef;
|
||||||
|
this.render();
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
this.tree.innerHTML = '';
|
||||||
|
|
||||||
|
if (this.workspaces.length === 0) {
|
||||||
|
const empty = document.createElement('div');
|
||||||
|
empty.className = 'sidebar-empty';
|
||||||
|
empty.textContent = this._loaded ? 'No workspaces' : 'Loading...';
|
||||||
|
this.tree.appendChild(empty);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const ws of this.workspaces) {
|
||||||
|
// Workspace group label
|
||||||
|
const groupLabel = document.createElement('div');
|
||||||
|
groupLabel.className = 'ws-group-label';
|
||||||
|
groupLabel.textContent = ws.name || ws.ref;
|
||||||
|
this.tree.appendChild(groupLabel);
|
||||||
|
|
||||||
|
// Surfaces within workspace
|
||||||
|
for (const pane of ws.panes) {
|
||||||
|
for (const surface of pane.surfaces) {
|
||||||
|
const item = document.createElement('div');
|
||||||
|
item.className = 'surface-item';
|
||||||
|
if (surface.ref === this.activeSurface) {
|
||||||
|
item.classList.add('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
const icon = document.createElement('span');
|
||||||
|
icon.className = 'surface-icon';
|
||||||
|
icon.textContent = surface.type === 'browser' ? '\u{1F310}' : '\u{1F4BB}';
|
||||||
|
|
||||||
|
const label = document.createElement('span');
|
||||||
|
label.className = 'ws-label';
|
||||||
|
label.textContent = surface.title || surface.ref;
|
||||||
|
|
||||||
|
item.appendChild(icon);
|
||||||
|
item.appendChild(label);
|
||||||
|
|
||||||
|
item.addEventListener('click', () => {
|
||||||
|
this.onSelectSurface(ws.ref, surface.ref);
|
||||||
|
this.setActiveSurface(surface.ref);
|
||||||
|
if (!this.isDesktop()) this.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
this.tree.appendChild(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.Sidebar = Sidebar;
|
||||||
@ -0,0 +1,38 @@
|
|||||||
|
class ThemeSwitcher {
|
||||||
|
constructor() {
|
||||||
|
this.btn = document.getElementById('theme-toggle');
|
||||||
|
this.current = localStorage.getItem('cmux-remote-theme') || 'auto';
|
||||||
|
|
||||||
|
this.apply();
|
||||||
|
this.btn.addEventListener('click', () => this.toggle());
|
||||||
|
}
|
||||||
|
|
||||||
|
toggle() {
|
||||||
|
const resolved = this.getResolved();
|
||||||
|
// Toggle to opposite of current effective theme
|
||||||
|
this.current = resolved === 'dark' ? 'light' : 'dark';
|
||||||
|
localStorage.setItem('cmux-remote-theme', this.current);
|
||||||
|
this.apply();
|
||||||
|
}
|
||||||
|
|
||||||
|
getResolved() {
|
||||||
|
if (this.current === 'auto') {
|
||||||
|
return window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark';
|
||||||
|
}
|
||||||
|
return this.current;
|
||||||
|
}
|
||||||
|
|
||||||
|
apply() {
|
||||||
|
const theme = this.getResolved();
|
||||||
|
document.documentElement.setAttribute('data-theme', theme);
|
||||||
|
this.btn.textContent = theme === 'dark' ? '\u{2600}' : '\u{1F319}';
|
||||||
|
|
||||||
|
// Update theme-color meta
|
||||||
|
const meta = document.querySelector('meta[name="theme-color"]');
|
||||||
|
if (meta) {
|
||||||
|
meta.content = theme === 'dark' ? '#0c0c14' : '#f8f9fc';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.ThemeSwitcher = ThemeSwitcher;
|
||||||
@ -0,0 +1,165 @@
|
|||||||
|
class VirtualKeyboard {
|
||||||
|
constructor(onSendText, onSendKey) {
|
||||||
|
this.onSendText = onSendText;
|
||||||
|
this.onSendKey = onSendKey;
|
||||||
|
this.ctrlActive = false;
|
||||||
|
this.ctrlLocked = false;
|
||||||
|
this.altActive = false;
|
||||||
|
this.altLocked = false;
|
||||||
|
this._repeatTimer = null;
|
||||||
|
this._repeatInterval = null;
|
||||||
|
|
||||||
|
this.textInput = document.getElementById('text-input');
|
||||||
|
this.sendBtn = document.getElementById('send-btn');
|
||||||
|
this.ctrlKey = document.getElementById('ctrl-key');
|
||||||
|
this.altKey = document.getElementById('alt-key');
|
||||||
|
|
||||||
|
this.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
static get REPEAT_KEYS() {
|
||||||
|
return new Set(['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'PageUp', 'PageDown']);
|
||||||
|
}
|
||||||
|
|
||||||
|
static get REPEAT_INITIAL_DELAY() { return 400; }
|
||||||
|
static get REPEAT_INTERVAL() { return 80; }
|
||||||
|
|
||||||
|
init() {
|
||||||
|
// Text input + send
|
||||||
|
this.sendBtn.addEventListener('click', () => this.submitText());
|
||||||
|
this.textInput.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
this.submitText();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Ctrl modifier
|
||||||
|
this.ctrlKey.addEventListener('click', () => this.toggleModifier('ctrl'));
|
||||||
|
this.ctrlKey.addEventListener('dblclick', () => this.lockModifier('ctrl'));
|
||||||
|
|
||||||
|
// Alt modifier
|
||||||
|
this.altKey.addEventListener('click', () => this.toggleModifier('alt'));
|
||||||
|
this.altKey.addEventListener('dblclick', () => this.lockModifier('alt'));
|
||||||
|
|
||||||
|
// Special keys
|
||||||
|
document.querySelectorAll('.key[data-key]').forEach((btn) => {
|
||||||
|
if (btn.classList.contains('modifier')) return;
|
||||||
|
const key = btn.dataset.key;
|
||||||
|
if (VirtualKeyboard.REPEAT_KEYS.has(key)) {
|
||||||
|
const startRepeat = () => {
|
||||||
|
this._clearRepeat();
|
||||||
|
this.handleKeyPress(key);
|
||||||
|
this._repeatTimer = setTimeout(() => {
|
||||||
|
this._repeatInterval = setInterval(() => {
|
||||||
|
this.handleKeyPress(key);
|
||||||
|
}, VirtualKeyboard.REPEAT_INTERVAL);
|
||||||
|
}, VirtualKeyboard.REPEAT_INITIAL_DELAY);
|
||||||
|
};
|
||||||
|
btn.addEventListener('mousedown', startRepeat);
|
||||||
|
btn.addEventListener('touchstart', startRepeat, { passive: true });
|
||||||
|
btn.addEventListener('mouseup', () => this._clearRepeat());
|
||||||
|
btn.addEventListener('mouseleave', () => this._clearRepeat());
|
||||||
|
btn.addEventListener('touchend', () => this._clearRepeat());
|
||||||
|
btn.addEventListener('touchcancel', () => this._clearRepeat());
|
||||||
|
} else {
|
||||||
|
btn.addEventListener('click', () => this.handleKeyPress(key));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_haptic() {
|
||||||
|
if (navigator.vibrate) navigator.vibrate(8);
|
||||||
|
}
|
||||||
|
|
||||||
|
_clearRepeat() {
|
||||||
|
clearTimeout(this._repeatTimer);
|
||||||
|
clearInterval(this._repeatInterval);
|
||||||
|
this._repeatTimer = null;
|
||||||
|
this._repeatInterval = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
submitText() {
|
||||||
|
this._haptic();
|
||||||
|
const text = this.textInput.value;
|
||||||
|
if (text) {
|
||||||
|
this.onSendText(text + '\n');
|
||||||
|
this.textInput.value = '';
|
||||||
|
} else {
|
||||||
|
// Empty submit = Enter key
|
||||||
|
this.onSendKey('Enter');
|
||||||
|
}
|
||||||
|
this.textInput.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
handleKeyPress(key) {
|
||||||
|
this._haptic();
|
||||||
|
// Check for shortcut keys like Ctrl-c
|
||||||
|
if (key.startsWith('Ctrl-')) {
|
||||||
|
this.onSendKey(key);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply active modifiers
|
||||||
|
let finalKey = key;
|
||||||
|
if (this.ctrlActive) {
|
||||||
|
finalKey = `Ctrl-${key}`;
|
||||||
|
if (!this.ctrlLocked) this.deactivateModifier('ctrl');
|
||||||
|
}
|
||||||
|
if (this.altActive) {
|
||||||
|
finalKey = `Alt-${key}`;
|
||||||
|
if (!this.altLocked) this.deactivateModifier('alt');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.onSendKey(finalKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleModifier(mod) {
|
||||||
|
this._haptic();
|
||||||
|
if (mod === 'ctrl') {
|
||||||
|
if (this.ctrlLocked) {
|
||||||
|
this.deactivateModifier('ctrl');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.ctrlActive = !this.ctrlActive;
|
||||||
|
this.ctrlKey.classList.toggle('active', this.ctrlActive);
|
||||||
|
} else if (mod === 'alt') {
|
||||||
|
if (this.altLocked) {
|
||||||
|
this.deactivateModifier('alt');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.altActive = !this.altActive;
|
||||||
|
this.altKey.classList.toggle('active', this.altActive);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lockModifier(mod) {
|
||||||
|
if (mod === 'ctrl') {
|
||||||
|
this.ctrlActive = true;
|
||||||
|
this.ctrlLocked = true;
|
||||||
|
this.ctrlKey.classList.add('active', 'locked');
|
||||||
|
} else if (mod === 'alt') {
|
||||||
|
this.altActive = true;
|
||||||
|
this.altLocked = true;
|
||||||
|
this.altKey.classList.add('active', 'locked');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
deactivateModifier(mod) {
|
||||||
|
if (mod === 'ctrl') {
|
||||||
|
this.ctrlActive = false;
|
||||||
|
this.ctrlLocked = false;
|
||||||
|
this.ctrlKey.classList.remove('active', 'locked');
|
||||||
|
} else if (mod === 'alt') {
|
||||||
|
this.altActive = false;
|
||||||
|
this.altLocked = false;
|
||||||
|
this.altKey.classList.remove('active', 'locked');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
focus() {
|
||||||
|
this.textInput.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.VirtualKeyboard = VirtualKeyboard;
|
||||||
@ -0,0 +1,133 @@
|
|||||||
|
class WebSocketClient {
|
||||||
|
constructor() {
|
||||||
|
this.ws = null;
|
||||||
|
this.handlers = new Map();
|
||||||
|
this.reconnectDelay = 1000;
|
||||||
|
this.maxReconnectDelay = 30000;
|
||||||
|
this.currentDelay = this.reconnectDelay;
|
||||||
|
this.shouldReconnect = true;
|
||||||
|
this.connected = false;
|
||||||
|
this.reconnectAttempt = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
connect(authRequired) {
|
||||||
|
this.authRequired = authRequired !== false;
|
||||||
|
const token = localStorage.getItem('cmux-remote-token');
|
||||||
|
if (this.authRequired && !token) {
|
||||||
|
window.location.href = '/login.html';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
|
const url = `${proto}//${location.host}`;
|
||||||
|
|
||||||
|
this.ws = new WebSocket(url);
|
||||||
|
|
||||||
|
this.ws.onopen = () => {
|
||||||
|
this.connected = true;
|
||||||
|
this.reconnectAttempt = 0;
|
||||||
|
this.currentDelay = this.reconnectDelay;
|
||||||
|
this.emit('connected');
|
||||||
|
|
||||||
|
// Authenticate (send token or empty for no-auth mode)
|
||||||
|
this.send({ type: 'auth', token: token || '' });
|
||||||
|
};
|
||||||
|
|
||||||
|
this.ws.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
const msg = JSON.parse(event.data);
|
||||||
|
this.emit(msg.type, msg);
|
||||||
|
|
||||||
|
// Redirect to login on auth failure (only if auth is required)
|
||||||
|
if (msg.type === 'auth-fail' && this.authRequired) {
|
||||||
|
localStorage.removeItem('cmux-remote-token');
|
||||||
|
window.location.href = '/login.html';
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[ws] Parse error:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
this.ws.onclose = () => {
|
||||||
|
this.connected = false;
|
||||||
|
this.emit('disconnected');
|
||||||
|
|
||||||
|
if (this.shouldReconnect) {
|
||||||
|
const attempt = ++this.reconnectAttempt;
|
||||||
|
const delay = this.currentDelay;
|
||||||
|
this.emit('reconnecting', { attempt, delay });
|
||||||
|
setTimeout(() => this.connect(this.authRequired), delay);
|
||||||
|
this.currentDelay = Math.min(this.currentDelay * 1.5, this.maxReconnectDelay);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
this.ws.onerror = (err) => {
|
||||||
|
console.error('[ws] Error:', err);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
send(msg) {
|
||||||
|
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
||||||
|
this.ws.send(JSON.stringify(msg));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
on(type, handler) {
|
||||||
|
if (!this.handlers.has(type)) {
|
||||||
|
this.handlers.set(type, []);
|
||||||
|
}
|
||||||
|
this.handlers.get(type).push(handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
off(type, handler) {
|
||||||
|
const list = this.handlers.get(type);
|
||||||
|
if (list) {
|
||||||
|
const idx = list.indexOf(handler);
|
||||||
|
if (idx >= 0) list.splice(idx, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
emit(type, data) {
|
||||||
|
const list = this.handlers.get(type);
|
||||||
|
if (list) {
|
||||||
|
for (const handler of list) {
|
||||||
|
handler(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
subscribe(workspace, surface) {
|
||||||
|
this.send({ type: 'subscribe', workspace, surface });
|
||||||
|
}
|
||||||
|
|
||||||
|
unsubscribe(surface) {
|
||||||
|
this.send({ type: 'unsubscribe', surface });
|
||||||
|
}
|
||||||
|
|
||||||
|
sendText(workspace, surface, text) {
|
||||||
|
this.send({ type: 'send-text', workspace, surface, text });
|
||||||
|
}
|
||||||
|
|
||||||
|
sendKey(workspace, surface, key) {
|
||||||
|
this.send({ type: 'send-key', workspace, surface, key });
|
||||||
|
}
|
||||||
|
|
||||||
|
listWorkspaces() {
|
||||||
|
this.send({ type: 'list-workspaces' });
|
||||||
|
}
|
||||||
|
|
||||||
|
requestScroll(workspace, surface, lines) {
|
||||||
|
this.send({ type: 'scroll-request', workspace, surface, lines });
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnect() {
|
||||||
|
this.shouldReconnect = false;
|
||||||
|
if (this.ws) this.ws.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
isConnected() {
|
||||||
|
return this.connected;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.WebSocketClient = WebSocketClient;
|
||||||
@ -0,0 +1,276 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||||
|
<title>cmux-remote</title>
|
||||||
|
<link rel="manifest" href="/manifest.json">
|
||||||
|
<meta name="theme-color" content="#0b0b13">
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||||
|
<link rel="stylesheet" href="/css/themes.css">
|
||||||
|
<style>
|
||||||
|
* { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Inter', system-ui, sans-serif;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Animated background */
|
||||||
|
.bg {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.bg-orb {
|
||||||
|
position: absolute;
|
||||||
|
border-radius: 50%;
|
||||||
|
filter: blur(80px);
|
||||||
|
opacity: 0.35;
|
||||||
|
}
|
||||||
|
.bg-orb-1 {
|
||||||
|
width: 500px; height: 500px;
|
||||||
|
background: radial-gradient(circle, var(--accent) 0%, transparent 70%);
|
||||||
|
top: -20%; left: -10%;
|
||||||
|
animation: orb-drift 18s ease-in-out infinite alternate;
|
||||||
|
}
|
||||||
|
.bg-orb-2 {
|
||||||
|
width: 400px; height: 400px;
|
||||||
|
background: radial-gradient(circle, #a78bfa 0%, transparent 70%);
|
||||||
|
bottom: -15%; right: -5%;
|
||||||
|
animation: orb-drift 22s ease-in-out infinite alternate-reverse;
|
||||||
|
}
|
||||||
|
.bg-orb-3 {
|
||||||
|
width: 300px; height: 300px;
|
||||||
|
background: radial-gradient(circle, #22d3ee 0%, transparent 70%);
|
||||||
|
top: 40%; left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
animation: orb-drift 15s ease-in-out infinite alternate;
|
||||||
|
opacity: 0.15;
|
||||||
|
}
|
||||||
|
@keyframes orb-drift {
|
||||||
|
from { transform: translate(0, 0) scale(1); }
|
||||||
|
to { transform: translate(40px, 30px) scale(1.08); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Grid overlay */
|
||||||
|
.bg::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background-image:
|
||||||
|
linear-gradient(var(--border-subtle) 1px, transparent 1px),
|
||||||
|
linear-gradient(90deg, var(--border-subtle) 1px, transparent 1px);
|
||||||
|
background-size: 48px 48px;
|
||||||
|
mask-image: radial-gradient(ellipse 80% 80% at 50% 50%, black 30%, transparent 100%);
|
||||||
|
-webkit-mask-image: radial-gradient(ellipse 80% 80% at 50% 50%, black 30%, transparent 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Card */
|
||||||
|
.login-card {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
width: 92%;
|
||||||
|
max-width: 360px;
|
||||||
|
padding: 2.25rem 2rem;
|
||||||
|
background: var(--bg-glass);
|
||||||
|
backdrop-filter: blur(32px);
|
||||||
|
-webkit-backdrop-filter: blur(32px);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
box-shadow: var(--shadow-lg), 0 0 0 1px var(--border-subtle) inset;
|
||||||
|
animation: card-in 0.5s cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Shine edge on card */
|
||||||
|
.login-card::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
border-radius: inherit;
|
||||||
|
background: linear-gradient(135deg, rgba(255,255,255,0.06) 0%, transparent 50%);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes card-in {
|
||||||
|
from { opacity: 0; transform: translateY(20px) scale(0.97); }
|
||||||
|
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Logo */
|
||||||
|
.logo {
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--accent-gradient);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin-bottom: 1.4rem;
|
||||||
|
box-shadow: 0 4px 20px var(--accent-glow);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.logo::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
border-radius: inherit;
|
||||||
|
background: linear-gradient(135deg, rgba(255,255,255,0.2) 0%, transparent 60%);
|
||||||
|
}
|
||||||
|
.logo svg {
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
color: #fff;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin: 0 0 0.3rem;
|
||||||
|
font-size: 1.4rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.025em;
|
||||||
|
background: linear-gradient(135deg, var(--text-primary) 0%, var(--text-secondary) 100%);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin: 0 0 1.8rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-dimmed);
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="password"] {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.8rem 1rem;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: rgba(0,0,0,0.2);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 120ms ease, box-shadow 120ms ease;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
[data-theme="light"] input[type="password"] {
|
||||||
|
background: rgba(255,255,255,0.7);
|
||||||
|
}
|
||||||
|
@media (prefers-color-scheme: light) {
|
||||||
|
:root:not([data-theme="dark"]) input[type="password"] {
|
||||||
|
background: rgba(255,255,255,0.7);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="password"]:focus {
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 0 0 3px var(--accent-glow);
|
||||||
|
}
|
||||||
|
input[type="password"]::placeholder {
|
||||||
|
color: var(--text-dimmed);
|
||||||
|
}
|
||||||
|
|
||||||
|
button[type="submit"] {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.82rem;
|
||||||
|
margin-top: 1.1rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--accent-gradient);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 120ms ease, transform 120ms ease, box-shadow 120ms ease;
|
||||||
|
box-shadow: 0 2px 10px var(--accent-glow);
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
button[type="submit"]::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: linear-gradient(135deg, rgba(255,255,255,0.1) 0%, transparent 60%);
|
||||||
|
}
|
||||||
|
button[type="submit"]:hover {
|
||||||
|
box-shadow: 0 4px 20px var(--accent-glow);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
button[type="submit"]:active {
|
||||||
|
transform: scale(0.98);
|
||||||
|
box-shadow: 0 1px 6px var(--accent-glow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
color: var(--red);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
margin-top: 0.9rem;
|
||||||
|
display: none;
|
||||||
|
padding: 0.55rem 0.85rem;
|
||||||
|
background: var(--red-glow);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
border: 1px solid rgba(248, 113, 113, 0.12);
|
||||||
|
}
|
||||||
|
.error.visible { display: block; }
|
||||||
|
|
||||||
|
button[type="submit"]:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
transform: none;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.bg-orb, .login-card { animation: none !important; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="bg">
|
||||||
|
<div class="bg-orb bg-orb-1"></div>
|
||||||
|
<div class="bg-orb bg-orb-2"></div>
|
||||||
|
<div class="bg-orb bg-orb-3"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="login-card">
|
||||||
|
<div class="logo">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<polyline points="4 17 10 11 4 5"></polyline>
|
||||||
|
<line x1="12" y1="19" x2="20" y2="19"></line>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h1>cmux-remote</h1>
|
||||||
|
<p class="subtitle">Connect to your terminal session</p>
|
||||||
|
<form id="login-form">
|
||||||
|
<label for="password">Password</label>
|
||||||
|
<input type="password" id="password" placeholder="••••••••" autocomplete="current-password" autofocus>
|
||||||
|
<button type="submit">Connect</button>
|
||||||
|
<p class="error" id="error"></p>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<script src="/js/auth.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"name": "cmux-remote",
|
||||||
|
"short_name": "cmux",
|
||||||
|
"description": "Remote terminal control for cmux",
|
||||||
|
"start_url": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"orientation": "any",
|
||||||
|
"theme_color": "#0c0c14",
|
||||||
|
"background_color": "#0c0c14",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><rect width='100' height='100' rx='22' fill='%236366f1'/><path d='M30 65 L45 35 L30 20' stroke='white' stroke-width='8' stroke-linecap='round' stroke-linejoin='round' fill='none'/><line x1='50' y1='70' x2='75' y2='70' stroke='white' stroke-width='8' stroke-linecap='round'/></svg>",
|
||||||
|
"sizes": "any",
|
||||||
|
"type": "image/svg+xml"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@ -0,0 +1,53 @@
|
|||||||
|
const CACHE_NAME = 'cmux-remote-v3';
|
||||||
|
const STATIC_ASSETS = [
|
||||||
|
'/',
|
||||||
|
'/index.html',
|
||||||
|
'/login.html',
|
||||||
|
'/css/main.css',
|
||||||
|
'/css/terminal.css',
|
||||||
|
'/css/keyboard.css',
|
||||||
|
'/css/sidebar.css',
|
||||||
|
'/css/themes.css',
|
||||||
|
'/js/app.js',
|
||||||
|
'/js/auth.js',
|
||||||
|
'/js/terminal-view.js',
|
||||||
|
'/js/websocket-client.js',
|
||||||
|
'/js/virtual-keyboard.js',
|
||||||
|
'/js/sidebar.js',
|
||||||
|
'/js/gestures.js',
|
||||||
|
'/js/theme.js',
|
||||||
|
];
|
||||||
|
|
||||||
|
self.addEventListener('install', (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
caches.open(CACHE_NAME).then((cache) => cache.addAll(STATIC_ASSETS))
|
||||||
|
);
|
||||||
|
self.skipWaiting();
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener('activate', (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
caches.keys().then((keys) =>
|
||||||
|
Promise.all(keys.filter((k) => k !== CACHE_NAME).map((k) => caches.delete(k)))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
self.clients.claim();
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener('fetch', (event) => {
|
||||||
|
// Skip API and WebSocket requests
|
||||||
|
const url = new URL(event.request.url);
|
||||||
|
if (url.pathname.startsWith('/api/') || event.request.headers.get('upgrade') === 'websocket') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.respondWith(
|
||||||
|
fetch(event.request)
|
||||||
|
.then((response) => {
|
||||||
|
const clone = response.clone();
|
||||||
|
caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone));
|
||||||
|
return response;
|
||||||
|
})
|
||||||
|
.catch(() => caches.match(event.request))
|
||||||
|
);
|
||||||
|
});
|
||||||
@ -0,0 +1,207 @@
|
|||||||
|
import { execFile } from 'node:child_process';
|
||||||
|
import { promisify } from 'node:util';
|
||||||
|
import type { WorkspaceInfo, PaneInfo, SurfaceInfo } from '../protocol/messages.js';
|
||||||
|
|
||||||
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
|
const MAX_CONCURRENT = 5;
|
||||||
|
const CACHE_TTL_MS = 2000;
|
||||||
|
|
||||||
|
interface CacheEntry<T> {
|
||||||
|
data: T;
|
||||||
|
timestamp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CmuxClient {
|
||||||
|
private cmuxPath: string;
|
||||||
|
private running = 0;
|
||||||
|
private queue: Array<() => void> = [];
|
||||||
|
private cache = new Map<string, CacheEntry<unknown>>();
|
||||||
|
private shuttingDown = false;
|
||||||
|
|
||||||
|
constructor(cmuxPath = 'cmux') {
|
||||||
|
this.cmuxPath = cmuxPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
shutdown(): void {
|
||||||
|
this.shuttingDown = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async throttle(): Promise<void> {
|
||||||
|
if (this.running < MAX_CONCURRENT) {
|
||||||
|
this.running++;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
this.queue.push(() => {
|
||||||
|
this.running++;
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private release(): void {
|
||||||
|
this.running--;
|
||||||
|
const next = this.queue.shift();
|
||||||
|
if (next) next();
|
||||||
|
}
|
||||||
|
|
||||||
|
private getCached<T>(key: string): T | null {
|
||||||
|
const entry = this.cache.get(key);
|
||||||
|
if (entry && Date.now() - entry.timestamp < CACHE_TTL_MS) {
|
||||||
|
return entry.data as T;
|
||||||
|
}
|
||||||
|
this.cache.delete(key);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private setCache<T>(key: string, data: T): void {
|
||||||
|
this.cache.set(key, { data, timestamp: Date.now() });
|
||||||
|
}
|
||||||
|
|
||||||
|
async exec(args: string[]): Promise<string> {
|
||||||
|
if (this.shuttingDown) return '';
|
||||||
|
await this.throttle();
|
||||||
|
try {
|
||||||
|
const { stdout } = await execFileAsync(this.cmuxPath, args, {
|
||||||
|
timeout: 10000,
|
||||||
|
maxBuffer: 1024 * 1024,
|
||||||
|
});
|
||||||
|
return stdout;
|
||||||
|
} catch (err) {
|
||||||
|
if (this.shuttingDown) return '';
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
this.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async listWorkspaces(): Promise<WorkspaceInfo[]> {
|
||||||
|
const cached = this.getCached<WorkspaceInfo[]>('workspaces');
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
|
const output = await this.exec(['tree', '--all']);
|
||||||
|
const workspaces = this.parseTree(output);
|
||||||
|
this.setCache('workspaces', workspaces);
|
||||||
|
return workspaces;
|
||||||
|
}
|
||||||
|
|
||||||
|
parseTree(output: string): WorkspaceInfo[] {
|
||||||
|
const workspaces: WorkspaceInfo[] = [];
|
||||||
|
let currentWs: WorkspaceInfo | null = null;
|
||||||
|
let currentPane: PaneInfo | null = null;
|
||||||
|
|
||||||
|
for (const line of output.split('\n')) {
|
||||||
|
// Strip tree-drawing characters and leading whitespace
|
||||||
|
const stripped = line.replace(/^[\s│├└─┬┤┼┌┐┘┴]+/g, '').trim();
|
||||||
|
if (!stripped) continue;
|
||||||
|
|
||||||
|
// Match workspace lines: "workspace workspace:1 "name" [flags]"
|
||||||
|
const wsMatch = stripped.match(/^workspace\s+(workspace:\d+)\s*(.*)/);
|
||||||
|
if (wsMatch) {
|
||||||
|
// Extract quoted name if present
|
||||||
|
const nameMatch = wsMatch[2].match(/"([^"]+)"/);
|
||||||
|
currentWs = {
|
||||||
|
id: wsMatch[1],
|
||||||
|
ref: wsMatch[1],
|
||||||
|
name: nameMatch?.[1] || undefined,
|
||||||
|
panes: [],
|
||||||
|
};
|
||||||
|
workspaces.push(currentWs);
|
||||||
|
currentPane = null;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Match pane lines: "pane pane:2 [flags]"
|
||||||
|
const paneMatch = stripped.match(/^pane\s+(pane:\d+)/);
|
||||||
|
if (paneMatch && currentWs) {
|
||||||
|
currentPane = {
|
||||||
|
id: paneMatch[1],
|
||||||
|
ref: paneMatch[1],
|
||||||
|
surfaces: [],
|
||||||
|
};
|
||||||
|
currentWs.panes.push(currentPane);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Match surface lines: "surface surface:2 [terminal] "title" [flags]"
|
||||||
|
const surfaceMatch = stripped.match(/^surface\s+(surface:\d+)\s*(?:\[(\w+)\])?\s*(.*)/);
|
||||||
|
if (surfaceMatch && currentPane) {
|
||||||
|
const titleMatch = surfaceMatch[3].match(/"([^"]+)"/);
|
||||||
|
currentPane.surfaces.push({
|
||||||
|
id: surfaceMatch[1],
|
||||||
|
ref: surfaceMatch[1],
|
||||||
|
type: surfaceMatch[2] || 'terminal',
|
||||||
|
title: titleMatch?.[1] || undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return workspaces;
|
||||||
|
}
|
||||||
|
|
||||||
|
async listPaneSurfaces(workspace: string, pane?: string): Promise<SurfaceInfo[]> {
|
||||||
|
const cacheKey = `surfaces:${workspace}:${pane || 'all'}`;
|
||||||
|
const cached = this.getCached<SurfaceInfo[]>(cacheKey);
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
|
const args = ['list-pane-surfaces', '--workspace', workspace];
|
||||||
|
if (pane) args.push('--pane', pane);
|
||||||
|
|
||||||
|
const output = await this.exec(args);
|
||||||
|
const surfaces = this.parseSurfaces(output);
|
||||||
|
this.setCache(cacheKey, surfaces);
|
||||||
|
return surfaces;
|
||||||
|
}
|
||||||
|
|
||||||
|
parseSurfaces(output: string): SurfaceInfo[] {
|
||||||
|
const surfaces: SurfaceInfo[] = [];
|
||||||
|
for (const line of output.split('\n')) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (!trimmed) continue;
|
||||||
|
const match = trimmed.match(/^(surface:\d+)\s*(?:\[(\w+)\])?\s*(.*)/);
|
||||||
|
if (match) {
|
||||||
|
surfaces.push({
|
||||||
|
id: match[1],
|
||||||
|
ref: match[1],
|
||||||
|
type: match[2] || 'terminal',
|
||||||
|
title: match[3]?.trim() || undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return surfaces;
|
||||||
|
}
|
||||||
|
|
||||||
|
async readScreen(
|
||||||
|
workspace: string,
|
||||||
|
surface: string,
|
||||||
|
opts?: { scrollback?: boolean; lines?: number }
|
||||||
|
): Promise<string> {
|
||||||
|
const args = ['read-screen', '--workspace', workspace, '--surface', surface];
|
||||||
|
if (opts?.scrollback) args.push('--scrollback');
|
||||||
|
if (opts?.lines) args.push('--lines', String(opts.lines));
|
||||||
|
return this.exec(args);
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendText(workspace: string, surface: string, text: string): Promise<void> {
|
||||||
|
await this.exec(['send', '--workspace', workspace, '--surface', surface, text]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendKey(workspace: string, surface: string, key: string): Promise<void> {
|
||||||
|
await this.exec(['send-key', '--workspace', workspace, '--surface', surface, key]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async capturePaneScrollback(
|
||||||
|
workspace: string,
|
||||||
|
surface: string,
|
||||||
|
lines?: number
|
||||||
|
): Promise<string> {
|
||||||
|
const args = ['capture-pane', '--workspace', workspace, '--surface', surface, '--scrollback'];
|
||||||
|
if (lines) args.push('--lines', String(lines));
|
||||||
|
return this.exec(args);
|
||||||
|
}
|
||||||
|
|
||||||
|
clearCache(): void {
|
||||||
|
this.cache.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,53 @@
|
|||||||
|
import type { CmuxClient } from './cmux-client.js';
|
||||||
|
|
||||||
|
// Map browser key names to cmux send-key names
|
||||||
|
const KEY_MAP: Record<string, string> = {
|
||||||
|
Enter: 'enter',
|
||||||
|
Escape: 'escape',
|
||||||
|
Tab: 'tab',
|
||||||
|
Backspace: 'backspace',
|
||||||
|
Delete: 'delete',
|
||||||
|
ArrowUp: 'arrow_up',
|
||||||
|
ArrowDown: 'arrow_down',
|
||||||
|
ArrowLeft: 'arrow_left',
|
||||||
|
ArrowRight: 'arrow_right',
|
||||||
|
Home: 'home',
|
||||||
|
End: 'end',
|
||||||
|
PageUp: 'page_up',
|
||||||
|
PageDown: 'page_down',
|
||||||
|
Space: 'space',
|
||||||
|
};
|
||||||
|
|
||||||
|
export class InputHandler {
|
||||||
|
private client: CmuxClient;
|
||||||
|
|
||||||
|
constructor(client: CmuxClient) {
|
||||||
|
this.client = client;
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleText(workspace: string, surface: string, text: string): Promise<void> {
|
||||||
|
await this.client.sendText(workspace, surface, text);
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleKey(workspace: string, surface: string, key: string): Promise<void> {
|
||||||
|
// Handle Ctrl+key combos (from frontend: "Ctrl-c", "Ctrl-d", etc.)
|
||||||
|
const ctrlMatch = key.match(/^(?:Ctrl|Control)[+-](.+)$/i);
|
||||||
|
if (ctrlMatch) {
|
||||||
|
const letter = ctrlMatch[1].toLowerCase();
|
||||||
|
await this.client.sendKey(workspace, surface, `ctrl+${letter}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle Alt+key combos
|
||||||
|
const altMatch = key.match(/^Alt[+-](.+)$/i);
|
||||||
|
if (altMatch) {
|
||||||
|
const letter = altMatch[1].toLowerCase();
|
||||||
|
await this.client.sendKey(workspace, surface, `alt+${letter}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map known keys
|
||||||
|
const mapped = KEY_MAP[key] || key;
|
||||||
|
await this.client.sendKey(workspace, surface, mapped);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,166 @@
|
|||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import { EventEmitter } from 'node:events';
|
||||||
|
import type { CmuxClient } from './cmux-client.js';
|
||||||
|
import { computeDiff } from '../utils/text-differ.js';
|
||||||
|
import type { DiffPatch } from '../protocol/messages.js';
|
||||||
|
|
||||||
|
interface PollTarget {
|
||||||
|
workspace: string;
|
||||||
|
surface: string;
|
||||||
|
subscribers: Set<string>;
|
||||||
|
lastHash: string;
|
||||||
|
lastLines: string[];
|
||||||
|
idleCount: number;
|
||||||
|
timer: ReturnType<typeof setTimeout> | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ScreenUpdate {
|
||||||
|
surface: string;
|
||||||
|
workspace: string;
|
||||||
|
full: boolean;
|
||||||
|
lines: string[];
|
||||||
|
patches?: DiffPatch[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ScreenPoller extends EventEmitter {
|
||||||
|
private client: CmuxClient;
|
||||||
|
private targets = new Map<string, PollTarget>();
|
||||||
|
private basePollRate: number;
|
||||||
|
private fullSyncInterval = 30000;
|
||||||
|
private lastFullSync = new Map<string, number>();
|
||||||
|
|
||||||
|
static readonly IDLE_THRESHOLD = 3;
|
||||||
|
static readonly DEEP_IDLE_MS = 10000;
|
||||||
|
|
||||||
|
constructor(client: CmuxClient, pollRate = 200) {
|
||||||
|
super();
|
||||||
|
this.client = client;
|
||||||
|
this.basePollRate = pollRate;
|
||||||
|
}
|
||||||
|
|
||||||
|
subscribe(clientId: string, workspace: string, surface: string): void {
|
||||||
|
const key = `${workspace}:${surface}`;
|
||||||
|
let target = this.targets.get(key);
|
||||||
|
|
||||||
|
if (!target) {
|
||||||
|
target = {
|
||||||
|
workspace,
|
||||||
|
surface,
|
||||||
|
subscribers: new Set(),
|
||||||
|
lastHash: '',
|
||||||
|
lastLines: [],
|
||||||
|
idleCount: 0,
|
||||||
|
timer: null,
|
||||||
|
};
|
||||||
|
this.targets.set(key, target);
|
||||||
|
}
|
||||||
|
|
||||||
|
target.subscribers.add(clientId);
|
||||||
|
|
||||||
|
if (!target.timer) {
|
||||||
|
this.schedulePoll(key, target, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
unsubscribe(clientId: string, surface?: string): void {
|
||||||
|
for (const [key, target] of this.targets) {
|
||||||
|
if (surface && !key.endsWith(`:${surface}`)) continue;
|
||||||
|
|
||||||
|
target.subscribers.delete(clientId);
|
||||||
|
|
||||||
|
if (target.subscribers.size === 0) {
|
||||||
|
if (target.timer) clearTimeout(target.timer);
|
||||||
|
target.timer = null;
|
||||||
|
this.targets.delete(key);
|
||||||
|
this.lastFullSync.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resetToFast(surface: string): void {
|
||||||
|
for (const [key, target] of this.targets) {
|
||||||
|
if (key.endsWith(`:${surface}`)) {
|
||||||
|
target.idleCount = 0;
|
||||||
|
// Reschedule with fast rate
|
||||||
|
if (target.timer) clearTimeout(target.timer);
|
||||||
|
this.schedulePoll(key, target, this.basePollRate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private getInterval(target: PollTarget): number {
|
||||||
|
if (target.idleCount >= ScreenPoller.IDLE_THRESHOLD) {
|
||||||
|
const idleTimeMs = target.idleCount * this.basePollRate;
|
||||||
|
if (idleTimeMs > ScreenPoller.DEEP_IDLE_MS) return 2000;
|
||||||
|
return 1000;
|
||||||
|
}
|
||||||
|
return this.basePollRate;
|
||||||
|
}
|
||||||
|
|
||||||
|
private schedulePoll(key: string, target: PollTarget, delay: number): void {
|
||||||
|
target.timer = setTimeout(() => this.poll(key, target), delay);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async poll(key: string, target: PollTarget): Promise<void> {
|
||||||
|
if (target.subscribers.size === 0) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const content = await this.client.readScreen(target.workspace, target.surface);
|
||||||
|
const hash = createHash('sha256').update(content).digest('hex');
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const needsFullSync =
|
||||||
|
!this.lastFullSync.has(key) ||
|
||||||
|
now - this.lastFullSync.get(key)! > this.fullSyncInterval;
|
||||||
|
|
||||||
|
if (hash !== target.lastHash) {
|
||||||
|
target.idleCount = 0;
|
||||||
|
const newLines = content.split('\n');
|
||||||
|
|
||||||
|
const update: ScreenUpdate = {
|
||||||
|
surface: target.surface,
|
||||||
|
workspace: target.workspace,
|
||||||
|
full: target.lastLines.length === 0 || needsFullSync,
|
||||||
|
lines: newLines,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!update.full && target.lastLines.length > 0) {
|
||||||
|
update.patches = computeDiff(target.lastLines, newLines);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (needsFullSync) {
|
||||||
|
this.lastFullSync.set(key, now);
|
||||||
|
}
|
||||||
|
|
||||||
|
target.lastHash = hash;
|
||||||
|
target.lastLines = newLines;
|
||||||
|
|
||||||
|
this.emit('update', update);
|
||||||
|
} else {
|
||||||
|
target.idleCount++;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
this.emit('error', { surface: target.surface, error: err });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schedule next poll
|
||||||
|
if (target.subscribers.size > 0) {
|
||||||
|
this.schedulePoll(key, target, this.getInterval(target));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getSubscriberCount(surface: string): number {
|
||||||
|
for (const [key, target] of this.targets) {
|
||||||
|
if (key.endsWith(`:${surface}`)) return target.subscribers.size;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy(): void {
|
||||||
|
for (const target of this.targets.values()) {
|
||||||
|
if (target.timer) clearTimeout(target.timer);
|
||||||
|
}
|
||||||
|
this.targets.clear();
|
||||||
|
this.removeAllListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,88 @@
|
|||||||
|
import type { ScreenPoller } from './screen-poller.js';
|
||||||
|
|
||||||
|
interface ClientSession {
|
||||||
|
id: string;
|
||||||
|
authenticated: boolean;
|
||||||
|
subscriptions: Set<string>; // "workspace:surface" keys
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SessionManager {
|
||||||
|
private sessions = new Map<string, ClientSession>();
|
||||||
|
private poller: ScreenPoller;
|
||||||
|
|
||||||
|
constructor(poller: ScreenPoller) {
|
||||||
|
this.poller = poller;
|
||||||
|
}
|
||||||
|
|
||||||
|
addClient(clientId: string): void {
|
||||||
|
this.sessions.set(clientId, {
|
||||||
|
id: clientId,
|
||||||
|
authenticated: false,
|
||||||
|
subscriptions: new Set(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
authenticateClient(clientId: string): void {
|
||||||
|
const session = this.sessions.get(clientId);
|
||||||
|
if (session) session.authenticated = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
isAuthenticated(clientId: string): boolean {
|
||||||
|
return this.sessions.get(clientId)?.authenticated ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
subscribe(clientId: string, workspace: string, surface: string): void {
|
||||||
|
const session = this.sessions.get(clientId);
|
||||||
|
if (!session) return;
|
||||||
|
|
||||||
|
const key = `${workspace}:${surface}`;
|
||||||
|
session.subscriptions.add(key);
|
||||||
|
this.poller.subscribe(clientId, workspace, surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
unsubscribe(clientId: string, surface: string): void {
|
||||||
|
const session = this.sessions.get(clientId);
|
||||||
|
if (!session) return;
|
||||||
|
|
||||||
|
for (const key of session.subscriptions) {
|
||||||
|
if (key.endsWith(`:${surface}`)) {
|
||||||
|
session.subscriptions.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.poller.unsubscribe(clientId, surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
removeClient(clientId: string): void {
|
||||||
|
const session = this.sessions.get(clientId);
|
||||||
|
if (!session) return;
|
||||||
|
|
||||||
|
// Unsubscribe from all surfaces
|
||||||
|
this.poller.unsubscribe(clientId);
|
||||||
|
this.sessions.delete(clientId);
|
||||||
|
}
|
||||||
|
|
||||||
|
getSubscribedClients(surface: string): string[] {
|
||||||
|
const clients: string[] = [];
|
||||||
|
for (const [id, session] of this.sessions) {
|
||||||
|
for (const key of session.subscriptions) {
|
||||||
|
if (key.endsWith(`:${surface}`)) {
|
||||||
|
clients.push(id);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return clients;
|
||||||
|
}
|
||||||
|
|
||||||
|
getClientCount(): number {
|
||||||
|
return this.sessions.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
getAuthenticatedCount(): number {
|
||||||
|
let count = 0;
|
||||||
|
for (const session of this.sessions.values()) {
|
||||||
|
if (session.authenticated) count++;
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,123 @@
|
|||||||
|
import { createServer } from 'node:http';
|
||||||
|
import { Command } from 'commander';
|
||||||
|
import qrcode from 'qrcode-terminal';
|
||||||
|
import { createApp } from './server/app.js';
|
||||||
|
import { AuthManager, generatePassphrase } from './server/auth.js';
|
||||||
|
import { WsServer } from './server/websocket.js';
|
||||||
|
import { CloudflareTunnel } from './server/tunnel.js';
|
||||||
|
import { CmuxClient } from './bridge/cmux-client.js';
|
||||||
|
import { ScreenPoller } from './bridge/screen-poller.js';
|
||||||
|
import { SessionManager } from './bridge/session-manager.js';
|
||||||
|
import { InputHandler } from './bridge/input-handler.js';
|
||||||
|
import type { Config } from './utils/config.js';
|
||||||
|
import { DEFAULT_CONFIG } from './utils/config.js';
|
||||||
|
|
||||||
|
const program = new Command();
|
||||||
|
|
||||||
|
program
|
||||||
|
.name('cmux-remote')
|
||||||
|
.description('Browser-based remote terminal control for cmux')
|
||||||
|
.version('0.1.0')
|
||||||
|
.option('-p, --port <port>', 'Local port', String(DEFAULT_CONFIG.port))
|
||||||
|
.option('-P, --password <pass>', 'Access password (or CMUX_REMOTE_PASSWORD env)')
|
||||||
|
.option('--no-tunnel', 'Disable Cloudflare Tunnel')
|
||||||
|
.option('--poll-rate <ms>', 'Poll interval in ms', String(DEFAULT_CONFIG.pollRate))
|
||||||
|
.option('--theme <theme>', 'Default theme (dark|light)', DEFAULT_CONFIG.theme)
|
||||||
|
.option('-v, --verbose', 'Verbose logging', false)
|
||||||
|
.action(async (opts) => {
|
||||||
|
const config: Config = {
|
||||||
|
port: parseInt(opts.port, 10),
|
||||||
|
password: opts.password || process.env.CMUX_REMOTE_PASSWORD || null,
|
||||||
|
tunnel: opts.tunnel !== false,
|
||||||
|
pollRate: parseInt(opts.pollRate, 10),
|
||||||
|
theme: opts.theme as 'dark' | 'light',
|
||||||
|
verbose: opts.verbose,
|
||||||
|
cmuxPath: process.env.CMUX_PATH || 'cmux',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initialize components
|
||||||
|
const auth = new AuthManager();
|
||||||
|
if (config.password) {
|
||||||
|
await auth.setPassword(config.password);
|
||||||
|
} else {
|
||||||
|
auth.disableAuth();
|
||||||
|
}
|
||||||
|
|
||||||
|
const cmux = new CmuxClient(config.cmuxPath);
|
||||||
|
const poller = new ScreenPoller(cmux, config.pollRate);
|
||||||
|
const sessions = new SessionManager(poller);
|
||||||
|
const input = new InputHandler(cmux);
|
||||||
|
|
||||||
|
// Create HTTP server; qrUrlRef is updated after tunnel starts
|
||||||
|
const qrUrlRef = { url: `http://localhost:${config.port}` };
|
||||||
|
const app = createApp(auth, qrUrlRef);
|
||||||
|
const server = createServer(app);
|
||||||
|
|
||||||
|
// WebSocket server
|
||||||
|
const wsServer = new WsServer({
|
||||||
|
server,
|
||||||
|
auth,
|
||||||
|
sessions,
|
||||||
|
poller,
|
||||||
|
input,
|
||||||
|
cmux,
|
||||||
|
verbose: config.verbose,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start listening
|
||||||
|
server.listen(config.port, () => {
|
||||||
|
console.log('');
|
||||||
|
console.log(' cmux-remote v0.1.0');
|
||||||
|
console.log(' ==================');
|
||||||
|
console.log('');
|
||||||
|
console.log(` Local: http://localhost:${config.port}`);
|
||||||
|
if (config.password) {
|
||||||
|
console.log(` Password: ${config.password}`);
|
||||||
|
} else {
|
||||||
|
console.log(` Auth: disabled (use -P to set password)`);
|
||||||
|
}
|
||||||
|
console.log('');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start tunnel
|
||||||
|
let tunnel: CloudflareTunnel | null = null;
|
||||||
|
if (config.tunnel) {
|
||||||
|
tunnel = new CloudflareTunnel();
|
||||||
|
try {
|
||||||
|
const tunnelUrl = await tunnel.start(config.port);
|
||||||
|
qrUrlRef.url = tunnelUrl;
|
||||||
|
console.log(` Tunnel: ${tunnelUrl}`);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(` Tunnel: Failed - ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Print QR code to terminal
|
||||||
|
console.log('');
|
||||||
|
qrcode.generate(qrUrlRef.url, { small: true }, (code: string) => {
|
||||||
|
for (const line of code.split('\n')) {
|
||||||
|
console.log(` ${line}`);
|
||||||
|
}
|
||||||
|
console.log('');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Graceful shutdown
|
||||||
|
let shuttingDown = false;
|
||||||
|
const shutdown = () => {
|
||||||
|
if (shuttingDown) return;
|
||||||
|
shuttingDown = true;
|
||||||
|
console.log('\nShutting down...');
|
||||||
|
cmux.shutdown();
|
||||||
|
poller.destroy();
|
||||||
|
wsServer.close();
|
||||||
|
tunnel?.stop();
|
||||||
|
server.close(() => process.exit(0));
|
||||||
|
// Force exit after 3s if connections don't drain
|
||||||
|
setTimeout(() => process.exit(0), 3000).unref();
|
||||||
|
};
|
||||||
|
|
||||||
|
process.on('SIGINT', shutdown);
|
||||||
|
process.on('SIGTERM', shutdown);
|
||||||
|
});
|
||||||
|
|
||||||
|
program.parse();
|
||||||
@ -0,0 +1,119 @@
|
|||||||
|
// Server -> Client messages
|
||||||
|
export interface ScreenMessage {
|
||||||
|
type: 'screen';
|
||||||
|
surface: string;
|
||||||
|
content: string;
|
||||||
|
lines: string[];
|
||||||
|
cursor?: { row: number; col: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScreenDiffMessage {
|
||||||
|
type: 'screen-diff';
|
||||||
|
surface: string;
|
||||||
|
patches: DiffPatch[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DiffPatch {
|
||||||
|
startLine: number;
|
||||||
|
deleteCount: number;
|
||||||
|
insertLines: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorkspacesMessage {
|
||||||
|
type: 'workspaces';
|
||||||
|
workspaces: WorkspaceInfo[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorkspaceInfo {
|
||||||
|
id: string;
|
||||||
|
ref: string;
|
||||||
|
name?: string;
|
||||||
|
panes: PaneInfo[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaneInfo {
|
||||||
|
id: string;
|
||||||
|
ref: string;
|
||||||
|
surfaces: SurfaceInfo[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SurfaceInfo {
|
||||||
|
id: string;
|
||||||
|
ref: string;
|
||||||
|
type: string;
|
||||||
|
title?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthResultMessage {
|
||||||
|
type: 'auth-ok' | 'auth-fail';
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ErrorMessage {
|
||||||
|
type: 'error';
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ServerMessage =
|
||||||
|
| ScreenMessage
|
||||||
|
| ScreenDiffMessage
|
||||||
|
| WorkspacesMessage
|
||||||
|
| AuthResultMessage
|
||||||
|
| ErrorMessage;
|
||||||
|
|
||||||
|
// Client -> Server messages
|
||||||
|
export interface AuthMessage {
|
||||||
|
type: 'auth';
|
||||||
|
token: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubscribeMessage {
|
||||||
|
type: 'subscribe';
|
||||||
|
workspace: string;
|
||||||
|
surface: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UnsubscribeMessage {
|
||||||
|
type: 'unsubscribe';
|
||||||
|
surface: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SendTextMessage {
|
||||||
|
type: 'send-text';
|
||||||
|
workspace: string;
|
||||||
|
surface: string;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SendKeyMessage {
|
||||||
|
type: 'send-key';
|
||||||
|
workspace: string;
|
||||||
|
surface: string;
|
||||||
|
key: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ListWorkspacesMessage {
|
||||||
|
type: 'list-workspaces';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ListSurfacesMessage {
|
||||||
|
type: 'list-surfaces';
|
||||||
|
workspace: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScrollRequestMessage {
|
||||||
|
type: 'scroll-request';
|
||||||
|
workspace: string;
|
||||||
|
surface: string;
|
||||||
|
lines: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ClientMessage =
|
||||||
|
| AuthMessage
|
||||||
|
| SubscribeMessage
|
||||||
|
| UnsubscribeMessage
|
||||||
|
| SendTextMessage
|
||||||
|
| SendKeyMessage
|
||||||
|
| ListWorkspacesMessage
|
||||||
|
| ListSurfacesMessage
|
||||||
|
| ScrollRequestMessage;
|
||||||
@ -0,0 +1,73 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { dirname, join } from 'node:path';
|
||||||
|
import QRCode from 'qrcode';
|
||||||
|
import type { AuthManager } from './auth.js';
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = dirname(__filename);
|
||||||
|
|
||||||
|
export function createApp(auth: AuthManager, qrUrlRef: { url: string }): express.Express {
|
||||||
|
const app = express();
|
||||||
|
|
||||||
|
// Security headers
|
||||||
|
app.use(auth.cspMiddleware());
|
||||||
|
|
||||||
|
// Parse JSON bodies
|
||||||
|
app.use(express.json());
|
||||||
|
|
||||||
|
// QR code endpoint — generates SVG for the current access URL
|
||||||
|
app.get('/api/qr.svg', async (_req, res) => {
|
||||||
|
try {
|
||||||
|
const svg = await QRCode.toString(qrUrlRef.url, { type: 'svg' });
|
||||||
|
res.setHeader('Content-Type', 'image/svg+xml');
|
||||||
|
res.send(svg);
|
||||||
|
} catch {
|
||||||
|
res.status(500).send('QR generation failed');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Access URL endpoint — returns the current access URL (tunnel or local)
|
||||||
|
app.get('/api/access-url', (_req, res) => {
|
||||||
|
res.json({ url: qrUrlRef.url });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Auth status endpoint
|
||||||
|
app.get('/api/auth/status', (_req, res) => {
|
||||||
|
res.json({ authRequired: !auth.isAuthDisabled() });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Auth endpoint
|
||||||
|
app.post('/api/auth/login', auth.loginHandler());
|
||||||
|
|
||||||
|
// Token verification endpoint
|
||||||
|
app.get('/api/auth/verify', (req, res) => {
|
||||||
|
if (auth.isAuthDisabled()) {
|
||||||
|
res.json({ valid: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const authHeader = req.headers.authorization;
|
||||||
|
if (!authHeader?.startsWith('Bearer ')) {
|
||||||
|
res.status(401).json({ valid: false });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const token = authHeader.slice(7);
|
||||||
|
const valid = auth.verifyToken(token);
|
||||||
|
res.json({ valid });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Static files — no-cache so changes are always picked up
|
||||||
|
const publicDir = join(__dirname, '..', '..', 'public');
|
||||||
|
app.use(express.static(publicDir, {
|
||||||
|
setHeaders: (res) => {
|
||||||
|
res.setHeader('Cache-Control', 'no-store');
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// SPA fallback — serve index.html for non-API, non-file routes
|
||||||
|
app.get('/{*splat}', (_req, res) => {
|
||||||
|
res.sendFile(join(publicDir, 'index.html'));
|
||||||
|
});
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
@ -0,0 +1,140 @@
|
|||||||
|
import bcrypt from 'bcrypt';
|
||||||
|
import jwt from 'jsonwebtoken';
|
||||||
|
import { randomBytes } from 'node:crypto';
|
||||||
|
import type { Request, Response, NextFunction } from 'express';
|
||||||
|
|
||||||
|
const SALT_ROUNDS = 10;
|
||||||
|
const TOKEN_EXPIRY = '24h';
|
||||||
|
const MAX_ATTEMPTS = 5;
|
||||||
|
const RATE_WINDOW_MS = 60000;
|
||||||
|
|
||||||
|
interface AttemptRecord {
|
||||||
|
count: number;
|
||||||
|
firstAttempt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AuthManager {
|
||||||
|
private passwordHash: string | null = null;
|
||||||
|
private jwtSecret: string;
|
||||||
|
private attempts = new Map<string, AttemptRecord>();
|
||||||
|
private authDisabled = false;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.jwtSecret = randomBytes(32).toString('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
disableAuth(): void {
|
||||||
|
this.authDisabled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
isAuthDisabled(): boolean {
|
||||||
|
return this.authDisabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
async setPassword(password: string): Promise<void> {
|
||||||
|
this.passwordHash = await bcrypt.hash(password, SALT_ROUNDS);
|
||||||
|
}
|
||||||
|
|
||||||
|
async verifyPassword(password: string): Promise<boolean> {
|
||||||
|
if (!this.passwordHash) return false;
|
||||||
|
return bcrypt.compare(password, this.passwordHash);
|
||||||
|
}
|
||||||
|
|
||||||
|
generateToken(): string {
|
||||||
|
return jwt.sign({ ts: Date.now() }, this.jwtSecret, { expiresIn: TOKEN_EXPIRY });
|
||||||
|
}
|
||||||
|
|
||||||
|
verifyToken(token: string): boolean {
|
||||||
|
if (this.authDisabled) return true;
|
||||||
|
try {
|
||||||
|
jwt.verify(token, this.jwtSecret);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isRateLimited(ip: string): boolean {
|
||||||
|
const record = this.attempts.get(ip);
|
||||||
|
if (!record) return false;
|
||||||
|
|
||||||
|
// Reset if window expired
|
||||||
|
if (Date.now() - record.firstAttempt > RATE_WINDOW_MS) {
|
||||||
|
this.attempts.delete(ip);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return record.count >= MAX_ATTEMPTS;
|
||||||
|
}
|
||||||
|
|
||||||
|
recordAttempt(ip: string): void {
|
||||||
|
const record = this.attempts.get(ip);
|
||||||
|
if (!record || Date.now() - record.firstAttempt > RATE_WINDOW_MS) {
|
||||||
|
this.attempts.set(ip, { count: 1, firstAttempt: Date.now() });
|
||||||
|
} else {
|
||||||
|
record.count++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resetAttempts(ip: string): void {
|
||||||
|
this.attempts.delete(ip);
|
||||||
|
}
|
||||||
|
|
||||||
|
loginHandler() {
|
||||||
|
return async (req: Request, res: Response): Promise<void> => {
|
||||||
|
const ip = req.ip || req.socket.remoteAddress || 'unknown';
|
||||||
|
|
||||||
|
if (this.isRateLimited(ip)) {
|
||||||
|
res.status(429).json({ error: 'Too many attempts. Try again later.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { password } = req.body as { password?: string };
|
||||||
|
if (!password) {
|
||||||
|
res.status(400).json({ error: 'Password required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.recordAttempt(ip);
|
||||||
|
|
||||||
|
const valid = await this.verifyPassword(password);
|
||||||
|
if (!valid) {
|
||||||
|
console.log(`[auth] Failed login from ${ip}`);
|
||||||
|
res.status(401).json({ error: 'Invalid password' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.resetAttempts(ip);
|
||||||
|
const token = this.generateToken();
|
||||||
|
console.log(`[auth] Successful login from ${ip}`);
|
||||||
|
res.json({ token });
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
cspMiddleware() {
|
||||||
|
return (_req: Request, res: Response, next: NextFunction): void => {
|
||||||
|
res.setHeader(
|
||||||
|
'Content-Security-Policy',
|
||||||
|
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self' wss: ws:; img-src 'self' data:;"
|
||||||
|
);
|
||||||
|
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||||
|
res.setHeader('X-Frame-Options', 'DENY');
|
||||||
|
res.setHeader('Referrer-Policy', 'no-referrer');
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generatePassphrase(): string {
|
||||||
|
const words = [
|
||||||
|
'alpha', 'bravo', 'cedar', 'delta', 'ember', 'frost', 'grape', 'hatch',
|
||||||
|
'ivory', 'junco', 'knack', 'lunar', 'maple', 'north', 'orbit', 'pearl',
|
||||||
|
'quilt', 'ridge', 'solar', 'thorn', 'ultra', 'vivid', 'watch', 'xenon',
|
||||||
|
];
|
||||||
|
const picked: string[] = [];
|
||||||
|
const bytes = randomBytes(4);
|
||||||
|
for (let i = 0; i < 4; i++) {
|
||||||
|
picked.push(words[bytes[i] % words.length]);
|
||||||
|
}
|
||||||
|
return picked.join('-');
|
||||||
|
}
|
||||||
@ -0,0 +1,65 @@
|
|||||||
|
import { spawn, type ChildProcess } from 'node:child_process';
|
||||||
|
|
||||||
|
export class CloudflareTunnel {
|
||||||
|
private process: ChildProcess | null = null;
|
||||||
|
private url: string | null = null;
|
||||||
|
|
||||||
|
async start(port: number): Promise<string> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
reject(new Error('Tunnel startup timed out after 30s'));
|
||||||
|
}, 30000);
|
||||||
|
|
||||||
|
this.process = spawn('cloudflared', ['tunnel', '--url', `http://localhost:${port}`], {
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleOutput = (data: Buffer) => {
|
||||||
|
const text = data.toString();
|
||||||
|
// cloudflared prints the URL to stderr
|
||||||
|
const urlMatch = text.match(/https:\/\/[a-z0-9-]+\.trycloudflare\.com/);
|
||||||
|
if (urlMatch && !this.url) {
|
||||||
|
this.url = urlMatch[0];
|
||||||
|
clearTimeout(timeout);
|
||||||
|
resolve(this.url);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
this.process.stdout?.on('data', handleOutput);
|
||||||
|
this.process.stderr?.on('data', handleOutput);
|
||||||
|
|
||||||
|
this.process.on('error', (err) => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
if (err.message.includes('ENOENT')) {
|
||||||
|
reject(
|
||||||
|
new Error(
|
||||||
|
'cloudflared not found. Install with: brew install cloudflared\n' +
|
||||||
|
'Or disable tunnel with: --no-tunnel'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
reject(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.process.on('exit', (code) => {
|
||||||
|
if (!this.url) {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
reject(new Error(`cloudflared exited with code ${code}`));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
getUrl(): string | null {
|
||||||
|
return this.url;
|
||||||
|
}
|
||||||
|
|
||||||
|
stop(): void {
|
||||||
|
if (this.process) {
|
||||||
|
this.process.kill('SIGTERM');
|
||||||
|
this.process = null;
|
||||||
|
this.url = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,197 @@
|
|||||||
|
import { WebSocketServer, WebSocket } from 'ws';
|
||||||
|
import type { Server } from 'node:http';
|
||||||
|
import { nanoid } from 'nanoid';
|
||||||
|
import type { AuthManager } from './auth.js';
|
||||||
|
import type { SessionManager } from '../bridge/session-manager.js';
|
||||||
|
import type { ScreenPoller } from '../bridge/screen-poller.js';
|
||||||
|
import type { InputHandler } from '../bridge/input-handler.js';
|
||||||
|
import type { CmuxClient } from '../bridge/cmux-client.js';
|
||||||
|
import type { ClientMessage, ServerMessage } from '../protocol/messages.js';
|
||||||
|
|
||||||
|
interface ClientInfo {
|
||||||
|
id: string;
|
||||||
|
ws: WebSocket;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class WsServer {
|
||||||
|
private wss: WebSocketServer;
|
||||||
|
private clients = new Map<string, ClientInfo>();
|
||||||
|
private auth: AuthManager;
|
||||||
|
private sessions: SessionManager;
|
||||||
|
private poller: ScreenPoller;
|
||||||
|
private input: InputHandler;
|
||||||
|
private cmux: CmuxClient;
|
||||||
|
private verbose: boolean;
|
||||||
|
|
||||||
|
constructor(opts: {
|
||||||
|
server: Server;
|
||||||
|
auth: AuthManager;
|
||||||
|
sessions: SessionManager;
|
||||||
|
poller: ScreenPoller;
|
||||||
|
input: InputHandler;
|
||||||
|
cmux: CmuxClient;
|
||||||
|
verbose?: boolean;
|
||||||
|
}) {
|
||||||
|
this.auth = opts.auth;
|
||||||
|
this.sessions = opts.sessions;
|
||||||
|
this.poller = opts.poller;
|
||||||
|
this.input = opts.input;
|
||||||
|
this.cmux = opts.cmux;
|
||||||
|
this.verbose = opts.verbose ?? false;
|
||||||
|
|
||||||
|
this.wss = new WebSocketServer({
|
||||||
|
server: opts.server,
|
||||||
|
perMessageDeflate: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.wss.on('connection', (ws) => this.handleConnection(ws));
|
||||||
|
|
||||||
|
// Listen for screen updates from poller
|
||||||
|
this.poller.on('update', (update) => this.broadcastUpdate(update));
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleConnection(ws: WebSocket): void {
|
||||||
|
const clientId = nanoid(12);
|
||||||
|
this.clients.set(clientId, { id: clientId, ws });
|
||||||
|
this.sessions.addClient(clientId);
|
||||||
|
|
||||||
|
if (this.verbose) console.log(`[ws] Client connected: ${clientId}`);
|
||||||
|
|
||||||
|
ws.on('message', (data) => {
|
||||||
|
try {
|
||||||
|
const msg = JSON.parse(data.toString()) as ClientMessage;
|
||||||
|
this.handleMessage(clientId, msg);
|
||||||
|
} catch (err) {
|
||||||
|
this.sendTo(clientId, { type: 'error', message: 'Invalid message format' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.on('close', () => {
|
||||||
|
if (this.verbose) console.log(`[ws] Client disconnected: ${clientId}`);
|
||||||
|
this.sessions.removeClient(clientId);
|
||||||
|
this.clients.delete(clientId);
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.on('error', (err) => {
|
||||||
|
console.error(`[ws] Client error ${clientId}:`, err.message);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleMessage(clientId: string, msg: ClientMessage): Promise<void> {
|
||||||
|
// Auth messages don't require prior authentication
|
||||||
|
if (msg.type === 'auth') {
|
||||||
|
const valid = this.auth.verifyToken(msg.token);
|
||||||
|
if (valid) {
|
||||||
|
this.sessions.authenticateClient(clientId);
|
||||||
|
this.sendTo(clientId, { type: 'auth-ok' });
|
||||||
|
} else {
|
||||||
|
this.sendTo(clientId, { type: 'auth-fail', message: 'Invalid token' });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// All other messages require authentication
|
||||||
|
if (!this.sessions.isAuthenticated(clientId)) {
|
||||||
|
this.sendTo(clientId, { type: 'auth-fail', message: 'Not authenticated' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (msg.type) {
|
||||||
|
case 'subscribe':
|
||||||
|
this.sessions.subscribe(clientId, msg.workspace, msg.surface);
|
||||||
|
if (this.verbose) console.log(`[ws] ${clientId} subscribed to ${msg.surface}`);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'unsubscribe':
|
||||||
|
this.sessions.unsubscribe(clientId, msg.surface);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'send-text':
|
||||||
|
if (this.verbose) console.log(`[ws] ${clientId} send-text to ${msg.surface}`);
|
||||||
|
this.poller.resetToFast(msg.surface);
|
||||||
|
await this.input.handleText(msg.workspace, msg.surface, msg.text);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'send-key':
|
||||||
|
if (this.verbose) console.log(`[ws] ${clientId} send-key ${msg.key} to ${msg.surface}`);
|
||||||
|
this.poller.resetToFast(msg.surface);
|
||||||
|
await this.input.handleKey(msg.workspace, msg.surface, msg.key);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'list-workspaces': {
|
||||||
|
const workspaces = await this.cmux.listWorkspaces();
|
||||||
|
this.sendTo(clientId, { type: 'workspaces', workspaces });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'list-surfaces': {
|
||||||
|
const surfaces = await this.cmux.listPaneSurfaces(msg.workspace);
|
||||||
|
// Wrap in workspace info format
|
||||||
|
const workspaces = await this.cmux.listWorkspaces();
|
||||||
|
this.sendTo(clientId, { type: 'workspaces', workspaces });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'scroll-request': {
|
||||||
|
const content = await this.cmux.capturePaneScrollback(
|
||||||
|
msg.workspace,
|
||||||
|
msg.surface,
|
||||||
|
msg.lines
|
||||||
|
);
|
||||||
|
const lines = content.split('\n');
|
||||||
|
this.sendTo(clientId, {
|
||||||
|
type: 'screen',
|
||||||
|
surface: msg.surface,
|
||||||
|
content,
|
||||||
|
lines,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
this.sendTo(clientId, { type: 'error', message: `Unknown message type` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private broadcastUpdate(update: {
|
||||||
|
surface: string;
|
||||||
|
workspace: string;
|
||||||
|
full: boolean;
|
||||||
|
lines: string[];
|
||||||
|
patches?: Array<{ startLine: number; deleteCount: number; insertLines: string[] }>;
|
||||||
|
}): void {
|
||||||
|
const subscribedClients = this.sessions.getSubscribedClients(update.surface);
|
||||||
|
|
||||||
|
for (const clientId of subscribedClients) {
|
||||||
|
if (update.full || !update.patches) {
|
||||||
|
this.sendTo(clientId, {
|
||||||
|
type: 'screen',
|
||||||
|
surface: update.surface,
|
||||||
|
content: update.lines.join('\n'),
|
||||||
|
lines: update.lines,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
this.sendTo(clientId, {
|
||||||
|
type: 'screen-diff',
|
||||||
|
surface: update.surface,
|
||||||
|
patches: update.patches,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sendTo(clientId: string, msg: ServerMessage): void {
|
||||||
|
const client = this.clients.get(clientId);
|
||||||
|
if (client && client.ws.readyState === WebSocket.OPEN) {
|
||||||
|
client.ws.send(JSON.stringify(msg));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getClientCount(): number {
|
||||||
|
return this.clients.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
close(): void {
|
||||||
|
this.wss.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,8 @@
|
|||||||
|
declare module 'qrcode-terminal' {
|
||||||
|
interface Options {
|
||||||
|
small?: boolean;
|
||||||
|
}
|
||||||
|
function generate(text: string, opts?: Options, callback?: (code: string) => void): void;
|
||||||
|
export { generate };
|
||||||
|
export default { generate };
|
||||||
|
}
|
||||||
@ -0,0 +1,19 @@
|
|||||||
|
export interface Config {
|
||||||
|
port: number;
|
||||||
|
password: string | null;
|
||||||
|
tunnel: boolean;
|
||||||
|
pollRate: number;
|
||||||
|
theme: 'dark' | 'light';
|
||||||
|
verbose: boolean;
|
||||||
|
cmuxPath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_CONFIG: Config = {
|
||||||
|
port: 9870,
|
||||||
|
password: null,
|
||||||
|
tunnel: true,
|
||||||
|
pollRate: 200,
|
||||||
|
theme: 'dark',
|
||||||
|
verbose: false,
|
||||||
|
cmuxPath: 'cmux',
|
||||||
|
};
|
||||||
@ -0,0 +1,47 @@
|
|||||||
|
import type { DiffPatch } from '../protocol/messages.js';
|
||||||
|
|
||||||
|
export function computeDiff(oldLines: string[], newLines: string[]): DiffPatch[] {
|
||||||
|
const patches: DiffPatch[] = [];
|
||||||
|
const maxLen = Math.max(oldLines.length, newLines.length);
|
||||||
|
|
||||||
|
let i = 0;
|
||||||
|
while (i < maxLen) {
|
||||||
|
// Skip identical lines
|
||||||
|
if (i < oldLines.length && i < newLines.length && oldLines[i] === newLines[i]) {
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Found a difference — collect consecutive changed lines
|
||||||
|
const start = i;
|
||||||
|
|
||||||
|
// Advance through differing lines
|
||||||
|
while (
|
||||||
|
i < maxLen &&
|
||||||
|
!(i < oldLines.length && i < newLines.length && oldLines[i] === newLines[i])
|
||||||
|
) {
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
|
||||||
|
patches.push({
|
||||||
|
startLine: start,
|
||||||
|
deleteCount: Math.min(i, oldLines.length) - start,
|
||||||
|
insertLines: newLines.slice(start, Math.min(i, newLines.length)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return patches;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyDiff(lines: string[], patches: DiffPatch[]): string[] {
|
||||||
|
const result = [...lines];
|
||||||
|
let offset = 0;
|
||||||
|
|
||||||
|
for (const patch of patches) {
|
||||||
|
const adjustedStart = patch.startLine + offset;
|
||||||
|
result.splice(adjustedStart, patch.deleteCount, ...patch.insertLines);
|
||||||
|
offset += patch.insertLines.length - patch.deleteCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
@ -0,0 +1,75 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { CmuxClient } from '../../src/bridge/cmux-client.js';
|
||||||
|
|
||||||
|
describe('CmuxClient', () => {
|
||||||
|
describe('parseTree', () => {
|
||||||
|
it('parses real cmux tree output with tree-drawing chars', () => {
|
||||||
|
const client = new CmuxClient();
|
||||||
|
const output = `window window:1 [current]
|
||||||
|
├── workspace workspace:1 "My Project" [selected]
|
||||||
|
│ ├── pane pane:2
|
||||||
|
│ │ ├── surface surface:2 [terminal] "zsh"
|
||||||
|
│ │ └── surface surface:13 [terminal] "node"
|
||||||
|
│ └── pane pane:3 [focused]
|
||||||
|
│ └── surface surface:4 [browser] "localhost"
|
||||||
|
└── workspace workspace:2 "Notes"
|
||||||
|
└── pane pane:6 [focused]
|
||||||
|
└── surface surface:6 [terminal] "vim" [selected]`;
|
||||||
|
|
||||||
|
const result = client.parseTree(output);
|
||||||
|
|
||||||
|
expect(result).toHaveLength(2);
|
||||||
|
expect(result[0].ref).toBe('workspace:1');
|
||||||
|
expect(result[0].name).toBe('My Project');
|
||||||
|
expect(result[0].panes).toHaveLength(2);
|
||||||
|
expect(result[0].panes[0].surfaces).toHaveLength(2);
|
||||||
|
expect(result[0].panes[0].surfaces[0].type).toBe('terminal');
|
||||||
|
expect(result[0].panes[0].surfaces[0].title).toBe('zsh');
|
||||||
|
expect(result[0].panes[1].surfaces[0].type).toBe('browser');
|
||||||
|
expect(result[0].panes[1].surfaces[0].title).toBe('localhost');
|
||||||
|
expect(result[1].panes[0].surfaces[0].title).toBe('vim');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles empty output', () => {
|
||||||
|
const client = new CmuxClient();
|
||||||
|
expect(client.parseTree('')).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles workspace without quoted name', () => {
|
||||||
|
const client = new CmuxClient();
|
||||||
|
const output = `window window:1
|
||||||
|
├── workspace workspace:1 [selected]
|
||||||
|
│ └── pane pane:1
|
||||||
|
│ └── surface surface:1 [terminal] [selected]`;
|
||||||
|
|
||||||
|
const result = client.parseTree(output);
|
||||||
|
expect(result[0].name).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('extracts titles with special characters', () => {
|
||||||
|
const client = new CmuxClient();
|
||||||
|
const output = `window window:1
|
||||||
|
└── workspace workspace:5 "✳ cmux-remote-plugin"
|
||||||
|
└── pane pane:9 [focused]
|
||||||
|
└── surface surface:14 [terminal] "⠂ cmux-remote-plugin" [selected]`;
|
||||||
|
|
||||||
|
const result = client.parseTree(output);
|
||||||
|
expect(result[0].name).toBe('✳ cmux-remote-plugin');
|
||||||
|
expect(result[0].panes[0].surfaces[0].title).toBe('⠂ cmux-remote-plugin');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('parseSurfaces', () => {
|
||||||
|
it('parses surface list', () => {
|
||||||
|
const client = new CmuxClient();
|
||||||
|
const output = `surface:1 [terminal] zsh
|
||||||
|
surface:2 [browser] localhost`;
|
||||||
|
|
||||||
|
const result = client.parseSurfaces(output);
|
||||||
|
expect(result).toHaveLength(2);
|
||||||
|
expect(result[0].ref).toBe('surface:1');
|
||||||
|
expect(result[0].type).toBe('terminal');
|
||||||
|
expect(result[1].type).toBe('browser');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -0,0 +1,114 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { ScreenPoller } from '../../src/bridge/screen-poller.js';
|
||||||
|
|
||||||
|
// Mock CmuxClient
|
||||||
|
function createMockClient(screenContent = 'line1\nline2\nline3') {
|
||||||
|
return {
|
||||||
|
readScreen: vi.fn().mockResolvedValue(screenContent),
|
||||||
|
sendText: vi.fn(),
|
||||||
|
sendKey: vi.fn(),
|
||||||
|
} as any;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ScreenPoller', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('emits update on first poll', async () => {
|
||||||
|
const client = createMockClient();
|
||||||
|
const poller = new ScreenPoller(client, 200);
|
||||||
|
const updates: any[] = [];
|
||||||
|
|
||||||
|
poller.on('update', (u: any) => updates.push(u));
|
||||||
|
poller.subscribe('client1', 'workspace:1', 'surface:1');
|
||||||
|
|
||||||
|
// Fast-forward past first poll
|
||||||
|
await vi.advanceTimersByTimeAsync(50);
|
||||||
|
|
||||||
|
expect(updates.length).toBe(1);
|
||||||
|
expect(updates[0].full).toBe(true);
|
||||||
|
expect(updates[0].lines).toEqual(['line1', 'line2', 'line3']);
|
||||||
|
|
||||||
|
poller.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not emit when content is unchanged', async () => {
|
||||||
|
const client = createMockClient();
|
||||||
|
const poller = new ScreenPoller(client, 200);
|
||||||
|
const updates: any[] = [];
|
||||||
|
|
||||||
|
poller.on('update', (u: any) => updates.push(u));
|
||||||
|
poller.subscribe('client1', 'workspace:1', 'surface:1');
|
||||||
|
|
||||||
|
// First poll
|
||||||
|
await vi.advanceTimersByTimeAsync(50);
|
||||||
|
expect(updates.length).toBe(1);
|
||||||
|
|
||||||
|
// Second poll — same content
|
||||||
|
await vi.advanceTimersByTimeAsync(250);
|
||||||
|
expect(updates.length).toBe(1);
|
||||||
|
|
||||||
|
poller.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends diff patches when content changes', async () => {
|
||||||
|
const client = createMockClient('line1\nline2\nline3');
|
||||||
|
const poller = new ScreenPoller(client, 200);
|
||||||
|
const updates: any[] = [];
|
||||||
|
|
||||||
|
poller.on('update', (u: any) => updates.push(u));
|
||||||
|
poller.subscribe('client1', 'workspace:1', 'surface:1');
|
||||||
|
|
||||||
|
// First poll
|
||||||
|
await vi.advanceTimersByTimeAsync(50);
|
||||||
|
expect(updates.length).toBe(1);
|
||||||
|
|
||||||
|
// Change content
|
||||||
|
client.readScreen.mockResolvedValue('line1\nline2-changed\nline3');
|
||||||
|
|
||||||
|
// Second poll
|
||||||
|
await vi.advanceTimersByTimeAsync(250);
|
||||||
|
expect(updates.length).toBe(2);
|
||||||
|
expect(updates[1].full).toBe(false);
|
||||||
|
expect(updates[1].patches).toBeDefined();
|
||||||
|
|
||||||
|
poller.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stops polling when all subscribers unsubscribe', async () => {
|
||||||
|
const client = createMockClient();
|
||||||
|
const poller = new ScreenPoller(client, 200);
|
||||||
|
|
||||||
|
poller.subscribe('client1', 'workspace:1', 'surface:1');
|
||||||
|
await vi.advanceTimersByTimeAsync(50);
|
||||||
|
|
||||||
|
poller.unsubscribe('client1', 'surface:1');
|
||||||
|
|
||||||
|
const callCount = client.readScreen.mock.calls.length;
|
||||||
|
await vi.advanceTimersByTimeAsync(500);
|
||||||
|
|
||||||
|
// Should not have polled again
|
||||||
|
expect(client.readScreen.mock.calls.length).toBe(callCount);
|
||||||
|
|
||||||
|
poller.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resets to fast polling on resetToFast', async () => {
|
||||||
|
const client = createMockClient();
|
||||||
|
const poller = new ScreenPoller(client, 200);
|
||||||
|
|
||||||
|
poller.subscribe('client1', 'workspace:1', 'surface:1');
|
||||||
|
await vi.advanceTimersByTimeAsync(50);
|
||||||
|
|
||||||
|
// Simulate idle
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
await vi.advanceTimersByTimeAsync(250);
|
||||||
|
}
|
||||||
|
|
||||||
|
poller.resetToFast('surface:1');
|
||||||
|
|
||||||
|
// Should have reset idle count internally
|
||||||
|
poller.destroy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -0,0 +1,85 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import { AuthManager, generatePassphrase } from '../../src/server/auth.js';
|
||||||
|
|
||||||
|
describe('AuthManager', () => {
|
||||||
|
let auth: AuthManager;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
auth = new AuthManager();
|
||||||
|
await auth.setPassword('test-password');
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('password verification', () => {
|
||||||
|
it('accepts correct password', async () => {
|
||||||
|
expect(await auth.verifyPassword('test-password')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects wrong password', async () => {
|
||||||
|
expect(await auth.verifyPassword('wrong')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('JWT tokens', () => {
|
||||||
|
it('generates and verifies a valid token', () => {
|
||||||
|
const token = auth.generateToken();
|
||||||
|
expect(auth.verifyToken(token)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects invalid tokens', () => {
|
||||||
|
expect(auth.verifyToken('not-a-real-token')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects tokens from different instance', async () => {
|
||||||
|
const token = auth.generateToken();
|
||||||
|
const other = new AuthManager();
|
||||||
|
await other.setPassword('x');
|
||||||
|
expect(other.verifyToken(token)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('rate limiting', () => {
|
||||||
|
it('allows initial attempts', () => {
|
||||||
|
expect(auth.isRateLimited('1.2.3.4')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blocks after max attempts', () => {
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
auth.recordAttempt('1.2.3.4');
|
||||||
|
}
|
||||||
|
expect(auth.isRateLimited('1.2.3.4')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not affect other IPs', () => {
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
auth.recordAttempt('1.2.3.4');
|
||||||
|
}
|
||||||
|
expect(auth.isRateLimited('5.6.7.8')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resets after successful auth', () => {
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
auth.recordAttempt('1.2.3.4');
|
||||||
|
}
|
||||||
|
auth.resetAttempts('1.2.3.4');
|
||||||
|
expect(auth.isRateLimited('1.2.3.4')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('generatePassphrase', () => {
|
||||||
|
it('generates a 4-word passphrase', () => {
|
||||||
|
const phrase = generatePassphrase();
|
||||||
|
const words = phrase.split('-');
|
||||||
|
expect(words.length).toBe(4);
|
||||||
|
for (const word of words) {
|
||||||
|
expect(word.length).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('generates different passphrases', () => {
|
||||||
|
const a = generatePassphrase();
|
||||||
|
const b = generatePassphrase();
|
||||||
|
// Extremely unlikely to be equal
|
||||||
|
expect(a).not.toBe(b);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -0,0 +1,64 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { computeDiff, applyDiff } from '../../src/utils/text-differ.js';
|
||||||
|
|
||||||
|
describe('text-differ', () => {
|
||||||
|
describe('computeDiff', () => {
|
||||||
|
it('returns empty patches for identical content', () => {
|
||||||
|
const lines = ['a', 'b', 'c'];
|
||||||
|
expect(computeDiff(lines, lines)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects a single line change', () => {
|
||||||
|
const oldLines = ['a', 'b', 'c'];
|
||||||
|
const newLines = ['a', 'B', 'c'];
|
||||||
|
const patches = computeDiff(oldLines, newLines);
|
||||||
|
|
||||||
|
expect(patches.length).toBe(1);
|
||||||
|
expect(patches[0].startLine).toBe(1);
|
||||||
|
expect(patches[0].deleteCount).toBe(1);
|
||||||
|
expect(patches[0].insertLines).toEqual(['B']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects added lines', () => {
|
||||||
|
const oldLines = ['a', 'b'];
|
||||||
|
const newLines = ['a', 'b', 'c', 'd'];
|
||||||
|
const patches = computeDiff(oldLines, newLines);
|
||||||
|
|
||||||
|
expect(patches.length).toBeGreaterThan(0);
|
||||||
|
const result = applyDiff(oldLines, patches);
|
||||||
|
expect(result).toEqual(newLines);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects removed lines', () => {
|
||||||
|
const oldLines = ['a', 'b', 'c', 'd'];
|
||||||
|
const newLines = ['a', 'd'];
|
||||||
|
const patches = computeDiff(oldLines, newLines);
|
||||||
|
|
||||||
|
const result = applyDiff(oldLines, patches);
|
||||||
|
expect(result).toEqual(newLines);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('applyDiff', () => {
|
||||||
|
it('applies patches to reconstruct new content', () => {
|
||||||
|
const old = ['line1', 'line2', 'line3', 'line4', 'line5'];
|
||||||
|
const now = ['line1', 'CHANGED', 'line3', 'ADDED', 'line4', 'line5'];
|
||||||
|
|
||||||
|
const patches = computeDiff(old, now);
|
||||||
|
const result = applyDiff(old, patches);
|
||||||
|
expect(result).toEqual(now);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles empty old lines', () => {
|
||||||
|
const patches = computeDiff([], ['a', 'b']);
|
||||||
|
const result = applyDiff([], patches);
|
||||||
|
expect(result).toEqual(['a', 'b']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles empty new lines', () => {
|
||||||
|
const patches = computeDiff(['a', 'b'], []);
|
||||||
|
const result = applyDiff(['a', 'b'], patches);
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "Node16",
|
||||||
|
"moduleResolution": "Node16",
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"declaration": true,
|
||||||
|
"sourceMap": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["node_modules", "dist", "test"]
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue