CORVO-AI commited on
Commit
ba293dc
·
verified ·
1 Parent(s): 05ba1d1

Update file-manager/app.py

Browse files
Files changed (1) hide show
  1. file-manager/app.py +185 -68
file-manager/app.py CHANGED
@@ -1,4 +1,4 @@
1
- import os, shutil, html, urllib.parse, pty, select, termios, struct, fcntl, signal, threading, json
2
  from flask import Flask, request, redirect, send_file, Response
3
  from werkzeug.middleware.dispatcher import DispatcherMiddleware
4
  from werkzeug.serving import run_simple
@@ -21,28 +21,31 @@ def mask_secrets(text):
21
  return "".join(out)
22
 
23
  # =================================================================
24
- # PERSISTENT PTY SHELL (real terminal: buffering, prompts, input,
25
- # cd persistence, colors). Starts at the system root "/".
26
  # =================================================================
 
 
27
  class PtyShell:
28
- def __init__(self):
 
 
29
  self.pid, self.fd = pty.fork()
30
  if self.pid == 0:
31
  os.environ["TERM"] = "xterm-256color"
32
- os.chdir("/") # start at the system root
33
  os.execvp("bash", ["bash", "-i"])
34
  else:
35
  flags = fcntl.fcntl(self.fd, fcntl.F_GETFL)
36
  fcntl.fcntl(self.fd, fcntl.F_SETFL, flags | os.O_NONBLOCK)
37
  self._set_size(40, 120)
38
- self.buffer = ""
 
39
  self.lock = threading.Lock()
40
  threading.Thread(target=self._reader, daemon=True).start()
41
 
42
  def _set_size(self, rows, cols):
43
  try:
44
- fcntl.ioctl(self.fd, termios.TIOCSWINSZ,
45
- struct.pack("HHHH", rows, cols, 0, 0))
46
  except Exception:
47
  pass
48
 
@@ -55,7 +58,9 @@ class PtyShell:
55
  if not data:
56
  break
57
  with self.lock:
58
- self.buffer += data.decode(errors="replace")
 
 
59
  except OSError:
60
  break
61
 
@@ -65,11 +70,16 @@ class PtyShell:
65
  except OSError:
66
  pass
67
 
68
- def read_new(self):
 
 
 
 
 
 
69
  with self.lock:
70
- data = self.buffer
71
- self.buffer = ""
72
- return mask_secrets(data)
73
 
74
  def alive(self):
75
  try:
@@ -84,16 +94,32 @@ class PtyShell:
84
  except OSError:
85
  pass
86
 
87
- # --- Multiple independent PTY shells, keyed by session id ---
88
- _shells = {}
 
 
 
 
 
 
 
 
 
89
 
90
- def get_shell(sid):
91
- sh = _shells.get(sid)
92
- if sh is None or not sh.alive():
93
- sh = PtyShell()
94
- _shells[sid] = sh
95
  return sh
96
 
 
 
 
 
 
 
 
97
  PAGE = """<!doctype html><html><head><title>Box</title>
98
  <style>
99
  body{{font-family:monospace;margin:20px;background:#0b0b0b;color:#eee}}
@@ -101,77 +127,168 @@ a{{color:#29BEFD;text-decoration:none}} a:hover{{text-decoration:underline}}
101
  table{{border-collapse:collapse;width:100%}} td,th{{padding:4px 8px;border-bottom:1px solid #222;text-align:left}}
102
  .bar{{background:#1a1a1a;padding:10px;margin-bottom:10px;border-radius:6px}}
103
  .tabs a{{margin-right:14px;font-weight:bold}}
104
- textarea{{width:100%;background:#000;color:#47E6C1;border:1px solid #333;padding:8px}}
 
105
  pre{{background:#000;color:#e6e6e6;padding:12px;border-radius:6px;overflow:auto;height:60vh;white-space:pre-wrap}}
106
  input[type=text]{{background:#000;color:#eee;border:1px solid #333;padding:8px;font-family:monospace}}
 
107
  button{{background:#F46821;color:#fff;border:0;padding:8px 14px;border-radius:4px;cursor:pointer}}
108
  </style></head><body>
109
- <div class="tabs bar"><a href="/fm/">📁 Files</a>
110
- <a href="/fm/term" target="_blank">🖥️ New Terminal</a>
111
  <a href="/reload">♻️ Reload</a><a href="/gpuinfo">🎮 GPU</a>
112
  <span style="color:#777">test mode · ephemeral</span></div>
113
  {body}</body></html>"""
