From e32d3d915571dcb9cc064cd1ae2a198f64fb1737 Mon Sep 17 00:00:00 2001 From: toorux Date: Tue, 6 Oct 2026 19:47:22 +0800 Subject: [PATCH] fix: keep local terminal usable without a network connection --- ui/src/terminal.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/ui/src/terminal.tsx b/ui/src/terminal.tsx index 7cb5ef4..81eac92 100644 --- a/ui/src/terminal.tsx +++ b/ui/src/terminal.tsx @@ -8,6 +8,7 @@ import './manager-tools.css'; type Session={id:string;name:string;exited:boolean;controlled:boolean}; export function TerminalManager(){ const[sessions,setSessions]=useState([]),[selected,setSelected]=useState(()=>sessionStorage.getItem('framely-terminal-session')??''),[error,setError]=useState(''),[connected,setConnected]=useState(false),[writable,setWritable]=useState(false),[exited,setExited]=useState(false),[truncated,setTruncated]=useState(false),[close,setClose]=useState(null),[name,setName]=useState(''); + const localPanel=['localhost','127.0.0.1','[::1]'].includes(location.hostname),reachable=()=>navigator.onLine!==false||localPanel; const host=useRef(null),socket=useRef(null),term=useRef(null),fit=useRef(null),reconnect=useRef(()=>{}); async function refresh(){try{const next=await managerApi('terminal',{operation:'list'});setSessions(next);setSelected(previous=>next.some(s=>s.id===previous)?previous:'');}catch(e){setError(String(e));}} useEffect(()=>{if(!name&&selected){const active=sessions.find(s=>s.id===selected);if(active)setName(active.name);}},[sessions,selected]); @@ -18,15 +19,15 @@ export function TerminalManager(){ let alive=true,lastSeen=performance.now(),timer:ReturnType|undefined,retry:ReturnType|undefined; const send=(v:object)=>{if(socket.current?.readyState===WebSocket.OPEN)socket.current.send(JSON.stringify(v));}; const encode=(s:string)=>{let bytes='';for(const v of new TextEncoder().encode(s))bytes+=String.fromCharCode(v);return btoa(bytes);}; - const input=terminal.onData(data=>{if(navigator.onLine&&performance.now()-lastSeen<3000)send({input:encode(data)});}); + const input=terminal.onData(data=>{if(reachable()&&performance.now()-lastSeen<3000)send({input:encode(data)});}); const resize=()=>{try{fitter.fit();send({cols:terminal.cols,rows:terminal.rows});}catch{}}; const observer=new ResizeObserver(resize);observer.observe(host.current); - function connect(){if(!alive)return;if(timer)clearInterval(timer);if(retry)clearTimeout(retry);socket.current?.close();terminal.options.disableStdin=true;setConnected(false);setWritable(false);if(!navigator.onLine)return;lastSeen=performance.now();terminal.reset();setTruncated(false);setExited(false);const ws=new WebSocket(`${location.protocol==='https:'?'wss:':'ws:'}//${location.host}/manager-api/terminal/ws?id=${encodeURIComponent(selected)}`);socket.current=ws; + function connect(){if(!alive)return;if(timer)clearInterval(timer);if(retry)clearTimeout(retry);socket.current?.close();terminal.options.disableStdin=true;setConnected(false);setWritable(false);if(!reachable())return;lastSeen=performance.now();terminal.reset();setTruncated(false);setExited(false);const ws=new WebSocket(`${location.protocol==='https:'?'wss:':'ws:'}//${location.host}/manager-api/terminal/ws?id=${encodeURIComponent(selected)}`);socket.current=ws; ws.onopen=()=>{if(!alive||socket.current!==ws)return;setConnected(true);setError('');resize();timer=setInterval(()=>{if(performance.now()-lastSeen>=3000){terminal.options.disableStdin=true;setConnected(false);setWritable(false);ws.close();return;}if(ws.readyState===WebSocket.OPEN)ws.send('{}');},40);}; ws.onmessage=e=>{if(!alive||socket.current!==ws)return;lastSeen=performance.now();try{const v=JSON.parse(e.data);setWritable(v.writable);terminal.options.disableStdin=!v.writable||v.exited;setExited(v.exited);if(v.truncated)setTruncated(true);if(v.data){const raw=atob(v.data);terminal.write(Uint8Array.from(raw,c=>c.charCodeAt(0)));}}catch(err){setError(String(err));}}; ws.onclose=()=>{if(!alive||socket.current!==ws)return;if(timer)clearInterval(timer);setConnected(false);setWritable(false);terminal.options.disableStdin=true;retry=setTimeout(connect,2000);};ws.onerror=()=>setError(t('终端连接中断,正在重连。')); } - const offline=()=>{terminal.options.disableStdin=true;setConnected(false);setWritable(false);socket.current?.close();};window.addEventListener('offline',offline);window.addEventListener('online',connect);reconnect.current=connect;connect();return()=>{window.removeEventListener('offline',offline);window.removeEventListener('online',connect);alive=false;if(timer)clearInterval(timer);if(retry)clearTimeout(retry);observer.disconnect();input.dispose();socket.current?.close();socket.current=null;terminal.dispose();term.current=null;}; + const offline=()=>{if(localPanel)return;terminal.options.disableStdin=true;setConnected(false);setWritable(false);socket.current?.close();};window.addEventListener('offline',offline);window.addEventListener('online',connect);reconnect.current=connect;connect();return()=>{window.removeEventListener('offline',offline);window.removeEventListener('online',connect);alive=false;if(timer)clearInterval(timer);if(retry)clearTimeout(retry);observer.disconnect();input.dispose();socket.current?.close();socket.current=null;terminal.dispose();term.current=null;}; },[selected]); return

{t('终端')}

{t('以 Steam 会话用户运行;可在终端内使用 sudo。')}

{error&&

{error}

}
{sessions.map(s=>
)}