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()