114
 
115
- # ---------------- TERMINAL PAGE (xterm.js, unique session per tab) ----------------
116
  @app.route("/term")
117
  def term_page():
118
  body = """
119
- <div class="bar"><b>Live terminal (xterm.js)</b> — each tab is its own shell, starts at /.</div>
120
- <div id="term" style="height:70vh;background:#000;border-radius:6px"></div>
 
 
 
 
 
121
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/xterm@5.3.0/css/xterm.css"/>
122
  <script src="https://cdn.jsdelivr.net/npm/xterm@5.3.0/lib/xterm.js"></script>
123
  <script src="https://cdn.jsdelivr.net/npm/xterm-addon-fit@0.8.0/lib/xterm-addon-fit.js"></script>
124
  <script>
125
- const SID = (crypto.randomUUID ? crypto.randomUUID()
126
- : String(Date.now()) + Math.random());
127
- const term = new Terminal({convertEol:false, cursorBlink:true,
128
- fontFamily:'monospace', fontSize:13});
129
- const fit = new FitAddon.FitAddon();
130
- term.loadAddon(fit);
131
- term.open(document.getElementById('term'));
132
- fit.fit();
133
- window.addEventListener('resize', () => fit.fit());
134
-
135
- term.onData(d => {
136
- fetch('/fm/term_in', {method:'POST',
137
- headers:{'Content-Type':'application/json'},
138
- body: JSON.stringify({sid: SID, data: d})});
139
- });
140
-
141
- async function poll(){
142
- try{
143
- const r = await fetch('/fm/term_out?sid=' + SID);
144
- const j = await r.json();
145
- if(j.data) term.write(j.data);
146
- }catch(e){}
147
- setTimeout(poll, 200);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
148
  }
149
- poll();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
150
  </script>"""
151
  return Response(PAGE.format(body=body))
152
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
153
  @app.route("/term_in", methods=["POST"])
154
  def term_in():
155
- data = request.get_json(force=True)
156
- get_shell(data.get("sid", "default")).write(data.get("data", ""))
157
- return Response(json.dumps({"ok": True}), mimetype="application/json")
 
 
158
 
159
  @app.route("/term_out")
160
  def term_out():
161
- sid = request.args.get("sid", "default")
162
- return Response(json.dumps({"data": get_shell(sid).read_new()}), mimetype="application/json")
163
-
164
- @app.route("/term_reset", methods=["POST"])
165
- def term_reset():
166
- data = request.get_json(force=True)
167
- sid = data.get("sid", "default")
168
- if sid in _shells:
169
- _shells[sid].kill()
170
- del _shells[sid]
171
- get_shell(sid)
172
- return Response(json.dumps({"ok": True}), mimetype="application/json")
173
 
174
- # ---------------- FILE MANAGER ----------------
175
  @app.route("/")
176
  def browse():
177
  path = os.path.abspath(request.args.get("path", "/"))
@@ -285,7 +402,7 @@ def touch():
285
  if name:
286
  full = os.path.join(base, name)
287
  if not os.path.exists(full):
288
- open(full, "a").close() # create empty file
289
  return redirect(f"/fm/edit?path={urllib.parse.quote(full)}")
290
  return redirect(f"/fm/?path={urllib.parse.quote(base)}")
291
 
@@ -309,4 +426,4 @@ def delete():
309
 
310
  if __name__ == "__main__":
311
  wrapped = DispatcherMiddleware(Flask("empty"), {"/fm": app})
312
- run_simple("0.0.0.0", 9001, wrapped, threaded=True) # threaded for concurrent poll+input
 
1
+ import os, shutil, html, urllib.parse, pty, select, termios, struct, fcntl, signal, threading, json, time
2
  from flask import Flask, request, redirect, send_file, Response
