From d3f2a83249f32b3bb764eb569ba9117e162a248f Mon Sep 17 00:00:00 2001 From: Mitchell-Johnson Date: Sat, 19 Sep 2026 10:44:17 -0700 Subject: [PATCH] Terminal: Ctrl+C copies a selection (else ^C), Ctrl+V pastes, over http too; drop Log out everywhere Co-Authored-By: Claude Fable 5.1 --- BUGS.md | 10 ++++++++-- internal/api/server.go | 10 ---------- internal/auth/auth.go | 9 --------- web/src/Settings.tsx | 6 ------ web/src/TerminalPane.tsx | 20 ++++++++++++++++++++ web/src/api.ts | 1 - 6 files changed, 28 insertions(+), 28 deletions(-) diff --git a/BUGS.md b/BUGS.md index 20b4569..98e4a09 100644 --- a/BUGS.md +++ b/BUGS.md @@ -204,5 +204,11 @@ and `every=` values on the sensor API; phone drawer, key bar and picker. 10. ✅ **Terminal tabs reorder** — drag on a desktop, ‹ › on a touch screen, remembered per machine in the browser — and **Alt+Shift+←/→** switches tabs, **Alt+Shift+T** opens one, with the keyboard left in the terminal each time. -11. ✅ **"Log out everywhere"** in Settings ends every session on every device (tokens are - not sessions and keep working). +11. ➖ **"Log out everywhere"** — built, then removed the same day at Mitchell's word: no need + for it on a single-user console. +12. ✅ **Real clipboard shortcuts in the terminal**, from a physical keyboard: **Ctrl+C copies + when text is selected** (and is still ^C when nothing is — the selection clears after a + copy, so a second Ctrl+C interrupts), **Ctrl+V pastes**, Ctrl+Shift+C / Ctrl+Shift+V do + the same unconditionally, Ctrl+Insert / Shift+Insert too. It rides the browser's own + copy and paste events, which xterm already answers, so it works over plain http on the + LAN where `navigator.clipboard` does not exist; bracketed paste is honoured. diff --git a/internal/api/server.go b/internal/api/server.go index b3455c4..bfa2298 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -107,7 +107,6 @@ func New(cfg *config.Config, sessions *auth.Sessions, mgr *term.Manager, col *me m.HandleFunc("POST /api/machines/{name}/update", s.auth(s.updateMachine)) m.HandleFunc("POST /api/machines-update-all", s.auth(s.updateAllMachines)) m.HandleFunc("POST /api/logout", s.auth(s.logout)) - m.HandleFunc("POST /api/logout/all", s.auth(s.logoutAll)) m.HandleFunc("GET /api/me", s.auth(s.me)) m.HandleFunc("GET /api/terminals", s.auth(s.listTerminals)) m.HandleFunc("POST /api/terminals", s.auth(s.createTerminal)) @@ -709,15 +708,6 @@ func (s *Server) logout(w http.ResponseWriter, r *http.Request) { w.WriteHeader(204) } -// logoutAll ends every session, this one included: a lost phone, a borrowed laptop. -// Tokens are not sessions and are untouched; revoke those by name. -func (s *Server) logoutAll(w http.ResponseWriter, r *http.Request) { - n := s.sessions.DeleteAll() - log.Printf("all %d session(s) ended (from %s)", n, clientIP(r)) - s.setCookie(w, r, "", -1) - jsonOK(w, 200, map[string]int{"ended": n}) -} - // clientKey is the address the limiter buckets by: the first X-Forwarded-For hop when a // reverse proxy set it, else the socket peer. func clientKey(r *http.Request) string { diff --git a/internal/auth/auth.go b/internal/auth/auth.go index 8343180..80a5bd1 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -146,15 +146,6 @@ func (s *Sessions) Valid(tok string) bool { return ok } -// DeleteAll ends every session on every device and says how many there were. -func (s *Sessions) DeleteAll() (n int) { - _ = s.st.Update(func(state *store.State) { - n = len(state.Sessions) - state.Sessions = map[string]time.Time{} - }) - return n -} - func (s *Sessions) Delete(tok string) { _ = s.st.Update(func(state *store.State) { delete(state.Sessions, tok) }) } diff --git a/web/src/Settings.tsx b/web/src/Settings.tsx index 6efec96..ab5dc1f 100644 --- a/web/src/Settings.tsx +++ b/web/src/Settings.tsx @@ -124,12 +124,6 @@ export default function Settings({ me }: { me: Me }) { -

Sessions

-
-
Signs out every browser and phone that is logged in to this Ham Lab, this one included. Access tokens are not sessions and keep working.
- { try { await api.logoutAll() } finally { window.dispatchEvent(new Event('hamlab:unauthenticated')) } }}>Log out everywhere -
- {making && setMaking(false)} onMade={(t) => { setMaking(false); setFresh(t); loadTokens() }} />}

About

diff --git a/web/src/TerminalPane.tsx b/web/src/TerminalPane.tsx index 67c76c6..3666376 100644 --- a/web/src/TerminalPane.tsx +++ b/web/src/TerminalPane.tsx @@ -84,6 +84,26 @@ export default function TerminalPane({ id, active, register, onStatus, onExit }: } connect() + // Ctrl+C / Ctrl+V as on any desktop. Returning false tells xterm to leave the key alone, + // and *not* preventing the default lets the browser fire its own copy / paste event — + // which xterm answers with its selection, or pastes (bracketed, if the shell asked). That + // path needs no navigator.clipboard, so it works over plain http on the LAN as well. + term.attachCustomKeyEventHandler((e) => { + if (e.type !== 'keydown') return true + const k = e.key.toLowerCase(), ctrl = e.ctrlKey && !e.altKey && !e.metaKey + const copy = (ctrl && k === 'c' && (e.shiftKey || term.hasSelection())) || (ctrl && k === 'insert') + const paste = (ctrl && k === 'v') || (e.shiftKey && !ctrl && k === 'insert') + if (copy) { + if (!term.hasSelection()) { e.preventDefault(); return false } // Ctrl+Shift+C with nothing selected: not devtools, not ^C + if (e.shiftKey) { e.preventDefault(); document.execCommand('copy') } // Chrome keeps Ctrl+Shift+C for itself; copy by hand + if (navigator.clipboard) navigator.clipboard.writeText(term.getSelection()).catch(() => {}) + window.setTimeout(() => term.clearSelection(), 50) // so the next Ctrl+C is an interrupt again + return false + } + if (paste) return false + return true + }) + term.onData((d) => { if (ctrlArmed && d.length === 1) { ctrlArmed = false diff --git a/web/src/api.ts b/web/src/api.ts index 0ca8211..10d6144 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -182,7 +182,6 @@ export const api = { changePassword: (current: string, password: string) => req('POST', '/api/password', { current, password }, true), tokens: () => req<{ name: string; created: string; scope?: string; sensors?: string[] }[]>('GET', '/api/tokens', undefined, true), createReadToken: (name: string, sensors: string[] = []) => req<{ token: string; name: string }>('POST', '/api/tokens/read', { name, sensors }, true), - logoutAll: () => req<{ ended: number }>('POST', '/api/logout/all', undefined, true), revokeToken: (name: string) => req('DELETE', `/api/tokens/${encodeURIComponent(name)}`, undefined, true), uiPrefs: () => req<{ theme?: string; refreshMs?: number }>('GET', '/api/prefs/ui', undefined, true), saveUIPrefs: (p: { theme?: string; refreshMs?: number }) => req('PUT', '/api/prefs/ui', p, true),