fix: keep local terminal usable without a network connection

This commit is contained in:
toorux committed 2026-10-06 19:47:22 +08:00
1 parent abe7f6e49d
commit e32d3d9155
1 file changed
+4 -3
+4 -3
View File
@@ -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<Session[]>([]),[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<Session|null>(null),[name,setName]=useState('');
const localPanel=['localhost','127.0.0.1','[::1]'].includes(location.hostname),reachable=()=>navigator.onLine!==false||localPanel;
const host=useRef<HTMLDivElement>(null),socket=useRef<WebSocket|null>(null),term=useRef<Terminal|null>(null),fit=useRef<FitAddon|null>(null),reconnect=useRef(()=>{});
async function refresh(){try{const next=await managerApi<Session[]>('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<typeof setInterval>|undefined,retry:ReturnType<typeof setTimeout>|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 <section className="manager-tool"><div className="list-heading"><div><h1>{t('终端')}</h1><p>{t('以 Steam 会话用户运行;可在终端内使用 sudo。')}</p></div><button onClick={()=>void create()}>{t('新建终端')}</button></div>{error&&<p className="error">{error}</p>}
<div className="terminal-sessions">{sessions.map(s=><div key={s.id}><button className={selected===s.id?'active':''} onClick={()=>{setSelected(s.id);setName(s.name);}}>{s.name}{s.exited?' · '+t('已结束'):''}</button><button aria-label={t('关闭终端')} onClick={()=>setClose(s)}>×</button></div>)}</div>