3
  from werkzeug.middleware.dispatcher import DispatcherMiddleware
4
  from werkzeug.serving import run_simple
 
21
  return "".join(out)
22
 
23
  # =================================================================
24
+ # PERSISTENT PTY SHELL with scrollback (reattachable). Starts at "/".
 
25
  # =================================================================
26
+ SCROLLBACK_LIMIT = 200_000 # chars of history kept per shell
27
+
28
  class PtyShell:
29
+ def __init__(self, name):
30
+ self.name = name
31
+ self.created = time.time()
32
  self.pid, self.fd = pty.fork()
33
  if self.pid == 0:
34
  os.environ["TERM"] = "xterm-256color"
35
+ os.chdir("/")
36
  os.execvp("bash", ["bash", "-i"])
37
  else:
38
  flags = fcntl.fcntl(self.fd, fcntl.F_GETFL)
39
  fcntl.fcntl(self.fd, fcntl.F_SETFL, flags | os.O_NONBLOCK)
40
  self._set_size(40, 120)
41
+ self.history = "" # full scrollback (for reattach)
42
+ self.pending = {} # per-viewer unread cursor: viewer_id -> index into history
43
  self.lock = threading.Lock()
44
  threading.Thread(target=self._reader, daemon=True).start()
45
 
46
  def _set_size(self, rows, cols):
47
  try:
48
+ fcntl.ioctl(self.fd, termios.TIOCSWINSZ, struct.pack("HHHH", rows, cols, 0, 0))
 
49
  except Exception:
50
  pass
51
 
 
58
  if not data:
59
  break
60
  with self.lock:
61
+ self.history += data.decode(errors="replace")
62
+ if len(self.history) > SCROLLBACK_LIMIT:
63
+ self.history = self.history[-SCROLLBACK_LIMIT:]
64
  except OSError:
65
  break
66
 
 
70
  except OSError:
71
  pass
72
 
73
+ def snapshot(self):
74
+ # full history — used when (re)attaching to replay what happened
75
+ with self.lock:
76
+ return mask_secrets(self.history), len(self.history)
77
+
78
+ def read_from(self, cursor):
79
+ # incremental output since a given cursor position
80
  with self.lock:
81
+ new = self.history[cursor:]
82
+ return mask_secrets(new), len(self.history)
 
83
 
84
  def alive(self):
85
  try:
 
94
  except OSError:
95
  pass
96
 
97
+ # --- Session manager: named shells that outlive browser tabs ---
98
+ _shells = {} # name -> PtyShell
99
+ _counter = {"n": 0}
100
+ _slock = threading.Lock()
101
+
102
+ def create_shell():
103
+ with _slock:
104
+ _counter["n"] += 1
105
+ name = f"Terminal {_counter['n']}"
106
+ _shells[name] = PtyShell(name)
107
+ return name
108
 
109
+ def get_shell(name):
110
+ sh = _shells.get(name)
111
+ if sh and not sh.alive():
112
+ del _shells[name]
113
+ return None
114
  return sh
115
 
116
+ def list_shells():
117
+ # prune dead ones, return live session names
118
+ for n in list(_shells.keys()):
119
+ if not _shells[n].alive():
120
+ del _shells[n]
121
+ return sorted(_shells.keys(), key=lambda n: _shells[n].created)
122
+
123
  PAGE = """<!doctype html><html><head><title>Box</title>
124
  <style>
125
  body{{font-family:monospace;margin:20px;background:#0b0b0b;color:#eee}}
 
127
  table{{border-collapse:collapse;width:100%}} td,th{{padding:4px 8px;border-bottom:1px solid #222;text-align:left}}
128
  .bar{{background:#1a1a1a;padding:10px;margin-bottom:10px;border-radius:6px}}
129
  .tabs a{{margin-right:14px;font-weight:bold}}
130
+ .tab{{display:inline-block;padding:6px 14px;margin-right:6px;background:#1a1a1a;border-radius:6px 6px 0 0;cursor:pointer}}
131
+ .tab.active{{background:#F46821;color:#fff}}
132
  pre{{background:#000;color:#e6e6e6;padding:12px;border-radius:6px;overflow:auto;height:60vh;white-space:pre-wrap}}
133
  input[type=text]{{background:#000;color:#eee;border:1px solid #333;padding:8px;font-family:monospace}}
134
+ textarea{{width:100%;background:#000;color:#47E6C1;border:1px solid #333;padding:8px}}
135
  button{{background:#F46821;color:#fff;border:0;padding:8px 14px;border-radius:4px;cursor:pointer}}
136
  </style></head><body>
137
+ <div class="tabs bar"><a href="/fm/">📁 Files</a><a href="/fm/term">🖥️ Terminals</a>
 
138
  <a href="/reload">♻️ Reload</a><a href="/gpuinfo">🎮 GPU</a>
139
  <span style="color:#777">test mode · ephemeral</span></div>
140
  {body}</body></html>"""
