HelloSun commited on
Commit
1500bc8
Β·
verified Β·
1 Parent(s): 496914b

Create app.py

Browse files
Files changed (1) hide show
  1. app.py +233 -0
app.py ADDED
@@ -0,0 +1,233 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import pty
3
+ import signal
4
+ import struct
5
+ import fcntl
6
+ import termios
7
+ import asyncio
8
+ import json
9
+ from fastapi import FastAPI, WebSocket, WebSocketDisconnect
10
+ from fastapi.responses import HTMLResponse
11
+ import uvicorn
12
+
13
+ app = FastAPI()
14
+
15
+ # ──────────────────────────── Frontend ────────────────────────────
16
+ HTML_PAGE = r"""<!DOCTYPE html>
17
+ <html lang="en">
18
+ <head>
19
+ <meta charset="utf-8"/>
20
+ <meta name="viewport" content="width=device-width,initial-scale=1"/>
21
+ <title>Web Terminal</title>
22
+ <link rel="stylesheet"
23
+ href="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/css/xterm.min.css"/>
24
+ <style>
25
+ *{margin:0;padding:0;box-sizing:border-box}
26
+ html,body{height:100%;overflow:hidden;background:#1e1e1e;color:#ccc;
27
+ font-family:'Segoe UI',system-ui,sans-serif}
28
+ #tab-bar{display:flex;align-items:flex-end;background:#252526;
29
+ padding:4px 6px 0;gap:2px;border-bottom:1px solid #3c3c3c;
30
+ min-height:38px;flex-shrink:0;overflow-x:auto}
31
+ .tab{background:#2d2d2d;color:#969696;padding:6px 14px 5px;
32
+ border-radius:6px 6px 0 0;cursor:pointer;font-size:13px;
33
+ display:flex;align-items:center;gap:8px;user-select:none;
34
+ white-space:nowrap;border:1px solid transparent;border-bottom:none}
35
+ .tab:hover{color:#ddd}
36
+ .tab.active{background:#1e1e1e;color:#fff;border-color:#3c3c3c}
37
+ .tab .x{color:#666;font-size:15px;line-height:1;padding:0 2px}
38
+ .tab .x:hover{color:#f55}
39
+ #add-btn{background:none;border:1px dashed #555;color:#777;
40
+ padding:3px 12px;border-radius:4px;cursor:pointer;
41
+ font-size:18px;margin-bottom:4px;margin-left:4px}
42
+ #add-btn:hover{color:#fff;border-color:#aaa}
43
+ #term-box{flex:1;overflow:hidden;padding:2px}
44
+ .tw{width:100%;height:100%;display:none}
45
+ .tw.active{display:block}
46
+ body{display:flex;flex-direction:column}
47
+ </style>
48
+ </head>
49
+ <body>
50
+ <div id="tab-bar"><button id="add-btn" title="New Terminal (Ctrl+Shift+T)">+</button></div>
51
+ <div id="term-box"></div>
52
+
53
+ <script src="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/lib/xterm.min.js"></script>
54
+ <script src="https://cdn.jsdelivr.net/npm/@xterm/addon-fit@0.10.0/lib/addon-fit.min.js"></script>
55
+ <script src="https://cdn.jsdelivr.net/npm/@xterm/addon-web-links@0.11.0/lib/addon-web-links.min.js"></script>
56
+ <script>
57
+ const bar=document.getElementById('tab-bar'),
58
+ box=document.getElementById('term-box'),
59
+ addBtn=document.getElementById('add-btn');
60
+ let tabs=[],activeId=null,cnt=0;
61
+
62
+ function wsUrl(){
63
+ const p=location.protocol==='https:'?'wss:':'ws:';
64
+ return `${p}//${location.host}/ws`;
65
+ }
66
+
67
+ function createTab(){
68
+ const id=++cnt;
69
+ /* tab button */
70
+ const el=document.createElement('div');
71
+ el.className='tab';el.dataset.id=id;
72
+ el.innerHTML=`<span>Term ${id}</span><span class="x">&times;</span>`;
73
+ bar.insertBefore(el,addBtn);
74
+ /* wrapper */
75
+ const wrap=document.createElement('div');
76
+ wrap.className='tw';wrap.dataset.id=id;
77
+ box.appendChild(wrap);
78
+ /* xterm */
79
+ const term=new Terminal({
80
+ cursorBlink:true,fontSize:14,
81
+ fontFamily:"'Cascadia Code','Fira Code',Consolas,monospace",
82
+ allowProposedApi:true,
83
+ theme:{background:'#1e1e1e',foreground:'#d4d4d4',cursor:'#aeafad',
84
+ selectionBackground:'#264f78'}
85
+ });
86
+ const fit=new FitAddon.FitAddon();
87
+ term.loadAddon(fit);
88
+ term.loadAddon(new WebLinksAddon.WebLinksAddon());
89
+ term.open(wrap);
90
+ fit.fit();
91
+ /* websocket */
92
+ const ws=new WebSocket(wsUrl());
93
+ ws.onopen=()=>ws.send(JSON.stringify({type:'resize',cols:term.cols,rows:term.rows}));
94
+ ws.onmessage=e=>{
95
+ try{const m=JSON.parse(e.data);if(m.type==='output')term.write(m.data);}
96
+ catch{term.write(e.data);}
97
+ };
98
+ ws.onclose=()=>term.write('\r\n\x1b[31m[Connection closed]\x1b[0m\r\n');
99
+ term.onData(d=>{if(ws.readyState===1)ws.send(JSON.stringify({type:'input',data:d}))});
100
+ term.onResize(({cols,rows})=>{
101
+ if(ws.readyState===1)ws.send(JSON.stringify({type:'resize',cols,rows}));
102
+ });
103
+ /* events */
104
+ el.onclick=e=>{
105
+ if(e.target.classList.contains('x'))closeTab(id);
106
+ else activate(id);
107
+ };
108
+ const obj={id,el,wrap,term,fit,ws};
109
+ tabs.push(obj);
110
+ activate(id);
111
+ return obj;
112
+ }
113
+
114
+ function activate(id){
115
+ activeId=id;
116
+ tabs.forEach(t=>{
117
+ const on=t.id===id;
118
+ t.el.classList.toggle('active',on);
119
+ t.wrap.classList.toggle('active',on);
120
+ if(on)setTimeout(()=>{t.fit.fit();t.term.focus()},30);
121
+ });
122
+ }
123
+
124
+ function closeTab(id){
125
+ const i=tabs.findIndex(t=>t.id===id);
126
+ if(i<0)return;
127
+ const t=tabs[i];
128
+ t.ws.close();t.term.dispose();t.el.remove();t.wrap.remove();
129
+ tabs.splice(i,1);
130
+ if(!tabs.length)return createTab();
131
+ if(activeId===id)activate(tabs[Math.min(i,tabs.length-1)].id);
132
+ }
133
+
134
+ addBtn.onclick=createTab;
135
+ window.addEventListener('resize',()=>tabs.forEach(t=>{
136
+ if(t.wrap.classList.contains('active'))t.fit.fit();
137
+ }));
138
+ document.addEventListener('keydown',e=>{
139
+ if(e.ctrlKey&&e.shiftKey&&e.key==='T'){e.preventDefault();createTab();}
140
+ if(e.ctrlKey&&e.shiftKey&&e.key==='W'){e.preventDefault();if(activeId)closeTab(activeId);}
141
+ });
142
+ createTab();
143
+ </script>
144
+ </body>
145
+ </html>"""
146
+
147
+ # ──────────────────────────── Routes ──────────────────────────────
148
+
149
+ @app.get("/")
150
+ async def index():
151
+ return HTMLResponse(HTML_PAGE)
152
+
153
+
154
+ @app.websocket("/ws")
155
+ async def terminal_ws(websocket: WebSocket):
156
+ await websocket.accept()
157
+
158
+ # ── fork a bash process inside a pty ──
159
+ pid, fd = pty.fork()
160
+ if pid == 0: # child
161
+ os.environ["TERM"] = "xterm-256color"
162
+ os.environ["COLORTERM"] = "truecolor"
163
+ os.execvp("bash", ["bash", "--login"])
164
+
165
+ # ── parent ──
166
+ loop = asyncio.get_running_loop()
167
+
168
+ def set_winsize(cols: int, rows: int):
169
+ fcntl.ioctl(fd, termios.TIOCSWINSZ,
170
+ struct.pack("HHHH", rows, cols, 0, 0))
171
+
172
+ set_winsize(120, 30)
173
+
174
+ # non-blocking master fd
175
+ fl = fcntl.fcntl(fd, fcntl.F_GETFL)
176
+ fcntl.fcntl(fd, fcntl.F_SETFL, fl | os.O_NONBLOCK)
177
+
178
+ queue: asyncio.Queue = asyncio.Queue()
179
+
180
+ def _on_read():
181
+ try:
182
+ data = os.read(fd, 65536)
183
+ queue.put_nowait(data if data else None)
184
+ except (BlockingIOError, OSError):
185
+ queue.put_nowait(None)
186
+
187
+ loop.add_reader(fd, _on_read)
188
+
189
+ async def _pty_to_ws():
190
+ while True:
191
+ chunk = await queue.get()
192
+ if chunk is None:
193
+ break
194
+ try:
195
+ await websocket.send_text(
196
+ json.dumps({"type": "output",
197
+ "data": chunk.decode("utf-8", errors="replace")}))
198
+ except Exception:
199
+ break
200
+
201
+ sender = asyncio.create_task(_pty_to_ws())
202
+
203
+ try:
204
+ while True:
205
+ raw = await websocket.receive_text()
206
+ msg = json.loads(raw)
207
+ if msg["type"] == "input":
208
+ os.write(fd, msg["data"].encode("utf-8"))
209
+ elif msg["type"] == "resize":
210
+ set_winsize(msg["cols"], msg["rows"])
211
+ except (WebSocketDisconnect, Exception):
212
+ pass
213
+ finally:
214
+ loop.remove_reader(fd)
215
+ sender.cancel()
216
+ for sig in (signal.SIGTERM, signal.SIGKILL):
217
+ try:
218
+ os.kill(pid, sig)
219
+ os.waitpid(pid, 0)
220
+ break
221
+ except Exception:
222
+ pass
223
+ try:
224
+ os.close(fd)
225
+ except Exception:
226
+ pass
227
+
228
+
229
+ # ──────────────────────────── Entry ───────────────────────────────
230
+ if __name__ == "__main__":
231
+ port = int(os.environ.get("PORT", 7860))
232
+ print(f"πŸš€ Terminal running on 0.0.0.0:{port}")
233
+ uvicorn.run(app, host="0.0.0.0", port=port, log_level="info")