Spaces:
Running
Running
File size: 6,396 Bytes
4f0aa08 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 | """One-command launcher for a QR-based self-enrollment session.
Starts the Flask app (threaded, bound to 0.0.0.0 so it can also be reached
directly over LAN) and a cloudflared quick tunnel (so it's also reachable
over the *public* internet β students on mobile data don't need to be on
the teacher's Wi-Fi). Once the tunnel is up, its URL is written to
activity_web/runtime/tunnel_url.txt, which the running Flask app reads to
build the QR code shown on the Attendance tab ("Show enrollment QR").
Usage:
python start_enrollment_session.py [--classroom cse8]
Ctrl+C stops both processes and clears the tunnel URL.
"""
from __future__ import annotations
import re
import shutil
import socket
import subprocess
import sys
import threading
import time
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent
sys.path.insert(0, str(REPO_ROOT))
from activity_web.backend.config import RUNTIME_DIR, TUNNEL_URL_PATH # noqa: E402
TUNNEL_URL_RE = re.compile(r"https://[a-zA-Z0-9\-]+\.trycloudflare\.com")
FLASK_PORT = 8080
def check_cloudflared() -> None:
if shutil.which("cloudflared") is None:
print(
"cloudflared isn't installed. On macOS: brew install cloudflared\n"
"(see https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/ "
"for other platforms)"
)
sys.exit(1)
def port_in_use(port: int) -> bool:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
return sock.connect_ex(("127.0.0.1", port)) == 0
def stream_output(pipe, prefix: str, on_line=None) -> None:
for line in iter(pipe.readline, ""):
if not line:
break
print(f"[{prefix}] {line.rstrip()}")
if on_line:
on_line(line)
def main() -> None:
sys.stdout.reconfigure(line_buffering=True)
check_cloudflared()
RUNTIME_DIR.mkdir(parents=True, exist_ok=True)
if port_in_use(FLASK_PORT):
print(
f"Port {FLASK_PORT} is already in use β an enrollment session is probably already\n"
f"running (maybe in another terminal, or left over from earlier). Either use that\n"
f"one, or stop it first:\n"
f" lsof -ti:{FLASK_PORT} | xargs kill; pkill -f 'cloudflared tunnel'\n"
f"then re-run this script. (Starting a second instance on top of a live one leaves\n"
f"a stale tunnel URL behind, which is exactly what caused the Cloudflare 1033 error.)"
)
sys.exit(1)
# Only cleared past this point β starting a second instance while one is
# already live must NOT touch a working session's tunnel_url.txt.
TUNNEL_URL_PATH.unlink(missing_ok=True)
flask_proc: subprocess.Popen | None = None
tunnel_proc: subprocess.Popen | None = None
def cleanup() -> None:
TUNNEL_URL_PATH.unlink(missing_ok=True)
for proc in (tunnel_proc, flask_proc):
if proc is not None and proc.poll() is None:
proc.terminate()
for proc in (tunnel_proc, flask_proc):
if proc is None:
continue
try:
proc.wait(timeout=5)
except subprocess.TimeoutExpired:
proc.kill()
try:
print(f"Starting the local server on http://0.0.0.0:{FLASK_PORT} ...")
flask_proc = subprocess.Popen(
[sys.executable, "-m", "activity_web.backend.app"],
cwd=REPO_ROOT,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
bufsize=1,
)
threading.Thread(target=stream_output, args=(flask_proc.stdout, "server"), daemon=True).start()
print("Starting the cloudflared tunnel ...")
tunnel_proc = subprocess.Popen(
["cloudflared", "tunnel", "--url", f"http://localhost:{FLASK_PORT}"],
cwd=REPO_ROOT,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
bufsize=1,
)
found_url: dict[str, str | None] = {"url": None}
def watch_for_url(line: str) -> None:
# Free quick tunnels have no uptime guarantee β if the connection
# drops and cloudflared reconnects, it can get reassigned a new
# hostname while this process keeps running. Always take the
# latest one (not just the first) so the QR panel self-heals
# instead of quietly serving a dead link.
match = TUNNEL_URL_RE.search(line)
if match and match.group(0) != found_url["url"]:
found_url["url"] = match.group(0)
TUNNEL_URL_PATH.write_text(found_url["url"], encoding="utf-8")
print(f"[tunnel] URL is now: {found_url['url']}")
threading.Thread(
target=stream_output, args=(tunnel_proc.stdout, "tunnel", watch_for_url), daemon=True
).start()
print("Waiting for the tunnel URL ...")
waited = 0.0
while not found_url["url"] and waited < 30:
time.sleep(0.5)
waited += 0.5
if flask_proc.poll() is not None:
print("The local server exited unexpectedly β check the [server] log above.")
return
if tunnel_proc.poll() is not None:
print("cloudflared exited unexpectedly β check the [tunnel] log above.")
return
if not found_url["url"]:
print("Timed out waiting for the tunnel URL. Check the [tunnel] log above for errors.")
return
print("\n" + "=" * 60)
print("Enrollment session is live.")
print(f" Public URL: {found_url['url']}")
print(f" Dashboard: http://localhost:{FLASK_PORT}/")
print("Open the dashboard, pick a classroom on the Attendance tab, and")
print("click 'Show enrollment QR' to display the QR code for students.")
print("=" * 60 + "\n")
print("Press Ctrl+C to stop the session.")
while True:
time.sleep(1)
if flask_proc.poll() is not None or tunnel_proc.poll() is not None:
print("A subprocess exited β stopping the session.")
break
except KeyboardInterrupt:
print("\nStopping...")
finally:
cleanup()
if __name__ == "__main__":
main()
|