141
 
142
+ # ---------------- WINDOWS-11-STYLE TERMINAL WORKSPACE ----------------
143
  @app.route("/term")
144
  def term_page():
145
  body = """
146
+ <div class="bar">
147
+ <b>Terminals</b> — sessions live on the box and survive closing the browser.
148
+ <span style="color:#777">Reopen this page anytime to reattach.</span>
149
+ </div>
150
+ <div id="tabs"></div>
151
+ <div id="term" style="height:68vh;background:#000;border-radius:0 6px 6px 6px"></div>
152
+
153
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/xterm@5.3.0/css/xterm.css"/>
154
  <script src="https://cdn.jsdelivr.net/npm/xterm@5.3.0/lib/xterm.js"></script>
155
  <script src="https://cdn.jsdelivr.net/npm/xterm-addon-fit@0.8.0/lib/xterm-addon-fit.js"></script>
156
  <script>
157
+ let term, fit, current = null, cursor = 0, pollTimer = null;
158
+
159
+ function initTerm(){
160
+ document.getElementById('term').innerHTML = '';
161
+ term = new Terminal({convertEol:false, cursorBlink:true, fontFamily:'monospace', fontSize:13});
162
+ fit = new FitAddon.FitAddon();
163
+ term.loadAddon(fit);
164
+ term.open(document.getElementById('term'));
165
+ fit.fit();
166
+ term.onData(d => {
167
+ if(!current) return;
168
+ fetch('/fm/term_in', {method:'POST', headers:{'Content-Type':'application/json'},
169
+ body: JSON.stringify({name: current, data: d})});
170
+ });
171
+ }
172
+ window.addEventListener('resize', () => { if(fit) fit.fit(); });
173
+
174
+ async function refreshTabs(){
175
+ const r = await fetch('/fm/term_list');
176
+ const names = (await r.json()).sessions;
177
+ const wrap = document.getElementById('tabs');
178
+ wrap.innerHTML = '';
179
+ names.forEach(n => {
180
+ const t = document.createElement('span');
181
+ t.className = 'tab' + (n===current ? ' active':'');
182
+ t.textContent = n + ' ✕';
183
+ t.onclick = (e) => { attach(n); };
184
+ // click the ✕ area to close
185
+ t.oncontextmenu = (e)=>{ e.preventDefault(); closeSession(n); };
186
+ wrap.appendChild(t);
187
+ });
188
+ const add = document.createElement('span');
189
+ add.className = 'tab'; add.textContent = '+ New';
190
+ add.onclick = newSession;
191
+ wrap.appendChild(add);
192
+ // if current session vanished, attach to first available
193
+ if(current && !names.includes(current)){ current = null; }
194
+ if(!current && names.length){ attach(names[0]); }
195
+ }
196
+
197
+ async function attach(name){
198
+ current = name; cursor = 0;
199
+ initTerm();
200
+ // replay full scrollback so you see what happened while away
201
+ const r = await fetch('/fm/term_snapshot?name=' + encodeURIComponent(name));
202
+ const j = await r.json();
203
+ term.write(j.data); cursor = j.cursor;
204
+ refreshTabs();
205
+ startPoll();
206
  }
207
+
208
+ function startPoll(){
209
+ if(pollTimer) clearTimeout(pollTimer);
210
+ async function poll(){
211
+ if(current){
212
+ try{
213
+ const r = await fetch('/fm/term_out?name=' + encodeURIComponent(current) + '&cursor=' + cursor);
214
+ const j = await r.json();
215
+ if(j.gone){ current = null; refreshTabs(); return; }
216
+ if(j.data) term.write(j.data);
217
+ cursor = j.cursor;
218
+ }catch(e){}
219
+ }
220
+ pollTimer = setTimeout(poll, 200);
221
+ }
222
+ poll();
223
+ }
224
+
225
+ async function newSession(){
226
+ const r = await fetch('/fm/term_new', {method:'POST'});
227
+ const j = await r.json();
228
+ await refreshTabs();
229
+ attach(j.name);
230
+ }
231
+
232
+ async function closeSession(name){
233
+ await fetch('/fm/term_close', {method:'POST', headers:{'Content-Type':'application/json'},
234
+ body: JSON.stringify({name})});
235
+ if(current===name) current=null;
236
+ refreshTabs();
237
+ }
238
+
239
+ // boot
240
+ (async () => {
241
+ await refreshTabs();
242
+ const r = await fetch('/fm/term_list');
243
+ const names = (await r.json()).sessions;
244
+ if(!names.length){ await newSession(); }
245
+ })();
246
  </script>"""
247
  return Response(PAGE.format(body=body))
248
 
249
+ @app.route("/term_list")
250
+ def term_list():
251
+ return Response(json.dumps({"sessions": list_shells()}), mimetype="application/json")
252
+
253
+ @app.route("/term_new", methods=["POST"])
254
+ def term_new():
255
+ return Response(json.dumps({"name": create_shell()}), mimetype="application/json")
256
+
257
+ @app.route("/term_close", methods=["POST"])
258
+ def term_close():
259
+ name = request.get_json(force=True).get("name")
260
+ sh = _shells.get(name)
261
+ if sh:
262
+ sh.kill()
263
+ del _shells[name]
264
+ return Response(json.dumps({"ok": True}), mimetype="application/json")
265
+
266
+ @app.route("/term_snapshot")
267
+ def term_snapshot():
268
+ sh = get_shell(request.args.get("name", ""))
269
+ if not sh:
270
+ return Response(json.dumps({"data": "", "cursor": 0, "gone": True}), mimetype="application/json")
271
+ data, cursor = sh.snapshot()
272
+ return Response(json.dumps({"data": data, "cursor": cursor}), mimetype="application/json")
273
+
274
  @app.route("/term_in", methods=["POST"])
275
  def term_in():
276
+ d = request.get_json(force=True)
277
+ sh = get_shell(d.get("name", ""))
278
+ if sh:
279
+ sh.write(d.get("data", ""))
280
+ return Response(json.dumps({"ok": bool(sh)}), mimetype="application/json")
281
 
282
  @app.route("/term_out")
283
  def term_out():
284
+ sh = get_shell(request.args.get("name", ""))
285
+ if not sh:
286
+ return Response(json.dumps({"gone": True}), mimetype="application/json")
287
+ cursor = int(request.args.get("cursor", 0))
288
+ data, newcur = sh.read_from(cursor)
289
+ return Response(json.dumps({"data": data, "cursor": newcur}), mimetype="application/json")
 
 
 
 
 
 
290
 
291
+ # ---------------- FILE MANAGER (unchanged) ----------------
292
  @app.route("/")
293
  def browse():
294
  path = os.path.abspath(request.args.get("path", "/"))
 
402
  if name:
403
  full = os.path.join(base, name)
404
  if not os.path.exists(full):
405
+ open(full, "a").close()
406
  return redirect(f"/fm/edit?path={urllib.parse.quote(full)}")
407
  return redirect(f"/fm/?path={urllib.parse.quote(base)}")
408
 
 
426
 
427
  if __name__ == "__main__":
428
  wrapped = DispatcherMiddleware(Flask("empty"), {"/fm": app})
429
+ run_simple("0.0.0.0", 9001, wrapped, threaded=True)