Spaces:
Running
Running
Commit ·
7547388
1
Parent(s): 773dbc3
Harden demo runtime and add browser Pygame runner
Browse files- .github/dependabot.yml +14 -0
- .github/workflows/security-audit.yml +32 -0
- Dockerfile +51 -5
- SECURITY.md +24 -0
- classroom_data/classes.json +0 -15
- classroom_service.py +154 -7
- classroom_static/admin.css +7 -1
- classroom_static/admin.html +4 -4
- classroom_static/admin.js +43 -1
- classroom_static/classroom.css +47 -12
- classroom_static/py-worker.js +2 -2
- classroom_static/pygame-runner.html +29 -0
- classroom_static/pygame-runner.js +128 -0
- classroom_static/pygame-support.js +96 -0
- classroom_static/student.html +142 -12
- classroom_static/student.js +13 -5
- runner.py +16 -16
- scripts/vendor_pyodide_packages.mjs +43 -0
- server.py +261 -36
- static/css/index.css +31 -34
- static/css/player.css +51 -33
- static/js/index.js +26 -20
- static/js/player.js +54 -16
- templates/index.html +6 -6
- templates/player.html +8 -8
- tests/test_classroom.py +186 -4
- tests/test_security.py +58 -0
.github/dependabot.yml
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version: 2
|
| 2 |
+
updates:
|
| 3 |
+
- package-ecosystem: pip
|
| 4 |
+
directory: "/"
|
| 5 |
+
schedule:
|
| 6 |
+
interval: weekly
|
| 7 |
+
- package-ecosystem: docker
|
| 8 |
+
directory: "/"
|
| 9 |
+
schedule:
|
| 10 |
+
interval: weekly
|
| 11 |
+
- package-ecosystem: github-actions
|
| 12 |
+
directory: "/"
|
| 13 |
+
schedule:
|
| 14 |
+
interval: weekly
|
.github/workflows/security-audit.yml
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: Dependency security audit
|
| 2 |
+
|
| 3 |
+
on:
|
| 4 |
+
pull_request:
|
| 5 |
+
push:
|
| 6 |
+
paths:
|
| 7 |
+
- "requirements.txt"
|
| 8 |
+
- "requirements-ml.txt"
|
| 9 |
+
- ".github/workflows/security-audit.yml"
|
| 10 |
+
schedule:
|
| 11 |
+
- cron: "23 3 * * 1"
|
| 12 |
+
workflow_dispatch:
|
| 13 |
+
|
| 14 |
+
permissions:
|
| 15 |
+
contents: read
|
| 16 |
+
|
| 17 |
+
jobs:
|
| 18 |
+
pip-audit:
|
| 19 |
+
runs-on: ubuntu-latest
|
| 20 |
+
steps:
|
| 21 |
+
- uses: actions/checkout@v4
|
| 22 |
+
- uses: actions/setup-python@v5
|
| 23 |
+
with:
|
| 24 |
+
python-version: "3.11"
|
| 25 |
+
cache: pip
|
| 26 |
+
- name: Install vulnerability scanner
|
| 27 |
+
run: python -m pip install pip-audit==2.10.1
|
| 28 |
+
- name: Audit runtime dependencies
|
| 29 |
+
run: pip-audit --strict -r requirements.txt
|
| 30 |
+
- name: Audit optional ML dependencies
|
| 31 |
+
if: ${{ hashFiles('requirements-ml.txt') != '' }}
|
| 32 |
+
run: pip-audit --strict -r requirements-ml.txt
|
Dockerfile
CHANGED
|
@@ -1,3 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
FROM python:3.11-slim
|
| 2 |
|
| 3 |
# System deps for OpenCV headless, Pygame, MediaPipe + CJK fonts
|
|
@@ -7,6 +23,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|
| 7 |
libgomp1 \
|
| 8 |
libgles2 \
|
| 9 |
libegl1 \
|
|
|
|
|
|
|
| 10 |
fonts-wqy-zenhei \
|
| 11 |
fonts-wqy-microhei \
|
| 12 |
fonts-noto-cjk \
|
|
@@ -18,23 +36,51 @@ ENV PYTHONUNBUFFERED=1 \
|
|
| 18 |
SDL_AUDIODRIVER=dummy \
|
| 19 |
PORT=7860 \
|
| 20 |
MAX_CONCURRENT_RUNNERS=3 \
|
|
|
|
|
|
|
| 21 |
SESSION_TIMEOUT_SEC=900 \
|
| 22 |
-
PROJECT_CACHE_TTL_SEC=10
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
|
| 24 |
WORKDIR /app
|
|
|
|
| 25 |
COPY requirements.txt .
|
| 26 |
|
| 27 |
# Base image without TensorFlow (smaller / faster cold start).
|
| 28 |
# To include Keras demos: COPY requirements-ml.txt . && pip install -r requirements-ml.txt
|
| 29 |
-
RUN pip install --no-cache-dir -r requirements.
|
| 30 |
|
| 31 |
COPY . .
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
|
| 33 |
-
RUN
|
| 34 |
-
&&
|
|
|
|
|
|
|
|
|
|
| 35 |
|
| 36 |
# Healthcheck for orchestrators
|
| 37 |
HEALTHCHECK --interval=30s --timeout=5s --start-period=40s --retries=3 \
|
| 38 |
CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:%s/health' % __import__('os').environ.get('PORT','7860'))" || exit 1
|
| 39 |
|
| 40 |
-
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM node:22-alpine AS pyodide-assets
|
| 2 |
+
ARG PYODIDE_VERSION=0.27.7
|
| 3 |
+
WORKDIR /build
|
| 4 |
+
COPY scripts/vendor_pyodide_packages.mjs ./vendor_pyodide_packages.mjs
|
| 5 |
+
RUN npm install --global --ignore-scripts --omit=dev pyodide@${PYODIDE_VERSION} codemirror@5.65.16 jszip@3.10.1 \
|
| 6 |
+
&& node ./vendor_pyodide_packages.mjs /usr/local/lib/node_modules/pyodide https://cdn.jsdelivr.net/pyodide/v${PYODIDE_VERSION}/full/
|
| 7 |
+
|
| 8 |
+
FROM python:3.11-slim AS python-dependencies
|
| 9 |
+
ARG PIP_TOOLS_VERSION=7.6.1
|
| 10 |
+
ARG PIP_AUDIT_VERSION=2.10.1
|
| 11 |
+
WORKDIR /build
|
| 12 |
+
COPY requirements.txt .
|
| 13 |
+
RUN python -m pip install --no-cache-dir pip-tools==${PIP_TOOLS_VERSION} pip-audit==${PIP_AUDIT_VERSION} \
|
| 14 |
+
&& pip-compile --resolver=backtracking --generate-hashes --output-file=requirements.lock requirements.txt \
|
| 15 |
+
&& pip-audit --strict -r requirements.lock
|
| 16 |
+
|
| 17 |
FROM python:3.11-slim
|
| 18 |
|
| 19 |
# System deps for OpenCV headless, Pygame, MediaPipe + CJK fonts
|
|
|
|
| 23 |
libgomp1 \
|
| 24 |
libgles2 \
|
| 25 |
libegl1 \
|
| 26 |
+
bubblewrap \
|
| 27 |
+
util-linux \
|
| 28 |
fonts-wqy-zenhei \
|
| 29 |
fonts-wqy-microhei \
|
| 30 |
fonts-noto-cjk \
|
|
|
|
| 36 |
SDL_AUDIODRIVER=dummy \
|
| 37 |
PORT=7860 \
|
| 38 |
MAX_CONCURRENT_RUNNERS=3 \
|
| 39 |
+
RUNNER_STARTS_PER_IP_PER_HOUR=120 \
|
| 40 |
+
RUNNER_START_COOLDOWN_SEC=0.75 \
|
| 41 |
SESSION_TIMEOUT_SEC=900 \
|
| 42 |
+
PROJECT_CACHE_TTL_SEC=10 \
|
| 43 |
+
ENABLE_HSTS=1 \
|
| 44 |
+
CLASSROOM_COURSE_QUOTA_MB=1024 \
|
| 45 |
+
CLASSROOM_MAX_VERSIONS_PER_KIND=100 \
|
| 46 |
+
CLASSROOM_CHECKPOINTS_PER_HOUR=60 \
|
| 47 |
+
CLASSROOM_SUBMISSIONS_PER_HOUR=20 \
|
| 48 |
+
CLASSROOM_VERSION_COOLDOWN_MS=1500 \
|
| 49 |
+
CLASSROOM_WS_CONNECTIONS_PER_MINUTE=300 \
|
| 50 |
+
CLASSROOM_WS_MESSAGES_PER_MINUTE=300 \
|
| 51 |
+
RUNNER_MEMORY_LIMIT_MB=3072 \
|
| 52 |
+
RUNNER_CPU_LIMIT_SEC=900 \
|
| 53 |
+
RUNNER_TMP_LIMIT_MB=256 \
|
| 54 |
+
RUNNER_PROJECT_LIMIT_MB=128 \
|
| 55 |
+
RUNNER_PROJECT_FILE_LIMIT=2000
|
| 56 |
|
| 57 |
WORKDIR /app
|
| 58 |
+
COPY --from=python-dependencies /build/requirements.lock /tmp/requirements.lock
|
| 59 |
COPY requirements.txt .
|
| 60 |
|
| 61 |
# Base image without TensorFlow (smaller / faster cold start).
|
| 62 |
# To include Keras demos: COPY requirements-ml.txt . && pip install -r requirements-ml.txt
|
| 63 |
+
RUN python -m pip install --no-cache-dir --require-hashes -r /tmp/requirements.lock
|
| 64 |
|
| 65 |
COPY . .
|
| 66 |
+
COPY --from=python-dependencies /build/requirements.lock /app/requirements.lock
|
| 67 |
+
COPY --from=pyodide-assets /usr/local/lib/node_modules/pyodide /app/classroom_static/vendor/pyodide
|
| 68 |
+
COPY --from=pyodide-assets /usr/local/lib/node_modules/codemirror/lib/codemirror.js /app/classroom_static/vendor/codemirror/codemirror.js
|
| 69 |
+
COPY --from=pyodide-assets /usr/local/lib/node_modules/codemirror/lib/codemirror.css /app/classroom_static/vendor/codemirror/codemirror.css
|
| 70 |
+
COPY --from=pyodide-assets /usr/local/lib/node_modules/codemirror/theme/material-darker.css /app/classroom_static/vendor/codemirror/material-darker.css
|
| 71 |
+
COPY --from=pyodide-assets /usr/local/lib/node_modules/codemirror/mode/python/python.js /app/classroom_static/vendor/codemirror/python.js
|
| 72 |
+
COPY --from=pyodide-assets /usr/local/lib/node_modules/jszip/dist/jszip.min.js /app/classroom_static/vendor/jszip/jszip.min.js
|
| 73 |
|
| 74 |
+
RUN groupadd --gid 10001 telearn \
|
| 75 |
+
&& useradd --uid 10001 --gid 10001 --create-home --shell /usr/sbin/nologin telearn \
|
| 76 |
+
&& mkdir -p /app/projects /app/models /app/static /app/classroom_data /tmp/telearn_runner /tmp/telearn_classroom \
|
| 77 |
+
&& chown -R telearn:telearn /app/projects /app/classroom_data /tmp/telearn_runner /tmp/telearn_classroom \
|
| 78 |
+
&& chmod 700 /tmp/telearn_runner /tmp/telearn_classroom
|
| 79 |
|
| 80 |
# Healthcheck for orchestrators
|
| 81 |
HEALTHCHECK --interval=30s --timeout=5s --start-period=40s --retries=3 \
|
| 82 |
CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:%s/health' % __import__('os').environ.get('PORT','7860'))" || exit 1
|
| 83 |
|
| 84 |
+
USER telearn
|
| 85 |
+
|
| 86 |
+
CMD uvicorn server:app --host 0.0.0.0 --port $PORT --ws-max-size 1048576 --ws-max-queue 8
|
SECURITY.md
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Security and deployment notes
|
| 2 |
+
|
| 3 |
+
## Student Python execution
|
| 4 |
+
|
| 5 |
+
- Docker installs Bubblewrap and `prlimit`; the app runs as the unprivileged `telearn` account.
|
| 6 |
+
- Each public Python run gets a copied, size-limited project workspace, Linux user/PID/network namespaces, read-only system mounts, and CPU/address-space/file/process limits. Runner environments are cleared and no network is exposed.
|
| 7 |
+
- The runner fails closed when the host does not support the required Linux sandbox. Confirm namespace creation in the Hugging Face Space after deployment; do not restore a direct, unsandboxed runner as a workaround.
|
| 8 |
+
- Uvicorn caps WebSocket frames at 1 MiB. Classroom sockets also have per-IP handshake and per-student message budgets; saved checkpoints/submissions have per-hour limits, cooldowns, a per-course quota, and a rolling retention cap.
|
| 9 |
+
|
| 10 |
+
## Classroom storage
|
| 11 |
+
|
| 12 |
+
Defaults are configured in `Dockerfile` and may be changed with `CLASSROOM_*` environment variables. The database retains up to 100 checkpoints and 100 submissions per student per Lesson, with a 1 GiB active-data quota per course. Older versions are removed from the active SQLite history and Hugging Face repository tree.
|
| 13 |
+
|
| 14 |
+
Deleting a file from a Hugging Face repository's current tree does not rewrite earlier Git commits. Therefore these limits bound the active version set and current tree, but cannot strictly cap the repository's historical Git object storage. A hard lifetime storage cap requires moving classroom versions to bounded object storage or periodically archiving/recreating the repository; neither is performed automatically.
|
| 15 |
+
|
| 16 |
+
## Browser security
|
| 17 |
+
|
| 18 |
+
The app sends CSP, COOP/COEP, `X-Content-Type-Options`, Referrer Policy, Permissions Policy, and HSTS (enabled in the Docker image). CodeMirror, JSZip, and Pyodide are served same-origin from exact package versions included during image build. The pinned `pygame-ce` wheel and its lockfile dependency closure are also vendored same-origin, with each wheel SHA-256 checked against Pyodide's lockfile during the image build. Google Fonts, the QR service, Appetize, and MakeCode remain explicit external sources. Student HTML remains inside an opaque-origin sandbox iframe.
|
| 19 |
+
|
| 20 |
+
After deployment, inspect the final public HTTPS response headers: Hugging Face's edge proxy must preserve or add HSTS and must not replace the application CSP with a weaker policy. Local source changes cannot prove what the edge returns.
|
| 21 |
+
|
| 22 |
+
## Dependency scanning and lock files
|
| 23 |
+
|
| 24 |
+
Every Docker build resolves `requirements.txt` in a Python 3.11 build stage, generates a complete transitive lock with hashes, audits that resolved lock using `pip-audit`, then installs the runtime dependencies with `--require-hashes`. The generated lock is included in the image as `/app/requirements.lock`; it is build-specific rather than checked into source, so rebuilding later may select newer versions allowed by `requirements.txt`. GitHub Actions also runs `pip-audit` on pull requests and weekly when this repo is mirrored to GitHub; the canonical remote for this project is Hugging Face, which does not execute that GitHub workflow by itself.
|
classroom_data/classes.json
CHANGED
|
@@ -1,9 +1,4 @@
|
|
| 1 |
[
|
| 2 |
-
{
|
| 3 |
-
"code": "L6A",
|
| 4 |
-
"name": "2026 Fall 進階專題",
|
| 5 |
-
"students": []
|
| 6 |
-
},
|
| 7 |
{
|
| 8 |
"code": "L6Sat",
|
| 9 |
"name": "2026Fall進階專題六午",
|
|
@@ -71,15 +66,5 @@
|
|
| 71 |
"display_name": "陳英赫"
|
| 72 |
}
|
| 73 |
]
|
| 74 |
-
},
|
| 75 |
-
{
|
| 76 |
-
"code": "dsad",
|
| 77 |
-
"name": "dasdasda",
|
| 78 |
-
"students": [
|
| 79 |
-
{
|
| 80 |
-
"student_id": "burger",
|
| 81 |
-
"display_name": "burger"
|
| 82 |
-
}
|
| 83 |
-
]
|
| 84 |
}
|
| 85 |
]
|
|
|
|
| 1 |
[
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
{
|
| 3 |
"code": "L6Sat",
|
| 4 |
"name": "2026Fall進階專題六午",
|
|
|
|
| 66 |
"display_name": "陳英赫"
|
| 67 |
}
|
| 68 |
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 69 |
}
|
| 70 |
]
|
classroom_service.py
CHANGED
|
@@ -1,6 +1,7 @@
|
|
| 1 |
"""Browser-side multi-file Python classroom for the Telearn demo Space."""
|
| 2 |
from __future__ import annotations
|
| 3 |
import asyncio, hmac, io, json, logging, os, re, sqlite3, threading, time, zipfile
|
|
|
|
| 4 |
from pathlib import Path, PurePosixPath
|
| 5 |
from typing import Any
|
| 6 |
from fastapi import APIRouter, HTTPException, WebSocket, WebSocketDisconnect
|
|
@@ -10,16 +11,57 @@ from pydantic import BaseModel
|
|
| 10 |
ROOT=Path(__file__).resolve().parent; STATIC=ROOT/"classroom_static"; DATA=ROOT/"classroom_data"; TEMPLATES=DATA/"templates"; REPAIRS=DATA/"repairs"; CHECKPOINTS=DATA/"checkpoints"; VERSIONS=DATA/"versions"; PUBLISHED=ROOT/"projects"
|
| 11 |
RUNTIME=Path(os.getenv("CLASSROOM_DATA_DIR","/tmp/telearn_classroom")); DB=RUNTIME/"classroom.sqlite3"
|
| 12 |
PASSWORD=os.getenv("CLASSROOM_TEACHER_PASSWORD",""); REPO=os.getenv("CLASSROOM_DEMO_REPO",os.getenv("SPACE_ID","Telearn/Project_Demo")).strip(); DEMO_CLASS=os.getenv("CLASSROOM_DEMO_CLASS","2026Fall進階專題"); TOKEN=os.getenv("HF_TOKEN","").strip()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
ALLOWED={".py",".json",".txt",".csv",".md",".toml",".yaml",".yml"}; ID_RE=re.compile(r"[^0-9A-Za-z_\-一-鿿]")
|
| 14 |
router=APIRouter(); lock=threading.Lock(); persist_lock=threading.Lock(); students={}; teachers={}; live={}; logger=logging.getLogger(__name__)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
|
| 16 |
class TeacherRequest(BaseModel): class_code:str; week_id:str; password:str
|
| 17 |
class CourseCreate(BaseModel): class_code:str; course_name:str=""; password:str
|
| 18 |
class CourseUpdate(BaseModel): class_code:str; course_name:str; password:str
|
| 19 |
class CourseDelete(BaseModel): class_code:str; password:str
|
| 20 |
class StudentCreate(BaseModel): class_code:str; display_names:list[str]; password:str
|
|
|
|
| 21 |
class StudentLogin(BaseModel): class_code:str; display_name:str
|
| 22 |
class StudentDelete(BaseModel): class_code:str; student_id:str; password:str
|
|
|
|
| 23 |
def clean(v,fallback="item",limit=48): return ID_RE.sub("",str(v).strip())[:limit] or fallback
|
| 24 |
def clean_label(v,fallback="",limit=80): return re.sub(r"[\x00-\x1f\x7f]", "", str(v).strip())[:limit] or fallback
|
| 25 |
def safe_path(v):
|
|
@@ -35,6 +77,7 @@ def norm_files(value):
|
|
| 35 |
def now(): return int(time.time()*1000)
|
| 36 |
def init_db():
|
| 37 |
RUNTIME.mkdir(parents=True,exist_ok=True)
|
|
|
|
| 38 |
with lock,sqlite3.connect(DB) as c:
|
| 39 |
c.execute("""CREATE TABLE IF NOT EXISTS classroom_projects(class_code TEXT,student_id TEXT,display_name TEXT,week_id TEXT,project_name TEXT,draft_files TEXT,final_files TEXT,active_file TEXT,output TEXT,status TEXT,needs_help INTEGER,updated_at INTEGER,submitted_at INTEGER,checkpoint_files TEXT,checkpoint_at INTEGER,PRIMARY KEY(class_code,student_id,week_id))""")
|
| 40 |
columns={row[1] for row in c.execute("PRAGMA table_info(classroom_projects)")}
|
|
@@ -44,6 +87,7 @@ def init_db():
|
|
| 44 |
c.execute("""CREATE TABLE IF NOT EXISTS classroom_members(class_code TEXT,student_id TEXT,display_name TEXT,created_at INTEGER,PRIMARY KEY(class_code,student_id))""")
|
| 45 |
c.execute("""CREATE TABLE IF NOT EXISTS classroom_versions(class_code TEXT,student_id TEXT,display_name TEXT,week_id TEXT,version_id TEXT,kind TEXT,project_name TEXT,files TEXT,created_at INTEGER,PRIMARY KEY(class_code,student_id,week_id,version_id))""")
|
| 46 |
c.execute("""CREATE TABLE IF NOT EXISTS classroom_migrations(migration_id TEXT PRIMARY KEY,applied_at INTEGER)""")
|
|
|
|
| 47 |
for item in json.loads((DATA/"classes.json").read_text(encoding="utf-8")):
|
| 48 |
code=clean(item.get("code"),"L6A")
|
| 49 |
c.execute("""INSERT INTO classroom_courses VALUES(?,?,?) ON CONFLICT(class_code) DO UPDATE SET course_name=excluded.course_name""",(code,clean_label(item.get("name"),code),now()))
|
|
@@ -108,6 +152,24 @@ def init_db():
|
|
| 108 |
if final is not None:final["main.py"]=fixed
|
| 109 |
c.execute("UPDATE classroom_projects SET draft_files=?,final_files=?,updated_at=? WHERE class_code=? AND student_id=? AND week_id=?",(json.dumps(draft,ensure_ascii=False),json.dumps(final,ensure_ascii=False) if final is not None else None,now(),class_code,student_id,week_id))
|
| 110 |
c.execute("INSERT INTO classroom_migrations VALUES(?,?)",(migration_id,now()))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 111 |
def empty(c,s,n,w): return {"class_code":c,"student_id":s,"display_name":n,"week_id":w,"project_name":"","files":{"main.py":"# 在這裡寫 Python\n"},"final_files":None,"checkpoint_files":None,"active_file":"main.py","output":"","status":"idle","needs_help":False,"updated_at":now(),"submitted_at":None,"checkpoint_at":None,"connected":True}
|
| 112 |
def from_row(r,connected=False): return {"class_code":r[0],"student_id":r[1],"display_name":r[2],"week_id":r[3],"project_name":r[4],"files":json.loads(r[5]),"final_files":json.loads(r[6]) if r[6] else None,"active_file":r[7],"output":r[8],"status":r[9],"needs_help":bool(r[10]),"updated_at":r[11],"submitted_at":r[12],"checkpoint_files":json.loads(r[13]) if r[13] else None,"checkpoint_at":r[14],"connected":connected}
|
| 113 |
SELECT="SELECT class_code,student_id,display_name,week_id,project_name,draft_files,final_files,active_file,output,status,needs_help,updated_at,submitted_at,checkpoint_files,checkpoint_at FROM classroom_projects"
|
|
@@ -145,12 +207,49 @@ def get_submission(class_code,student_id,week_id):
|
|
| 145 |
with lock,sqlite3.connect(DB) as db:row=db.execute(SELECT+" WHERE class_code=? AND student_id=? AND week_id=? AND final_files IS NOT NULL",(clean(class_code,""),clean(student_id,""),clean(week_id,""))).fetchone()
|
| 146 |
if not row:raise HTTPException(404,"找不到這份提交")
|
| 147 |
return from_row(row)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 148 |
def record_version(state,kind):
|
| 149 |
created_at=state["checkpoint_at"] if kind=="checkpoint" else state["submitted_at"]
|
| 150 |
files=state["checkpoint_files"] if kind=="checkpoint" else state["final_files"]
|
| 151 |
version_id=f"{created_at}-{kind}-{time.time_ns()%1000000:06d}"
|
| 152 |
record={"class_code":state["class_code"],"student_id":state["student_id"],"display_name":state["display_name"],"week_id":state["week_id"],"version_id":version_id,"kind":kind,"project_name":state["project_name"],"created_at":created_at,"files":dict(files)}
|
| 153 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 154 |
return record
|
| 155 |
def version_history(class_code,student_id):
|
| 156 |
with lock,sqlite3.connect(DB) as db:rows=db.execute("SELECT week_id,version_id,kind,project_name,files,created_at FROM classroom_versions WHERE class_code=? AND student_id=? ORDER BY created_at DESC,version_id DESC",(class_code,student_id)).fetchall()
|
|
@@ -230,11 +329,20 @@ def persist_catalog(message,deletions=None):
|
|
| 230 |
def persist_version(record):
|
| 231 |
if not TOKEN:return "版本已保存在教室,但尚未永久寫入 Space;請設定 HF_TOKEN。"
|
| 232 |
try:
|
| 233 |
-
from huggingface_hub import CommitOperationAdd,HfApi
|
| 234 |
-
|
|
|
|
|
|
|
| 235 |
path=PurePosixPath("classroom_data","versions",clean(record["class_code"],""),clean(record["student_id"],""),clean(record["week_id"],"week"),f"{clean(record['version_id'],'version')}.json").as_posix()
|
| 236 |
label="Checkpoint" if record["kind"]=="checkpoint" else "Submission"
|
| 237 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 238 |
return ""
|
| 239 |
except Exception as exc:
|
| 240 |
logger.exception("Could not persist student version to %s",REPO)
|
|
@@ -308,6 +416,31 @@ async def create_students(req:StudentCreate):
|
|
| 308 |
for class_code,week_id in list(teachers):
|
| 309 |
if class_code==code:await broadcast(code,week_id)
|
| 310 |
return {"ok":True,"created":created,"warning":warning}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 311 |
@router.delete("/api/classroom/students")
|
| 312 |
async def delete_student(req:StudentDelete):
|
| 313 |
check_password(req.password); code=clean(req.class_code,"L6A"); student_id=clean(req.student_id,"student");deletions=archived_paths(code,student_id)
|
|
@@ -356,6 +489,8 @@ def publish(req:TeacherRequest):
|
|
| 356 |
|
| 357 |
@router.websocket("/ws/classroom/student")
|
| 358 |
async def student_ws(ws:WebSocket):
|
|
|
|
|
|
|
| 359 |
c=clean(ws.query_params.get("class_code"),"L6A");s=clean(ws.query_params.get("student_id"),"student");requested_name=clean_label(ws.query_params.get("display_name"),"");w=clean(ws.query_params.get("week_id"),"week02");await ws.accept()
|
| 360 |
member=find_member(c,requested_name,s)
|
| 361 |
if not member:await ws.close(code=4404,reason="not on roster");return
|
|
@@ -367,15 +502,27 @@ async def student_ws(ws:WebSocket):
|
|
| 367 |
students[key]=ws;state=load(c,s,n,w);state["connected"]=True;live[key]=state;save(state);await ws.send_json({"type":"restore","state":state});await broadcast(c,w)
|
| 368 |
try:
|
| 369 |
while True:
|
| 370 |
-
m=await ws.receive_json()
|
|
|
|
|
|
|
|
|
|
| 371 |
if kind=="project":
|
| 372 |
state["files"]=norm_files(m.get("files"));state["project_name"]=clean_label(m.get("project_name"),"");active=safe_path(m.get("active_file","main.py"));state["active_file"]=active if active in state["files"] else "main.py";state["updated_at"]=now()
|
| 373 |
elif kind=="output":state["output"]=str(m.get("output",""))[-40000:]
|
| 374 |
elif kind=="status":state["status"]=m.get("status") if m.get("status") in {"idle","running","success","error"} else "idle"
|
| 375 |
elif kind=="help":state["needs_help"]=bool(m.get("needs_help"))
|
| 376 |
elif kind=="checkpoint":
|
| 377 |
-
|
| 378 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 379 |
elif kind=="ping":await ws.send_json({"type":"pong","needs_help":state["needs_help"],"submitted_at":state["submitted_at"],"checkpoint_at":state.get("checkpoint_at")});continue
|
| 380 |
else:continue
|
| 381 |
save(state)
|
|
|
|
| 1 |
"""Browser-side multi-file Python classroom for the Telearn demo Space."""
|
| 2 |
from __future__ import annotations
|
| 3 |
import asyncio, hmac, io, json, logging, os, re, sqlite3, threading, time, zipfile
|
| 4 |
+
from collections import deque
|
| 5 |
from pathlib import Path, PurePosixPath
|
| 6 |
from typing import Any
|
| 7 |
from fastapi import APIRouter, HTTPException, WebSocket, WebSocketDisconnect
|
|
|
|
| 11 |
ROOT=Path(__file__).resolve().parent; STATIC=ROOT/"classroom_static"; DATA=ROOT/"classroom_data"; TEMPLATES=DATA/"templates"; REPAIRS=DATA/"repairs"; CHECKPOINTS=DATA/"checkpoints"; VERSIONS=DATA/"versions"; PUBLISHED=ROOT/"projects"
|
| 12 |
RUNTIME=Path(os.getenv("CLASSROOM_DATA_DIR","/tmp/telearn_classroom")); DB=RUNTIME/"classroom.sqlite3"
|
| 13 |
PASSWORD=os.getenv("CLASSROOM_TEACHER_PASSWORD",""); REPO=os.getenv("CLASSROOM_DEMO_REPO",os.getenv("SPACE_ID","Telearn/Project_Demo")).strip(); DEMO_CLASS=os.getenv("CLASSROOM_DEMO_CLASS","2026Fall進階專題"); TOKEN=os.getenv("HF_TOKEN","").strip()
|
| 14 |
+
COURSE_STORAGE_QUOTA_BYTES=max(16, int(os.getenv("CLASSROOM_COURSE_QUOTA_MB", "1024"))) * 1024 * 1024
|
| 15 |
+
MAX_VERSIONS_PER_KIND=max(2, int(os.getenv("CLASSROOM_MAX_VERSIONS_PER_KIND", "100")))
|
| 16 |
+
CHECKPOINTS_PER_HOUR=max(1, int(os.getenv("CLASSROOM_CHECKPOINTS_PER_HOUR", "60")))
|
| 17 |
+
SUBMISSIONS_PER_HOUR=max(1, int(os.getenv("CLASSROOM_SUBMISSIONS_PER_HOUR", "20")))
|
| 18 |
+
VERSION_WRITE_COOLDOWN_MS=max(0, int(os.getenv("CLASSROOM_VERSION_COOLDOWN_MS", "1500")))
|
| 19 |
+
STUDENT_WS_CONNECTIONS_PER_MINUTE=max(10, int(os.getenv("CLASSROOM_WS_CONNECTIONS_PER_MINUTE", "300")))
|
| 20 |
+
STUDENT_WS_MESSAGES_PER_MINUTE=max(60, int(os.getenv("CLASSROOM_WS_MESSAGES_PER_MINUTE", "300")))
|
| 21 |
ALLOWED={".py",".json",".txt",".csv",".md",".toml",".yaml",".yml"}; ID_RE=re.compile(r"[^0-9A-Za-z_\-一-鿿]")
|
| 22 |
router=APIRouter(); lock=threading.Lock(); persist_lock=threading.Lock(); students={}; teachers={}; live={}; logger=logging.getLogger(__name__)
|
| 23 |
+
_student_connection_windows={}
|
| 24 |
+
_student_message_windows={}
|
| 25 |
+
|
| 26 |
+
def allow_student_connection(remote_ip):
|
| 27 |
+
"""Bound repeated WebSocket handshakes per client IP without persisting IPs."""
|
| 28 |
+
current=time.monotonic(); window=_student_connection_windows.get(remote_ip)
|
| 29 |
+
if window is None:
|
| 30 |
+
window=deque(); _student_connection_windows[remote_ip]=window
|
| 31 |
+
while window and current-window[0]>=60:window.popleft()
|
| 32 |
+
if len(window)>=STUDENT_WS_CONNECTIONS_PER_MINUTE:return False
|
| 33 |
+
window.append(current)
|
| 34 |
+
if len(_student_connection_windows)>4096:
|
| 35 |
+
for key,old_window in list(_student_connection_windows.items()):
|
| 36 |
+
while old_window and current-old_window[0]>=60:old_window.popleft()
|
| 37 |
+
if not old_window:_student_connection_windows.pop(key,None)
|
| 38 |
+
if len(_student_connection_windows)<=3072:break
|
| 39 |
+
return True
|
| 40 |
+
|
| 41 |
+
def allow_student_message(student_key):
|
| 42 |
+
"""Apply a rolling message budget across reconnects for the same student/workspace."""
|
| 43 |
+
current=time.monotonic(); window=_student_message_windows.get(student_key)
|
| 44 |
+
if window is None:
|
| 45 |
+
window=deque(); _student_message_windows[student_key]=window
|
| 46 |
+
while window and current-window[0]>=60:window.popleft()
|
| 47 |
+
if len(window)>=STUDENT_WS_MESSAGES_PER_MINUTE:return False
|
| 48 |
+
window.append(current)
|
| 49 |
+
if len(_student_message_windows)>4096:
|
| 50 |
+
for key,old_window in list(_student_message_windows.items()):
|
| 51 |
+
while old_window and current-old_window[0]>=60:old_window.popleft()
|
| 52 |
+
if not old_window:_student_message_windows.pop(key,None)
|
| 53 |
+
if len(_student_message_windows)<=3072:break
|
| 54 |
+
return True
|
| 55 |
|
| 56 |
class TeacherRequest(BaseModel): class_code:str; week_id:str; password:str
|
| 57 |
class CourseCreate(BaseModel): class_code:str; course_name:str=""; password:str
|
| 58 |
class CourseUpdate(BaseModel): class_code:str; course_name:str; password:str
|
| 59 |
class CourseDelete(BaseModel): class_code:str; password:str
|
| 60 |
class StudentCreate(BaseModel): class_code:str; display_names:list[str]; password:str
|
| 61 |
+
class StudentUpdate(BaseModel): class_code:str; student_id:str; display_name:str; password:str
|
| 62 |
class StudentLogin(BaseModel): class_code:str; display_name:str
|
| 63 |
class StudentDelete(BaseModel): class_code:str; student_id:str; password:str
|
| 64 |
+
class VersionLimitError(Exception): pass
|
| 65 |
def clean(v,fallback="item",limit=48): return ID_RE.sub("",str(v).strip())[:limit] or fallback
|
| 66 |
def clean_label(v,fallback="",limit=80): return re.sub(r"[\x00-\x1f\x7f]", "", str(v).strip())[:limit] or fallback
|
| 67 |
def safe_path(v):
|
|
|
|
| 77 |
def now(): return int(time.time()*1000)
|
| 78 |
def init_db():
|
| 79 |
RUNTIME.mkdir(parents=True,exist_ok=True)
|
| 80 |
+
os.chmod(RUNTIME,0o700)
|
| 81 |
with lock,sqlite3.connect(DB) as c:
|
| 82 |
c.execute("""CREATE TABLE IF NOT EXISTS classroom_projects(class_code TEXT,student_id TEXT,display_name TEXT,week_id TEXT,project_name TEXT,draft_files TEXT,final_files TEXT,active_file TEXT,output TEXT,status TEXT,needs_help INTEGER,updated_at INTEGER,submitted_at INTEGER,checkpoint_files TEXT,checkpoint_at INTEGER,PRIMARY KEY(class_code,student_id,week_id))""")
|
| 83 |
columns={row[1] for row in c.execute("PRAGMA table_info(classroom_projects)")}
|
|
|
|
| 87 |
c.execute("""CREATE TABLE IF NOT EXISTS classroom_members(class_code TEXT,student_id TEXT,display_name TEXT,created_at INTEGER,PRIMARY KEY(class_code,student_id))""")
|
| 88 |
c.execute("""CREATE TABLE IF NOT EXISTS classroom_versions(class_code TEXT,student_id TEXT,display_name TEXT,week_id TEXT,version_id TEXT,kind TEXT,project_name TEXT,files TEXT,created_at INTEGER,PRIMARY KEY(class_code,student_id,week_id,version_id))""")
|
| 89 |
c.execute("""CREATE TABLE IF NOT EXISTS classroom_migrations(migration_id TEXT PRIMARY KEY,applied_at INTEGER)""")
|
| 90 |
+
c.execute("""CREATE TABLE IF NOT EXISTS classroom_write_windows(class_code TEXT,student_id TEXT,week_id TEXT,kind TEXT,window_started INTEGER,write_count INTEGER,last_write INTEGER,PRIMARY KEY(class_code,student_id,week_id,kind))""")
|
| 91 |
for item in json.loads((DATA/"classes.json").read_text(encoding="utf-8")):
|
| 92 |
code=clean(item.get("code"),"L6A")
|
| 93 |
c.execute("""INSERT INTO classroom_courses VALUES(?,?,?) ON CONFLICT(class_code) DO UPDATE SET course_name=excluded.course_name""",(code,clean_label(item.get("name"),code),now()))
|
|
|
|
| 152 |
if final is not None:final["main.py"]=fixed
|
| 153 |
c.execute("UPDATE classroom_projects SET draft_files=?,final_files=?,updated_at=? WHERE class_code=? AND student_id=? AND week_id=?",(json.dumps(draft,ensure_ascii=False),json.dumps(final,ensure_ascii=False) if final is not None else None,now(),class_code,student_id,week_id))
|
| 154 |
c.execute("INSERT INTO classroom_migrations VALUES(?,?)",(migration_id,now()))
|
| 155 |
+
c.execute("""UPDATE classroom_projects SET display_name=(
|
| 156 |
+
SELECT display_name FROM classroom_members
|
| 157 |
+
WHERE classroom_members.class_code=classroom_projects.class_code
|
| 158 |
+
AND classroom_members.student_id=classroom_projects.student_id
|
| 159 |
+
) WHERE EXISTS (
|
| 160 |
+
SELECT 1 FROM classroom_members
|
| 161 |
+
WHERE classroom_members.class_code=classroom_projects.class_code
|
| 162 |
+
AND classroom_members.student_id=classroom_projects.student_id
|
| 163 |
+
)""")
|
| 164 |
+
c.execute("""UPDATE classroom_versions SET display_name=(
|
| 165 |
+
SELECT display_name FROM classroom_members
|
| 166 |
+
WHERE classroom_members.class_code=classroom_versions.class_code
|
| 167 |
+
AND classroom_members.student_id=classroom_versions.student_id
|
| 168 |
+
) WHERE EXISTS (
|
| 169 |
+
SELECT 1 FROM classroom_members
|
| 170 |
+
WHERE classroom_members.class_code=classroom_versions.class_code
|
| 171 |
+
AND classroom_members.student_id=classroom_versions.student_id
|
| 172 |
+
)""")
|
| 173 |
def empty(c,s,n,w): return {"class_code":c,"student_id":s,"display_name":n,"week_id":w,"project_name":"","files":{"main.py":"# 在這裡寫 Python\n"},"final_files":None,"checkpoint_files":None,"active_file":"main.py","output":"","status":"idle","needs_help":False,"updated_at":now(),"submitted_at":None,"checkpoint_at":None,"connected":True}
|
| 174 |
def from_row(r,connected=False): return {"class_code":r[0],"student_id":r[1],"display_name":r[2],"week_id":r[3],"project_name":r[4],"files":json.loads(r[5]),"final_files":json.loads(r[6]) if r[6] else None,"active_file":r[7],"output":r[8],"status":r[9],"needs_help":bool(r[10]),"updated_at":r[11],"submitted_at":r[12],"checkpoint_files":json.loads(r[13]) if r[13] else None,"checkpoint_at":r[14],"connected":connected}
|
| 175 |
SELECT="SELECT class_code,student_id,display_name,week_id,project_name,draft_files,final_files,active_file,output,status,needs_help,updated_at,submitted_at,checkpoint_files,checkpoint_at FROM classroom_projects"
|
|
|
|
| 207 |
with lock,sqlite3.connect(DB) as db:row=db.execute(SELECT+" WHERE class_code=? AND student_id=? AND week_id=? AND final_files IS NOT NULL",(clean(class_code,""),clean(student_id,""),clean(week_id,""))).fetchone()
|
| 208 |
if not row:raise HTTPException(404,"找不到這份提交")
|
| 209 |
return from_row(row)
|
| 210 |
+
def consume_version_write(class_code,student_id,week_id,kind):
|
| 211 |
+
"""Persist a per-student/workspace hourly write budget in SQLite."""
|
| 212 |
+
current=now(); window_ms=60*60*1000
|
| 213 |
+
limit=CHECKPOINTS_PER_HOUR if kind=="checkpoint" else SUBMISSIONS_PER_HOUR
|
| 214 |
+
with lock,sqlite3.connect(DB) as db:
|
| 215 |
+
row=db.execute("SELECT window_started,write_count,last_write FROM classroom_write_windows WHERE class_code=? AND student_id=? AND week_id=? AND kind=?",(class_code,student_id,week_id,kind)).fetchone()
|
| 216 |
+
if row is None or current-row[0]>=window_ms:
|
| 217 |
+
window_started=current; count=0; last_write=0
|
| 218 |
+
else:
|
| 219 |
+
window_started,count,last_write=row
|
| 220 |
+
if last_write and current-last_write<VERSION_WRITE_COOLDOWN_MS:
|
| 221 |
+
raise VersionLimitError("儲存太頻繁,請稍候再試。")
|
| 222 |
+
if count>=limit:
|
| 223 |
+
raise VersionLimitError(f"已達本 Lesson 每小時{limit}次上限,稍後再試。")
|
| 224 |
+
db.execute("INSERT INTO classroom_write_windows VALUES(?,?,?,?,?,?,?) ON CONFLICT(class_code,student_id,week_id,kind) DO UPDATE SET window_started=excluded.window_started,write_count=excluded.write_count,last_write=excluded.last_write",(class_code,student_id,week_id,kind,window_started,count+1,current))
|
| 225 |
+
|
| 226 |
def record_version(state,kind):
|
| 227 |
created_at=state["checkpoint_at"] if kind=="checkpoint" else state["submitted_at"]
|
| 228 |
files=state["checkpoint_files"] if kind=="checkpoint" else state["final_files"]
|
| 229 |
version_id=f"{created_at}-{kind}-{time.time_ns()%1000000:06d}"
|
| 230 |
record={"class_code":state["class_code"],"student_id":state["student_id"],"display_name":state["display_name"],"week_id":state["week_id"],"version_id":version_id,"kind":kind,"project_name":state["project_name"],"created_at":created_at,"files":dict(files)}
|
| 231 |
+
encoded_files=json.dumps(record["files"],ensure_ascii=False)
|
| 232 |
+
draft_json=json.dumps(state["files"],ensure_ascii=False)
|
| 233 |
+
final_json=json.dumps(state["final_files"],ensure_ascii=False) if state["final_files"] else None
|
| 234 |
+
checkpoint_json=json.dumps(state["checkpoint_files"],ensure_ascii=False) if state["checkpoint_files"] else None
|
| 235 |
+
with lock,sqlite3.connect(DB) as db:
|
| 236 |
+
db.execute("BEGIN IMMEDIATE")
|
| 237 |
+
existing=db.execute("SELECT version_id,created_at,length(CAST(files AS BLOB)),kind FROM classroom_versions WHERE class_code=? AND student_id=? AND week_id=? AND kind=? ORDER BY created_at DESC,version_id DESC",(record["class_code"],record["student_id"],record["week_id"],kind)).fetchall()
|
| 238 |
+
pruned=existing[MAX_VERSIONS_PER_KIND-1:] if len(existing)>=MAX_VERSIONS_PER_KIND else []
|
| 239 |
+
pruned_bytes=sum(row[2] or 0 for row in pruned)
|
| 240 |
+
version_bytes=db.execute("SELECT COALESCE(SUM(length(CAST(files AS BLOB))),0) FROM classroom_versions WHERE class_code=?",(record["class_code"],)).fetchone()[0]
|
| 241 |
+
current_bytes=db.execute("SELECT COALESCE(SUM(length(CAST(draft_files AS BLOB))+COALESCE(length(CAST(final_files AS BLOB)),0)+COALESCE(length(CAST(checkpoint_files AS BLOB)),0)),0) FROM classroom_projects WHERE class_code=?",(record["class_code"],)).fetchone()[0]
|
| 242 |
+
prior=db.execute("SELECT COALESCE(length(CAST(draft_files AS BLOB)),0)+COALESCE(length(CAST(final_files AS BLOB)),0)+COALESCE(length(CAST(checkpoint_files AS BLOB)),0) FROM classroom_projects WHERE class_code=? AND student_id=? AND week_id=?",(record["class_code"],record["student_id"],record["week_id"])).fetchone()
|
| 243 |
+
prior_bytes=prior[0] if prior else 0
|
| 244 |
+
candidate_current=len(draft_json.encode())+(len(final_json.encode()) if final_json else 0)+(len(checkpoint_json.encode()) if checkpoint_json else 0)
|
| 245 |
+
projected=version_bytes-pruned_bytes+len(encoded_files.encode())+current_bytes-prior_bytes+candidate_current
|
| 246 |
+
if projected>COURSE_STORAGE_QUOTA_BYTES:
|
| 247 |
+
raise VersionLimitError(f"本課程已達 {COURSE_STORAGE_QUOTA_BYTES//(1024*1024)} MB 儲存上限,請聯絡老師整理舊版本。")
|
| 248 |
+
for old_version_id,_,_,_ in pruned:
|
| 249 |
+
db.execute("DELETE FROM classroom_versions WHERE class_code=? AND student_id=? AND week_id=? AND version_id=?",(record["class_code"],record["student_id"],record["week_id"],old_version_id))
|
| 250 |
+
db.execute("INSERT INTO classroom_versions VALUES(?,?,?,?,?,?,?,?,?)",(record["class_code"],record["student_id"],record["display_name"],record["week_id"],version_id,kind,record["project_name"],encoded_files,created_at))
|
| 251 |
+
if pruned:
|
| 252 |
+
record["_pruned"]=[{"week_id":record["week_id"],"version_id":row[0]} for row in pruned]
|
| 253 |
return record
|
| 254 |
def version_history(class_code,student_id):
|
| 255 |
with lock,sqlite3.connect(DB) as db:rows=db.execute("SELECT week_id,version_id,kind,project_name,files,created_at FROM classroom_versions WHERE class_code=? AND student_id=? ORDER BY created_at DESC,version_id DESC",(class_code,student_id)).fetchall()
|
|
|
|
| 329 |
def persist_version(record):
|
| 330 |
if not TOKEN:return "版本已保存在教室,但尚未永久寫入 Space;請設定 HF_TOKEN。"
|
| 331 |
try:
|
| 332 |
+
from huggingface_hub import CommitOperationAdd,CommitOperationDelete,HfApi
|
| 333 |
+
pruned=record.get("_pruned",[])
|
| 334 |
+
payload={key:value for key,value in record.items() if key!="_pruned"}
|
| 335 |
+
content=(json.dumps(payload,ensure_ascii=False,indent=2)+"\n").encode()
|
| 336 |
path=PurePosixPath("classroom_data","versions",clean(record["class_code"],""),clean(record["student_id"],""),clean(record["week_id"],"week"),f"{clean(record['version_id'],'version')}.json").as_posix()
|
| 337 |
label="Checkpoint" if record["kind"]=="checkpoint" else "Submission"
|
| 338 |
+
api=HfApi(token=TOKEN);operations=[]
|
| 339 |
+
if pruned:
|
| 340 |
+
existing=set(api.list_repo_files(repo_id=REPO,repo_type="space"))
|
| 341 |
+
for old in pruned:
|
| 342 |
+
old_path=PurePosixPath("classroom_data","versions",clean(record["class_code"],""),clean(record["student_id"],""),clean(old["week_id"],"week"),f"{clean(old['version_id'],'version')}.json").as_posix()
|
| 343 |
+
if old_path in existing:operations.append(CommitOperationDelete(path_in_repo=old_path))
|
| 344 |
+
operations.append(CommitOperationAdd(path_in_repo=path,path_or_fileobj=content))
|
| 345 |
+
with persist_lock:api.create_commit(repo_id=REPO,repo_type="space",operations=operations,commit_message=f"{label} {record['class_code']} {record['student_id']} {record['week_id']}")
|
| 346 |
return ""
|
| 347 |
except Exception as exc:
|
| 348 |
logger.exception("Could not persist student version to %s",REPO)
|
|
|
|
| 416 |
for class_code,week_id in list(teachers):
|
| 417 |
if class_code==code:await broadcast(code,week_id)
|
| 418 |
return {"ok":True,"created":created,"warning":warning}
|
| 419 |
+
@router.put("/api/classroom/students")
|
| 420 |
+
async def update_student(req:StudentUpdate):
|
| 421 |
+
check_password(req.password);code=clean(req.class_code,"");student_id=clean(req.student_id,"student");name=clean_label(req.display_name,"")
|
| 422 |
+
if not code or not student_id:raise HTTPException(400,"找不到班級或學生")
|
| 423 |
+
if not name:raise HTTPException(400,"請輸入學生姓名")
|
| 424 |
+
with lock,sqlite3.connect(DB) as db:
|
| 425 |
+
members=db.execute("SELECT student_id,display_name FROM classroom_members WHERE class_code=?",(code,)).fetchall()
|
| 426 |
+
current=next((member for member in members if member[0]==student_id),None)
|
| 427 |
+
if not current:raise HTTPException(404,"找不到這位學生")
|
| 428 |
+
if any(member_id!=student_id and display_name.casefold()==name.casefold() for member_id,display_name in members):
|
| 429 |
+
raise HTTPException(409,"這個班級已有相同姓名,請確認名單")
|
| 430 |
+
db.execute("UPDATE classroom_members SET display_name=? WHERE class_code=? AND student_id=?",(name,code,student_id))
|
| 431 |
+
db.execute("UPDATE classroom_projects SET display_name=? WHERE class_code=? AND student_id=?",(name,code,student_id))
|
| 432 |
+
db.execute("UPDATE classroom_versions SET display_name=? WHERE class_code=? AND student_id=?",(name,code,student_id))
|
| 433 |
+
for key,state in list(live.items()):
|
| 434 |
+
if key[0]!=code or key[1]!=student_id:continue
|
| 435 |
+
state["display_name"]=name
|
| 436 |
+
target=students.get(key)
|
| 437 |
+
if target:
|
| 438 |
+
try:await target.send_json({"type":"identity","display_name":name})
|
| 439 |
+
except Exception:pass
|
| 440 |
+
warning=persist_catalog(f"Rename classroom student {student_id} in {code}")
|
| 441 |
+
for class_code,week_id in list(teachers):
|
| 442 |
+
if class_code==code:await broadcast(code,week_id)
|
| 443 |
+
return {"ok":True,"student_id":student_id,"display_name":name,"warning":warning}
|
| 444 |
@router.delete("/api/classroom/students")
|
| 445 |
async def delete_student(req:StudentDelete):
|
| 446 |
check_password(req.password); code=clean(req.class_code,"L6A"); student_id=clean(req.student_id,"student");deletions=archived_paths(code,student_id)
|
|
|
|
| 489 |
|
| 490 |
@router.websocket("/ws/classroom/student")
|
| 491 |
async def student_ws(ws:WebSocket):
|
| 492 |
+
remote_ip=ws.client.host if ws.client else "unknown"
|
| 493 |
+
if not allow_student_connection(remote_ip):await ws.close(code=4429,reason="connection rate limited");return
|
| 494 |
c=clean(ws.query_params.get("class_code"),"L6A");s=clean(ws.query_params.get("student_id"),"student");requested_name=clean_label(ws.query_params.get("display_name"),"");w=clean(ws.query_params.get("week_id"),"week02");await ws.accept()
|
| 495 |
member=find_member(c,requested_name,s)
|
| 496 |
if not member:await ws.close(code=4404,reason="not on roster");return
|
|
|
|
| 502 |
students[key]=ws;state=load(c,s,n,w);state["connected"]=True;live[key]=state;save(state);await ws.send_json({"type":"restore","state":state});await broadcast(c,w)
|
| 503 |
try:
|
| 504 |
while True:
|
| 505 |
+
m=await ws.receive_json()
|
| 506 |
+
if not allow_student_message(key):
|
| 507 |
+
await ws.close(code=4429,reason="message rate limited");break
|
| 508 |
+
kind=m.get("type");version=None
|
| 509 |
if kind=="project":
|
| 510 |
state["files"]=norm_files(m.get("files"));state["project_name"]=clean_label(m.get("project_name"),"");active=safe_path(m.get("active_file","main.py"));state["active_file"]=active if active in state["files"] else "main.py";state["updated_at"]=now()
|
| 511 |
elif kind=="output":state["output"]=str(m.get("output",""))[-40000:]
|
| 512 |
elif kind=="status":state["status"]=m.get("status") if m.get("status") in {"idle","running","success","error"} else "idle"
|
| 513 |
elif kind=="help":state["needs_help"]=bool(m.get("needs_help"))
|
| 514 |
elif kind=="checkpoint":
|
| 515 |
+
try:
|
| 516 |
+
consume_version_write(c,s,w,"checkpoint")
|
| 517 |
+
state["files"]=norm_files(m.get("files",state["files"]));state["project_name"]=clean_label(m.get("project_name",state["project_name"]),"");state["checkpoint_files"]=dict(state["files"]);state["checkpoint_at"]=now();state["updated_at"]=state["checkpoint_at"];version=record_version(state,"checkpoint")
|
| 518 |
+
except VersionLimitError as exc:
|
| 519 |
+
await ws.send_json({"type":"write_rejected","message":str(exc)});continue
|
| 520 |
+
elif kind=="submit":
|
| 521 |
+
try:
|
| 522 |
+
consume_version_write(c,s,w,"submit")
|
| 523 |
+
state["files"]=norm_files(m.get("files",state["files"]));state["project_name"]=clean_label(m.get("project_name",state["project_name"]),"");state["final_files"]=dict(state["files"]);state["submitted_at"]=now();state["updated_at"]=state["submitted_at"];version=record_version(state,"submit")
|
| 524 |
+
except VersionLimitError as exc:
|
| 525 |
+
await ws.send_json({"type":"write_rejected","message":str(exc)});continue
|
| 526 |
elif kind=="ping":await ws.send_json({"type":"pong","needs_help":state["needs_help"],"submitted_at":state["submitted_at"],"checkpoint_at":state.get("checkpoint_at")});continue
|
| 527 |
else:continue
|
| 528 |
save(state)
|
classroom_static/admin.css
CHANGED
|
@@ -56,6 +56,7 @@
|
|
| 56 |
.classroom-manager { min-width:0;min-height:0;display:grid;grid-template-rows:auto auto minmax(0,1fr);overflow:hidden; }
|
| 57 |
.classroom-fields { display:grid;grid-template-columns:minmax(150px,.7fr) minmax(220px,1fr) auto;gap:1rem;align-items:end;padding:.8rem 1.5rem;border-bottom:1px solid var(--line);background:var(--surface); }
|
| 58 |
.classroom-fields>label,.backup-controls label,.manager-add>label { display:grid;gap:.35rem;color:var(--muted);font-size:.76rem;font-weight:800; }
|
|
|
|
| 59 |
.backup-controls { display:flex;align-items:end;gap:.5rem; }
|
| 60 |
.backup-controls select { min-width:220px; }
|
| 61 |
.classroom-workspace { min-height:0;display:grid;grid-template-columns:280px minmax(0,1fr); }
|
|
@@ -68,9 +69,14 @@
|
|
| 68 |
.manager-title strong,.manager-title small { display:block; }
|
| 69 |
.manager-title small,.manager-title span { color:var(--muted);font-size:.75rem; }
|
| 70 |
.manager-list { min-height:0;overflow:auto;padding:.4rem; }
|
| 71 |
-
.manager-person-row { display:grid;grid-template-columns:minmax(0,1fr) 36px;align-items:center;border-radius:var(--radius-sm); }
|
| 72 |
.manager-person { width:100%;min-height:40px;padding:0 .7rem;border:0;border-radius:var(--radius-sm);background:transparent;color:var(--ink);text-align:left;font-weight:700; }
|
| 73 |
.manager-person:hover,.manager-person.active { background:var(--action-soft);color:var(--navy); }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 74 |
.manager-person-row .remove-file { min-height:36px;border:0;background:transparent;color:var(--muted);font-size:1.1rem; }
|
| 75 |
.manager-person-row .remove-file:hover { color:var(--danger); }
|
| 76 |
.submission-manager { min-width:0;min-height:0;display:grid;grid-template-rows:auto minmax(0,1fr);overflow:hidden; }
|
|
|
|
| 56 |
.classroom-manager { min-width:0;min-height:0;display:grid;grid-template-rows:auto auto minmax(0,1fr);overflow:hidden; }
|
| 57 |
.classroom-fields { display:grid;grid-template-columns:minmax(150px,.7fr) minmax(220px,1fr) auto;gap:1rem;align-items:end;padding:.8rem 1.5rem;border-bottom:1px solid var(--line);background:var(--surface); }
|
| 58 |
.classroom-fields>label,.backup-controls label,.manager-add>label { display:grid;gap:.35rem;color:var(--muted);font-size:.76rem;font-weight:800; }
|
| 59 |
+
.classroom-fields>label small { color:var(--muted);font-size:.68rem;font-weight:500;line-height:1.35; }
|
| 60 |
.backup-controls { display:flex;align-items:end;gap:.5rem; }
|
| 61 |
.backup-controls select { min-width:220px; }
|
| 62 |
.classroom-workspace { min-height:0;display:grid;grid-template-columns:280px minmax(0,1fr); }
|
|
|
|
| 69 |
.manager-title strong,.manager-title small { display:block; }
|
| 70 |
.manager-title small,.manager-title span { color:var(--muted);font-size:.75rem; }
|
| 71 |
.manager-list { min-height:0;overflow:auto;padding:.4rem; }
|
| 72 |
+
.manager-person-row { display:grid;grid-template-columns:minmax(0,1fr) auto 36px;align-items:center;border-radius:var(--radius-sm); }
|
| 73 |
.manager-person { width:100%;min-height:40px;padding:0 .7rem;border:0;border-radius:var(--radius-sm);background:transparent;color:var(--ink);text-align:left;font-weight:700; }
|
| 74 |
.manager-person:hover,.manager-person.active { background:var(--action-soft);color:var(--navy); }
|
| 75 |
+
.student-edit-button { min-height:32px;padding-inline:.5rem;font-size:.72rem;white-space:nowrap; }
|
| 76 |
+
.student-edit-form { display:grid;grid-template-columns:minmax(0,1fr) auto auto;gap:.35rem;padding:.4rem;border-radius:var(--radius-sm);background:var(--surface-subtle); }
|
| 77 |
+
.student-edit-form input { grid-column:1/-1;min-width:0;min-height:36px; }
|
| 78 |
+
.student-edit-form .btn,.student-edit-form .copy-button { min-height:32px;padding-inline:.65rem; }
|
| 79 |
+
.student-edit-error { grid-column:1/-1;margin:0;color:var(--danger);font-size:.72rem;line-height:1.4; }
|
| 80 |
.manager-person-row .remove-file { min-height:36px;border:0;background:transparent;color:var(--muted);font-size:1.1rem; }
|
| 81 |
.manager-person-row .remove-file:hover { color:var(--danger); }
|
| 82 |
.submission-manager { min-width:0;min-height:0;display:grid;grid-template-rows:auto minmax(0,1fr);overflow:hidden; }
|
classroom_static/admin.html
CHANGED
|
@@ -4,8 +4,8 @@
|
|
| 4 |
<meta charset="utf-8">
|
| 5 |
<meta name="viewport" content="width=device-width,initial-scale=1">
|
| 6 |
<title>Telearn 內容管理後台</title>
|
| 7 |
-
<link rel="stylesheet" href="/classroom-assets/classroom.css?v=2026fall-
|
| 8 |
-
<link rel="stylesheet" href="/classroom-assets/admin.css?v=2026fall-
|
| 9 |
</head>
|
| 10 |
<body class="admin-body">
|
| 11 |
<header class="app-header">
|
|
@@ -28,7 +28,7 @@
|
|
| 28 |
<aside class="admin-sidebar classroom-sidebar"><div class="admin-sidebar-head"><div><strong>我的班級</strong><small id="classroomTotal">尚未建立班級</small></div><button class="copy-button" id="newClassroomButton">+新增</button></div><div class="admin-list" id="classroomList"></div></aside>
|
| 29 |
<section class="classroom-manager">
|
| 30 |
<header class="admin-editor-head"><div><h1 id="classroomHeading">選擇班級</h1><p>管理班級、學生名單與已提交的正式版本。</p></div><div class="admin-actions"><button class="copy-button danger-action" id="deleteClassroomButton" disabled>刪除班級</button><button class="btn" id="saveClassroomButton" disabled>儲存班級</button></div></header>
|
| 31 |
-
<div class="classroom-fields"><label>班級代碼<input class="text-field" id="classroomCode" disabled></label><label>班級名稱<input class="text-field" id="classroomName" disabled></label><div class="backup-controls"><label>備份 Lesson<select class="text-field" id="backupWeek" disabled></select></label><button class="copy-button" id="backupSubmissionsButton" disabled>備份 Lesson 提交</button></div></div>
|
| 32 |
<div class="classroom-workspace">
|
| 33 |
<aside class="student-manager"><form class="manager-add" id="adminStudentForm"><label for="adminStudentNames">新增學生(多人可用逗號分開)</label><div><input class="text-field" id="adminStudentNames" disabled><button class="btn compact" id="adminAddStudentsButton" disabled>加入</button></div></form><div class="manager-title"><strong>學生名單</strong><span id="adminStudentTotal">0 人</span></div><div class="manager-list" id="adminStudentList"></div></aside>
|
| 34 |
<section class="submission-manager"><div class="manager-title"><div><strong id="submissionHeading">已提交內容</strong><small id="submissionTotal">0 份</small></div></div><div class="submission-workspace"><div class="submission-list" id="submissionList"></div><div class="submission-detail"><header><div><strong id="submissionProject">尚未選擇提交</strong><small id="submissionMeta"></small></div><button class="copy-button danger-action" id="deleteSubmissionButton" disabled>刪除提交</button></header><nav class="submission-files" id="submissionFiles"></nav><pre id="submissionCode">選擇一份提交查看內容。</pre></div></div></section>
|
|
@@ -48,6 +48,6 @@
|
|
| 48 |
|
| 49 |
<dialog id="adminLoginDialog"><form class="dialog-content form-stack" id="adminLoginForm"><div><h1>登入內容管理後台</h1><p>使用與教師控制台相同的密碼。</p></div><label><span class="field-label">教師密碼</span><input class="text-field" id="adminPassword" type="password" required autofocus></label><p class="form-error" id="adminLoginError"></p><button class="btn" type="submit">登入</button></form></dialog>
|
| 50 |
<div class="toast" id="toast"></div>
|
| 51 |
-
<script src="/classroom-assets/admin.js?v=2026fall-
|
| 52 |
</body>
|
| 53 |
</html>
|
|
|
|
| 4 |
<meta charset="utf-8">
|
| 5 |
<meta name="viewport" content="width=device-width,initial-scale=1">
|
| 6 |
<title>Telearn 內容管理後台</title>
|
| 7 |
+
<link rel="stylesheet" href="/classroom-assets/classroom.css?v=2026fall-9">
|
| 8 |
+
<link rel="stylesheet" href="/classroom-assets/admin.css?v=2026fall-7">
|
| 9 |
</head>
|
| 10 |
<body class="admin-body">
|
| 11 |
<header class="app-header">
|
|
|
|
| 28 |
<aside class="admin-sidebar classroom-sidebar"><div class="admin-sidebar-head"><div><strong>我的班級</strong><small id="classroomTotal">尚未建立班級</small></div><button class="copy-button" id="newClassroomButton">+新增</button></div><div class="admin-list" id="classroomList"></div></aside>
|
| 29 |
<section class="classroom-manager">
|
| 30 |
<header class="admin-editor-head"><div><h1 id="classroomHeading">選擇班級</h1><p>管理班級、學生名單與已提交的正式版本。</p></div><div class="admin-actions"><button class="copy-button danger-action" id="deleteClassroomButton" disabled>刪除班級</button><button class="btn" id="saveClassroomButton" disabled>儲存班級</button></div></header>
|
| 31 |
+
<div class="classroom-fields"><label>班級代碼<input class="text-field" id="classroomCode" disabled><small>建立後固定,避免作品與進度失去關聯。</small></label><label>班級名稱<input class="text-field" id="classroomName" disabled></label><div class="backup-controls"><label>備份 Lesson<select class="text-field" id="backupWeek" disabled></select></label><button class="copy-button" id="backupSubmissionsButton" disabled>備份 Lesson 提交</button></div></div>
|
| 32 |
<div class="classroom-workspace">
|
| 33 |
<aside class="student-manager"><form class="manager-add" id="adminStudentForm"><label for="adminStudentNames">新增學生(多人可用逗號分開)</label><div><input class="text-field" id="adminStudentNames" disabled><button class="btn compact" id="adminAddStudentsButton" disabled>加入</button></div></form><div class="manager-title"><strong>學生名單</strong><span id="adminStudentTotal">0 人</span></div><div class="manager-list" id="adminStudentList"></div></aside>
|
| 34 |
<section class="submission-manager"><div class="manager-title"><div><strong id="submissionHeading">已提交內容</strong><small id="submissionTotal">0 份</small></div></div><div class="submission-workspace"><div class="submission-list" id="submissionList"></div><div class="submission-detail"><header><div><strong id="submissionProject">尚未選擇提交</strong><small id="submissionMeta"></small></div><button class="copy-button danger-action" id="deleteSubmissionButton" disabled>刪除提交</button></header><nav class="submission-files" id="submissionFiles"></nav><pre id="submissionCode">選擇一份提交查看內容。</pre></div></div></section>
|
|
|
|
| 48 |
|
| 49 |
<dialog id="adminLoginDialog"><form class="dialog-content form-stack" id="adminLoginForm"><div><h1>登入內容管理後台</h1><p>使用與教師控制台相同的密碼。</p></div><label><span class="field-label">教師密碼</span><input class="text-field" id="adminPassword" type="password" required autofocus></label><p class="form-error" id="adminLoginError"></p><button class="btn" type="submit">登入</button></form></dialog>
|
| 50 |
<div class="toast" id="toast"></div>
|
| 51 |
+
<script src="/classroom-assets/admin.js?v=2026fall-8"></script>
|
| 52 |
</body>
|
| 53 |
</html>
|
classroom_static/admin.js
CHANGED
|
@@ -28,6 +28,7 @@ let demoFile = "";
|
|
| 28 |
let demoAssets = [];
|
| 29 |
let selectedClassroom = "";
|
| 30 |
let selectedClassroomStudent = "";
|
|
|
|
| 31 |
let selectedSubmission = null;
|
| 32 |
let submissionFiles = {};
|
| 33 |
let submissionFile = "";
|
|
@@ -363,6 +364,7 @@ function renderClassrooms() {
|
|
| 363 |
E.classroomList.querySelectorAll("[data-classroom]").forEach(button => button.onclick = () => {
|
| 364 |
selectedClassroom = button.dataset.classroom;
|
| 365 |
selectedClassroomStudent = "";
|
|
|
|
| 366 |
selectedSubmission = null;
|
| 367 |
renderClassrooms();
|
| 368 |
});
|
|
@@ -377,6 +379,8 @@ function renderClassroomDetail() {
|
|
| 377 |
E.classroomName.disabled = !enabled && selectedClassroom !== "__new__";
|
| 378 |
E.saveClassroomButton.disabled = !enabled && selectedClassroom !== "__new__";
|
| 379 |
E.deleteClassroomButton.disabled = !enabled;
|
|
|
|
|
|
|
| 380 |
[E.backupWeek, E.backupSubmissionsButton, E.adminStudentNames, E.adminAddStudentsButton].forEach(element => element.disabled = !enabled);
|
| 381 |
if (selectedClassroom === "__new__") {
|
| 382 |
E.classroomCode.value = "";
|
|
@@ -391,10 +395,22 @@ function renderClassroomDetail() {
|
|
| 391 |
function renderClassroomStudents() {
|
| 392 |
const members = currentClassroom()?.students || [];
|
| 393 |
if (selectedClassroomStudent && !members.some(item => item.student_id === selectedClassroomStudent)) selectedClassroomStudent = "";
|
|
|
|
| 394 |
E.adminStudentTotal.textContent = `${members.length} 人`;
|
| 395 |
-
E.adminStudentList.innerHTML = members.length ? `<button class="manager-person ${selectedClassroomStudent ? "" : "active"}" data-student="">全部學生</button>` + members.map(member =>
|
|
|
|
|
|
|
|
|
|
| 396 |
E.adminStudentList.querySelectorAll("[data-student]").forEach(button => button.onclick = () => { selectedClassroomStudent = button.dataset.student; selectedSubmission = null; renderClassroomStudents(); });
|
| 397 |
E.adminStudentList.querySelectorAll("[data-remove-student]").forEach(button => button.onclick = () => removeClassroomStudent(button.dataset.removeStudent));
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 398 |
renderSubmissions();
|
| 399 |
}
|
| 400 |
|
|
@@ -444,6 +460,7 @@ function resetSubmissionDetail() {
|
|
| 444 |
E.newClassroomButton.onclick = () => {
|
| 445 |
selectedClassroom = "__new__";
|
| 446 |
selectedClassroomStudent = "";
|
|
|
|
| 447 |
resetSubmissionDetail();
|
| 448 |
renderClassrooms();
|
| 449 |
E.classroomCode.focus();
|
|
@@ -483,6 +500,31 @@ E.adminStudentForm.onsubmit = async event => {
|
|
| 483 |
finally { setBusy(E.adminAddStudentsButton, false); }
|
| 484 |
};
|
| 485 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 486 |
async function removeClassroomStudent(studentId) {
|
| 487 |
const member = currentClassroom()?.students.find(item => item.student_id === studentId);
|
| 488 |
if (!member || !confirm(`從這個班級刪除「${member.display_name}」?\n\n教室進度與提交封存也會被移除。`)) return;
|
|
|
|
| 28 |
let demoAssets = [];
|
| 29 |
let selectedClassroom = "";
|
| 30 |
let selectedClassroomStudent = "";
|
| 31 |
+
let editingClassroomStudent = "";
|
| 32 |
let selectedSubmission = null;
|
| 33 |
let submissionFiles = {};
|
| 34 |
let submissionFile = "";
|
|
|
|
| 364 |
E.classroomList.querySelectorAll("[data-classroom]").forEach(button => button.onclick = () => {
|
| 365 |
selectedClassroom = button.dataset.classroom;
|
| 366 |
selectedClassroomStudent = "";
|
| 367 |
+
editingClassroomStudent = "";
|
| 368 |
selectedSubmission = null;
|
| 369 |
renderClassrooms();
|
| 370 |
});
|
|
|
|
| 379 |
E.classroomName.disabled = !enabled && selectedClassroom !== "__new__";
|
| 380 |
E.saveClassroomButton.disabled = !enabled && selectedClassroom !== "__new__";
|
| 381 |
E.deleteClassroomButton.disabled = !enabled;
|
| 382 |
+
E.saveClassroomButton.textContent = selectedClassroom === "__new__" ? "建立班級" : "儲存班級名稱";
|
| 383 |
+
E.saveClassroomButton.dataset.label = E.saveClassroomButton.textContent;
|
| 384 |
[E.backupWeek, E.backupSubmissionsButton, E.adminStudentNames, E.adminAddStudentsButton].forEach(element => element.disabled = !enabled);
|
| 385 |
if (selectedClassroom === "__new__") {
|
| 386 |
E.classroomCode.value = "";
|
|
|
|
| 395 |
function renderClassroomStudents() {
|
| 396 |
const members = currentClassroom()?.students || [];
|
| 397 |
if (selectedClassroomStudent && !members.some(item => item.student_id === selectedClassroomStudent)) selectedClassroomStudent = "";
|
| 398 |
+
if (editingClassroomStudent && !members.some(item => item.student_id === editingClassroomStudent)) editingClassroomStudent = "";
|
| 399 |
E.adminStudentTotal.textContent = `${members.length} 人`;
|
| 400 |
+
E.adminStudentList.innerHTML = members.length ? `<button class="manager-person ${selectedClassroomStudent ? "" : "active"}" data-student="">全部學生</button>` + members.map(member => {
|
| 401 |
+
if (member.student_id === editingClassroomStudent) return `<form class="student-edit-form" data-student-edit-form="${esc(member.student_id)}"><input class="text-field" name="display_name" aria-label="學生姓名" maxlength="80" value="${esc(member.display_name)}" required><button class="btn compact" type="submit">儲存</button><button class="copy-button" type="button" data-cancel-student-edit>取消</button><p class="student-edit-error" role="alert"></p></form>`;
|
| 402 |
+
return `<div class="manager-person-row"><button class="manager-person ${member.student_id === selectedClassroomStudent ? "active" : ""}" data-student="${esc(member.student_id)}">${esc(member.display_name)}</button><button class="copy-button student-edit-button" type="button" data-edit-student="${esc(member.student_id)}" aria-label="編輯 ${esc(member.display_name)} 的姓名">改名</button><button class="remove-file" type="button" data-remove-student="${esc(member.student_id)}" aria-label="刪除 ${esc(member.display_name)}">×</button></div>`;
|
| 403 |
+
}).join("") : '<div class="admin-empty">這個班級尚無學生。</div>';
|
| 404 |
E.adminStudentList.querySelectorAll("[data-student]").forEach(button => button.onclick = () => { selectedClassroomStudent = button.dataset.student; selectedSubmission = null; renderClassroomStudents(); });
|
| 405 |
E.adminStudentList.querySelectorAll("[data-remove-student]").forEach(button => button.onclick = () => removeClassroomStudent(button.dataset.removeStudent));
|
| 406 |
+
E.adminStudentList.querySelectorAll("[data-edit-student]").forEach(button => button.onclick = () => {
|
| 407 |
+
editingClassroomStudent = button.dataset.editStudent;
|
| 408 |
+
renderClassroomStudents();
|
| 409 |
+
const input = E.adminStudentList.querySelector('[data-student-edit-form] input[name="display_name"]');
|
| 410 |
+
input?.focus(); input?.select();
|
| 411 |
+
});
|
| 412 |
+
E.adminStudentList.querySelectorAll("[data-cancel-student-edit]").forEach(button => button.onclick = () => { editingClassroomStudent = ""; renderClassroomStudents(); });
|
| 413 |
+
E.adminStudentList.querySelectorAll("[data-student-edit-form]").forEach(form => form.onsubmit = event => renameClassroomStudent(event, form));
|
| 414 |
renderSubmissions();
|
| 415 |
}
|
| 416 |
|
|
|
|
| 460 |
E.newClassroomButton.onclick = () => {
|
| 461 |
selectedClassroom = "__new__";
|
| 462 |
selectedClassroomStudent = "";
|
| 463 |
+
editingClassroomStudent = "";
|
| 464 |
resetSubmissionDetail();
|
| 465 |
renderClassrooms();
|
| 466 |
E.classroomCode.focus();
|
|
|
|
| 500 |
finally { setBusy(E.adminAddStudentsButton, false); }
|
| 501 |
};
|
| 502 |
|
| 503 |
+
async function renameClassroomStudent(event, form) {
|
| 504 |
+
event.preventDefault();
|
| 505 |
+
const studentId = form.dataset.studentEditForm;
|
| 506 |
+
const displayName = form.querySelector('[name="display_name"]').value.trim();
|
| 507 |
+
const errorLabel = form.querySelector(".student-edit-error");
|
| 508 |
+
if (!displayName) { errorLabel.textContent = "請輸入學生姓名"; return; }
|
| 509 |
+
const button = form.querySelector('button[type="submit"]');
|
| 510 |
+
const previousSubmission = selectedSubmission;
|
| 511 |
+
setBusy(button, true, "儲存中…");
|
| 512 |
+
errorLabel.textContent = "";
|
| 513 |
+
try {
|
| 514 |
+
const data = await request("/api/classroom/students", { class_code: selectedClassroom, student_id: studentId, display_name: displayName }, "PUT");
|
| 515 |
+
editingClassroomStudent = "";
|
| 516 |
+
await refreshClassrooms(selectedClassroom, studentId);
|
| 517 |
+
if (previousSubmission?.student_id === studentId) {
|
| 518 |
+
const updated = visibleSubmissions().find(item => item.student_id === studentId && item.week_id === previousSubmission.week_id);
|
| 519 |
+
if (updated) await selectSubmission(updated);
|
| 520 |
+
else resetSubmissionDetail();
|
| 521 |
+
}
|
| 522 |
+
toast(data.warning || "學生姓名已更新;既有作品與進度已保留");
|
| 523 |
+
} catch (error) {
|
| 524 |
+
errorLabel.textContent = error.message;
|
| 525 |
+
} finally { setBusy(button, false); }
|
| 526 |
+
}
|
| 527 |
+
|
| 528 |
async function removeClassroomStudent(studentId) {
|
| 529 |
const member = currentClassroom()?.students.find(item => item.student_id === studentId);
|
| 530 |
if (!member || !confirm(`從這個班級刪除「${member.display_name}」?\n\n教室進度與提交封存也會被移除。`)) return;
|
classroom_static/classroom.css
CHANGED
|
@@ -153,11 +153,11 @@ button:disabled { cursor: not-allowed; opacity: .48; }
|
|
| 153 |
|
| 154 |
.panel { min-width: 0; min-height: 0; overflow: hidden; background: var(--surface); }
|
| 155 |
.reference-panel { grid-area: reference; display:grid;grid-template-rows:44px minmax(0,1fr); }
|
| 156 |
-
.editor-panel { grid-area: editor; display:grid;grid-template-rows:auto minmax(0,1fr);background:var(--editor); }
|
| 157 |
.console-panel { grid-area: console; display:grid;grid-template-rows:52px minmax(0,1fr); }
|
| 158 |
.panel-resizer { position:relative;min-width:9px;min-height:0;background:var(--shell);cursor:col-resize;touch-action:none; }
|
| 159 |
-
.panel-resizer::after { content:"";position:absolute;inset:0 auto 0 4px;width:1px;background:var(--line);transition:background-color 160ms ease,
|
| 160 |
-
.panel-resizer:hover::after,.panel-resizer:focus-visible::after,.panel-resizer.dragging::after {
|
| 161 |
.panel-resizer:focus-visible { outline:3px solid var(--help);outline-offset:-3px; }
|
| 162 |
.reference-resizer { grid-area:reference-resizer; }
|
| 163 |
.console-resizer { grid-area:console-resizer; }
|
|
@@ -182,7 +182,8 @@ body.panel-resizing { cursor:col-resize;user-select:none; }
|
|
| 182 |
padding: 0 var(--space-lg);
|
| 183 |
border-bottom: 1px solid var(--line);
|
| 184 |
}
|
| 185 |
-
.panel-heading h1
|
|
|
|
| 186 |
.panel-heading.dark { min-width:0;padding-block:6px;flex-wrap:wrap;border-color:var(--editor-line);background:var(--editor-raised);color:var(--editor-ink); }
|
| 187 |
.side-tabs { height:44px;display:flex;border-bottom:1px solid var(--line); }
|
| 188 |
.side-tab { flex:1;border:0;background:var(--surface);color:var(--muted);font-size:.8rem;font-weight:800; }
|
|
@@ -257,14 +258,23 @@ body.panel-resizing { cursor:col-resize;user-select:none; }
|
|
| 257 |
.checkpoint-action:hover { background: var(--success-soft); }
|
| 258 |
|
| 259 |
.file-name { min-width:0;flex:1 1 100px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font:700 .875rem/1 "SFMono-Regular",Consolas,monospace;color:oklch(.82 .025 255); }
|
| 260 |
-
.editor-host { min-height:0; }
|
| 261 |
.CodeMirror { height: 100%; font: 500 1rem/1.7 "SFMono-Regular", Consolas, monospace; background: var(--editor); }
|
| 262 |
.CodeMirror-gutters { background: var(--editor); border-right: 1px solid var(--editor-line); }
|
| 263 |
|
| 264 |
-
.console-body { min-height:0;display:grid;grid-template-rows:minmax(120px,1fr) auto auto; }
|
| 265 |
-
.console-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 266 |
.field-help { margin: var(--space-sm) 0 0; color: var(--muted); font-size: .75rem; line-height: 1.45; }
|
| 267 |
.console-output {
|
|
|
|
|
|
|
|
|
|
| 268 |
margin: 0;
|
| 269 |
padding: var(--space-lg);
|
| 270 |
overflow: auto;
|
|
@@ -274,6 +284,11 @@ body.panel-resizing { cursor:col-resize;user-select:none; }
|
|
| 274 |
white-space: pre-wrap;
|
| 275 |
word-break: break-word;
|
| 276 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 277 |
.console-input-row { display:grid;grid-template-columns:auto minmax(0,1fr) auto;align-items:center;gap:var(--space-sm);padding:var(--space-sm) var(--space-md);border-top:1px solid var(--editor-line);background:var(--editor-raised);color:var(--editor-ink); }
|
| 278 |
.console-input-row>span { color:oklch(.76 .15 150);font:800 1.1rem/1 monospace; }
|
| 279 |
.console-input-row input { min-width:0;height:38px;padding:0 var(--space-md);border:1px solid var(--editor-line);border-radius:var(--radius-sm);background:var(--editor);color:var(--editor-ink);font:500 .875rem/1.4 "SFMono-Regular",Consolas,monospace; }
|
|
@@ -349,10 +364,14 @@ dialog::backdrop { background: oklch(0.16 0.04 255 / .72); }
|
|
| 349 |
.toast.visible { opacity: 1; transform: translateX(-50%) translateY(0); }
|
| 350 |
|
| 351 |
@media (max-width: 980px) {
|
| 352 |
-
.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 353 |
.editor-toggle { display:none; }
|
| 354 |
.panel-resizer { display:none; }
|
| 355 |
-
.student-layout { display:block;min-height:
|
| 356 |
.mobile-tabs { display:flex;flex:none;background:var(--surface);border-bottom:1px solid var(--line); }
|
| 357 |
.mobile-tab { flex: 1; min-height: 46px; border: 0; background: var(--surface); color: var(--muted); font-weight: 800; }
|
| 358 |
.mobile-tab[aria-selected="true"] { color: var(--navy); box-shadow: inset 0 -3px 0 var(--action); }
|
|
@@ -362,9 +381,25 @@ dialog::backdrop { background: oklch(0.16 0.04 255 / .72); }
|
|
| 362 |
}
|
| 363 |
|
| 364 |
@media (max-width: 720px) {
|
| 365 |
-
.
|
| 366 |
-
.
|
| 367 |
-
.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 368 |
.teacher-layout { display: block; }
|
| 369 |
.roster-panel { height:100%;min-height:0;border-right:0; }
|
| 370 |
.teacher-detail { position: fixed; inset: 60px 0 0; z-index: 18; display: none; }
|
|
|
|
| 153 |
|
| 154 |
.panel { min-width: 0; min-height: 0; overflow: hidden; background: var(--surface); }
|
| 155 |
.reference-panel { grid-area: reference; display:grid;grid-template-rows:44px minmax(0,1fr); }
|
| 156 |
+
.editor-panel { grid-area: editor; display:grid;grid-template-columns:minmax(0,1fr);grid-template-rows:auto minmax(0,1fr);background:var(--editor); }
|
| 157 |
.console-panel { grid-area: console; display:grid;grid-template-rows:52px minmax(0,1fr); }
|
| 158 |
.panel-resizer { position:relative;min-width:9px;min-height:0;background:var(--shell);cursor:col-resize;touch-action:none; }
|
| 159 |
+
.panel-resizer::after { content:"";position:absolute;inset:0 auto 0 4px;width:1px;background:var(--line);transform:scaleX(1);transform-origin:left center;transition:background-color 160ms ease,transform 160ms ease; }
|
| 160 |
+
.panel-resizer:hover::after,.panel-resizer:focus-visible::after,.panel-resizer.dragging::after { transform:scaleX(2);background:var(--action); }
|
| 161 |
.panel-resizer:focus-visible { outline:3px solid var(--help);outline-offset:-3px; }
|
| 162 |
.reference-resizer { grid-area:reference-resizer; }
|
| 163 |
.console-resizer { grid-area:console-resizer; }
|
|
|
|
| 182 |
padding: 0 var(--space-lg);
|
| 183 |
border-bottom: 1px solid var(--line);
|
| 184 |
}
|
| 185 |
+
.panel-heading h1 { margin: 0; font-size: 1.5rem; }
|
| 186 |
+
.panel-heading h2 { margin: 0; font-size: 1.1875rem; }
|
| 187 |
.panel-heading.dark { min-width:0;padding-block:6px;flex-wrap:wrap;border-color:var(--editor-line);background:var(--editor-raised);color:var(--editor-ink); }
|
| 188 |
.side-tabs { height:44px;display:flex;border-bottom:1px solid var(--line); }
|
| 189 |
.side-tab { flex:1;border:0;background:var(--surface);color:var(--muted);font-size:.8rem;font-weight:800; }
|
|
|
|
| 258 |
.checkpoint-action:hover { background: var(--success-soft); }
|
| 259 |
|
| 260 |
.file-name { min-width:0;flex:1 1 100px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font:700 .875rem/1 "SFMono-Regular",Consolas,monospace;color:oklch(.82 .025 255); }
|
| 261 |
+
.editor-host { min-width:0;min-height:0; }
|
| 262 |
.CodeMirror { height: 100%; font: 500 1rem/1.7 "SFMono-Regular", Consolas, monospace; background: var(--editor); }
|
| 263 |
.CodeMirror-gutters { background: var(--editor); border-right: 1px solid var(--editor-line); }
|
| 264 |
|
| 265 |
+
.console-body { min-height:0;display:grid;grid-template-rows:auto minmax(120px,1fr) auto auto; }
|
| 266 |
+
.console-tabs { min-height:38px;display:flex;align-items:stretch;gap:2px;padding:var(--space-xs) var(--space-sm);border-bottom:1px solid var(--line);background:var(--surface); }
|
| 267 |
+
.console-tabs[hidden],#pygameFrame[hidden] { display:none; }
|
| 268 |
+
.console-tab { min-width:88px;padding:0 var(--space-md);border:0;border-radius:var(--radius-sm);background:transparent;color:var(--muted);font-size:.78rem;font-weight:800; }
|
| 269 |
+
.console-tab:hover { background:var(--surface-subtle);color:var(--navy); }
|
| 270 |
+
.console-tab.active { background:var(--action-soft);color:var(--navy); }
|
| 271 |
+
.console-tab:focus-visible { outline:2px solid var(--action);outline-offset:1px; }
|
| 272 |
+
.console-stream { position:relative;min-height:0;display:grid;grid-template-columns:minmax(0,1fr);grid-template-rows:minmax(0,1fr) auto;background:var(--editor); }
|
| 273 |
.field-help { margin: var(--space-sm) 0 0; color: var(--muted); font-size: .75rem; line-height: 1.45; }
|
| 274 |
.console-output {
|
| 275 |
+
grid-area:1 / 1;
|
| 276 |
+
min-width:0;
|
| 277 |
+
min-height:0;
|
| 278 |
margin: 0;
|
| 279 |
padding: var(--space-lg);
|
| 280 |
overflow: auto;
|
|
|
|
| 284 |
white-space: pre-wrap;
|
| 285 |
word-break: break-word;
|
| 286 |
}
|
| 287 |
+
.console-stream[data-view="pygame"] .console-output { visibility:hidden; }
|
| 288 |
+
.console-stream[data-view="pygame"] .console-output[hidden] { display:none; }
|
| 289 |
+
#pygameFrame { display:block;grid-area:1 / 1;z-index:1;width:100%;height:100%;min-width:0;min-height:0;border:0;background:var(--editor);visibility:hidden; }
|
| 290 |
+
.console-stream[data-view="pygame"] #pygameFrame { visibility:visible; }
|
| 291 |
+
.console-input-row { grid-area:2 / 1; }
|
| 292 |
.console-input-row { display:grid;grid-template-columns:auto minmax(0,1fr) auto;align-items:center;gap:var(--space-sm);padding:var(--space-sm) var(--space-md);border-top:1px solid var(--editor-line);background:var(--editor-raised);color:var(--editor-ink); }
|
| 293 |
.console-input-row>span { color:oklch(.76 .15 150);font:800 1.1rem/1 monospace; }
|
| 294 |
.console-input-row input { min-width:0;height:38px;padding:0 var(--space-md);border:1px solid var(--editor-line);border-radius:var(--radius-sm);background:var(--editor);color:var(--editor-ink);font:500 .875rem/1.4 "SFMono-Regular",Consolas,monospace; }
|
|
|
|
| 364 |
.toast.visible { opacity: 1; transform: translateX(-50%) translateY(0); }
|
| 365 |
|
| 366 |
@media (max-width: 980px) {
|
| 367 |
+
html.student-page { height:auto;min-height:100%;overflow-x:hidden;overflow-y:auto; }
|
| 368 |
+
html.student-page body { height:auto;min-height:100vh;min-height:100dvh;overflow:visible;padding-bottom:env(safe-area-inset-bottom); }
|
| 369 |
+
.student-page .project-bar { align-items:stretch;flex-direction:column;padding:var(--space-md); }
|
| 370 |
+
.student-page .project-actions { overflow:visible;flex-wrap:wrap;align-items:stretch;justify-content:flex-start; }
|
| 371 |
+
.student-page .project-actions>* { flex:0 1 auto;min-width:0;max-width:100%; }
|
| 372 |
.editor-toggle { display:none; }
|
| 373 |
.panel-resizer { display:none; }
|
| 374 |
+
.student-layout { display:block;height:min(68dvh,700px);min-height:360px;flex:none; }
|
| 375 |
.mobile-tabs { display:flex;flex:none;background:var(--surface);border-bottom:1px solid var(--line); }
|
| 376 |
.mobile-tab { flex: 1; min-height: 46px; border: 0; background: var(--surface); color: var(--muted); font-weight: 800; }
|
| 377 |
.mobile-tab[aria-selected="true"] { color: var(--navy); box-shadow: inset 0 -3px 0 var(--action); }
|
|
|
|
| 381 |
}
|
| 382 |
|
| 383 |
@media (max-width: 720px) {
|
| 384 |
+
.student-page .app-header { min-height:0;flex-wrap:wrap;gap:var(--space-sm);padding:var(--space-sm) var(--space-md); }
|
| 385 |
+
.student-page .brand { width:100%;min-width:0; }
|
| 386 |
+
.student-page .brand-copy { min-width:0; }
|
| 387 |
+
.student-page .brand-copy span { display:block;max-width:calc(100vw - 72px);overflow:hidden;text-overflow:ellipsis;white-space:nowrap; }
|
| 388 |
+
.student-page .header-actions { width:100%;flex-wrap:wrap;justify-content:space-between;gap:var(--space-xs); }
|
| 389 |
+
.student-page .connection-state { min-height:44px;padding-inline:var(--space-sm); }
|
| 390 |
+
.student-page .header-actions .btn { min-height:44px;padding-inline:.6rem; }
|
| 391 |
+
.student-page .project-context { display:grid;width:100%;grid-template-columns:max-content minmax(0,1fr);align-items:center; }
|
| 392 |
+
.student-page .project-context select,.student-page .project-context input { width:100%;min-width:0;max-width:none; }
|
| 393 |
+
.student-page .project-actions { display:grid;width:100%;grid-template-columns:repeat(2,minmax(0,1fr));align-items:stretch; }
|
| 394 |
+
.student-page .project-actions>* { width:100%;min-width:0;min-height:44px;padding:.4rem .5rem;line-height:1.3;white-space:normal;overflow-wrap:anywhere; }
|
| 395 |
+
.student-page .student-layout { height:min(68dvh,680px);min-height:360px; }
|
| 396 |
+
.mobile-tab { min-height:48px; }
|
| 397 |
+
.student-page dialog { max-height:calc(100dvh - 32px);overflow:auto; }
|
| 398 |
+
.student-page .dialog-content { padding:var(--space-xl); }
|
| 399 |
+
.student-page .progress-item { align-items:flex-start;flex-direction:column; }
|
| 400 |
+
.student-page .progress-item span { min-width:0;overflow-wrap:anywhere; }
|
| 401 |
+
.student-page .console-status { min-height:56px;flex-wrap:wrap;row-gap:4px;padding-block:var(--space-sm); }
|
| 402 |
+
.student-page .toast { left:max(12px,env(safe-area-inset-left));right:max(12px,env(safe-area-inset-right));max-width:calc(100vw - 24px);text-align:center;overflow-wrap:anywhere; }
|
| 403 |
.teacher-layout { display: block; }
|
| 404 |
.roster-panel { height:100%;min-height:0;border-right:0; }
|
| 405 |
.teacher-detail { position: fixed; inset: 60px 0 0; z-index: 18; display: none; }
|
classroom_static/py-worker.js
CHANGED
|
@@ -4,8 +4,8 @@ let inputBytes = null;
|
|
| 4 |
|
| 5 |
async function initialize() {
|
| 6 |
try {
|
| 7 |
-
importScripts("
|
| 8 |
-
pyodide = await loadPyodide({ indexURL: "
|
| 9 |
pyodide.setStdout({
|
| 10 |
write(buffer) {
|
| 11 |
postMessage({ type: "stream", channel: "stdout", bytes: buffer });
|
|
|
|
| 4 |
|
| 5 |
async function initialize() {
|
| 6 |
try {
|
| 7 |
+
importScripts("/classroom-assets/vendor/pyodide/pyodide.js");
|
| 8 |
+
pyodide = await loadPyodide({ indexURL: "/classroom-assets/vendor/pyodide/" });
|
| 9 |
pyodide.setStdout({
|
| 10 |
write(buffer) {
|
| 11 |
postMessage({ type: "stream", channel: "stdout", bytes: buffer });
|
classroom_static/pygame-runner.html
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!doctype html>
|
| 2 |
+
<html lang="zh-Hant">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="utf-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
| 6 |
+
<meta name="color-scheme" content="dark">
|
| 7 |
+
<title>瀏覽器版 Pygame 執行器</title>
|
| 8 |
+
<style>
|
| 9 |
+
:root { color-scheme: dark; font: 14px/1.5 system-ui, sans-serif; background: #111923; color: #e8edf4; }
|
| 10 |
+
* { box-sizing: border-box; }
|
| 11 |
+
html, body { width: 100%; height: 100%; margin: 0; overflow: hidden; }
|
| 12 |
+
body { display: grid; grid-template-rows: auto minmax(0, 1fr); }
|
| 13 |
+
.runner-note { padding: 8px 12px; border-bottom: 1px solid #303a49; color: #aeb9c8; font-size: 12px; }
|
| 14 |
+
.stage { min-width: 0; min-height: 0; display: grid; place-items: center; overflow: auto; padding: 10px; }
|
| 15 |
+
canvas { display: block; width: min(100%, 800px); height: auto; max-height: 100%; aspect-ratio: 4 / 3; background: #090e14; outline: none; }
|
| 16 |
+
#runnerMessage { max-width: 38rem; color: #c4cfdb; text-align: center; white-space: pre-wrap; }
|
| 17 |
+
#runnerMessage[hidden] { display: none; }
|
| 18 |
+
</style>
|
| 19 |
+
</head>
|
| 20 |
+
<body>
|
| 21 |
+
<div class="runner-note">瀏覽器版 Pygame · 點遊戲畫面後可用鍵盤操作</div>
|
| 22 |
+
<main class="stage">
|
| 23 |
+
<canvas id="canvas" width="640" height="480" tabindex="0" aria-label="Pygame 遊戲畫面"></canvas>
|
| 24 |
+
<p id="runnerMessage" aria-live="polite">正在準備 Pygame…</p>
|
| 25 |
+
</main>
|
| 26 |
+
<script src="/classroom-assets/vendor/pyodide/pyodide.js"></script>
|
| 27 |
+
<script src="/classroom-assets/pygame-runner.js?v=2026fall-1"></script>
|
| 28 |
+
</body>
|
| 29 |
+
</html>
|
classroom_static/pygame-runner.js
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
(() => {
|
| 2 |
+
const canvas = document.getElementById("canvas");
|
| 3 |
+
const message = document.getElementById("runnerMessage");
|
| 4 |
+
const PYODIDE_URL = "/classroom-assets/vendor/pyodide/";
|
| 5 |
+
let pyodide = null;
|
| 6 |
+
let busy = false;
|
| 7 |
+
let outputDecoders = {};
|
| 8 |
+
|
| 9 |
+
function tell(type, details = {}) {
|
| 10 |
+
window.parent.postMessage({ type, ...details }, "*");
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
function showMessage(text) {
|
| 14 |
+
message.textContent = text;
|
| 15 |
+
message.hidden = false;
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
function hideMessage() {
|
| 19 |
+
message.hidden = true;
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
function setStream(channel) {
|
| 23 |
+
return {
|
| 24 |
+
isatty: false,
|
| 25 |
+
write(bytes) {
|
| 26 |
+
const decoder = outputDecoders[channel];
|
| 27 |
+
const text = decoder.decode(bytes, { stream: true });
|
| 28 |
+
if (text) tell("output", { runId: window.currentRunId, text });
|
| 29 |
+
return bytes.length;
|
| 30 |
+
},
|
| 31 |
+
};
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
async function initialize() {
|
| 35 |
+
try {
|
| 36 |
+
if (typeof loadPyodide !== "function") throw new Error("Pyodide 主程式沒有載入");
|
| 37 |
+
pyodide = await loadPyodide({ indexURL: PYODIDE_URL });
|
| 38 |
+
pyodide.canvas.setCanvas2D(canvas);
|
| 39 |
+
pyodide._api._skip_unwind_fatal_error = true;
|
| 40 |
+
tell("ready");
|
| 41 |
+
showMessage("Pygame 已就緒,按下「執行」開始遊戲。");
|
| 42 |
+
} catch (error) {
|
| 43 |
+
const text = `瀏覽器版 Pygame 載入失敗:${String(error)}`;
|
| 44 |
+
showMessage(text);
|
| 45 |
+
tell("load_error", { text });
|
| 46 |
+
}
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
async function run(files, runId) {
|
| 50 |
+
if (!pyodide || busy) return;
|
| 51 |
+
busy = true;
|
| 52 |
+
window.currentRunId = runId;
|
| 53 |
+
outputDecoders = { stdout: new TextDecoder(), stderr: new TextDecoder() };
|
| 54 |
+
pyodide.setStdout(setStream("stdout"));
|
| 55 |
+
pyodide.setStderr(setStream("stderr"));
|
| 56 |
+
pyodide.setStdin({ stdin: () => { throw new Error("瀏覽器版 Pygame 不支援 input();請改用 pygame 的鍵盤或滑鼠事件。"); } });
|
| 57 |
+
showMessage("正在載入 Pygame…");
|
| 58 |
+
tell("status", { runId, label: "載入 Pygame 套件…" });
|
| 59 |
+
|
| 60 |
+
try {
|
| 61 |
+
await pyodide.loadPackage("pygame-ce");
|
| 62 |
+
pyodide.runPython(`
|
| 63 |
+
import os, shutil, sys
|
| 64 |
+
root = "/home/pyodide/classroom_pygame"
|
| 65 |
+
shutil.rmtree(root, ignore_errors=True)
|
| 66 |
+
os.makedirs(root, exist_ok=True)
|
| 67 |
+
for name, module in list(sys.modules.items()):
|
| 68 |
+
if str(getattr(module, "__file__", "")).startswith(root):
|
| 69 |
+
del sys.modules[name]
|
| 70 |
+
`);
|
| 71 |
+
for (const [path, content] of Object.entries(files || {})) {
|
| 72 |
+
if (path.startsWith("/") || path.split(/[\\/]/).includes("..")) continue;
|
| 73 |
+
const fullPath = `/home/pyodide/classroom_pygame/${path}`;
|
| 74 |
+
pyodide.FS.mkdirTree(fullPath.slice(0, fullPath.lastIndexOf("/")));
|
| 75 |
+
pyodide.FS.writeFile(fullPath, content, { encoding: "utf8" });
|
| 76 |
+
}
|
| 77 |
+
pyodide.globals.set("classroom_entry", "main.py");
|
| 78 |
+
canvas.focus();
|
| 79 |
+
hideMessage();
|
| 80 |
+
tell("status", { runId, label: "遊戲執行中" });
|
| 81 |
+
const succeeded = await pyodide.runPythonAsync(`
|
| 82 |
+
import asyncio, inspect, os, sys, traceback
|
| 83 |
+
root = "/home/pyodide/classroom_pygame"
|
| 84 |
+
os.chdir(root)
|
| 85 |
+
if root not in sys.path:
|
| 86 |
+
sys.path.insert(0, root)
|
| 87 |
+
entry = os.path.join(root, classroom_entry)
|
| 88 |
+
namespace = {"__name__": "__main__", "__file__": entry}
|
| 89 |
+
try:
|
| 90 |
+
with open(entry, "r", encoding="utf-8") as source:
|
| 91 |
+
exec(compile(source.read(), entry, "exec"), namespace)
|
| 92 |
+
main = namespace.get("main")
|
| 93 |
+
if inspect.iscoroutinefunction(main):
|
| 94 |
+
await main()
|
| 95 |
+
True
|
| 96 |
+
except BaseException:
|
| 97 |
+
traceback.print_exc()
|
| 98 |
+
raise
|
| 99 |
+
finally:
|
| 100 |
+
import pygame
|
| 101 |
+
pygame.quit()
|
| 102 |
+
`);
|
| 103 |
+
for (const channel of ["stdout", "stderr"]) {
|
| 104 |
+
const rest = outputDecoders[channel].decode();
|
| 105 |
+
if (rest) tell("output", { runId, text: rest });
|
| 106 |
+
}
|
| 107 |
+
tell(succeeded ? "done" : "error", { runId });
|
| 108 |
+
showMessage(succeeded ? "遊戲已結束。按「執行」可重新開始。" : "遊戲發生錯誤,請查看文字輸出。" );
|
| 109 |
+
} catch (error) {
|
| 110 |
+
for (const channel of ["stdout", "stderr"]) {
|
| 111 |
+
const rest = outputDecoders[channel].decode();
|
| 112 |
+
if (rest) tell("output", { runId, text: rest });
|
| 113 |
+
}
|
| 114 |
+
const text = String(error);
|
| 115 |
+
tell("error", { runId, text });
|
| 116 |
+
showMessage("遊戲發生錯誤,請查看文字輸出。");
|
| 117 |
+
} finally {
|
| 118 |
+
busy = false;
|
| 119 |
+
}
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
window.addEventListener("message", event => {
|
| 123 |
+
if (event.source !== window.parent || event.data?.type !== "run") return;
|
| 124 |
+
run(event.data.files, event.data.runId);
|
| 125 |
+
});
|
| 126 |
+
|
| 127 |
+
initialize();
|
| 128 |
+
})();
|
classroom_static/pygame-support.js
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
(() => {
|
| 2 |
+
let frame = null;
|
| 3 |
+
let ready = false;
|
| 4 |
+
let bootPromise = null;
|
| 5 |
+
let resolveBoot = null;
|
| 6 |
+
let rejectBoot = null;
|
| 7 |
+
let timeout = null;
|
| 8 |
+
let runId = 0;
|
| 9 |
+
let activeRunId = 0;
|
| 10 |
+
let onMessage = () => {};
|
| 11 |
+
|
| 12 |
+
function receive(event) {
|
| 13 |
+
if (!frame || event.source !== frame.contentWindow) return;
|
| 14 |
+
const message = event.data || {};
|
| 15 |
+
if (message.type === "ready") {
|
| 16 |
+
ready = true;
|
| 17 |
+
clearTimeout(timeout);
|
| 18 |
+
timeout = null;
|
| 19 |
+
resolveBoot?.();
|
| 20 |
+
resolveBoot = null;
|
| 21 |
+
rejectBoot = null;
|
| 22 |
+
bootPromise = null;
|
| 23 |
+
return;
|
| 24 |
+
}
|
| 25 |
+
if (message.type === "load_error") {
|
| 26 |
+
const error = new Error(message.text || "無法載入瀏覽器版 Pygame");
|
| 27 |
+
clearTimeout(timeout);
|
| 28 |
+
timeout = null;
|
| 29 |
+
rejectBoot?.(error);
|
| 30 |
+
resolveBoot = null;
|
| 31 |
+
rejectBoot = null;
|
| 32 |
+
bootPromise = null;
|
| 33 |
+
onMessage({ ...message, runId: activeRunId });
|
| 34 |
+
return;
|
| 35 |
+
}
|
| 36 |
+
if (message.runId && message.runId !== activeRunId) return;
|
| 37 |
+
onMessage(message);
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
window.addEventListener("message", receive);
|
| 41 |
+
|
| 42 |
+
window.ClassroomPygame = {
|
| 43 |
+
attach(target, handler) {
|
| 44 |
+
frame = target;
|
| 45 |
+
onMessage = handler;
|
| 46 |
+
},
|
| 47 |
+
|
| 48 |
+
isPygame(files) {
|
| 49 |
+
return Object.entries(files || {}).some(([path, code]) =>
|
| 50 |
+
path.toLowerCase().endsWith(".py") &&
|
| 51 |
+
/^\s*(?:import\s+pygame(?:\s|,|$)|from\s+pygame(?:\.|\s+import\s))/m.test(code)
|
| 52 |
+
);
|
| 53 |
+
},
|
| 54 |
+
|
| 55 |
+
async run(files) {
|
| 56 |
+
const thisRun = ++runId;
|
| 57 |
+
activeRunId = thisRun;
|
| 58 |
+
frame.hidden = false;
|
| 59 |
+
if (!ready) {
|
| 60 |
+
if (!bootPromise) {
|
| 61 |
+
bootPromise = new Promise((resolve, reject) => {
|
| 62 |
+
resolveBoot = resolve;
|
| 63 |
+
rejectBoot = reject;
|
| 64 |
+
timeout = setTimeout(() => {
|
| 65 |
+
const error = new Error("Pygame 載入逾時,請確認網路連線後重試。");
|
| 66 |
+
bootPromise = null;
|
| 67 |
+
resolveBoot = null;
|
| 68 |
+
rejectBoot = null;
|
| 69 |
+
reject(error);
|
| 70 |
+
}, 60000);
|
| 71 |
+
frame.src = "/classroom-assets/pygame-runner.html?v=2026fall-1";
|
| 72 |
+
});
|
| 73 |
+
}
|
| 74 |
+
await bootPromise;
|
| 75 |
+
}
|
| 76 |
+
if (thisRun !== runId) return;
|
| 77 |
+
frame.contentWindow.postMessage({ type: "run", runId: thisRun, files }, "*");
|
| 78 |
+
},
|
| 79 |
+
|
| 80 |
+
stop() {
|
| 81 |
+
++runId;
|
| 82 |
+
activeRunId = runId;
|
| 83 |
+
ready = false;
|
| 84 |
+
clearTimeout(timeout);
|
| 85 |
+
timeout = null;
|
| 86 |
+
if (rejectBoot) rejectBoot(new Error("Pygame 已停止"));
|
| 87 |
+
resolveBoot = null;
|
| 88 |
+
rejectBoot = null;
|
| 89 |
+
bootPromise = null;
|
| 90 |
+
if (frame) {
|
| 91 |
+
frame.hidden = true;
|
| 92 |
+
frame.src = "about:blank";
|
| 93 |
+
}
|
| 94 |
+
},
|
| 95 |
+
};
|
| 96 |
+
})();
|
classroom_static/student.html
CHANGED
|
@@ -1,12 +1,142 @@
|
|
| 1 |
-
<!doctype html>
|
| 2 |
-
<
|
| 3 |
-
<
|
| 4 |
-
<
|
| 5 |
-
<
|
| 6 |
-
<
|
| 7 |
-
<
|
| 8 |
-
<
|
| 9 |
-
<
|
| 10 |
-
<
|
| 11 |
-
<
|
| 12 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!doctype html>
|
| 2 |
+
<html lang="zh-Hant" class="student-page">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="utf-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
|
| 6 |
+
<meta name="color-scheme" content="light">
|
| 7 |
+
<title>Python 專案教室|學生工作區</title>
|
| 8 |
+
<link rel="stylesheet" href="/classroom-assets/vendor/codemirror/codemirror.css">
|
| 9 |
+
<link rel="stylesheet" href="/classroom-assets/vendor/codemirror/material-darker.css">
|
| 10 |
+
<link rel="stylesheet" href="/classroom-assets/classroom.css?v=2026fall-16">
|
| 11 |
+
</head>
|
| 12 |
+
<body>
|
| 13 |
+
<header class="app-header">
|
| 14 |
+
<div class="brand">
|
| 15 |
+
<div class="brand-mark">Py</div>
|
| 16 |
+
<div class="brand-copy">
|
| 17 |
+
<strong>Python 專案教室</strong>
|
| 18 |
+
<span id="studentIdentity">尚未進入教室</span>
|
| 19 |
+
</div>
|
| 20 |
+
</div>
|
| 21 |
+
<div class="header-actions">
|
| 22 |
+
<div class="connection-state"><i class="status-dot" id="connectionDot"></i><span id="connectionText">未連線</span></div>
|
| 23 |
+
<button class="btn help compact" id="helpButton">請老師協助</button>
|
| 24 |
+
<button class="btn secondary compact" id="identityButton">更換身分</button>
|
| 25 |
+
</div>
|
| 26 |
+
</header>
|
| 27 |
+
|
| 28 |
+
<section class="project-bar">
|
| 29 |
+
<div class="project-context">
|
| 30 |
+
<label for="weekSelect">Lesson</label><select id="weekSelect"></select>
|
| 31 |
+
<label for="projectName">專案</label><input id="projectName" maxlength="36">
|
| 32 |
+
</div>
|
| 33 |
+
<div class="project-actions">
|
| 34 |
+
<button class="copy-button" id="templateButton">匯入專案起始套版</button>
|
| 35 |
+
<button class="copy-button" id="progressButton">選擇舊進度</button>
|
| 36 |
+
<button class="copy-button" id="importButton">匯入 .py / ZIP</button>
|
| 37 |
+
<button class="copy-button" id="exportButton">匯出專案</button>
|
| 38 |
+
<button class="copy-button checkpoint-action" id="checkpointButton">暫存本週進度</button>
|
| 39 |
+
<button class="btn compact" id="submitButton">提交正式版本</button>
|
| 40 |
+
<input id="fileImport" type="file" accept=".py,.zip" hidden>
|
| 41 |
+
</div>
|
| 42 |
+
</section>
|
| 43 |
+
|
| 44 |
+
<nav class="mobile-tabs" role="tablist">
|
| 45 |
+
<button class="mobile-tab" data-panel="referencePanel">專案/語法</button>
|
| 46 |
+
<button class="mobile-tab" data-panel="editorPanel" aria-selected="true">程式</button>
|
| 47 |
+
<button class="mobile-tab" data-panel="consolePanel">執行結果</button>
|
| 48 |
+
</nav>
|
| 49 |
+
|
| 50 |
+
<main class="student-layout" id="studentLayout">
|
| 51 |
+
<aside class="panel reference-panel" id="referencePanel">
|
| 52 |
+
<div class="side-tabs">
|
| 53 |
+
<button class="side-tab active" data-side="filesView">專案檔案</button>
|
| 54 |
+
<button class="side-tab" data-side="syntaxView">語法查詢</button>
|
| 55 |
+
</div>
|
| 56 |
+
<section class="side-view" id="filesView">
|
| 57 |
+
<div class="panel-heading"><h1>檔案夾</h1><button class="copy-button" id="newFileButton">+新增</button></div>
|
| 58 |
+
<div class="file-list" id="fileList"></div>
|
| 59 |
+
<p class="side-note">入口固定為 <code>main.py</code>,可用 <code>import</code> 引入其他 Python 檔。</p>
|
| 60 |
+
</section>
|
| 61 |
+
<section class="side-view" id="syntaxView" hidden>
|
| 62 |
+
<div class="panel-heading"><h1>Python 語法查詢</h1><span id="snippetCount">0 項</span></div>
|
| 63 |
+
<div class="search-wrap">
|
| 64 |
+
<label class="field-label" for="syntaxSearch">輸入語法或用途</label>
|
| 65 |
+
<input class="text-field" id="syntaxSearch" type="search">
|
| 66 |
+
<div class="chip-row" id="categoryFilters"></div>
|
| 67 |
+
</div>
|
| 68 |
+
<div class="snippet-list" id="snippetList"></div>
|
| 69 |
+
</section>
|
| 70 |
+
</aside>
|
| 71 |
+
|
| 72 |
+
<div class="panel-resizer reference-resizer" id="referenceResizer" role="separator" aria-label="調整專案側欄寬度" aria-orientation="vertical" tabindex="0" title="拖曳調整寬度;雙擊恢復預設"></div>
|
| 73 |
+
|
| 74 |
+
<section class="panel editor-panel mobile-active" id="editorPanel">
|
| 75 |
+
<div class="panel-heading dark">
|
| 76 |
+
<span class="file-name" id="activeFileLabel">main.py</span>
|
| 77 |
+
<div class="editor-toolbar">
|
| 78 |
+
<button class="editor-toggle" id="toggleReferenceButton" aria-pressed="false">隱藏側欄</button>
|
| 79 |
+
<button class="editor-toggle" id="toggleConsoleButton" aria-pressed="false">隱藏 Console</button>
|
| 80 |
+
<button class="btn stop compact" id="stopButton" disabled>停止</button>
|
| 81 |
+
<button class="btn compact" id="runButton" disabled>載入 Python…</button>
|
| 82 |
+
</div>
|
| 83 |
+
</div>
|
| 84 |
+
<div class="editor-host"><textarea id="codeEditor" aria-label="程式碼"></textarea></div>
|
| 85 |
+
</section>
|
| 86 |
+
|
| 87 |
+
<div class="panel-resizer console-resizer" id="consoleResizer" role="separator" aria-label="調整 Console 寬度" aria-orientation="vertical" tabindex="0" title="拖曳調整寬度;雙擊恢復預設"></div>
|
| 88 |
+
|
| 89 |
+
<aside class="panel console-panel" id="consolePanel">
|
| 90 |
+
<div class="panel-heading"><h2>執行結果</h2><button class="copy-button" id="clearOutputButton">清除</button></div>
|
| 91 |
+
<div class="console-body">
|
| 92 |
+
<div class="console-tabs" id="consoleTabs" role="tablist" aria-label="執行結果檢視" hidden>
|
| 93 |
+
<button class="console-tab active" id="consoleOutputTab" role="tab" aria-selected="true">文字輸出</button>
|
| 94 |
+
<button class="console-tab" id="pygameOutputTab" role="tab" aria-selected="false">遊戲畫面</button>
|
| 95 |
+
</div>
|
| 96 |
+
<div class="console-stream" id="consoleStream" data-view="console">
|
| 97 |
+
<pre class="console-output" id="consoleOutput">Python 執行環境載入中…</pre>
|
| 98 |
+
<iframe id="pygameFrame" title="Pygame 遊戲畫面" sandbox="allow-scripts" hidden></iframe>
|
| 99 |
+
<form class="console-input-row" id="consoleInputForm" hidden>
|
| 100 |
+
<span aria-hidden="true">›</span>
|
| 101 |
+
<label class="sr-only" for="consoleInput">Python 程式要求輸入</label>
|
| 102 |
+
<input id="consoleInput" autocomplete="off">
|
| 103 |
+
<button class="btn compact" type="submit">送出</button>
|
| 104 |
+
</form>
|
| 105 |
+
</div>
|
| 106 |
+
<p class="console-hint" id="consoleHint">一般 Python 可使用 <code>input()</code>。Pygame 使用瀏覽器畫布,需用 <code>async main()</code> 與 <code>await asyncio.sleep()</code>;目前支援程式繪圖,不支援 <code>input()</code> 或外部圖片/音效檔。範例請在語法查詢搜尋 Pygame。</p>
|
| 107 |
+
<div class="console-status">
|
| 108 |
+
<i class="status-dot" id="runStatusDot"></i><span id="runStatusText">準備環境</span>
|
| 109 |
+
<span>·</span><span id="saveStatus">尚未同步</span>
|
| 110 |
+
<span>·</span><span id="submitStatus">尚未暫存或提交</span>
|
| 111 |
+
</div>
|
| 112 |
+
</div>
|
| 113 |
+
</aside>
|
| 114 |
+
</main>
|
| 115 |
+
|
| 116 |
+
<dialog id="identityDialog">
|
| 117 |
+
<form class="dialog-content form-stack" id="identityForm">
|
| 118 |
+
<div><h1>進入程式教室</h1><p>選擇班級並輸入自己在名單上的姓名。</p></div>
|
| 119 |
+
<div><label class="field-label" for="classCode">課程</label><select class="text-field" id="classCode" required></select></div>
|
| 120 |
+
<div><label class="field-label" for="displayName">姓名</label><input class="text-field" id="displayName" autocomplete="name" required><p class="field-help">姓名必須與老師建立的名單一致。</p></div>
|
| 121 |
+
<p class="form-error" id="identityError" role="alert"></p>
|
| 122 |
+
<button class="btn" type="submit">進入教室</button>
|
| 123 |
+
</form>
|
| 124 |
+
</dialog>
|
| 125 |
+
|
| 126 |
+
<dialog id="progressDialog">
|
| 127 |
+
<div class="dialog-content">
|
| 128 |
+
<h2>選擇已儲存版本</h2>
|
| 129 |
+
<p>每次暫存與正式提交都會保留;載入只會建立工作副本,不會刪除其他版本。</p>
|
| 130 |
+
<div class="progress-list" id="progressList"></div>
|
| 131 |
+
<button class="btn secondary dialog-close">關閉</button>
|
| 132 |
+
</div>
|
| 133 |
+
</dialog>
|
| 134 |
+
<div class="toast" id="toast"></div>
|
| 135 |
+
|
| 136 |
+
<script src="/classroom-assets/vendor/codemirror/codemirror.js"></script>
|
| 137 |
+
<script src="/classroom-assets/vendor/codemirror/python.js"></script>
|
| 138 |
+
<script src="/classroom-assets/vendor/jszip/jszip.min.js"></script>
|
| 139 |
+
<script src="/classroom-assets/pygame-support.js?v=2026fall-1"></script>
|
| 140 |
+
<script src="/classroom-assets/student.js?v=2026fall-14"></script>
|
| 141 |
+
</body>
|
| 142 |
+
</html>
|
classroom_static/student.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
| 1 |
-
const SNIPPETS=[["基礎","輸入與輸出",'name = input("名字:")\nprint("你好", name)'],["判斷","if / elif / else",'if score >= 90:\n print("A")\nelse:\n print("繼續加油")'],["迴圈","for 與 range",'for number in range(1, 6):\n print(number)'],["迴圈","while",'while True:\n choice = input("選擇:")\n if choice == "q":\n break'],["資料","list",'items = []\nitems.append("線索")\nfor item in items:\n print(item)'],["資料","dict",'student = {"name": "Alex"}\nprint(student.get("team", "未分組"))'],["函式","def / return",'def add(a, b):\n return a + b'],["檔案","引入自己的模組",'from clues import find_clue'],["檔案","讀取 JSON",'import json\nwith open("data.json", encoding="utf-8") as file:\n data = json.load(file)'],["錯誤","try / except",'try:\n age = int(input("年齡:"))\nexcept ValueError:\n print("請輸入整數")']].map(([category,title,code])=>({category,title,code}));
|
| 2 |
-
const $=id=>document.getElementById(id),E={};["identityDialog","identityForm","identityError","classCode","displayName","studentIdentity","identityButton","connectionDot","connectionText","helpButton","weekSelect","projectName","templateButton","progressButton","importButton","exportButton","checkpointButton","submitButton","fileImport","fileList","newFileButton","activeFileLabel","syntaxSearch","categoryFilters","snippetList","snippetCount","runButton","stopButton","toggleReferenceButton","toggleConsoleButton","studentLayout","editorPanel","referencePanel","consolePanel","referenceResizer","consoleResizer","consoleInputForm","consoleInput","consoleOutput","clearOutputButton","runStatusDot","runStatusText","saveStatus","submitStatus","toast","progressDialog","progressList"].forEach(id=>E[id]=$(id));
|
| 3 |
const editor=CodeMirror.fromTextArea($("codeEditor"),{mode:"python",theme:"material-darker",lineNumbers:true,indentUnit:4,tabSize:4,extraKeys:{Tab:cm=>cm.replaceSelection(" ")}});
|
| 4 |
let studentId=localStorage.getItem("classroom_student_id")||"",allowLegacyDraft=!!studentId,catalog={courses:[]};
|
| 5 |
-
let course,files={"main.py":"# 在這裡寫 Python\n"},activeFile="main.py",socket,worker,workerReady=false,running=false,needsHelp=false,checkpointAt=null,submittedAt=null,output="",suppress=false,timer,reconnect,category="全部",inputBuffer,inputControl,inputBytes,stdoutDecoder=new TextDecoder(),stderrDecoder=new TextDecoder();
|
| 6 |
const clean=v=>String(v||"").trim().replace(/[^0-9A-Za-z_\-一-鿿]/g,"").slice(0,48),lesson=()=>E.weekSelect.value||"week02",lessonInfo=()=>course?.weeks.find(item=>item.id===lesson()),workspace=()=>lessonInfo()?.workspace_id||lesson(),key=()=>`classroom_project_${localStorage.getItem("classroom_code")}_${studentId}_${workspace()}`,legacyKey=()=>`classroom_project_${localStorage.getItem("classroom_code")}_${workspace()}`;
|
| 7 |
function toast(t){E.toast.textContent=t;E.toast.classList.add("visible");setTimeout(()=>E.toast.classList.remove("visible"),1900)}
|
| 8 |
function send(m){if(socket?.readyState!==WebSocket.OPEN)return false;socket.send(JSON.stringify(m));return true}
|
|
@@ -32,8 +32,14 @@ function connect(){
|
|
| 32 |
const src=local&&Number(local.savedAt||0)>Number(s.updated_at||0)?local:server;
|
| 33 |
output=src.output||"";E.consoleOutput.textContent=output||"尚無執行結果。";checkpointAt=s.checkpoint_at;submittedAt=s.submitted_at;needsHelp=!!s.needs_help;renderState();setProject(src.files,src.projectName,src.activeFile);send({type:"output",output});
|
| 34 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
if(m.type==="help_ack"){needsHelp=false;renderState();toast("老師已看到你的協助請求")}
|
| 36 |
-
if(
|
|
|
|
| 37 |
};
|
| 38 |
current.onclose=async e=>{if(socket!==current)return;E.connectionDot.className="status-dot error";E.connectionText.textContent="已離線";E.saveStatus.textContent="保存在此裝置";if(e.code===4004||e.code===4404){localStorage.removeItem("classroom_student_id");localStorage.removeItem("classroom_name");studentId="";await loadCatalog();E.identityError.textContent=e.code===4004?"老師已將你從名單移除。":"姓名與班級名單不符,請重新輸入。";E.identityDialog.showModal();return}reconnect=setTimeout(connect,2500)};
|
| 39 |
}
|
|
@@ -74,6 +80,8 @@ function initResizablePanels(){
|
|
| 74 |
}
|
| 75 |
function setLayout(referenceHidden,consoleHidden){E.studentLayout.classList.toggle("reference-hidden",referenceHidden);E.studentLayout.classList.toggle("console-hidden",consoleHidden);E.toggleReferenceButton.setAttribute("aria-pressed",String(referenceHidden));E.toggleConsoleButton.setAttribute("aria-pressed",String(consoleHidden));E.toggleReferenceButton.textContent=referenceHidden?"顯示側欄":"隱藏側欄";E.toggleConsoleButton.textContent=consoleHidden?"顯示 Console":"隱藏 Console";localStorage.setItem("classroom_hide_reference",String(referenceHidden));localStorage.setItem("classroom_hide_console",String(consoleHidden));requestAnimationFrame(()=>{syncPanelWidths(false);refreshEditorLayout()})}
|
| 76 |
function renderLesson(){const item=lessonInfo(),shared=item?.workspace_id&&item.workspace_id!==item.id;E.templateButton.disabled=!item?.template;E.templateButton.textContent=item?.template?"匯入專案起始套版":shared?"沿用目前專案":"尚未提供套版"}
|
|
|
|
|
|
|
| 77 |
function initWorker(){worker?.terminate();workerReady=false;running=false;E.consoleInputForm.hidden=true;E.runButton.disabled=true;E.runButton.textContent="載入 Python…";if(!crossOriginIsolated||typeof SharedArrayBuffer==="undefined"){append("瀏覽器未啟用互動式 Python。請重新整理,並使用最新版 Chrome、Edge 或 Safari。\n");runState("error","環境不支援");return}inputBuffer=new SharedArrayBuffer(16392);inputControl=new Int32Array(inputBuffer,0,2);inputBytes=new Uint8Array(inputBuffer,8);worker=new Worker("/classroom-assets/py-worker.js?v=2026fall-4");worker.onmessage=e=>{const m=e.data;if(m.type==="ready"){workerReady=true;E.runButton.disabled=false;E.runButton.textContent="執行";runState("online","準備完成")}else if(m.type==="stream"){const decoder=m.channel==="stderr"?stderrDecoder:stdoutDecoder;append(decoder.decode(m.bytes,{stream:true}))}else if(m.type==="input_request"){E.consoleInputForm.hidden=false;E.consoleInput.value="";E.runStatusText.textContent="等待輸入";E.consoleInput.focus()}else if(m.type==="done"){E.consoleInputForm.hidden=true;runState("success","執行成功")}else if(m.type==="python_error"){E.consoleInputForm.hidden=true;if(m.text)append(`${m.text}\n`);runState("error","程式錯誤")}else if(m.type==="load_error"){append(`Python 載入失敗:${m.text}\n`);runState("error","載入失敗")}};worker.onerror=e=>{append(`Python 執行器錯誤:${e.message}\n`);runState("error","執行器錯誤")}}
|
| 78 |
function runState(s,t){running=s==="running";E.runStatusDot.className=`status-dot ${s}`;E.runStatusText.textContent=t;E.runButton.disabled=!workerReady||running;E.stopButton.disabled=!running;send({type:"status",status:s==="online"?"idle":s})}
|
| 79 |
function append(t){output=(output+t).slice(-40000);E.consoleOutput.textContent=output;E.consoleOutput.scrollTop=E.consoleOutput.scrollHeight;saveLocal();send({type:"output",output})}
|
|
@@ -87,6 +95,6 @@ E.importButton.onclick=()=>E.fileImport.click();E.fileImport.onchange=async()=>{
|
|
| 87 |
E.exportButton.onclick=async()=>{files[activeFile]=editor.getValue();const z=new JSZip();Object.entries(files).forEach(([p,t])=>z.file(p,t));const blob=await z.generateAsync({type:"blob"}),a=document.createElement("a");a.href=URL.createObjectURL(blob);a.download=`${clean(E.projectName.value)||"project"}.zip`;a.click();URL.revokeObjectURL(a.href)};
|
| 88 |
E.checkpointButton.onclick=()=>{files[activeFile]=editor.getValue();if(!files["main.py"].trim())return toast("main.py 不能為空");saveLocal();if(!send({type:"checkpoint",files,project_name:E.projectName.value}))return toast("目前離線,請連線後再暫存");checkpointAt=Date.now();renderState();toast("已新增一個暫存版本,不會發布到 Demo")};
|
| 89 |
E.submitButton.onclick=()=>{files[activeFile]=editor.getValue();if(!files["main.py"].trim())return toast("main.py 不能為空");if(!confirm("新增一個正式提交版本?之前的暫存與提交都會保留。"))return;saveLocal();if(!send({type:"submit",files,project_name:E.projectName.value}))return toast("目前離線,請連線後再提交");submittedAt=Date.now();renderState();toast("已新增一個正式提交版本")};E.helpButton.onclick=()=>{needsHelp=!needsHelp;renderState();send({type:"help",needs_help:needsHelp})};
|
| 90 |
-
E.runButton.onclick=()=>{if(!workerReady||running)return;files[activeFile]=editor.getValue();output="";E.consoleOutput.textContent="";E.consoleInputForm.hidden=true;stdoutDecoder=new TextDecoder();stderrDecoder=new TextDecoder();Atomics.store(inputControl,0,0);runState("running","執行中");worker.postMessage({type:"run",files,entry:"main.py",inputBuffer})};E.consoleInputForm.onsubmit=e=>{e.preventDefault();if(!running||E.consoleInputForm.hidden)return;const encoded=new TextEncoder().encode(E.consoleInput.value);if(encoded.length>inputBytes.length)return toast("單次輸入內容太長");inputBytes.fill(0);inputBytes.set(encoded);Atomics.store(inputControl,1,encoded.length);append(`${E.consoleInput.value}\n`);E.consoleInputForm.hidden=true;E.runStatusText.textContent="執行中";Atomics.store(inputControl,0,1);Atomics.notify(inputControl,0,1)};E.stopButton.onclick=()=>{append("\n[已停止]\n");runState("idle","已停止");initWorker()};E.clearOutputButton.onclick=()=>{output="";E.consoleOutput.textContent="尚無執行結果。";send({type:"output",output:""})};E.syntaxSearch.oninput=renderSyntax;editor.on("change",()=>{if(!suppress){files[activeFile]=editor.getValue();sync()}});
|
| 91 |
E.toggleReferenceButton.onclick=()=>setLayout(!E.studentLayout.classList.contains("reference-hidden"),E.studentLayout.classList.contains("console-hidden"));E.toggleConsoleButton.onclick=()=>setLayout(E.studentLayout.classList.contains("reference-hidden"),!E.studentLayout.classList.contains("console-hidden"));document.querySelectorAll(".side-tab").forEach(b=>b.onclick=()=>{document.querySelectorAll(".side-tab").forEach(x=>x.classList.toggle("active",x===b));document.querySelectorAll(".side-view").forEach(v=>v.hidden=v.id!==b.dataset.side)});document.querySelectorAll(".mobile-tab").forEach(b=>b.onclick=()=>{document.querySelectorAll(".mobile-tab").forEach(x=>x.setAttribute("aria-selected",String(x===b)));document.querySelectorAll(".student-layout>.panel").forEach(p=>p.classList.toggle("mobile-active",p.id===b.dataset.panel));if(b.dataset.panel==="editorPanel")refreshEditorLayout()});setInterval(()=>send({type:"ping"}),4000);
|
| 92 |
(async()=>{[course]=await Promise.all([(await fetch("/api/classroom/course")).json(),loadCatalog()]);E.weekSelect.innerHTML=course.weeks.map(w=>`<option value="${w.id}">Lesson ${w.lesson??w.week}|${esc(w.title)}${w.workspace_id&&w.workspace_id!==w.id?"(同一專案)":""}</option>`).join("");const saved=localStorage.getItem("classroom_week");E.weekSelect.value=course.weeks.some(w=>w.id===saved)?saved:"week02";renderLesson();renderFiles();renderSyntax();initResizablePanels();setLayout(localStorage.getItem("classroom_hide_reference")==="true",localStorage.getItem("classroom_hide_console")==="true");initWorker();connect()})();
|
|
|
|
| 1 |
+
const SNIPPETS=[["基礎","輸入與輸出",'name = input("名字:")\nprint("你好", name)'],["判斷","if / elif / else",'if score >= 90:\n print("A")\nelse:\n print("繼續加油")'],["迴圈","for 與 range",'for number in range(1, 6):\n print(number)'],["迴圈","while",'while True:\n choice = input("選擇:")\n if choice == "q":\n break'],["資料","list",'items = []\nitems.append("線索")\nfor item in items:\n print(item)'],["資料","dict",'student = {"name": "Alex"}\nprint(student.get("team", "未分組"))'],["函式","def / return",'def add(a, b):\n return a + b'],["檔案","引入自己的模組",'from clues import find_clue'],["檔案","讀取 JSON",'import json\nwith open("data.json", encoding="utf-8") as file:\n data = json.load(file)'],["錯誤","try / except",'try:\n age = int(input("年齡:"))\nexcept ValueError:\n print("請輸入整數")'],["Pygame","瀏覽器版 Pygame 入門",'import asyncio\nimport pygame\n\nasync def main():\n pygame.init()\n screen = pygame.display.set_mode((640, 480))\n pygame.display.set_caption("我的第一個遊戲")\n clock = pygame.time.Clock()\n x = 300\n running = True\n\n while running:\n for event in pygame.event.get():\n if event.type == pygame.QUIT:\n running = False\n\n keys = pygame.key.get_pressed()\n if keys[pygame.K_LEFT]:\n x -= 4\n if keys[pygame.K_RIGHT]:\n x += 4\n\n screen.fill((24, 34, 52))\n pygame.draw.circle(screen, (255, 190, 80), (x, 240), 28)\n pygame.display.flip()\n await asyncio.sleep(1 / 60)\n\n pygame.quit()\n\n# 瀏覽器版會自動呼叫 async main(),不用另外加 asyncio.run(main())']].map(([category,title,code])=>({category,title,code}));
|
| 2 |
+
const $=id=>document.getElementById(id),E={};["identityDialog","identityForm","identityError","classCode","displayName","studentIdentity","identityButton","connectionDot","connectionText","helpButton","weekSelect","projectName","templateButton","progressButton","importButton","exportButton","checkpointButton","submitButton","fileImport","fileList","newFileButton","activeFileLabel","syntaxSearch","categoryFilters","snippetList","snippetCount","runButton","stopButton","toggleReferenceButton","toggleConsoleButton","studentLayout","editorPanel","referencePanel","consolePanel","referenceResizer","consoleResizer","consoleInputForm","consoleInput","consoleOutput","consoleTabs","consoleOutputTab","pygameOutputTab","consoleStream","pygameFrame","consoleHint","clearOutputButton","runStatusDot","runStatusText","saveStatus","submitStatus","toast","progressDialog","progressList"].forEach(id=>E[id]=$(id));
|
| 3 |
const editor=CodeMirror.fromTextArea($("codeEditor"),{mode:"python",theme:"material-darker",lineNumbers:true,indentUnit:4,tabSize:4,extraKeys:{Tab:cm=>cm.replaceSelection(" ")}});
|
| 4 |
let studentId=localStorage.getItem("classroom_student_id")||"",allowLegacyDraft=!!studentId,catalog={courses:[]};
|
| 5 |
+
let course,files={"main.py":"# 在這裡寫 Python\n"},activeFile="main.py",socket,worker,workerReady=false,running=false,pygameRunning=false,needsHelp=false,checkpointAt=null,submittedAt=null,output="",suppress=false,timer,reconnect,category="全部",inputBuffer,inputControl,inputBytes,stdoutDecoder=new TextDecoder(),stderrDecoder=new TextDecoder();
|
| 6 |
const clean=v=>String(v||"").trim().replace(/[^0-9A-Za-z_\-一-鿿]/g,"").slice(0,48),lesson=()=>E.weekSelect.value||"week02",lessonInfo=()=>course?.weeks.find(item=>item.id===lesson()),workspace=()=>lessonInfo()?.workspace_id||lesson(),key=()=>`classroom_project_${localStorage.getItem("classroom_code")}_${studentId}_${workspace()}`,legacyKey=()=>`classroom_project_${localStorage.getItem("classroom_code")}_${workspace()}`;
|
| 7 |
function toast(t){E.toast.textContent=t;E.toast.classList.add("visible");setTimeout(()=>E.toast.classList.remove("visible"),1900)}
|
| 8 |
function send(m){if(socket?.readyState!==WebSocket.OPEN)return false;socket.send(JSON.stringify(m));return true}
|
|
|
|
| 32 |
const src=local&&Number(local.savedAt||0)>Number(s.updated_at||0)?local:server;
|
| 33 |
output=src.output||"";E.consoleOutput.textContent=output||"尚無執行結果。";checkpointAt=s.checkpoint_at;submittedAt=s.submitted_at;needsHelp=!!s.needs_help;renderState();setProject(src.files,src.projectName,src.activeFile);send({type:"output",output});
|
| 34 |
}
|
| 35 |
+
if(m.type==="identity"&&typeof m.display_name==="string"){
|
| 36 |
+
localStorage.setItem("classroom_name",m.display_name);
|
| 37 |
+
E.studentIdentity.textContent=`${clean(localStorage.getItem("classroom_code"))} · ${m.display_name}`;
|
| 38 |
+
toast("老師已更新你的名單姓名");
|
| 39 |
+
}
|
| 40 |
if(m.type==="help_ack"){needsHelp=false;renderState();toast("老師已看到你的協助請求")}
|
| 41 |
+
if(m.type==="write_rejected"){E.saveStatus.textContent="未儲存";toast(m.message||"儲存受到限制,請稍後再試");send({type:"ping"})}
|
| 42 |
+
if(["ack","pong"].includes(m.type)){if(typeof m.needs_help==="boolean"){const seen=needsHelp&&!m.needs_help;needsHelp=m.needs_help;if(seen)toast("老師已看到你的協助請求")}if(Object.prototype.hasOwnProperty.call(m,"checkpoint_at"))checkpointAt=m.checkpoint_at;if(Object.prototype.hasOwnProperty.call(m,"submitted_at"))submittedAt=m.submitted_at;renderState();if(m.type==="ack")E.saveStatus.textContent="已同步";if(m.version_warning)toast(m.version_warning)}
|
| 43 |
};
|
| 44 |
current.onclose=async e=>{if(socket!==current)return;E.connectionDot.className="status-dot error";E.connectionText.textContent="已離線";E.saveStatus.textContent="保存在此裝置";if(e.code===4004||e.code===4404){localStorage.removeItem("classroom_student_id");localStorage.removeItem("classroom_name");studentId="";await loadCatalog();E.identityError.textContent=e.code===4004?"老師已將你從名單移除。":"姓名與班級名單不符,請重新輸入。";E.identityDialog.showModal();return}reconnect=setTimeout(connect,2500)};
|
| 45 |
}
|
|
|
|
| 80 |
}
|
| 81 |
function setLayout(referenceHidden,consoleHidden){E.studentLayout.classList.toggle("reference-hidden",referenceHidden);E.studentLayout.classList.toggle("console-hidden",consoleHidden);E.toggleReferenceButton.setAttribute("aria-pressed",String(referenceHidden));E.toggleConsoleButton.setAttribute("aria-pressed",String(consoleHidden));E.toggleReferenceButton.textContent=referenceHidden?"顯示側欄":"隱藏側欄";E.toggleConsoleButton.textContent=consoleHidden?"顯示 Console":"隱藏 Console";localStorage.setItem("classroom_hide_reference",String(referenceHidden));localStorage.setItem("classroom_hide_console",String(consoleHidden));requestAnimationFrame(()=>{syncPanelWidths(false);refreshEditorLayout()})}
|
| 82 |
function renderLesson(){const item=lessonInfo(),shared=item?.workspace_id&&item.workspace_id!==item.id;E.templateButton.disabled=!item?.template;E.templateButton.textContent=item?.template?"匯入專案起始套版":shared?"沿用目前專案":"尚未提供套版"}
|
| 83 |
+
function setConsoleView(view){E.consoleStream.dataset.view=view;E.consoleOutputTab.classList.toggle("active",view==="console");E.pygameOutputTab.classList.toggle("active",view==="pygame");E.consoleOutputTab.setAttribute("aria-selected",String(view==="console"));E.pygameOutputTab.setAttribute("aria-selected",String(view==="pygame"))}
|
| 84 |
+
ClassroomPygame.attach(E.pygameFrame,message=>{if(message.type==="output"&&message.text)append(message.text);if(message.type==="status"&&message.label)E.runStatusText.textContent=message.label;if(message.type==="done"){pygameRunning=false;runState("success","遊戲已結束")}if(message.type==="error"||message.type==="load_error"){pygameRunning=false;if(message.text)append(`${message.text}\n`);runState("error","Pygame 執行錯誤");setConsoleView("console")}});
|
| 85 |
function initWorker(){worker?.terminate();workerReady=false;running=false;E.consoleInputForm.hidden=true;E.runButton.disabled=true;E.runButton.textContent="載入 Python…";if(!crossOriginIsolated||typeof SharedArrayBuffer==="undefined"){append("瀏覽器未啟用互動式 Python。請重新整理,並使用最新版 Chrome、Edge 或 Safari。\n");runState("error","環境不支援");return}inputBuffer=new SharedArrayBuffer(16392);inputControl=new Int32Array(inputBuffer,0,2);inputBytes=new Uint8Array(inputBuffer,8);worker=new Worker("/classroom-assets/py-worker.js?v=2026fall-4");worker.onmessage=e=>{const m=e.data;if(m.type==="ready"){workerReady=true;E.runButton.disabled=false;E.runButton.textContent="執行";runState("online","準備完成")}else if(m.type==="stream"){const decoder=m.channel==="stderr"?stderrDecoder:stdoutDecoder;append(decoder.decode(m.bytes,{stream:true}))}else if(m.type==="input_request"){E.consoleInputForm.hidden=false;E.consoleInput.value="";E.runStatusText.textContent="等待輸入";E.consoleInput.focus()}else if(m.type==="done"){E.consoleInputForm.hidden=true;runState("success","執行成功")}else if(m.type==="python_error"){E.consoleInputForm.hidden=true;if(m.text)append(`${m.text}\n`);runState("error","程式錯誤")}else if(m.type==="load_error"){append(`Python 載入失敗:${m.text}\n`);runState("error","載入失敗")}};worker.onerror=e=>{append(`Python 執行器錯誤:${e.message}\n`);runState("error","執行器錯誤")}}
|
| 86 |
function runState(s,t){running=s==="running";E.runStatusDot.className=`status-dot ${s}`;E.runStatusText.textContent=t;E.runButton.disabled=!workerReady||running;E.stopButton.disabled=!running;send({type:"status",status:s==="online"?"idle":s})}
|
| 87 |
function append(t){output=(output+t).slice(-40000);E.consoleOutput.textContent=output;E.consoleOutput.scrollTop=E.consoleOutput.scrollHeight;saveLocal();send({type:"output",output})}
|
|
|
|
| 95 |
E.exportButton.onclick=async()=>{files[activeFile]=editor.getValue();const z=new JSZip();Object.entries(files).forEach(([p,t])=>z.file(p,t));const blob=await z.generateAsync({type:"blob"}),a=document.createElement("a");a.href=URL.createObjectURL(blob);a.download=`${clean(E.projectName.value)||"project"}.zip`;a.click();URL.revokeObjectURL(a.href)};
|
| 96 |
E.checkpointButton.onclick=()=>{files[activeFile]=editor.getValue();if(!files["main.py"].trim())return toast("main.py 不能為空");saveLocal();if(!send({type:"checkpoint",files,project_name:E.projectName.value}))return toast("目前離線,請連線後再暫存");checkpointAt=Date.now();renderState();toast("已新增一個暫存版本,不會發布到 Demo")};
|
| 97 |
E.submitButton.onclick=()=>{files[activeFile]=editor.getValue();if(!files["main.py"].trim())return toast("main.py 不能為空");if(!confirm("新增一個正式提交版本?之前的暫存與提交都會保留。"))return;saveLocal();if(!send({type:"submit",files,project_name:E.projectName.value}))return toast("目前離線,請連線後再提交");submittedAt=Date.now();renderState();toast("已新增一個正式提交版本")};E.helpButton.onclick=()=>{needsHelp=!needsHelp;renderState();send({type:"help",needs_help:needsHelp})};
|
| 98 |
+
E.runButton.onclick=async()=>{if(!workerReady||running)return;files[activeFile]=editor.getValue();output="";E.consoleOutput.textContent="";E.consoleInputForm.hidden=true;stdoutDecoder=new TextDecoder();stderrDecoder=new TextDecoder();if(ClassroomPygame.isPygame(files)){pygameRunning=true;E.consoleTabs.hidden=false;E.pygameFrame.hidden=false;setConsoleView("pygame");runState("running","準備瀏覽器版 Pygame");try{await ClassroomPygame.run(files)}catch(error){if(!running)return;pygameRunning=false;append(`Pygame 載入失敗:${error.message}\n`);runState("error","Pygame 載入失敗");setConsoleView("console")}return}ClassroomPygame.stop();pygameRunning=false;E.consoleTabs.hidden=true;E.pygameFrame.hidden=true;setConsoleView("console");Atomics.store(inputControl,0,0);runState("running","執行中");worker.postMessage({type:"run",files,entry:"main.py",inputBuffer})};E.consoleInputForm.onsubmit=e=>{e.preventDefault();if(!running||E.consoleInputForm.hidden)return;const encoded=new TextEncoder().encode(E.consoleInput.value);if(encoded.length>inputBytes.length)return toast("單次輸入內容太長");inputBytes.fill(0);inputBytes.set(encoded);Atomics.store(inputControl,1,encoded.length);append(`${E.consoleInput.value}\n`);E.consoleInputForm.hidden=true;E.runStatusText.textContent="執行中";Atomics.store(inputControl,0,1);Atomics.notify(inputControl,0,1)};E.stopButton.onclick=()=>{append("\n[已停止]\n");if(pygameRunning){ClassroomPygame.stop();pygameRunning=false;setConsoleView("console");runState("idle","已停止");return}runState("idle","已停止");initWorker()};E.consoleOutputTab.onclick=()=>setConsoleView("console");E.pygameOutputTab.onclick=()=>setConsoleView("pygame");E.clearOutputButton.onclick=()=>{output="";E.consoleOutput.textContent="尚無執行結果。";send({type:"output",output:""})};E.syntaxSearch.oninput=renderSyntax;editor.on("change",()=>{if(!suppress){files[activeFile]=editor.getValue();sync()}});
|
| 99 |
E.toggleReferenceButton.onclick=()=>setLayout(!E.studentLayout.classList.contains("reference-hidden"),E.studentLayout.classList.contains("console-hidden"));E.toggleConsoleButton.onclick=()=>setLayout(E.studentLayout.classList.contains("reference-hidden"),!E.studentLayout.classList.contains("console-hidden"));document.querySelectorAll(".side-tab").forEach(b=>b.onclick=()=>{document.querySelectorAll(".side-tab").forEach(x=>x.classList.toggle("active",x===b));document.querySelectorAll(".side-view").forEach(v=>v.hidden=v.id!==b.dataset.side)});document.querySelectorAll(".mobile-tab").forEach(b=>b.onclick=()=>{document.querySelectorAll(".mobile-tab").forEach(x=>x.setAttribute("aria-selected",String(x===b)));document.querySelectorAll(".student-layout>.panel").forEach(p=>p.classList.toggle("mobile-active",p.id===b.dataset.panel));if(b.dataset.panel==="editorPanel")refreshEditorLayout()});setInterval(()=>send({type:"ping"}),4000);
|
| 100 |
(async()=>{[course]=await Promise.all([(await fetch("/api/classroom/course")).json(),loadCatalog()]);E.weekSelect.innerHTML=course.weeks.map(w=>`<option value="${w.id}">Lesson ${w.lesson??w.week}|${esc(w.title)}${w.workspace_id&&w.workspace_id!==w.id?"(同一專案)":""}</option>`).join("");const saved=localStorage.getItem("classroom_week");E.weekSelect.value=course.weeks.some(w=>w.id===saved)?saved:"week02";renderLesson();renderFiles();renderSyntax();initResizablePanels();setLayout(localStorage.getItem("classroom_hide_reference")==="true",localStorage.getItem("classroom_hide_console")==="true");initWorker();connect()})();
|
runner.py
CHANGED
|
@@ -1,8 +1,8 @@
|
|
| 1 |
"""
|
| 2 |
Headless runner for student Pygame / OpenCV projects.
|
| 3 |
|
| 4 |
-
Connects back to the server over
|
| 5 |
-
APIs, and streams JPEG frames + logs to the browser.
|
| 6 |
"""
|
| 7 |
from __future__ import annotations
|
| 8 |
|
|
@@ -22,7 +22,7 @@ import numpy as np
|
|
| 22 |
# Parse CLI before importing pygame/opencv (env must be set first)
|
| 23 |
parser = argparse.ArgumentParser()
|
| 24 |
parser.add_argument("--project_path", required=True, help="Path to the student's main.py")
|
| 25 |
-
parser.add_argument("--
|
| 26 |
parser.add_argument(
|
| 27 |
"--models_dir",
|
| 28 |
default=None,
|
|
@@ -101,10 +101,10 @@ def _adjust_frame_interval(queue_depth: int) -> None:
|
|
| 101 |
_current_frame_interval = max(_min_frame_interval, _current_frame_interval * 0.95)
|
| 102 |
|
| 103 |
|
| 104 |
-
# -----------------
|
| 105 |
def reader_thread_func(sock):
|
| 106 |
global shared_webcam_frame, shared_keys, shared_mouse_pos, shared_mouse_buttons
|
| 107 |
-
print("[Runner-
|
| 108 |
try:
|
| 109 |
while True:
|
| 110 |
type_bytes = sock.recv(4)
|
|
@@ -213,9 +213,9 @@ def reader_thread_func(sock):
|
|
| 213 |
pass
|
| 214 |
|
| 215 |
except Exception as e:
|
| 216 |
-
print(f"[Runner-
|
| 217 |
finally:
|
| 218 |
-
print("[Runner-
|
| 219 |
try:
|
| 220 |
sock.close()
|
| 221 |
except Exception:
|
|
@@ -223,7 +223,7 @@ def reader_thread_func(sock):
|
|
| 223 |
|
| 224 |
|
| 225 |
def writer_thread_func(sock):
|
| 226 |
-
print("[Runner-
|
| 227 |
try:
|
| 228 |
while True:
|
| 229 |
msg_type, payload = send_queue.get()
|
|
@@ -238,20 +238,20 @@ def writer_thread_func(sock):
|
|
| 238 |
sock.sendall(len(payload).to_bytes(4, byteorder="big"))
|
| 239 |
sock.sendall(payload)
|
| 240 |
except Exception as e:
|
| 241 |
-
print(f"[Runner-
|
| 242 |
finally:
|
| 243 |
-
print("[Runner-
|
| 244 |
try:
|
| 245 |
sock.close()
|
| 246 |
except Exception:
|
| 247 |
pass
|
| 248 |
|
| 249 |
|
| 250 |
-
sock = socket.socket(socket.
|
| 251 |
try:
|
| 252 |
-
sock.connect(
|
| 253 |
except Exception as e:
|
| 254 |
-
print(f"Error connecting to server
|
| 255 |
sys.exit(1)
|
| 256 |
|
| 257 |
reader_thread = threading.Thread(target=reader_thread_func, args=(sock,), daemon=True)
|
|
@@ -261,7 +261,7 @@ writer_thread.start()
|
|
| 261 |
|
| 262 |
|
| 263 |
# ----------------- Buffered stdout/stderr -----------------
|
| 264 |
-
class
|
| 265 |
"""Buffer log fragments and flush on newline or timer to reduce IPC chatter."""
|
| 266 |
|
| 267 |
def __init__(self, original_stream, flush_interval=0.05):
|
|
@@ -306,8 +306,8 @@ class StreamToTCP:
|
|
| 306 |
self._flush_unlocked()
|
| 307 |
|
| 308 |
|
| 309 |
-
sys.stdout =
|
| 310 |
-
sys.stderr =
|
| 311 |
|
| 312 |
# ----------------- Stdin / input() -----------------
|
| 313 |
import builtins
|
|
|
|
| 1 |
"""
|
| 2 |
Headless runner for student Pygame / OpenCV projects.
|
| 3 |
|
| 4 |
+
Connects back to the server over a private Unix socket, monkeypatches
|
| 5 |
+
display/input APIs, and streams JPEG frames + logs to the browser.
|
| 6 |
"""
|
| 7 |
from __future__ import annotations
|
| 8 |
|
|
|
|
| 22 |
# Parse CLI before importing pygame/opencv (env must be set first)
|
| 23 |
parser = argparse.ArgumentParser()
|
| 24 |
parser.add_argument("--project_path", required=True, help="Path to the student's main.py")
|
| 25 |
+
parser.add_argument("--socket-path", required=True, help="Private Unix socket used for IPC")
|
| 26 |
parser.add_argument(
|
| 27 |
"--models_dir",
|
| 28 |
default=None,
|
|
|
|
| 101 |
_current_frame_interval = max(_min_frame_interval, _current_frame_interval * 0.95)
|
| 102 |
|
| 103 |
|
| 104 |
+
# ----------------- private IPC threads -----------------
|
| 105 |
def reader_thread_func(sock):
|
| 106 |
global shared_webcam_frame, shared_keys, shared_mouse_pos, shared_mouse_buttons
|
| 107 |
+
print("[Runner-IPC] Reader thread started.")
|
| 108 |
try:
|
| 109 |
while True:
|
| 110 |
type_bytes = sock.recv(4)
|
|
|
|
| 213 |
pass
|
| 214 |
|
| 215 |
except Exception as e:
|
| 216 |
+
print(f"[Runner-IPC] Reader error: {e}", file=sys.__stderr__)
|
| 217 |
finally:
|
| 218 |
+
print("[Runner-IPC] Reader thread finished.", file=sys.__stderr__)
|
| 219 |
try:
|
| 220 |
sock.close()
|
| 221 |
except Exception:
|
|
|
|
| 223 |
|
| 224 |
|
| 225 |
def writer_thread_func(sock):
|
| 226 |
+
print("[Runner-IPC] Writer thread started.")
|
| 227 |
try:
|
| 228 |
while True:
|
| 229 |
msg_type, payload = send_queue.get()
|
|
|
|
| 238 |
sock.sendall(len(payload).to_bytes(4, byteorder="big"))
|
| 239 |
sock.sendall(payload)
|
| 240 |
except Exception as e:
|
| 241 |
+
print(f"[Runner-IPC] Writer error: {e}", file=sys.__stderr__)
|
| 242 |
finally:
|
| 243 |
+
print("[Runner-IPC] Writer thread finished.", file=sys.__stderr__)
|
| 244 |
try:
|
| 245 |
sock.close()
|
| 246 |
except Exception:
|
| 247 |
pass
|
| 248 |
|
| 249 |
|
| 250 |
+
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
| 251 |
try:
|
| 252 |
+
sock.connect(args.socket_path)
|
| 253 |
except Exception as e:
|
| 254 |
+
print(f"Error connecting to server IPC socket: {e}")
|
| 255 |
sys.exit(1)
|
| 256 |
|
| 257 |
reader_thread = threading.Thread(target=reader_thread_func, args=(sock,), daemon=True)
|
|
|
|
| 261 |
|
| 262 |
|
| 263 |
# ----------------- Buffered stdout/stderr -----------------
|
| 264 |
+
class StreamToIPC:
|
| 265 |
"""Buffer log fragments and flush on newline or timer to reduce IPC chatter."""
|
| 266 |
|
| 267 |
def __init__(self, original_stream, flush_interval=0.05):
|
|
|
|
| 306 |
self._flush_unlocked()
|
| 307 |
|
| 308 |
|
| 309 |
+
sys.stdout = StreamToIPC(sys.stdout)
|
| 310 |
+
sys.stderr = StreamToIPC(sys.stderr)
|
| 311 |
|
| 312 |
# ----------------- Stdin / input() -----------------
|
| 313 |
import builtins
|
scripts/vendor_pyodide_packages.mjs
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { createHash } from "node:crypto";
|
| 2 |
+
import { readFile, writeFile } from "node:fs/promises";
|
| 3 |
+
import path from "node:path";
|
| 4 |
+
|
| 5 |
+
const [distributionDir, packageBaseUrl] = process.argv.slice(2);
|
| 6 |
+
if (!distributionDir || !packageBaseUrl) {
|
| 7 |
+
throw new Error("Usage: vendor_pyodide_packages.mjs <pyodide-dir> <package-base-url>");
|
| 8 |
+
}
|
| 9 |
+
|
| 10 |
+
const normalize = (name) => name.toLowerCase().replace(/[-_.]+/g, "-");
|
| 11 |
+
const lock = JSON.parse(await readFile(path.join(distributionDir, "pyodide-lock.json"), "utf8"));
|
| 12 |
+
const records = lock.packages || {};
|
| 13 |
+
const names = new Map(Object.keys(records).map((name) => [normalize(name), name]));
|
| 14 |
+
const pygameKey = names.get("pygame-ce");
|
| 15 |
+
if (!pygameKey) throw new Error("Pinned Pyodide lock does not contain pygame-ce");
|
| 16 |
+
|
| 17 |
+
const pending = [pygameKey];
|
| 18 |
+
const required = new Set();
|
| 19 |
+
while (pending.length) {
|
| 20 |
+
const name = pending.pop();
|
| 21 |
+
if (required.has(name)) continue;
|
| 22 |
+
required.add(name);
|
| 23 |
+
for (const dependency of records[name].depends || []) {
|
| 24 |
+
const dependencyKey = names.get(normalize(dependency));
|
| 25 |
+
if (!dependencyKey) throw new Error(`Unknown Pyodide dependency ${dependency} required by ${name}`);
|
| 26 |
+
pending.push(dependencyKey);
|
| 27 |
+
}
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
const base = packageBaseUrl.endsWith("/") ? packageBaseUrl : `${packageBaseUrl}/`;
|
| 31 |
+
for (const name of required) {
|
| 32 |
+
const record = records[name];
|
| 33 |
+
if (!record.file_name) continue;
|
| 34 |
+
const response = await fetch(new URL(encodeURIComponent(record.file_name), base));
|
| 35 |
+
if (!response.ok) throw new Error(`Could not fetch pinned Pyodide package ${name}: HTTP ${response.status}`);
|
| 36 |
+
const bytes = Buffer.from(await response.arrayBuffer());
|
| 37 |
+
const digest = createHash("sha256").update(bytes).digest("hex");
|
| 38 |
+
if (!record.sha256 || digest !== record.sha256) {
|
| 39 |
+
throw new Error(`Pyodide package integrity check failed for ${name}`);
|
| 40 |
+
}
|
| 41 |
+
await writeFile(path.join(distributionDir, record.file_name), bytes, { mode: 0o644 });
|
| 42 |
+
console.log(`Vendored ${name} ${record.version} (${bytes.length} bytes)`);
|
| 43 |
+
}
|
server.py
CHANGED
|
@@ -13,10 +13,19 @@ import json
|
|
| 13 |
import logging
|
| 14 |
import os
|
| 15 |
import signal
|
|
|
|
|
|
|
|
|
|
| 16 |
import subprocess
|
| 17 |
import sys
|
|
|
|
| 18 |
import time
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
from typing import Any, Optional
|
|
|
|
| 20 |
|
| 21 |
from fastapi import FastAPI, Request, WebSocket, WebSocketDisconnect
|
| 22 |
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, RedirectResponse
|
|
@@ -45,11 +54,25 @@ HEARTBEAT_TIMEOUT_SEC = max(
|
|
| 45 |
)
|
| 46 |
PROJECT_CACHE_TTL_SEC = float(os.environ.get("PROJECT_CACHE_TTL_SEC", "10"))
|
| 47 |
RUNNER_CONNECT_TIMEOUT_SEC = float(os.environ.get("RUNNER_CONNECT_TIMEOUT_SEC", "8"))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
|
| 49 |
os.makedirs(PROJECTS_DIR, exist_ok=True)
|
| 50 |
os.makedirs(TEMPLATES_DIR, exist_ok=True)
|
| 51 |
os.makedirs(STATIC_DIR, exist_ok=True)
|
| 52 |
os.makedirs(MODELS_DIR, exist_ok=True)
|
|
|
|
|
|
|
| 53 |
|
| 54 |
logging.basicConfig(
|
| 55 |
level=logging.INFO,
|
|
@@ -67,10 +90,40 @@ app.include_router(admin_router)
|
|
| 67 |
|
| 68 |
@app.middleware("http")
|
| 69 |
async def browser_runtime_headers(request: Request, call_next):
|
| 70 |
-
"""
|
|
|
|
|
|
|
| 71 |
response = await call_next(request)
|
| 72 |
response.headers["Cross-Origin-Opener-Policy"] = "same-origin"
|
| 73 |
response.headers["Cross-Origin-Embedder-Policy"] = "credentialless"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 74 |
return response
|
| 75 |
|
| 76 |
# Concurrency + live session metrics
|
|
@@ -88,6 +141,32 @@ _metrics = {
|
|
| 88 |
|
| 89 |
# Project tree cache
|
| 90 |
_project_cache: dict[str, Any] = {"tree": None, "built_at": 0.0, "signature": None}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 91 |
|
| 92 |
|
| 93 |
def _session_idle_state(
|
|
@@ -194,7 +273,7 @@ def _scan_projects_uncached() -> dict:
|
|
| 194 |
try:
|
| 195 |
with open(link_txt, "r", encoding="utf-8") as f:
|
| 196 |
url = f.read().strip()
|
| 197 |
-
if url:
|
| 198 |
projects.append({"name": project_name, "type": "external", "url": url})
|
| 199 |
except OSError:
|
| 200 |
pass
|
|
@@ -290,6 +369,140 @@ def _safe_project_path(*parts: str) -> Optional[str]:
|
|
| 290 |
return abs_path
|
| 291 |
|
| 292 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 293 |
# ---------------------------------------------------------------------------
|
| 294 |
# Static / logo
|
| 295 |
# ---------------------------------------------------------------------------
|
|
@@ -465,14 +678,17 @@ async def run_project_page(request: Request, student_token: str, project_name: s
|
|
| 465 |
if os.path.exists(appetize_path):
|
| 466 |
try:
|
| 467 |
with open(appetize_path, "r", encoding="utf-8") as f:
|
| 468 |
-
|
|
|
|
|
|
|
| 469 |
except OSError:
|
| 470 |
pass
|
| 471 |
elif os.path.exists(os.path.join(proj_dir, "link.txt")):
|
| 472 |
-
proj_type = "external"
|
| 473 |
try:
|
| 474 |
with open(os.path.join(proj_dir, "link.txt"), "r", encoding="utf-8") as f:
|
| 475 |
-
external_url = f.read()
|
|
|
|
|
|
|
| 476 |
except OSError:
|
| 477 |
pass
|
| 478 |
|
|
@@ -571,10 +787,9 @@ async def play_project_ws_internal(
|
|
| 571 |
await websocket.close()
|
| 572 |
return
|
| 573 |
|
| 574 |
-
|
| 575 |
-
|
| 576 |
-
)
|
| 577 |
-
if not os.path.exists(project_path):
|
| 578 |
await websocket.accept()
|
| 579 |
await websocket.send_json(
|
| 580 |
{"type": "log", "data": "錯誤: 找不到該專案的 main.py 檔案!\n"}
|
|
@@ -603,6 +818,17 @@ async def play_project_ws_internal(
|
|
| 603 |
await websocket.close(code=1013)
|
| 604 |
return
|
| 605 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 606 |
await websocket.accept()
|
| 607 |
session_id = f"{real_class}/{real_student}/{project_name}/{time.time():.0f}"
|
| 608 |
started_at = time.time()
|
|
@@ -621,8 +847,9 @@ async def play_project_ws_internal(
|
|
| 621 |
_metrics["sessions_started"] += 1
|
| 622 |
|
| 623 |
process = None
|
| 624 |
-
|
| 625 |
runner_writer = None
|
|
|
|
| 626 |
|
| 627 |
try:
|
| 628 |
connected_event = asyncio.Event()
|
|
@@ -634,20 +861,21 @@ async def play_project_ws_internal(
|
|
| 634 |
runner_writer = writer
|
| 635 |
connected_event.set()
|
| 636 |
|
| 637 |
-
|
| 638 |
-
|
| 639 |
-
|
| 640 |
-
|
| 641 |
-
|
| 642 |
-
|
| 643 |
-
|
| 644 |
-
|
| 645 |
-
|
| 646 |
-
|
| 647 |
-
|
| 648 |
-
"
|
| 649 |
-
|
| 650 |
-
|
|
|
|
| 651 |
|
| 652 |
popen_kwargs: dict[str, Any] = {
|
| 653 |
"stdout": subprocess.DEVNULL,
|
|
@@ -657,18 +885,13 @@ async def play_project_ws_internal(
|
|
| 657 |
popen_kwargs["start_new_session"] = True
|
| 658 |
|
| 659 |
process = await asyncio.create_subprocess_exec(*cmd, **popen_kwargs)
|
| 660 |
-
log.info(
|
| 661 |
-
"Started runner pid=%s session=%s port=%s",
|
| 662 |
-
process.pid,
|
| 663 |
-
session_id,
|
| 664 |
-
tcp_port,
|
| 665 |
-
)
|
| 666 |
|
| 667 |
try:
|
| 668 |
await asyncio.wait_for(
|
| 669 |
connected_event.wait(), timeout=RUNNER_CONNECT_TIMEOUT_SEC
|
| 670 |
)
|
| 671 |
-
log.info("Runner pid=%s connected via
|
| 672 |
await websocket.send_json(
|
| 673 |
{"type": "status", "data": "running"}
|
| 674 |
)
|
|
@@ -680,9 +903,9 @@ async def play_project_ws_internal(
|
|
| 680 |
)
|
| 681 |
except asyncio.TimeoutError:
|
| 682 |
_metrics["sessions_failed_connect"] += 1
|
| 683 |
-
log.error("Runner pid=%s connect timeout", process.pid)
|
| 684 |
await websocket.send_json(
|
| 685 |
-
{"type": "log", "data": "
|
| 686 |
)
|
| 687 |
await websocket.send_json({"type": "status", "data": "error"})
|
| 688 |
return
|
|
@@ -878,9 +1101,11 @@ async def play_project_ws_internal(
|
|
| 878 |
await runner_writer.wait_closed()
|
| 879 |
except Exception:
|
| 880 |
pass
|
| 881 |
-
if
|
| 882 |
-
|
| 883 |
-
await
|
|
|
|
|
|
|
| 884 |
try:
|
| 885 |
await websocket.close()
|
| 886 |
except Exception:
|
|
|
|
| 13 |
import logging
|
| 14 |
import os
|
| 15 |
import signal
|
| 16 |
+
import socket
|
| 17 |
+
import shutil
|
| 18 |
+
import stat
|
| 19 |
import subprocess
|
| 20 |
import sys
|
| 21 |
+
import tempfile
|
| 22 |
import time
|
| 23 |
+
import secrets
|
| 24 |
+
import re
|
| 25 |
+
from collections import deque
|
| 26 |
+
from pathlib import Path
|
| 27 |
from typing import Any, Optional
|
| 28 |
+
from urllib.parse import urlsplit
|
| 29 |
|
| 30 |
from fastapi import FastAPI, Request, WebSocket, WebSocketDisconnect
|
| 31 |
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, RedirectResponse
|
|
|
|
| 54 |
)
|
| 55 |
PROJECT_CACHE_TTL_SEC = float(os.environ.get("PROJECT_CACHE_TTL_SEC", "10"))
|
| 56 |
RUNNER_CONNECT_TIMEOUT_SEC = float(os.environ.get("RUNNER_CONNECT_TIMEOUT_SEC", "8"))
|
| 57 |
+
RUNNER_TMP_DIR = os.environ.get("RUNNER_TMP_DIR", "/tmp/telearn_runner")
|
| 58 |
+
RUNNER_MEMORY_LIMIT_MB = max(512, int(os.environ.get("RUNNER_MEMORY_LIMIT_MB", "3072")))
|
| 59 |
+
RUNNER_CPU_LIMIT_SEC = max(30, int(os.environ.get("RUNNER_CPU_LIMIT_SEC", "900")))
|
| 60 |
+
RUNNER_TMP_LIMIT_MB = max(64, int(os.environ.get("RUNNER_TMP_LIMIT_MB", "256")))
|
| 61 |
+
RUNNER_PROJECT_LIMIT_MB = max(8, int(os.environ.get("RUNNER_PROJECT_LIMIT_MB", "128")))
|
| 62 |
+
RUNNER_PROJECT_FILE_LIMIT = max(20, int(os.environ.get("RUNNER_PROJECT_FILE_LIMIT", "2000")))
|
| 63 |
+
RUNNER_STARTS_PER_IP_PER_HOUR = max(1, int(os.environ.get("RUNNER_STARTS_PER_IP_PER_HOUR", "120")))
|
| 64 |
+
RUNNER_START_COOLDOWN_SEC = max(0.5, float(os.environ.get("RUNNER_START_COOLDOWN_SEC", "0.75")))
|
| 65 |
+
ENABLE_HSTS = os.environ.get("ENABLE_HSTS", "false").lower() in {"1", "true", "yes"}
|
| 66 |
+
HSTS_MAX_AGE_SEC = max(0, int(os.environ.get("HSTS_MAX_AGE_SEC", "31536000")))
|
| 67 |
+
BWRAP_PATH = shutil.which("bwrap")
|
| 68 |
+
PRLIMIT_PATH = shutil.which("prlimit")
|
| 69 |
|
| 70 |
os.makedirs(PROJECTS_DIR, exist_ok=True)
|
| 71 |
os.makedirs(TEMPLATES_DIR, exist_ok=True)
|
| 72 |
os.makedirs(STATIC_DIR, exist_ok=True)
|
| 73 |
os.makedirs(MODELS_DIR, exist_ok=True)
|
| 74 |
+
os.makedirs(RUNNER_TMP_DIR, mode=0o700, exist_ok=True)
|
| 75 |
+
os.chmod(RUNNER_TMP_DIR, 0o700)
|
| 76 |
|
| 77 |
logging.basicConfig(
|
| 78 |
level=logging.INFO,
|
|
|
|
| 90 |
|
| 91 |
@app.middleware("http")
|
| 92 |
async def browser_runtime_headers(request: Request, call_next):
|
| 93 |
+
"""Set browser isolation and response security policies."""
|
| 94 |
+
nonce = secrets.token_urlsafe(18)
|
| 95 |
+
request.scope.setdefault("state", {})["csp_nonce"] = nonce
|
| 96 |
response = await call_next(request)
|
| 97 |
response.headers["Cross-Origin-Opener-Policy"] = "same-origin"
|
| 98 |
response.headers["Cross-Origin-Embedder-Policy"] = "credentialless"
|
| 99 |
+
response.headers["X-Content-Type-Options"] = "nosniff"
|
| 100 |
+
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
|
| 101 |
+
response.headers["Permissions-Policy"] = "camera=(self), microphone=(), geolocation=(), payment=(), usb=()"
|
| 102 |
+
response.headers["Content-Security-Policy"] = (
|
| 103 |
+
"default-src 'self'; base-uri 'self'; object-src 'none'; "
|
| 104 |
+
f"script-src 'self' 'nonce-{nonce}' 'wasm-unsafe-eval'; "
|
| 105 |
+
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; "
|
| 106 |
+
"img-src 'self' data: blob: https://api.qrserver.com; "
|
| 107 |
+
"font-src 'self' data: https://fonts.gstatic.com; "
|
| 108 |
+
"connect-src 'self' ws: wss:; "
|
| 109 |
+
"worker-src 'self' blob:; "
|
| 110 |
+
"frame-src 'self' https://appetize.io https://*.appetize.io https://makecode.com https://*.makecode.com; "
|
| 111 |
+
"frame-ancestors 'self'; form-action 'self'"
|
| 112 |
+
)
|
| 113 |
+
# Student-authored web projects need JavaScript, but must remain an
|
| 114 |
+
# opaque-origin sandbox even when opened directly instead of in the player.
|
| 115 |
+
if request.url.path.startswith("/static_projects/"):
|
| 116 |
+
response.headers["Content-Security-Policy"] = (
|
| 117 |
+
"sandbox allow-scripts allow-forms allow-pointer-lock; "
|
| 118 |
+
"default-src 'self' data: blob: https:; "
|
| 119 |
+
"script-src 'self' 'unsafe-inline' 'unsafe-eval' https:; "
|
| 120 |
+
"style-src 'self' 'unsafe-inline' https:; "
|
| 121 |
+
"img-src 'self' data: blob: https:; font-src 'self' data: https:; "
|
| 122 |
+
"connect-src 'self' https: wss:; media-src 'self' data: blob: https:; "
|
| 123 |
+
"object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'self'"
|
| 124 |
+
)
|
| 125 |
+
if HSTS_MAX_AGE_SEC and (ENABLE_HSTS or request.url.scheme == "https"):
|
| 126 |
+
response.headers["Strict-Transport-Security"] = f"max-age={HSTS_MAX_AGE_SEC}"
|
| 127 |
return response
|
| 128 |
|
| 129 |
# Concurrency + live session metrics
|
|
|
|
| 141 |
|
| 142 |
# Project tree cache
|
| 143 |
_project_cache: dict[str, Any] = {"tree": None, "built_at": 0.0, "signature": None}
|
| 144 |
+
_runner_start_windows: dict[str, deque] = {}
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
def allow_runner_start(remote_ip: str) -> bool:
|
| 148 |
+
"""Limit public server-side Python starts per client IP across sessions."""
|
| 149 |
+
current = time.monotonic()
|
| 150 |
+
window = _runner_start_windows.get(remote_ip)
|
| 151 |
+
if window is None:
|
| 152 |
+
window = deque()
|
| 153 |
+
_runner_start_windows[remote_ip] = window
|
| 154 |
+
while window and current - window[0] >= 3600:
|
| 155 |
+
window.popleft()
|
| 156 |
+
if window and current - window[-1] < RUNNER_START_COOLDOWN_SEC:
|
| 157 |
+
return False
|
| 158 |
+
if len(window) >= RUNNER_STARTS_PER_IP_PER_HOUR:
|
| 159 |
+
return False
|
| 160 |
+
window.append(current)
|
| 161 |
+
if len(_runner_start_windows) > 4096:
|
| 162 |
+
for key, old_window in list(_runner_start_windows.items()):
|
| 163 |
+
while old_window and current - old_window[0] >= 3600:
|
| 164 |
+
old_window.popleft()
|
| 165 |
+
if not old_window:
|
| 166 |
+
_runner_start_windows.pop(key, None)
|
| 167 |
+
if len(_runner_start_windows) <= 3072:
|
| 168 |
+
break
|
| 169 |
+
return True
|
| 170 |
|
| 171 |
|
| 172 |
def _session_idle_state(
|
|
|
|
| 273 |
try:
|
| 274 |
with open(link_txt, "r", encoding="utf-8") as f:
|
| 275 |
url = f.read().strip()
|
| 276 |
+
if _safe_external_url(url):
|
| 277 |
projects.append({"name": project_name, "type": "external", "url": url})
|
| 278 |
except OSError:
|
| 279 |
pass
|
|
|
|
| 369 |
return abs_path
|
| 370 |
|
| 371 |
|
| 372 |
+
def _resolve_python_project(class_name: str, student_name: str, project_name: str) -> str:
|
| 373 |
+
"""Resolve only catalogued Python projects beneath their student's folder."""
|
| 374 |
+
student_projects = scan_projects().get(class_name, {}).get(student_name, [])
|
| 375 |
+
allowed = any(item["name"] == project_name and item["type"] == "python" for item in student_projects)
|
| 376 |
+
student_root = os.path.realpath(os.path.join(PROJECTS_DIR, class_name, student_name))
|
| 377 |
+
project_root = os.path.realpath(os.path.join(student_root, project_name))
|
| 378 |
+
if not allowed or os.path.commonpath((student_root, project_root)) != student_root:
|
| 379 |
+
raise FileNotFoundError("project is not a listed Python project")
|
| 380 |
+
main_file = os.path.join(project_root, "main.py")
|
| 381 |
+
if not os.path.isfile(main_file):
|
| 382 |
+
raise FileNotFoundError("project has no main.py")
|
| 383 |
+
return project_root
|
| 384 |
+
|
| 385 |
+
|
| 386 |
+
def _safe_external_url(value: str) -> str:
|
| 387 |
+
"""Only expose absolute HTTPS project links to browser navigation/iframes."""
|
| 388 |
+
value = value.strip()
|
| 389 |
+
try:
|
| 390 |
+
parsed = urlsplit(value)
|
| 391 |
+
if parsed.scheme == "https" and parsed.hostname and not parsed.username and not parsed.password:
|
| 392 |
+
return value
|
| 393 |
+
except ValueError:
|
| 394 |
+
pass
|
| 395 |
+
return ""
|
| 396 |
+
|
| 397 |
+
|
| 398 |
+
def _stage_runner_project(source_root: str) -> tuple[str, str]:
|
| 399 |
+
"""Copy regular project files into an ephemeral, per-run writable directory."""
|
| 400 |
+
session_root = tempfile.mkdtemp(prefix="run-", dir=RUNNER_TMP_DIR)
|
| 401 |
+
os.chmod(session_root, 0o700)
|
| 402 |
+
workspace = os.path.join(session_root, "project")
|
| 403 |
+
os.mkdir(workspace, 0o700)
|
| 404 |
+
total_bytes = 0
|
| 405 |
+
total_files = 0
|
| 406 |
+
ignored_dirs = {".git", ".venv", "venv", "__pycache__", "node_modules"}
|
| 407 |
+
for current, dirs, files in os.walk(source_root, followlinks=False):
|
| 408 |
+
dirs[:] = [name for name in dirs if name not in ignored_dirs and not os.path.islink(os.path.join(current, name))]
|
| 409 |
+
relative = os.path.relpath(current, source_root)
|
| 410 |
+
target_dir = workspace if relative == "." else os.path.join(workspace, relative)
|
| 411 |
+
if target_dir != workspace:
|
| 412 |
+
os.makedirs(target_dir, mode=0o700, exist_ok=True)
|
| 413 |
+
for name in files:
|
| 414 |
+
source = os.path.join(current, name)
|
| 415 |
+
try:
|
| 416 |
+
info = os.lstat(source)
|
| 417 |
+
except OSError:
|
| 418 |
+
continue
|
| 419 |
+
if not stat.S_ISREG(info.st_mode):
|
| 420 |
+
continue
|
| 421 |
+
lowered = name.casefold()
|
| 422 |
+
if lowered == ".env" or lowered.startswith(".env.") or lowered in {
|
| 423 |
+
"credentials.json", "service-account.json", "secrets.json", "token.txt",
|
| 424 |
+
}:
|
| 425 |
+
continue
|
| 426 |
+
total_files += 1
|
| 427 |
+
total_bytes += info.st_size
|
| 428 |
+
if total_files > RUNNER_PROJECT_FILE_LIMIT:
|
| 429 |
+
shutil.rmtree(session_root, ignore_errors=True)
|
| 430 |
+
raise ValueError(f"專案檔案數超過上限({RUNNER_PROJECT_FILE_LIMIT} 個)")
|
| 431 |
+
if total_bytes > RUNNER_PROJECT_LIMIT_MB * 1024 * 1024:
|
| 432 |
+
shutil.rmtree(session_root, ignore_errors=True)
|
| 433 |
+
raise ValueError(f"專案資料超過執行上限({RUNNER_PROJECT_LIMIT_MB} MB)")
|
| 434 |
+
destination = os.path.join(target_dir, name)
|
| 435 |
+
shutil.copyfile(source, destination)
|
| 436 |
+
os.chmod(destination, 0o600)
|
| 437 |
+
os.chmod(target_dir, 0o700)
|
| 438 |
+
if not os.path.isfile(os.path.join(workspace, "main.py")):
|
| 439 |
+
shutil.rmtree(session_root, ignore_errors=True)
|
| 440 |
+
raise FileNotFoundError("專案沒有可執行的 main.py")
|
| 441 |
+
return session_root, workspace
|
| 442 |
+
|
| 443 |
+
|
| 444 |
+
def _runner_sandbox_command(workspace: str, socket_path: str) -> list[str]:
|
| 445 |
+
"""Build a fail-closed bubblewrap command with no network or app secrets."""
|
| 446 |
+
if sys.platform != "linux" or not BWRAP_PATH or not PRLIMIT_PATH:
|
| 447 |
+
raise RuntimeError("伺服器缺少 Linux bubblewrap/prlimit,已拒絕未隔離執行。")
|
| 448 |
+
|
| 449 |
+
bwrap = [
|
| 450 |
+
BWRAP_PATH,
|
| 451 |
+
"--die-with-parent",
|
| 452 |
+
"--new-session",
|
| 453 |
+
"--unshare-user",
|
| 454 |
+
"--unshare-pid",
|
| 455 |
+
"--unshare-ipc",
|
| 456 |
+
"--unshare-net",
|
| 457 |
+
"--unshare-uts",
|
| 458 |
+
"--unshare-cgroup-try",
|
| 459 |
+
"--disable-userns",
|
| 460 |
+
]
|
| 461 |
+
for system_path in ("/usr", "/lib", "/lib64", "/bin", "/sbin", "/etc", "/opt"):
|
| 462 |
+
if os.path.exists(system_path):
|
| 463 |
+
bwrap.extend(("--ro-bind", system_path, system_path))
|
| 464 |
+
bwrap.extend((
|
| 465 |
+
"--dev", "/dev",
|
| 466 |
+
"--proc", "/proc",
|
| 467 |
+
"--size", str(RUNNER_TMP_LIMIT_MB * 1024 * 1024),
|
| 468 |
+
"--tmpfs", "/tmp",
|
| 469 |
+
"--dir", "/workspace",
|
| 470 |
+
"--dir", "/workspace/project",
|
| 471 |
+
"--bind", workspace, "/workspace/project",
|
| 472 |
+
"--ro-bind", os.path.join(BASE_DIR, "runner.py"), "/runner.py",
|
| 473 |
+
"--ro-bind", MODELS_DIR, "/models",
|
| 474 |
+
"--ro-bind", os.path.dirname(socket_path), "/run-ipc",
|
| 475 |
+
"--clearenv",
|
| 476 |
+
"--setenv", "PATH", "/usr/local/bin:/usr/bin:/bin",
|
| 477 |
+
"--setenv", "HOME", "/tmp",
|
| 478 |
+
"--setenv", "LANG", "C.UTF-8",
|
| 479 |
+
"--setenv", "PYTHONNOUSERSITE", "1",
|
| 480 |
+
"--setenv", "PYTHONDONTWRITEBYTECODE", "1",
|
| 481 |
+
"--setenv", "SDL_VIDEODRIVER", "dummy",
|
| 482 |
+
"--setenv", "SDL_AUDIODRIVER", "dummy",
|
| 483 |
+
"--chdir", "/workspace/project",
|
| 484 |
+
"--",
|
| 485 |
+
sys.executable,
|
| 486 |
+
"/runner.py",
|
| 487 |
+
"--project_path", "/workspace/project/main.py",
|
| 488 |
+
"--socket-path", "/run-ipc/runner.sock",
|
| 489 |
+
"--models_dir", "/models",
|
| 490 |
+
))
|
| 491 |
+
memory_bytes = RUNNER_MEMORY_LIMIT_MB * 1024 * 1024
|
| 492 |
+
file_bytes = 128 * 1024 * 1024
|
| 493 |
+
return [
|
| 494 |
+
PRLIMIT_PATH,
|
| 495 |
+
f"--cpu={RUNNER_CPU_LIMIT_SEC}:{RUNNER_CPU_LIMIT_SEC}",
|
| 496 |
+
f"--as={memory_bytes}:{memory_bytes}",
|
| 497 |
+
f"--fsize={file_bytes}:{file_bytes}",
|
| 498 |
+
"--nproc=64:64",
|
| 499 |
+
"--nofile=128:128",
|
| 500 |
+
"--core=0:0",
|
| 501 |
+
"--",
|
| 502 |
+
*bwrap,
|
| 503 |
+
]
|
| 504 |
+
|
| 505 |
+
|
| 506 |
# ---------------------------------------------------------------------------
|
| 507 |
# Static / logo
|
| 508 |
# ---------------------------------------------------------------------------
|
|
|
|
| 678 |
if os.path.exists(appetize_path):
|
| 679 |
try:
|
| 680 |
with open(appetize_path, "r", encoding="utf-8") as f:
|
| 681 |
+
candidate_key = f.read().strip()
|
| 682 |
+
if re.fullmatch(r"[A-Za-z0-9_-]{1,256}", candidate_key):
|
| 683 |
+
appetize_key = candidate_key
|
| 684 |
except OSError:
|
| 685 |
pass
|
| 686 |
elif os.path.exists(os.path.join(proj_dir, "link.txt")):
|
|
|
|
| 687 |
try:
|
| 688 |
with open(os.path.join(proj_dir, "link.txt"), "r", encoding="utf-8") as f:
|
| 689 |
+
external_url = _safe_external_url(f.read())
|
| 690 |
+
if external_url:
|
| 691 |
+
proj_type = "external"
|
| 692 |
except OSError:
|
| 693 |
pass
|
| 694 |
|
|
|
|
| 787 |
await websocket.close()
|
| 788 |
return
|
| 789 |
|
| 790 |
+
try:
|
| 791 |
+
source_root = _resolve_python_project(real_class, real_student, project_name)
|
| 792 |
+
except (OSError, ValueError):
|
|
|
|
| 793 |
await websocket.accept()
|
| 794 |
await websocket.send_json(
|
| 795 |
{"type": "log", "data": "錯誤: 找不到該專案的 main.py 檔案!\n"}
|
|
|
|
| 818 |
await websocket.close(code=1013)
|
| 819 |
return
|
| 820 |
|
| 821 |
+
remote_ip = websocket.client.host if websocket.client else "unknown"
|
| 822 |
+
if not allow_runner_start(remote_ip):
|
| 823 |
+
runner_semaphore.release()
|
| 824 |
+
acquired = False
|
| 825 |
+
_metrics["sessions_rejected"] += 1
|
| 826 |
+
await websocket.accept()
|
| 827 |
+
await websocket.send_json({"type": "log", "data": "這個網路來源的專案啟動次數已達上限,請稍後再試。\n"})
|
| 828 |
+
await websocket.send_json({"type": "status", "data": "rate_limited"})
|
| 829 |
+
await websocket.close(code=4429, reason="runner start rate limited")
|
| 830 |
+
return
|
| 831 |
+
|
| 832 |
await websocket.accept()
|
| 833 |
session_id = f"{real_class}/{real_student}/{project_name}/{time.time():.0f}"
|
| 834 |
started_at = time.time()
|
|
|
|
| 847 |
_metrics["sessions_started"] += 1
|
| 848 |
|
| 849 |
process = None
|
| 850 |
+
ipc_server = None
|
| 851 |
runner_writer = None
|
| 852 |
+
session_root = None
|
| 853 |
|
| 854 |
try:
|
| 855 |
connected_event = asyncio.Event()
|
|
|
|
| 861 |
runner_writer = writer
|
| 862 |
connected_event.set()
|
| 863 |
|
| 864 |
+
try:
|
| 865 |
+
session_root, workspace = _stage_runner_project(source_root)
|
| 866 |
+
ipc_dir = os.path.join(session_root, "ipc")
|
| 867 |
+
os.mkdir(ipc_dir, 0o700)
|
| 868 |
+
socket_path = os.path.join(ipc_dir, "runner.sock")
|
| 869 |
+
cmd = _runner_sandbox_command(workspace, socket_path)
|
| 870 |
+
ipc_server = await asyncio.start_unix_server(
|
| 871 |
+
handle_runner_conn, path=socket_path
|
| 872 |
+
)
|
| 873 |
+
os.chmod(socket_path, 0o600)
|
| 874 |
+
except (OSError, RuntimeError, ValueError) as exc:
|
| 875 |
+
log.warning("Refused to start unsandboxed runner: %s", exc)
|
| 876 |
+
await websocket.send_json({"type": "log", "data": f"安全執行環境無法啟動,已停止執行:{exc}\n"})
|
| 877 |
+
await websocket.send_json({"type": "status", "data": "error"})
|
| 878 |
+
return
|
| 879 |
|
| 880 |
popen_kwargs: dict[str, Any] = {
|
| 881 |
"stdout": subprocess.DEVNULL,
|
|
|
|
| 885 |
popen_kwargs["start_new_session"] = True
|
| 886 |
|
| 887 |
process = await asyncio.create_subprocess_exec(*cmd, **popen_kwargs)
|
| 888 |
+
log.info("Started sandboxed runner pid=%s session=%s", process.pid, session_id)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 889 |
|
| 890 |
try:
|
| 891 |
await asyncio.wait_for(
|
| 892 |
connected_event.wait(), timeout=RUNNER_CONNECT_TIMEOUT_SEC
|
| 893 |
)
|
| 894 |
+
log.info("Runner pid=%s connected via private IPC", process.pid)
|
| 895 |
await websocket.send_json(
|
| 896 |
{"type": "status", "data": "running"}
|
| 897 |
)
|
|
|
|
| 903 |
)
|
| 904 |
except asyncio.TimeoutError:
|
| 905 |
_metrics["sessions_failed_connect"] += 1
|
| 906 |
+
log.error("Runner pid=%s sandbox IPC connect timeout; returncode=%s", process.pid, process.returncode)
|
| 907 |
await websocket.send_json(
|
| 908 |
+
{"type": "log", "data": "安全執行環境無法連線(可能是主機未啟用 Linux namespace);已拒絕未隔離執行。\n"}
|
| 909 |
)
|
| 910 |
await websocket.send_json({"type": "status", "data": "error"})
|
| 911 |
return
|
|
|
|
| 1101 |
await runner_writer.wait_closed()
|
| 1102 |
except Exception:
|
| 1103 |
pass
|
| 1104 |
+
if ipc_server is not None:
|
| 1105 |
+
ipc_server.close()
|
| 1106 |
+
await ipc_server.wait_closed()
|
| 1107 |
+
if session_root is not None:
|
| 1108 |
+
shutil.rmtree(session_root, ignore_errors=True)
|
| 1109 |
try:
|
| 1110 |
await websocket.close()
|
| 1111 |
except Exception:
|
static/css/index.css
CHANGED
|
@@ -3,9 +3,9 @@
|
|
| 3 |
--panel-bg: #111827;
|
| 4 |
--border-color: #1f2937;
|
| 5 |
--border-hover: #374151;
|
| 6 |
-
--primary: #
|
| 7 |
-
--secondary: #
|
| 8 |
-
--accent: #
|
| 9 |
--text-main: #f9fafb;
|
| 10 |
--text-muted: #9ca3af;
|
| 11 |
}
|
|
@@ -76,7 +76,7 @@
|
|
| 76 |
}
|
| 77 |
|
| 78 |
.brand-logo:hover .brand-name {
|
| 79 |
-
color:
|
| 80 |
}
|
| 81 |
|
| 82 |
.nav-links {
|
|
@@ -120,7 +120,7 @@
|
|
| 120 |
}
|
| 121 |
|
| 122 |
.badge-top span {
|
| 123 |
-
color: #
|
| 124 |
font-weight: 600;
|
| 125 |
font-size: 0.75rem;
|
| 126 |
letter-spacing: 1px;
|
|
@@ -142,10 +142,8 @@
|
|
| 142 |
}
|
| 143 |
}
|
| 144 |
|
| 145 |
-
header h1 span.
|
| 146 |
-
|
| 147 |
-
-webkit-background-clip: text;
|
| 148 |
-
-webkit-text-fill-color: transparent;
|
| 149 |
}
|
| 150 |
|
| 151 |
header p {
|
|
@@ -166,7 +164,6 @@
|
|
| 166 |
max-width: 1350px;
|
| 167 |
width: calc(100% - 4rem);
|
| 168 |
margin: 0 auto 2rem;
|
| 169 |
-
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
|
| 170 |
display: flex;
|
| 171 |
flex-direction: column;
|
| 172 |
gap: 1rem;
|
|
@@ -182,7 +179,7 @@
|
|
| 182 |
padding-bottom: 0.6rem;
|
| 183 |
}
|
| 184 |
|
| 185 |
-
.generator-header
|
| 186 |
font-size: 0.95rem;
|
| 187 |
font-weight: 700;
|
| 188 |
color: #ffffff;
|
|
@@ -257,7 +254,7 @@
|
|
| 257 |
|
| 258 |
.result-group button {
|
| 259 |
background: var(--primary);
|
| 260 |
-
color: #
|
| 261 |
border: none;
|
| 262 |
padding: 0.7rem 1.5rem;
|
| 263 |
border-radius: 12px;
|
|
@@ -302,7 +299,6 @@
|
|
| 302 |
padding: 2rem;
|
| 303 |
display: flex;
|
| 304 |
flex-direction: column;
|
| 305 |
-
box-shadow: 0 15px 35px rgba(0, 0, 0, 0.25);
|
| 306 |
transition: all 0.2s ease;
|
| 307 |
height: 620px;
|
| 308 |
overflow: hidden; /* Prevent parent scrolling */
|
|
@@ -387,16 +383,16 @@
|
|
| 387 |
border-left-color: var(--primary);
|
| 388 |
}
|
| 389 |
#panel-classes .item-card.active {
|
| 390 |
-
background: rgba(
|
| 391 |
-
border-color: rgba(
|
| 392 |
}
|
| 393 |
|
| 394 |
#panel-students .item-card:hover, #panel-students .item-card.active {
|
| 395 |
border-left-color: var(--accent);
|
| 396 |
}
|
| 397 |
#panel-students .item-card.active {
|
| 398 |
-
background: rgba(
|
| 399 |
-
border-color: rgba(
|
| 400 |
}
|
| 401 |
|
| 402 |
/* Project Cards */
|
|
@@ -421,7 +417,7 @@
|
|
| 421 |
|
| 422 |
.project-card.hover-python:hover {
|
| 423 |
border-left-color: var(--primary);
|
| 424 |
-
border-color: rgba(
|
| 425 |
}
|
| 426 |
|
| 427 |
.project-card.hover-scratch:hover {
|
|
@@ -430,8 +426,8 @@
|
|
| 430 |
}
|
| 431 |
|
| 432 |
.project-card.hover-makecode:hover {
|
| 433 |
-
border-left-color:
|
| 434 |
-
border-color: rgba(
|
| 435 |
}
|
| 436 |
|
| 437 |
.project-card.hover-html:hover {
|
|
@@ -440,8 +436,8 @@
|
|
| 440 |
}
|
| 441 |
|
| 442 |
.project-card.hover-apk:hover {
|
| 443 |
-
border-left-color:
|
| 444 |
-
border-color: rgba(
|
| 445 |
}
|
| 446 |
|
| 447 |
.project-meta {
|
|
@@ -469,9 +465,9 @@
|
|
| 469 |
}
|
| 470 |
|
| 471 |
.tag-python {
|
| 472 |
-
background: rgba(
|
| 473 |
-
color: #
|
| 474 |
-
border: 1px solid rgba(
|
| 475 |
}
|
| 476 |
|
| 477 |
.tag-scratch {
|
|
@@ -481,9 +477,9 @@
|
|
| 481 |
}
|
| 482 |
|
| 483 |
.tag-makecode {
|
| 484 |
-
background: rgba(
|
| 485 |
-
color: #
|
| 486 |
-
border: 1px solid rgba(
|
| 487 |
}
|
| 488 |
|
| 489 |
.tag-html {
|
|
@@ -493,9 +489,9 @@
|
|
| 493 |
}
|
| 494 |
|
| 495 |
.tag-apk {
|
| 496 |
-
background: rgba(
|
| 497 |
-
color: #
|
| 498 |
-
border: 1px solid rgba(
|
| 499 |
}
|
| 500 |
|
| 501 |
/* Action launch button */
|
|
@@ -521,9 +517,10 @@
|
|
| 521 |
|
| 522 |
.btn-launch-python {
|
| 523 |
background: var(--primary);
|
|
|
|
| 524 |
}
|
| 525 |
.btn-launch-python:hover {
|
| 526 |
-
background: #
|
| 527 |
}
|
| 528 |
|
| 529 |
.btn-launch-scratch {
|
|
@@ -549,9 +546,10 @@
|
|
| 549 |
|
| 550 |
.btn-launch-apk {
|
| 551 |
background: var(--accent);
|
|
|
|
| 552 |
}
|
| 553 |
.btn-launch-apk:hover {
|
| 554 |
-
background: #
|
| 555 |
}
|
| 556 |
|
| 557 |
/* Placeholder */
|
|
@@ -592,4 +590,3 @@
|
|
| 592 |
z-index: 1;
|
| 593 |
margin-top: auto;
|
| 594 |
}
|
| 595 |
-
|
|
|
|
| 3 |
--panel-bg: #111827;
|
| 4 |
--border-color: #1f2937;
|
| 5 |
--border-hover: #374151;
|
| 6 |
+
--primary: #f3b64a;
|
| 7 |
+
--secondary: #57b8a5;
|
| 8 |
+
--accent: #ef8068;
|
| 9 |
--text-main: #f9fafb;
|
| 10 |
--text-muted: #9ca3af;
|
| 11 |
}
|
|
|
|
| 76 |
}
|
| 77 |
|
| 78 |
.brand-logo:hover .brand-name {
|
| 79 |
+
color: var(--primary);
|
| 80 |
}
|
| 81 |
|
| 82 |
.nav-links {
|
|
|
|
| 120 |
}
|
| 121 |
|
| 122 |
.badge-top span {
|
| 123 |
+
color: #f6ca78;
|
| 124 |
font-weight: 600;
|
| 125 |
font-size: 0.75rem;
|
| 126 |
letter-spacing: 1px;
|
|
|
|
| 142 |
}
|
| 143 |
}
|
| 144 |
|
| 145 |
+
header h1 span.headline-emphasis {
|
| 146 |
+
color: var(--primary);
|
|
|
|
|
|
|
| 147 |
}
|
| 148 |
|
| 149 |
header p {
|
|
|
|
| 164 |
max-width: 1350px;
|
| 165 |
width: calc(100% - 4rem);
|
| 166 |
margin: 0 auto 2rem;
|
|
|
|
| 167 |
display: flex;
|
| 168 |
flex-direction: column;
|
| 169 |
gap: 1rem;
|
|
|
|
| 179 |
padding-bottom: 0.6rem;
|
| 180 |
}
|
| 181 |
|
| 182 |
+
.generator-header h2 {
|
| 183 |
font-size: 0.95rem;
|
| 184 |
font-weight: 700;
|
| 185 |
color: #ffffff;
|
|
|
|
| 254 |
|
| 255 |
.result-group button {
|
| 256 |
background: var(--primary);
|
| 257 |
+
color: #17212b;
|
| 258 |
border: none;
|
| 259 |
padding: 0.7rem 1.5rem;
|
| 260 |
border-radius: 12px;
|
|
|
|
| 299 |
padding: 2rem;
|
| 300 |
display: flex;
|
| 301 |
flex-direction: column;
|
|
|
|
| 302 |
transition: all 0.2s ease;
|
| 303 |
height: 620px;
|
| 304 |
overflow: hidden; /* Prevent parent scrolling */
|
|
|
|
| 383 |
border-left-color: var(--primary);
|
| 384 |
}
|
| 385 |
#panel-classes .item-card.active {
|
| 386 |
+
background: rgba(243, 182, 74, 0.07);
|
| 387 |
+
border-color: rgba(243, 182, 74, 0.2);
|
| 388 |
}
|
| 389 |
|
| 390 |
#panel-students .item-card:hover, #panel-students .item-card.active {
|
| 391 |
border-left-color: var(--accent);
|
| 392 |
}
|
| 393 |
#panel-students .item-card.active {
|
| 394 |
+
background: rgba(239, 128, 104, 0.07);
|
| 395 |
+
border-color: rgba(239, 128, 104, 0.2);
|
| 396 |
}
|
| 397 |
|
| 398 |
/* Project Cards */
|
|
|
|
| 417 |
|
| 418 |
.project-card.hover-python:hover {
|
| 419 |
border-left-color: var(--primary);
|
| 420 |
+
border-color: rgba(243, 182, 74, 0.2);
|
| 421 |
}
|
| 422 |
|
| 423 |
.project-card.hover-scratch:hover {
|
|
|
|
| 426 |
}
|
| 427 |
|
| 428 |
.project-card.hover-makecode:hover {
|
| 429 |
+
border-left-color: var(--secondary);
|
| 430 |
+
border-color: rgba(87, 184, 165, 0.2);
|
| 431 |
}
|
| 432 |
|
| 433 |
.project-card.hover-html:hover {
|
|
|
|
| 436 |
}
|
| 437 |
|
| 438 |
.project-card.hover-apk:hover {
|
| 439 |
+
border-left-color: var(--accent);
|
| 440 |
+
border-color: rgba(239, 128, 104, 0.2);
|
| 441 |
}
|
| 442 |
|
| 443 |
.project-meta {
|
|
|
|
| 465 |
}
|
| 466 |
|
| 467 |
.tag-python {
|
| 468 |
+
background: rgba(243, 182, 74, 0.1);
|
| 469 |
+
color: #f6ca78;
|
| 470 |
+
border: 1px solid rgba(243, 182, 74, 0.2);
|
| 471 |
}
|
| 472 |
|
| 473 |
.tag-scratch {
|
|
|
|
| 477 |
}
|
| 478 |
|
| 479 |
.tag-makecode {
|
| 480 |
+
background: rgba(87, 184, 165, 0.1);
|
| 481 |
+
color: #a7e2d6;
|
| 482 |
+
border: 1px solid rgba(87, 184, 165, 0.2);
|
| 483 |
}
|
| 484 |
|
| 485 |
.tag-html {
|
|
|
|
| 489 |
}
|
| 490 |
|
| 491 |
.tag-apk {
|
| 492 |
+
background: rgba(239, 128, 104, 0.1);
|
| 493 |
+
color: #ffb9aa;
|
| 494 |
+
border: 1px solid rgba(239, 128, 104, 0.2);
|
| 495 |
}
|
| 496 |
|
| 497 |
/* Action launch button */
|
|
|
|
| 517 |
|
| 518 |
.btn-launch-python {
|
| 519 |
background: var(--primary);
|
| 520 |
+
color: #17212b;
|
| 521 |
}
|
| 522 |
.btn-launch-python:hover {
|
| 523 |
+
background: #dfa12f;
|
| 524 |
}
|
| 525 |
|
| 526 |
.btn-launch-scratch {
|
|
|
|
| 546 |
|
| 547 |
.btn-launch-apk {
|
| 548 |
background: var(--accent);
|
| 549 |
+
color: #17212b;
|
| 550 |
}
|
| 551 |
.btn-launch-apk:hover {
|
| 552 |
+
background: #dc6e58;
|
| 553 |
}
|
| 554 |
|
| 555 |
/* Placeholder */
|
|
|
|
| 590 |
z-index: 1;
|
| 591 |
margin-top: auto;
|
| 592 |
}
|
|
|
static/css/player.css
CHANGED
|
@@ -3,9 +3,9 @@
|
|
| 3 |
--panel-bg: #111827;
|
| 4 |
--border-color: #1f2937;
|
| 5 |
--border-hover: #374151;
|
| 6 |
-
--primary: #
|
| 7 |
-
--secondary: #
|
| 8 |
-
--accent: #
|
| 9 |
--text-main: #f9fafb;
|
| 10 |
--text-muted: #9ca3af;
|
| 11 |
--success: #10b981;
|
|
@@ -99,9 +99,8 @@
|
|
| 99 |
}
|
| 100 |
|
| 101 |
.project-path {
|
| 102 |
-
font-size: 0.
|
| 103 |
color: var(--text-muted);
|
| 104 |
-
text-transform: uppercase;
|
| 105 |
letter-spacing: 0.5px;
|
| 106 |
font-weight: 600;
|
| 107 |
}
|
|
@@ -118,11 +117,11 @@
|
|
| 118 |
align-items: center;
|
| 119 |
gap: 8px;
|
| 120 |
font-size: 0.82rem;
|
| 121 |
-
background: rgba(
|
| 122 |
-
color: var(--
|
| 123 |
padding: 0.35rem 0.9rem;
|
| 124 |
border-radius: 30px;
|
| 125 |
-
border: 1px solid rgba(
|
| 126 |
font-weight: 600;
|
| 127 |
}
|
| 128 |
|
|
@@ -130,6 +129,20 @@
|
|
| 130 |
width: 7px;
|
| 131 |
height: 7px;
|
| 132 |
border-radius: 50%;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 133 |
background-color: var(--success);
|
| 134 |
animation: pulse 1.5s infinite;
|
| 135 |
}
|
|
@@ -144,6 +157,12 @@
|
|
| 144 |
animation: none;
|
| 145 |
}
|
| 146 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 147 |
/* Main Grid Wrapper */
|
| 148 |
.main-container {
|
| 149 |
display: grid;
|
|
@@ -237,8 +256,8 @@
|
|
| 237 |
|
| 238 |
.focus-prompt.active {
|
| 239 |
color: var(--secondary);
|
| 240 |
-
border-color: rgba(
|
| 241 |
-
background: rgba(
|
| 242 |
}
|
| 243 |
|
| 244 |
/* Right Panel styling */
|
|
@@ -290,7 +309,7 @@
|
|
| 290 |
}
|
| 291 |
|
| 292 |
.btn-webcam.active {
|
| 293 |
-
background: rgba(
|
| 294 |
border-color: var(--secondary);
|
| 295 |
color: var(--secondary);
|
| 296 |
}
|
|
@@ -318,7 +337,7 @@
|
|
| 318 |
|
| 319 |
.btn-restart {
|
| 320 |
background: var(--primary);
|
| 321 |
-
color: #
|
| 322 |
}
|
| 323 |
.btn-restart:hover {
|
| 324 |
opacity: 0.9;
|
|
@@ -330,7 +349,7 @@
|
|
| 330 |
border: 1px solid rgba(239, 68, 68, 0.15);
|
| 331 |
}
|
| 332 |
.btn-stop:hover {
|
| 333 |
-
background:
|
| 334 |
color: #fff;
|
| 335 |
}
|
| 336 |
|
|
@@ -455,11 +474,12 @@
|
|
| 455 |
/* APK Card Tag / Button */
|
| 456 |
.btn-launch-apk {
|
| 457 |
background: var(--accent) !important;
|
|
|
|
| 458 |
}
|
| 459 |
.btn-launch-apk:hover {
|
| 460 |
-
background: #
|
| 461 |
transform: translateY(-1px);
|
| 462 |
-
box-shadow: 0 4px 12px rgba(
|
| 463 |
}
|
| 464 |
|
| 465 |
/* Smartphone Mockup Container */
|
|
@@ -534,7 +554,7 @@
|
|
| 534 |
|
| 535 |
.phone-screen {
|
| 536 |
flex-grow: 1;
|
| 537 |
-
background: linear-gradient(135deg, #
|
| 538 |
display: flex;
|
| 539 |
flex-direction: column;
|
| 540 |
align-items: center;
|
|
@@ -558,23 +578,21 @@
|
|
| 558 |
.android-icon-wrapper {
|
| 559 |
width: 80px;
|
| 560 |
height: 80px;
|
| 561 |
-
background: rgba(
|
| 562 |
-
border: 2px solid rgba(
|
| 563 |
border-radius: 50%;
|
| 564 |
display: flex;
|
| 565 |
align-items: center;
|
| 566 |
justify-content: center;
|
| 567 |
-
animation:
|
| 568 |
}
|
| 569 |
|
| 570 |
-
@keyframes
|
| 571 |
0%, 100% {
|
| 572 |
transform: scale(1);
|
| 573 |
-
box-shadow: 0 0 0 0 rgba(168, 85, 247, 0.4);
|
| 574 |
}
|
| 575 |
50% {
|
| 576 |
-
transform: scale(1.
|
| 577 |
-
box-shadow: 0 0 20px 5px rgba(168, 85, 247, 0.2);
|
| 578 |
}
|
| 579 |
}
|
| 580 |
|
|
@@ -663,8 +681,8 @@
|
|
| 663 |
}
|
| 664 |
|
| 665 |
.guide-step-number {
|
| 666 |
-
background: rgba(
|
| 667 |
-
color: #
|
| 668 |
width: 22px;
|
| 669 |
height: 22px;
|
| 670 |
border-radius: 50%;
|
|
@@ -683,7 +701,7 @@
|
|
| 683 |
}
|
| 684 |
|
| 685 |
.guide-link {
|
| 686 |
-
color: #
|
| 687 |
text-decoration: underline;
|
| 688 |
transition: color 0.2s;
|
| 689 |
}
|
|
@@ -722,8 +740,8 @@
|
|
| 722 |
.loading-spinner {
|
| 723 |
width: 36px;
|
| 724 |
height: 36px;
|
| 725 |
-
border: 3px solid rgba(
|
| 726 |
-
border-top-color: #
|
| 727 |
border-radius: 50%;
|
| 728 |
animation: telearn-spin 0.8s linear infinite;
|
| 729 |
}
|
|
@@ -772,7 +790,7 @@
|
|
| 772 |
width: min(420px, 100%);
|
| 773 |
box-sizing: border-box;
|
| 774 |
padding: 2rem;
|
| 775 |
-
border:
|
| 776 |
border-radius: 20px;
|
| 777 |
background: #111827;
|
| 778 |
box-shadow: 0 24px 70px rgba(0, 0, 0, 0.5);
|
|
@@ -807,8 +825,8 @@
|
|
| 807 |
padding: 0.85rem 1rem;
|
| 808 |
border: 0;
|
| 809 |
border-radius: 12px;
|
| 810 |
-
background:
|
| 811 |
-
color: #
|
| 812 |
font: inherit;
|
| 813 |
font-weight: 700;
|
| 814 |
cursor: pointer;
|
|
@@ -816,7 +834,7 @@
|
|
| 816 |
|
| 817 |
.idle-dialog-card button:hover,
|
| 818 |
.idle-dialog-card button:focus-visible {
|
| 819 |
-
background: #
|
| 820 |
-
outline: 3px solid rgba(
|
| 821 |
outline-offset: 3px;
|
| 822 |
}
|
|
|
|
| 3 |
--panel-bg: #111827;
|
| 4 |
--border-color: #1f2937;
|
| 5 |
--border-hover: #374151;
|
| 6 |
+
--primary: #f3b64a;
|
| 7 |
+
--secondary: #57b8a5;
|
| 8 |
+
--accent: #ef8068;
|
| 9 |
--text-main: #f9fafb;
|
| 10 |
--text-muted: #9ca3af;
|
| 11 |
--success: #10b981;
|
|
|
|
| 99 |
}
|
| 100 |
|
| 101 |
.project-path {
|
| 102 |
+
font-size: 0.75rem;
|
| 103 |
color: var(--text-muted);
|
|
|
|
| 104 |
letter-spacing: 0.5px;
|
| 105 |
font-weight: 600;
|
| 106 |
}
|
|
|
|
| 117 |
align-items: center;
|
| 118 |
gap: 8px;
|
| 119 |
font-size: 0.82rem;
|
| 120 |
+
background: rgba(243, 182, 74, 0.06);
|
| 121 |
+
color: var(--text-muted);
|
| 122 |
padding: 0.35rem 0.9rem;
|
| 123 |
border-radius: 30px;
|
| 124 |
+
border: 1px solid rgba(243, 182, 74, 0.15);
|
| 125 |
font-weight: 600;
|
| 126 |
}
|
| 127 |
|
|
|
|
| 129 |
width: 7px;
|
| 130 |
height: 7px;
|
| 131 |
border-radius: 50%;
|
| 132 |
+
background-color: #7d8798;
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
.status-connecting .status-dot {
|
| 136 |
+
background-color: var(--primary);
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
.status-running {
|
| 140 |
+
background: rgba(16, 185, 129, 0.06);
|
| 141 |
+
color: var(--success);
|
| 142 |
+
border-color: rgba(16, 185, 129, 0.15);
|
| 143 |
+
}
|
| 144 |
+
|
| 145 |
+
.status-running .status-dot {
|
| 146 |
background-color: var(--success);
|
| 147 |
animation: pulse 1.5s infinite;
|
| 148 |
}
|
|
|
|
| 157 |
animation: none;
|
| 158 |
}
|
| 159 |
|
| 160 |
+
.status-idle {
|
| 161 |
+
background: rgba(255, 255, 255, 0.03);
|
| 162 |
+
color: var(--text-muted);
|
| 163 |
+
border-color: var(--border-color);
|
| 164 |
+
}
|
| 165 |
+
|
| 166 |
/* Main Grid Wrapper */
|
| 167 |
.main-container {
|
| 168 |
display: grid;
|
|
|
|
| 256 |
|
| 257 |
.focus-prompt.active {
|
| 258 |
color: var(--secondary);
|
| 259 |
+
border-color: rgba(87, 184, 165, 0.3);
|
| 260 |
+
background: rgba(87, 184, 165, 0.05);
|
| 261 |
}
|
| 262 |
|
| 263 |
/* Right Panel styling */
|
|
|
|
| 309 |
}
|
| 310 |
|
| 311 |
.btn-webcam.active {
|
| 312 |
+
background: rgba(87, 184, 165, 0.06);
|
| 313 |
border-color: var(--secondary);
|
| 314 |
color: var(--secondary);
|
| 315 |
}
|
|
|
|
| 337 |
|
| 338 |
.btn-restart {
|
| 339 |
background: var(--primary);
|
| 340 |
+
color: #17212b;
|
| 341 |
}
|
| 342 |
.btn-restart:hover {
|
| 343 |
opacity: 0.9;
|
|
|
|
| 349 |
border: 1px solid rgba(239, 68, 68, 0.15);
|
| 350 |
}
|
| 351 |
.btn-stop:hover {
|
| 352 |
+
background: #b91c1c;
|
| 353 |
color: #fff;
|
| 354 |
}
|
| 355 |
|
|
|
|
| 474 |
/* APK Card Tag / Button */
|
| 475 |
.btn-launch-apk {
|
| 476 |
background: var(--accent) !important;
|
| 477 |
+
color: #17212b !important;
|
| 478 |
}
|
| 479 |
.btn-launch-apk:hover {
|
| 480 |
+
background: #dc6e58 !important;
|
| 481 |
transform: translateY(-1px);
|
| 482 |
+
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.28);
|
| 483 |
}
|
| 484 |
|
| 485 |
/* Smartphone Mockup Container */
|
|
|
|
| 554 |
|
| 555 |
.phone-screen {
|
| 556 |
flex-grow: 1;
|
| 557 |
+
background: linear-gradient(135deg, #2b271f 0%, #0f172a 100%);
|
| 558 |
display: flex;
|
| 559 |
flex-direction: column;
|
| 560 |
align-items: center;
|
|
|
|
| 578 |
.android-icon-wrapper {
|
| 579 |
width: 80px;
|
| 580 |
height: 80px;
|
| 581 |
+
background: rgba(239, 128, 104, 0.15);
|
| 582 |
+
border: 2px solid rgba(239, 128, 104, 0.3);
|
| 583 |
border-radius: 50%;
|
| 584 |
display: flex;
|
| 585 |
align-items: center;
|
| 586 |
justify-content: center;
|
| 587 |
+
animation: icon-breathe 2.4s ease-in-out infinite;
|
| 588 |
}
|
| 589 |
|
| 590 |
+
@keyframes icon-breathe {
|
| 591 |
0%, 100% {
|
| 592 |
transform: scale(1);
|
|
|
|
| 593 |
}
|
| 594 |
50% {
|
| 595 |
+
transform: scale(1.035);
|
|
|
|
| 596 |
}
|
| 597 |
}
|
| 598 |
|
|
|
|
| 681 |
}
|
| 682 |
|
| 683 |
.guide-step-number {
|
| 684 |
+
background: rgba(239, 128, 104, 0.15);
|
| 685 |
+
color: #ef8068;
|
| 686 |
width: 22px;
|
| 687 |
height: 22px;
|
| 688 |
border-radius: 50%;
|
|
|
|
| 701 |
}
|
| 702 |
|
| 703 |
.guide-link {
|
| 704 |
+
color: #ef8068;
|
| 705 |
text-decoration: underline;
|
| 706 |
transition: color 0.2s;
|
| 707 |
}
|
|
|
|
| 740 |
.loading-spinner {
|
| 741 |
width: 36px;
|
| 742 |
height: 36px;
|
| 743 |
+
border: 3px solid rgba(243, 182, 74, 0.2);
|
| 744 |
+
border-top-color: #f3b64a;
|
| 745 |
border-radius: 50%;
|
| 746 |
animation: telearn-spin 0.8s linear infinite;
|
| 747 |
}
|
|
|
|
| 790 |
width: min(420px, 100%);
|
| 791 |
box-sizing: border-box;
|
| 792 |
padding: 2rem;
|
| 793 |
+
border: 0;
|
| 794 |
border-radius: 20px;
|
| 795 |
background: #111827;
|
| 796 |
box-shadow: 0 24px 70px rgba(0, 0, 0, 0.5);
|
|
|
|
| 825 |
padding: 0.85rem 1rem;
|
| 826 |
border: 0;
|
| 827 |
border-radius: 12px;
|
| 828 |
+
background: var(--primary);
|
| 829 |
+
color: #17212b;
|
| 830 |
font: inherit;
|
| 831 |
font-weight: 700;
|
| 832 |
cursor: pointer;
|
|
|
|
| 834 |
|
| 835 |
.idle-dialog-card button:hover,
|
| 836 |
.idle-dialog-card button:focus-visible {
|
| 837 |
+
background: #dfa12f;
|
| 838 |
+
outline: 3px solid rgba(243, 182, 74, 0.45);
|
| 839 |
outline-offset: 3px;
|
| 840 |
}
|
static/js/index.js
CHANGED
|
@@ -177,10 +177,10 @@
|
|
| 177 |
classes.forEach(cls => {
|
| 178 |
const card = document.createElement('div');
|
| 179 |
card.className = `item-card ${activeClass === cls ? 'active' : ''}`;
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
card.onclick = () => selectClass(cls);
|
| 185 |
classList.appendChild(card);
|
| 186 |
});
|
|
@@ -216,10 +216,10 @@
|
|
| 216 |
students.forEach(std => {
|
| 217 |
const card = document.createElement('div');
|
| 218 |
card.className = `item-card ${activeStudent === std ? 'active' : ''}`;
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
card.onclick = () => selectStudent(std);
|
| 224 |
studentList.appendChild(card);
|
| 225 |
});
|
|
@@ -288,16 +288,22 @@
|
|
| 288 |
|
| 289 |
const card = document.createElement('div');
|
| 290 |
card.className = `project-card ${hoverClass}`;
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 301 |
projectList.appendChild(card);
|
| 302 |
});
|
| 303 |
}
|
|
@@ -310,5 +316,5 @@
|
|
| 310 |
window.open(url, '_blank');
|
| 311 |
}
|
| 312 |
|
| 313 |
-
|
| 314 |
-
|
|
|
|
| 177 |
classes.forEach(cls => {
|
| 178 |
const card = document.createElement('div');
|
| 179 |
card.className = `item-card ${activeClass === cls ? 'active' : ''}`;
|
| 180 |
+
const label = document.createElement('span');
|
| 181 |
+
label.textContent = cls;
|
| 182 |
+
card.append(label);
|
| 183 |
+
card.insertAdjacentHTML('beforeend', '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><path d="M9 18l6-6-6-6"/></svg>');
|
| 184 |
card.onclick = () => selectClass(cls);
|
| 185 |
classList.appendChild(card);
|
| 186 |
});
|
|
|
|
| 216 |
students.forEach(std => {
|
| 217 |
const card = document.createElement('div');
|
| 218 |
card.className = `item-card ${activeStudent === std ? 'active' : ''}`;
|
| 219 |
+
const label = document.createElement('span');
|
| 220 |
+
label.textContent = std;
|
| 221 |
+
card.append(label);
|
| 222 |
+
card.insertAdjacentHTML('beforeend', '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><path d="M9 18l6-6-6-6"/></svg>');
|
| 223 |
card.onclick = () => selectStudent(std);
|
| 224 |
studentList.appendChild(card);
|
| 225 |
});
|
|
|
|
| 288 |
|
| 289 |
const card = document.createElement('div');
|
| 290 |
card.className = `project-card ${hoverClass}`;
|
| 291 |
+
const meta = document.createElement('div');
|
| 292 |
+
meta.className = 'project-meta';
|
| 293 |
+
const title = document.createElement('span');
|
| 294 |
+
title.className = 'project-title';
|
| 295 |
+
title.textContent = proj.name;
|
| 296 |
+
const tag = document.createElement('span');
|
| 297 |
+
tag.className = `project-tag ${tagClass}`;
|
| 298 |
+
tag.textContent = tagText;
|
| 299 |
+
meta.append(title, tag);
|
| 300 |
+
const launch = document.createElement('button');
|
| 301 |
+
launch.type = 'button';
|
| 302 |
+
launch.className = `btn-launch ${btnClass}`;
|
| 303 |
+
launch.insertAdjacentHTML('afterbegin', '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3.5"><path d="M5 3l14 9-14 9V3z"/></svg>');
|
| 304 |
+
launch.append(document.createTextNode('啟動專題專區'));
|
| 305 |
+
launch.addEventListener('click', () => launchProject(proj.name));
|
| 306 |
+
card.append(meta, launch);
|
| 307 |
projectList.appendChild(card);
|
| 308 |
});
|
| 309 |
}
|
|
|
|
| 316 |
window.open(url, '_blank');
|
| 317 |
}
|
| 318 |
|
| 319 |
+
document.getElementById("btn-copy-url")?.addEventListener("click", copyGeneratedUrl);
|
| 320 |
+
window.addEventListener("load", fetchProjects, { once: true });
|
static/js/player.js
CHANGED
|
@@ -128,15 +128,24 @@
|
|
| 128 |
|
| 129 |
if (projectType === "python") {
|
| 130 |
if (viewOnly) {
|
| 131 |
-
statusWrapper.classList.
|
|
|
|
| 132 |
statusText.innerText = "僅供預覽";
|
| 133 |
document.getElementById("python-device-section").style.display = "none";
|
| 134 |
const img = document.createElement("div");
|
| 135 |
img.style.cssText = "color:#94a3b8;text-align:center;padding:2rem;max-width:28rem;line-height:1.6;";
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 140 |
viewportFrame.appendChild(img);
|
| 141 |
appendConsole("[系統] view=1 僅預覽模式,未啟動執行器。\n");
|
| 142 |
} else {
|
|
@@ -167,7 +176,10 @@
|
|
| 167 |
|
| 168 |
// Generate QR code
|
| 169 |
const qrUrl = `https://api.qrserver.com/v1/create-qr-code/?size=180x180&data=${encodeURIComponent(dlUrl)}`;
|
| 170 |
-
document.
|
|
|
|
|
|
|
|
|
|
| 171 |
|
| 172 |
// Setup Phone mockup
|
| 173 |
viewportFrame.classList.add("iframe-mode");
|
|
@@ -205,26 +217,34 @@
|
|
| 205 |
const iframe = document.createElement("iframe");
|
| 206 |
iframe.className = "phone-screen-iframe";
|
| 207 |
iframe.src = `https://appetize.io/embed/${encodeURIComponent(appetizeKey)}?device=pixel4&scale=auto&autoplay=false&orientation=portrait&deviceColor=black`;
|
|
|
|
| 208 |
screen.appendChild(iframe);
|
| 209 |
} else {
|
| 210 |
screen.innerHTML = `
|
| 211 |
<div class="android-icon-wrapper">
|
| 212 |
-
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="#
|
| 213 |
<path d="M4 10v6m16-6v6M6 18c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V9H6v9zm3-13l-1.5-2M15 5l1.5-2M9 13v.01M15 13v.01" stroke-linecap="round" stroke-linejoin="round"/>
|
| 214 |
</svg>
|
| 215 |
</div>
|
| 216 |
<div>
|
| 217 |
-
<h4 style="font-weight: 800; font-size: 1.25rem; color: #ffffff; margin-bottom: 0.5rem; text-shadow: 0 2px 4px rgba(0,0,0,0.3);">
|
| 218 |
<p style="font-size: 0.8rem; color: rgba(255,255,255,0.7); line-height: 1.4; padding: 0 1rem;">本專案為 Android 應用程式</p>
|
| 219 |
</div>
|
| 220 |
<div style="background: rgba(0,0,0,0.2); border: 1px solid rgba(255,255,255,0.05); padding: 0.8rem; border-radius: 12px; width: 100%; box-sizing: border-box; display: flex; flex-direction: column; align-items: center; gap: 8px;">
|
| 221 |
<span style="font-size: 0.72rem; color: var(--text-muted); line-height: 1.3;">掃描此條碼以安裝至您的實體手機</span>
|
| 222 |
-
<
|
| 223 |
</div>
|
| 224 |
-
<a
|
| 225 |
下載 APK 安裝檔
|
| 226 |
</a>
|
| 227 |
`;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 228 |
}
|
| 229 |
phone.appendChild(screen);
|
| 230 |
viewportFrame.appendChild(phone);
|
|
@@ -237,7 +257,14 @@
|
|
| 237 |
helper.style.textAlign = "center";
|
| 238 |
helper.style.maxWidth = "320px";
|
| 239 |
helper.style.lineHeight = "1.5";
|
| 240 |
-
helper.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 241 |
viewportFrame.appendChild(helper);
|
| 242 |
}
|
| 243 |
} else {
|
|
@@ -251,7 +278,9 @@
|
|
| 251 |
viewportFrame.classList.add("iframe-mode");
|
| 252 |
const iframe = document.createElement("iframe");
|
| 253 |
iframe.className = "iframe-viewport";
|
| 254 |
-
iframe.
|
|
|
|
|
|
|
| 255 |
iframe.src = `/static_projects/${encodeURIComponent(studentToken)}/${encodeURIComponent(projectName)}/index.html`;
|
| 256 |
viewportFrame.appendChild(iframe);
|
| 257 |
} else if (projectType === "external") {
|
|
@@ -266,6 +295,7 @@
|
|
| 266 |
iframe.allow = "autoplay; fullscreen; gamepad";
|
| 267 |
iframe.setAttribute("allowfullscreen", "true");
|
| 268 |
iframe.setAttribute("credentialless", "");
|
|
|
|
| 269 |
iframe.src = externalUrl;
|
| 270 |
viewportFrame.appendChild(iframe);
|
| 271 |
return;
|
|
@@ -304,7 +334,8 @@
|
|
| 304 |
stopReason = null;
|
| 305 |
|
| 306 |
socket.onopen = () => {
|
| 307 |
-
statusWrapper.classList.remove("status-stopped");
|
|
|
|
| 308 |
statusText.innerText = "已連線 (運作中)";
|
| 309 |
appendConsole("[系統] 連線成功,學生專案已啟動。\n");
|
| 310 |
appendConsole(`[系統] 閒置 ${Math.round(idleWarningSec/60)} 分鐘會提醒,${Math.round(idleTimeoutSec/60)} 分鐘後自動釋放名額。\n`);
|
|
@@ -337,11 +368,12 @@
|
|
| 337 |
} else if (data.type === "idle_resumed") {
|
| 338 |
hideIdleWarning();
|
| 339 |
} else if (data.type === "status") {
|
| 340 |
-
if (["stopped", "timeout", "idle_timeout", "connection_timeout", "error", "busy"].includes(data.data)) {
|
| 341 |
setStoppedStatus(data.data);
|
| 342 |
setLoading(false);
|
| 343 |
} else if (data.data === "running") {
|
| 344 |
-
statusWrapper.classList.remove("status-stopped");
|
|
|
|
| 345 |
statusText.innerText = "已連線 (運作中)";
|
| 346 |
}
|
| 347 |
}
|
|
@@ -367,6 +399,7 @@
|
|
| 367 |
|
| 368 |
function setStoppedStatus(reason) {
|
| 369 |
if (reason) stopReason = reason;
|
|
|
|
| 370 |
statusWrapper.classList.add("status-stopped");
|
| 371 |
const labels = {
|
| 372 |
stopped: "已停止",
|
|
@@ -374,7 +407,8 @@
|
|
| 374 |
idle_timeout: "閒置結束",
|
| 375 |
connection_timeout: "連線已離開",
|
| 376 |
error: "錯誤",
|
| 377 |
-
busy: "名額已滿"
|
|
|
|
| 378 |
};
|
| 379 |
statusText.innerText = labels[reason] || "已停止";
|
| 380 |
stopWebcamStream();
|
|
@@ -596,6 +630,10 @@
|
|
| 596 |
}
|
| 597 |
|
| 598 |
window.onload = initViewport;
|
|
|
|
|
|
|
|
|
|
|
|
|
| 599 |
setupActivityTracking();
|
| 600 |
window.onbeforeunload = () => {
|
| 601 |
stopWebcamStream();
|
|
|
|
| 128 |
|
| 129 |
if (projectType === "python") {
|
| 130 |
if (viewOnly) {
|
| 131 |
+
statusWrapper.classList.remove("status-connecting");
|
| 132 |
+
statusWrapper.classList.add("status-idle");
|
| 133 |
statusText.innerText = "僅供預覽";
|
| 134 |
document.getElementById("python-device-section").style.display = "none";
|
| 135 |
const img = document.createElement("div");
|
| 136 |
img.style.cssText = "color:#94a3b8;text-align:center;padding:2rem;max-width:28rem;line-height:1.6;";
|
| 137 |
+
const title = document.createElement("h3");
|
| 138 |
+
title.textContent = projectName;
|
| 139 |
+
title.style.cssText = "color:#fff;margin-bottom:0.75rem;";
|
| 140 |
+
const identity = document.createElement("p");
|
| 141 |
+
identity.textContent = `班級:${className}|學生:${studentName}`;
|
| 142 |
+
const note = document.createElement("p");
|
| 143 |
+
note.style.cssText = "margin-top:1rem;font-size:0.9rem;";
|
| 144 |
+
note.append("此連結為「僅預覽」模式,不會在伺服器上執行程式。若要實際操作,請使用不含 ");
|
| 145 |
+
const query = document.createElement("code");
|
| 146 |
+
query.textContent = "?view=1";
|
| 147 |
+
note.append(query, " 的連結。");
|
| 148 |
+
img.append(title, identity, note);
|
| 149 |
viewportFrame.appendChild(img);
|
| 150 |
appendConsole("[系統] view=1 僅預覽模式,未啟動執行器。\n");
|
| 151 |
} else {
|
|
|
|
| 176 |
|
| 177 |
// Generate QR code
|
| 178 |
const qrUrl = `https://api.qrserver.com/v1/create-qr-code/?size=180x180&data=${encodeURIComponent(dlUrl)}`;
|
| 179 |
+
const apkQrImage = document.createElement("img");
|
| 180 |
+
apkQrImage.src = qrUrl;
|
| 181 |
+
apkQrImage.alt = "QR Code";
|
| 182 |
+
document.getElementById("apk-qr-image").replaceChildren(apkQrImage);
|
| 183 |
|
| 184 |
// Setup Phone mockup
|
| 185 |
viewportFrame.classList.add("iframe-mode");
|
|
|
|
| 217 |
const iframe = document.createElement("iframe");
|
| 218 |
iframe.className = "phone-screen-iframe";
|
| 219 |
iframe.src = `https://appetize.io/embed/${encodeURIComponent(appetizeKey)}?device=pixel4&scale=auto&autoplay=false&orientation=portrait&deviceColor=black`;
|
| 220 |
+
iframe.referrerPolicy = "no-referrer";
|
| 221 |
screen.appendChild(iframe);
|
| 222 |
} else {
|
| 223 |
screen.innerHTML = `
|
| 224 |
<div class="android-icon-wrapper">
|
| 225 |
+
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="#ef8068" stroke-width="1.8">
|
| 226 |
<path d="M4 10v6m16-6v6M6 18c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V9H6v9zm3-13l-1.5-2M15 5l1.5-2M9 13v.01M15 13v.01" stroke-linecap="round" stroke-linejoin="round"/>
|
| 227 |
</svg>
|
| 228 |
</div>
|
| 229 |
<div>
|
| 230 |
+
<h4 id="apk-project-title" style="font-weight: 800; font-size: 1.25rem; color: #ffffff; margin-bottom: 0.5rem; text-shadow: 0 2px 4px rgba(0,0,0,0.3);"></h4>
|
| 231 |
<p style="font-size: 0.8rem; color: rgba(255,255,255,0.7); line-height: 1.4; padding: 0 1rem;">本專案為 Android 應用程式</p>
|
| 232 |
</div>
|
| 233 |
<div style="background: rgba(0,0,0,0.2); border: 1px solid rgba(255,255,255,0.05); padding: 0.8rem; border-radius: 12px; width: 100%; box-sizing: border-box; display: flex; flex-direction: column; align-items: center; gap: 8px;">
|
| 234 |
<span style="font-size: 0.72rem; color: var(--text-muted); line-height: 1.3;">掃描此條碼以安裝至您的實體手機</span>
|
| 235 |
+
<div id="apk-qr-slot"></div>
|
| 236 |
</div>
|
| 237 |
+
<a id="apk-download-link" class="btn-launch btn-launch-apk" style="text-decoration: none; width: 100%; font-size: 0.82rem;" download>
|
| 238 |
下載 APK 安裝檔
|
| 239 |
</a>
|
| 240 |
`;
|
| 241 |
+
screen.querySelector("#apk-project-title").textContent = projectName;
|
| 242 |
+
const qrImage = document.createElement("img");
|
| 243 |
+
qrImage.src = qrUrl;
|
| 244 |
+
qrImage.alt = "QR Code";
|
| 245 |
+
qrImage.style.cssText = "width:100px;height:100px;border-radius:6px;border:4px solid #fff;display:block;";
|
| 246 |
+
screen.querySelector("#apk-qr-slot").append(qrImage);
|
| 247 |
+
screen.querySelector("#apk-download-link").href = dlUrl;
|
| 248 |
}
|
| 249 |
phone.appendChild(screen);
|
| 250 |
viewportFrame.appendChild(phone);
|
|
|
|
| 257 |
helper.style.textAlign = "center";
|
| 258 |
helper.style.maxWidth = "320px";
|
| 259 |
helper.style.lineHeight = "1.5";
|
| 260 |
+
helper.append("⚠️ 因 Appetize.io 免費方案限制內嵌功能,若畫面顯示「未啟用內嵌」,請 ");
|
| 261 |
+
const openAppetize = document.createElement("a");
|
| 262 |
+
openAppetize.href = `https://appetize.io/app/${encodeURIComponent(appetizeKey)}`;
|
| 263 |
+
openAppetize.target = "_blank";
|
| 264 |
+
openAppetize.rel = "noopener noreferrer";
|
| 265 |
+
openAppetize.textContent = "點此在新分頁中打開模擬器";
|
| 266 |
+
openAppetize.style.cssText = "color:#ef8068;text-decoration:underline;font-weight:600;";
|
| 267 |
+
helper.append(openAppetize, "。");
|
| 268 |
viewportFrame.appendChild(helper);
|
| 269 |
}
|
| 270 |
} else {
|
|
|
|
| 278 |
viewportFrame.classList.add("iframe-mode");
|
| 279 |
const iframe = document.createElement("iframe");
|
| 280 |
iframe.className = "iframe-viewport";
|
| 281 |
+
iframe.setAttribute("sandbox", "allow-scripts allow-forms allow-pointer-lock");
|
| 282 |
+
iframe.referrerPolicy = "no-referrer";
|
| 283 |
+
iframe.allow = "autoplay; fullscreen";
|
| 284 |
iframe.src = `/static_projects/${encodeURIComponent(studentToken)}/${encodeURIComponent(projectName)}/index.html`;
|
| 285 |
viewportFrame.appendChild(iframe);
|
| 286 |
} else if (projectType === "external") {
|
|
|
|
| 295 |
iframe.allow = "autoplay; fullscreen; gamepad";
|
| 296 |
iframe.setAttribute("allowfullscreen", "true");
|
| 297 |
iframe.setAttribute("credentialless", "");
|
| 298 |
+
iframe.referrerPolicy = "no-referrer";
|
| 299 |
iframe.src = externalUrl;
|
| 300 |
viewportFrame.appendChild(iframe);
|
| 301 |
return;
|
|
|
|
| 334 |
stopReason = null;
|
| 335 |
|
| 336 |
socket.onopen = () => {
|
| 337 |
+
statusWrapper.classList.remove("status-stopped", "status-connecting", "status-idle");
|
| 338 |
+
statusWrapper.classList.add("status-running");
|
| 339 |
statusText.innerText = "已連線 (運作中)";
|
| 340 |
appendConsole("[系統] 連線成功,學生專案已啟動。\n");
|
| 341 |
appendConsole(`[系統] 閒置 ${Math.round(idleWarningSec/60)} 分鐘會提醒,${Math.round(idleTimeoutSec/60)} 分鐘後自動釋放名額。\n`);
|
|
|
|
| 368 |
} else if (data.type === "idle_resumed") {
|
| 369 |
hideIdleWarning();
|
| 370 |
} else if (data.type === "status") {
|
| 371 |
+
if (["stopped", "timeout", "idle_timeout", "connection_timeout", "error", "busy", "rate_limited"].includes(data.data)) {
|
| 372 |
setStoppedStatus(data.data);
|
| 373 |
setLoading(false);
|
| 374 |
} else if (data.data === "running") {
|
| 375 |
+
statusWrapper.classList.remove("status-stopped", "status-connecting", "status-idle");
|
| 376 |
+
statusWrapper.classList.add("status-running");
|
| 377 |
statusText.innerText = "已連線 (運作中)";
|
| 378 |
}
|
| 379 |
}
|
|
|
|
| 399 |
|
| 400 |
function setStoppedStatus(reason) {
|
| 401 |
if (reason) stopReason = reason;
|
| 402 |
+
statusWrapper.classList.remove("status-running", "status-connecting", "status-idle");
|
| 403 |
statusWrapper.classList.add("status-stopped");
|
| 404 |
const labels = {
|
| 405 |
stopped: "已停止",
|
|
|
|
| 407 |
idle_timeout: "閒置結束",
|
| 408 |
connection_timeout: "連線已離開",
|
| 409 |
error: "錯誤",
|
| 410 |
+
busy: "名額已滿",
|
| 411 |
+
rate_limited: "啟動太頻繁"
|
| 412 |
};
|
| 413 |
statusText.innerText = labels[reason] || "已停止";
|
| 414 |
stopWebcamStream();
|
|
|
|
| 630 |
}
|
| 631 |
|
| 632 |
window.onload = initViewport;
|
| 633 |
+
document.getElementById("btn-back")?.addEventListener("click", () => window.close());
|
| 634 |
+
document.getElementById("btn-restart")?.addEventListener("click", restartProject);
|
| 635 |
+
document.getElementById("btn-stop")?.addEventListener("click", stopProjectElegant);
|
| 636 |
+
document.getElementById("btn-clear-console")?.addEventListener("click", clearConsole);
|
| 637 |
setupActivityTracking();
|
| 638 |
window.onbeforeunload = () => {
|
| 639 |
stopWebcamStream();
|
templates/index.html
CHANGED
|
@@ -27,10 +27,10 @@
|
|
| 27 |
<!-- Top Banner -->
|
| 28 |
<header>
|
| 29 |
<div class="badge-top">
|
| 30 |
-
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="color: #
|
| 31 |
<span>學生創意專案展示場</span>
|
| 32 |
</div>
|
| 33 |
-
<h1>發揮程式創意<br><span class="
|
| 34 |
<p>得能教育學生成果展示場,匯集了學生們的邏輯程式設計、趣味互動遊戲與多元網頁創作成果。</p>
|
| 35 |
</header>
|
| 36 |
|
|
@@ -38,8 +38,8 @@
|
|
| 38 |
{% if not restrict_class and not restrict_student %}
|
| 39 |
<div class="link-generator-panel" id="link-generator">
|
| 40 |
<div class="generator-header">
|
| 41 |
-
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="color: #
|
| 42 |
-
<
|
| 43 |
</div>
|
| 44 |
<div class="generator-body">
|
| 45 |
<div class="select-group">
|
|
@@ -56,7 +56,7 @@
|
|
| 56 |
</div>
|
| 57 |
<div class="result-group">
|
| 58 |
<input type="text" id="gen-url-result" readonly />
|
| 59 |
-
<button id="btn-copy-url"
|
| 60 |
</div>
|
| 61 |
</div>
|
| 62 |
</div>
|
|
@@ -108,7 +108,7 @@
|
|
| 108 |
<p>© 2026 得能教育 Telearn Education. All rights reserved.</p>
|
| 109 |
</footer>
|
| 110 |
|
| 111 |
-
<script>
|
| 112 |
window.TELEARN_INDEX = {
|
| 113 |
restrictClass: {{ restrict_class|tojson }},
|
| 114 |
restrictStudent: {{ restrict_student|tojson }}
|
|
|
|
| 27 |
<!-- Top Banner -->
|
| 28 |
<header>
|
| 29 |
<div class="badge-top">
|
| 30 |
+
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="color: #f3b64a; margin-right: 8px;"><path d="M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .963 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.581a.5.5 0 0 1 0 .964L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.963 0z"/><path d="M20 3v4"/><path d="M22 5h-4"/><path d="M4 17v2"/><path d="M5 18H3"/></svg>
|
| 31 |
<span>學生創意專案展示場</span>
|
| 32 |
</div>
|
| 33 |
+
<h1>發揮程式創意<br><span class="headline-emphasis">展現無限可能</span></h1>
|
| 34 |
<p>得能教育學生成果展示場,匯集了學生們的邏輯程式設計、趣味互動遊戲與多元網頁創作成果。</p>
|
| 35 |
</header>
|
| 36 |
|
|
|
|
| 38 |
{% if not restrict_class and not restrict_student %}
|
| 39 |
<div class="link-generator-panel" id="link-generator">
|
| 40 |
<div class="generator-header">
|
| 41 |
+
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="color: #f3b64a;"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>
|
| 42 |
+
<h2 style="margin-left: 8px;">分享連結產生器</h2>
|
| 43 |
</div>
|
| 44 |
<div class="generator-body">
|
| 45 |
<div class="select-group">
|
|
|
|
| 56 |
</div>
|
| 57 |
<div class="result-group">
|
| 58 |
<input type="text" id="gen-url-result" readonly />
|
| 59 |
+
<button id="btn-copy-url">複製連結</button>
|
| 60 |
</div>
|
| 61 |
</div>
|
| 62 |
</div>
|
|
|
|
| 108 |
<p>© 2026 得能教育 Telearn Education. All rights reserved.</p>
|
| 109 |
</footer>
|
| 110 |
|
| 111 |
+
<script nonce="{{ request.state.csp_nonce }}">
|
| 112 |
window.TELEARN_INDEX = {
|
| 113 |
restrictClass: {{ restrict_class|tojson }},
|
| 114 |
restrictStudent: {{ restrict_student|tojson }}
|
templates/player.html
CHANGED
|
@@ -17,7 +17,7 @@
|
|
| 17 |
<img src="/images/logo-light.png" alt="Telearn Logo" class="logo-img" />
|
| 18 |
<span class="brand-name">Telearn 得能教育</span>
|
| 19 |
</a>
|
| 20 |
-
<button class="btn-back"
|
| 21 |
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><path d="M19 12H5M12 19l-7-7 7-7"/></svg>
|
| 22 |
返回大廳
|
| 23 |
</button>
|
|
@@ -27,7 +27,7 @@
|
|
| 27 |
</div>
|
| 28 |
</div>
|
| 29 |
|
| 30 |
-
<div class="status-wrapper" id="server-status">
|
| 31 |
<div class="status-dot"></div>
|
| 32 |
<span id="status-text">連線中...</span>
|
| 33 |
</div>
|
|
@@ -66,11 +66,11 @@
|
|
| 66 |
</button>
|
| 67 |
|
| 68 |
<div class="action-buttons">
|
| 69 |
-
<button class="btn-action btn-restart"
|
| 70 |
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><path d="M21.5 2v6h-6M21.34 15.57a10 10 0 1 1-.57-8.38l5.67-5.67"/></svg>
|
| 71 |
重設
|
| 72 |
</button>
|
| 73 |
-
<button class="btn-action btn-stop"
|
| 74 |
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/></svg>
|
| 75 |
終止
|
| 76 |
</button>
|
|
@@ -80,7 +80,7 @@
|
|
| 80 |
<div class="terminal-section" id="python-terminal-section">
|
| 81 |
<div class="terminal-header-bar">
|
| 82 |
<h3 class="device-title">終端機日誌</h3>
|
| 83 |
-
<button class="btn-clear"
|
| 84 |
</div>
|
| 85 |
<div class="terminal-box" id="console-logs"></div>
|
| 86 |
<div class="terminal-input-wrapper">
|
|
@@ -94,7 +94,7 @@
|
|
| 94 |
<div style="display: flex; flex-direction: column; gap: 1rem;">
|
| 95 |
<h3 class="device-title">Android APK 專案</h3>
|
| 96 |
<div class="apk-download-card">
|
| 97 |
-
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="#
|
| 98 |
<path d="M12 2v16m0 0l-4-4m4 4l4-4M4 22h16" stroke-linecap="round" stroke-linejoin="round"/>
|
| 99 |
</svg>
|
| 100 |
<div class="apk-info-text">
|
|
@@ -111,7 +111,7 @@
|
|
| 111 |
<div class="qr-section">
|
| 112 |
<h3 class="device-title">掃描二維碼安裝</h3>
|
| 113 |
<div class="qr-code-wrapper">
|
| 114 |
-
<
|
| 115 |
</div>
|
| 116 |
<span class="qr-tip">使用 Android 裝置掃描此條碼,即可直接下載並安裝此應用程式</span>
|
| 117 |
</div>
|
|
@@ -149,7 +149,7 @@
|
|
| 149 |
<video id="webcam-video" autoplay playsinline width="640" height="480"></video>
|
| 150 |
<canvas id="webcam-canvas" width="640" height="480"></canvas>
|
| 151 |
|
| 152 |
-
<script>
|
| 153 |
window.TELEARN_PLAYER = {
|
| 154 |
className: {{ class_name|tojson }},
|
| 155 |
studentName: {{ student_name|tojson }},
|
|
|
|
| 17 |
<img src="/images/logo-light.png" alt="Telearn Logo" class="logo-img" />
|
| 18 |
<span class="brand-name">Telearn 得能教育</span>
|
| 19 |
</a>
|
| 20 |
+
<button class="btn-back" id="btn-back">
|
| 21 |
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><path d="M19 12H5M12 19l-7-7 7-7"/></svg>
|
| 22 |
返回大廳
|
| 23 |
</button>
|
|
|
|
| 27 |
</div>
|
| 28 |
</div>
|
| 29 |
|
| 30 |
+
<div class="status-wrapper status-connecting" id="server-status">
|
| 31 |
<div class="status-dot"></div>
|
| 32 |
<span id="status-text">連線中...</span>
|
| 33 |
</div>
|
|
|
|
| 66 |
</button>
|
| 67 |
|
| 68 |
<div class="action-buttons">
|
| 69 |
+
<button class="btn-action btn-restart" id="btn-restart">
|
| 70 |
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><path d="M21.5 2v6h-6M21.34 15.57a10 10 0 1 1-.57-8.38l5.67-5.67"/></svg>
|
| 71 |
重設
|
| 72 |
</button>
|
| 73 |
+
<button class="btn-action btn-stop" id="btn-stop">
|
| 74 |
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/></svg>
|
| 75 |
終止
|
| 76 |
</button>
|
|
|
|
| 80 |
<div class="terminal-section" id="python-terminal-section">
|
| 81 |
<div class="terminal-header-bar">
|
| 82 |
<h3 class="device-title">終端機日誌</h3>
|
| 83 |
+
<button class="btn-clear" id="btn-clear-console">清除</button>
|
| 84 |
</div>
|
| 85 |
<div class="terminal-box" id="console-logs"></div>
|
| 86 |
<div class="terminal-input-wrapper">
|
|
|
|
| 94 |
<div style="display: flex; flex-direction: column; gap: 1rem;">
|
| 95 |
<h3 class="device-title">Android APK 專案</h3>
|
| 96 |
<div class="apk-download-card">
|
| 97 |
+
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="#ef8068" stroke-width="1.5">
|
| 98 |
<path d="M12 2v16m0 0l-4-4m4 4l4-4M4 22h16" stroke-linecap="round" stroke-linejoin="round"/>
|
| 99 |
</svg>
|
| 100 |
<div class="apk-info-text">
|
|
|
|
| 111 |
<div class="qr-section">
|
| 112 |
<h3 class="device-title">掃描二維碼安裝</h3>
|
| 113 |
<div class="qr-code-wrapper">
|
| 114 |
+
<div id="apk-qr-image"></div>
|
| 115 |
</div>
|
| 116 |
<span class="qr-tip">使用 Android 裝置掃描此條碼,即可直接下載並安裝此應用程式</span>
|
| 117 |
</div>
|
|
|
|
| 149 |
<video id="webcam-video" autoplay playsinline width="640" height="480"></video>
|
| 150 |
<canvas id="webcam-canvas" width="640" height="480"></canvas>
|
| 151 |
|
| 152 |
+
<script nonce="{{ request.state.csp_nonce }}">
|
| 153 |
window.TELEARN_PLAYER = {
|
| 154 |
className: {{ class_name|tojson }},
|
| 155 |
studentName: {{ student_name|tojson }},
|
tests/test_classroom.py
CHANGED
|
@@ -16,7 +16,11 @@ class ClassroomServiceTests(unittest.TestCase):
|
|
| 16 |
classroom.RUNTIME = Path(self.temp_dir.name)
|
| 17 |
classroom.DB = classroom.RUNTIME / "classroom.sqlite3"
|
| 18 |
classroom.live.clear()
|
|
|
|
|
|
|
| 19 |
classroom.init_db()
|
|
|
|
|
|
|
| 20 |
|
| 21 |
def tearDown(self):
|
| 22 |
self.temp_dir.cleanup()
|
|
@@ -71,12 +75,13 @@ class ClassroomServiceTests(unittest.TestCase):
|
|
| 71 |
with zipfile.ZipFile(io.BytesIO(classroom.make_zip("L6A", "week02"))) as archive:
|
| 72 |
self.assertEqual(archive.namelist(), [])
|
| 73 |
|
| 74 |
-
def
|
| 75 |
classroom.init_db()
|
| 76 |
loaded = classroom.load("L6Sat", "李合盛", "李合盛", "week02")
|
| 77 |
|
| 78 |
-
|
| 79 |
-
self.
|
|
|
|
| 80 |
matching = [
|
| 81 |
item
|
| 82 |
for item in classroom.version_history("L6Sat", "李合盛")
|
|
@@ -84,6 +89,7 @@ class ClassroomServiceTests(unittest.TestCase):
|
|
| 84 |
and item["submitted_at"] == loaded["checkpoint_at"]
|
| 85 |
]
|
| 86 |
self.assertEqual(len(matching), 1)
|
|
|
|
| 87 |
|
| 88 |
def test_progress_picker_lists_every_checkpoint_and_submission_newest_first(self):
|
| 89 |
state = classroom.empty("L6A", "student-1", "Alex", "week02")
|
|
@@ -173,6 +179,119 @@ class ClassroomServiceTests(unittest.TestCase):
|
|
| 173 |
self.assertEqual(classroom.snapshot("L6A", "week01"), [])
|
| 174 |
self.assertEqual(classroom.snapshot("L6A", "week02"), [])
|
| 175 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 176 |
def test_course_is_available_even_when_repo_persistence_is_unavailable(self):
|
| 177 |
old_password, old_token = classroom.PASSWORD, classroom.TOKEN
|
| 178 |
try:
|
|
@@ -240,7 +359,7 @@ class ClassroomServiceTests(unittest.TestCase):
|
|
| 240 |
course = next(item for item in classroom.catalog()["courses"] if item["class_code"] == "L6Sat")
|
| 241 |
submissions = [item for item in classroom.submission_catalog() if item["class_code"] == "L6Sat"]
|
| 242 |
self.assertEqual(len(course["students"]), 8)
|
| 243 |
-
self.assertEqual(len(submissions),
|
| 244 |
restored = classroom.get_submission("L6Sat", "胡惟媗", "week02")
|
| 245 |
self.assertEqual(restored["project_name"], "校園謎案調查局")
|
| 246 |
self.assertEqual(set(restored["final_files"]), {"main.py", "case_data.json"})
|
|
@@ -276,6 +395,69 @@ class ClassroomServiceTests(unittest.TestCase):
|
|
| 276 |
self.assertEqual(progress[0]["source"], "published")
|
| 277 |
self.assertIn("已發布", progress[0]["files"]["main.py"])
|
| 278 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 279 |
|
| 280 |
if __name__ == "__main__":
|
| 281 |
unittest.main()
|
|
|
|
| 16 |
classroom.RUNTIME = Path(self.temp_dir.name)
|
| 17 |
classroom.DB = classroom.RUNTIME / "classroom.sqlite3"
|
| 18 |
classroom.live.clear()
|
| 19 |
+
classroom._student_connection_windows.clear()
|
| 20 |
+
classroom._student_message_windows.clear()
|
| 21 |
classroom.init_db()
|
| 22 |
+
with classroom.lock, classroom.sqlite3.connect(classroom.DB) as db:
|
| 23 |
+
db.execute("INSERT OR IGNORE INTO classroom_courses VALUES(?,?,?)", ("L6A", "2026 Fall 進階專題", 0))
|
| 24 |
|
| 25 |
def tearDown(self):
|
| 26 |
self.temp_dir.cleanup()
|
|
|
|
| 75 |
with zipfile.ZipFile(io.BytesIO(classroom.make_zip("L6A", "week02"))) as archive:
|
| 76 |
self.assertEqual(archive.namelist(), [])
|
| 77 |
|
| 78 |
+
def test_bootstrap_preserves_checkpoint_separately_from_later_repair(self):
|
| 79 |
classroom.init_db()
|
| 80 |
loaded = classroom.load("L6Sat", "李合盛", "李合盛", "week02")
|
| 81 |
|
| 82 |
+
repaired = (classroom.REPAIRS / "L6Sat" / "week02" / "李合盛.py").read_text(encoding="utf-8")
|
| 83 |
+
self.assertEqual(loaded["files"]["main.py"], repaired)
|
| 84 |
+
self.assertNotEqual(loaded["files"], loaded["checkpoint_files"])
|
| 85 |
matching = [
|
| 86 |
item
|
| 87 |
for item in classroom.version_history("L6Sat", "李合盛")
|
|
|
|
| 89 |
and item["submitted_at"] == loaded["checkpoint_at"]
|
| 90 |
]
|
| 91 |
self.assertEqual(len(matching), 1)
|
| 92 |
+
self.assertEqual(matching[0]["files"], loaded["checkpoint_files"])
|
| 93 |
|
| 94 |
def test_progress_picker_lists_every_checkpoint_and_submission_newest_first(self):
|
| 95 |
state = classroom.empty("L6A", "student-1", "Alex", "week02")
|
|
|
|
| 179 |
self.assertEqual(classroom.snapshot("L6A", "week01"), [])
|
| 180 |
self.assertEqual(classroom.snapshot("L6A", "week02"), [])
|
| 181 |
|
| 182 |
+
def test_teacher_can_rename_student_without_changing_identity_or_work(self):
|
| 183 |
+
old_password, old_token = classroom.PASSWORD, classroom.TOKEN
|
| 184 |
+
try:
|
| 185 |
+
classroom.PASSWORD = "teacher-test"
|
| 186 |
+
classroom.TOKEN = ""
|
| 187 |
+
state = classroom.empty("L6A", "student-1", "舊姓名", "week02")
|
| 188 |
+
state["project_name"] = "校園謎案"
|
| 189 |
+
state["files"] = {"main.py": 'print("目前進度")\n'}
|
| 190 |
+
state["checkpoint_files"] = dict(state["files"])
|
| 191 |
+
state["checkpoint_at"] = 100
|
| 192 |
+
state["final_files"] = {"main.py": 'print("正式提交")\n'}
|
| 193 |
+
state["submitted_at"] = 200
|
| 194 |
+
classroom.record_version(state, "checkpoint")
|
| 195 |
+
classroom.record_version(state, "submit")
|
| 196 |
+
classroom.save(state)
|
| 197 |
+
|
| 198 |
+
request = classroom.StudentUpdate(
|
| 199 |
+
class_code="L6A", student_id="student-1",
|
| 200 |
+
display_name="新姓名", password="teacher-test",
|
| 201 |
+
)
|
| 202 |
+
result = asyncio.run(classroom.update_student(request))
|
| 203 |
+
self.assertTrue(result["ok"])
|
| 204 |
+
self.assertEqual(result["student_id"], "student-1")
|
| 205 |
+
|
| 206 |
+
member = classroom.catalog()["courses"]
|
| 207 |
+
course = next(item for item in member if item["class_code"] == "L6A")
|
| 208 |
+
self.assertEqual(course["students"], [{"student_id": "student-1", "display_name": "新姓名"}])
|
| 209 |
+
restored = classroom.load("L6A", "student-1", "新姓名", "week02")
|
| 210 |
+
self.assertEqual(restored["files"], state["files"])
|
| 211 |
+
self.assertEqual(restored["final_files"], state["final_files"])
|
| 212 |
+
submitted = next(item for item in classroom.submission_catalog() if item["class_code"] == "L6A")
|
| 213 |
+
self.assertEqual(submitted["display_name"], "新姓名")
|
| 214 |
+
with classroom.sqlite3.connect(classroom.DB) as db:
|
| 215 |
+
names = db.execute(
|
| 216 |
+
"SELECT DISTINCT display_name FROM classroom_versions WHERE class_code=? AND student_id=?",
|
| 217 |
+
("L6A", "student-1"),
|
| 218 |
+
).fetchall()
|
| 219 |
+
self.assertEqual(names, [("新姓名",)])
|
| 220 |
+
classroom.init_db()
|
| 221 |
+
submitted = next(item for item in classroom.submission_catalog() if item["class_code"] == "L6A")
|
| 222 |
+
self.assertEqual(submitted["display_name"], "新姓名")
|
| 223 |
+
finally:
|
| 224 |
+
classroom.PASSWORD, classroom.TOKEN = old_password, old_token
|
| 225 |
+
|
| 226 |
+
def test_teacher_cannot_rename_student_to_an_existing_name(self):
|
| 227 |
+
old_password, old_token = classroom.PASSWORD, classroom.TOKEN
|
| 228 |
+
try:
|
| 229 |
+
classroom.PASSWORD = "teacher-test"
|
| 230 |
+
classroom.TOKEN = ""
|
| 231 |
+
classroom.save(classroom.empty("L6A", "student-1", "Alex", "week02"))
|
| 232 |
+
classroom.save(classroom.empty("L6A", "student-2", "Sam", "week02"))
|
| 233 |
+
request = classroom.StudentUpdate(
|
| 234 |
+
class_code="L6A", student_id="student-1",
|
| 235 |
+
display_name=" sam ", password="teacher-test",
|
| 236 |
+
)
|
| 237 |
+
with self.assertRaises(classroom.HTTPException) as error:
|
| 238 |
+
asyncio.run(classroom.update_student(request))
|
| 239 |
+
self.assertEqual(error.exception.status_code, 409)
|
| 240 |
+
self.assertEqual(
|
| 241 |
+
classroom.find_member("L6A", "Alex", "student-1")["display_name"],
|
| 242 |
+
"Alex",
|
| 243 |
+
)
|
| 244 |
+
finally:
|
| 245 |
+
classroom.PASSWORD, classroom.TOKEN = old_password, old_token
|
| 246 |
+
|
| 247 |
+
def test_current_roster_name_wins_over_older_snapshot_names_on_startup(self):
|
| 248 |
+
old_paths = classroom.DATA, classroom.TEMPLATES, classroom.CHECKPOINTS, classroom.VERSIONS, classroom.REPAIRS
|
| 249 |
+
data = Path(self.temp_dir.name) / "seeded-classroom-data"
|
| 250 |
+
submission_root = data / "submissions" / "C1" / "student-1"
|
| 251 |
+
version_root = data / "versions" / "C1" / "student-1" / "week02"
|
| 252 |
+
submission_root.mkdir(parents=True)
|
| 253 |
+
version_root.mkdir(parents=True)
|
| 254 |
+
(data / "classes.json").write_text(json.dumps([{
|
| 255 |
+
"code": "C1", "name": "測試班",
|
| 256 |
+
"students": [{"student_id": "student-1", "display_name": "新姓名"}],
|
| 257 |
+
}], ensure_ascii=False), encoding="utf-8")
|
| 258 |
+
files = {"main.py": 'print("保留作品")\n'}
|
| 259 |
+
old_submission = {
|
| 260 |
+
"class_code": "C1", "student_id": "student-1", "display_name": "舊姓名",
|
| 261 |
+
"week_id": "week02", "project_name": "校園謎案", "submitted_at": 100, "files": files,
|
| 262 |
+
}
|
| 263 |
+
old_version = {
|
| 264 |
+
"class_code": "C1", "student_id": "student-1", "display_name": "舊姓名",
|
| 265 |
+
"week_id": "week02", "version_id": "100-submit-one", "kind": "submit",
|
| 266 |
+
"project_name": "校園謎案", "created_at": 100, "files": files,
|
| 267 |
+
}
|
| 268 |
+
(submission_root / "week02.json").write_text(json.dumps(old_submission, ensure_ascii=False), encoding="utf-8")
|
| 269 |
+
(version_root / "100-submit-one.json").write_text(json.dumps(old_version, ensure_ascii=False), encoding="utf-8")
|
| 270 |
+
try:
|
| 271 |
+
classroom.DATA = data
|
| 272 |
+
classroom.TEMPLATES = data / "templates"
|
| 273 |
+
classroom.CHECKPOINTS = data / "checkpoints"
|
| 274 |
+
classroom.VERSIONS = data / "versions"
|
| 275 |
+
classroom.REPAIRS = data / "repairs"
|
| 276 |
+
classroom.init_db()
|
| 277 |
+
|
| 278 |
+
course = next(item for item in classroom.catalog()["courses"] if item["class_code"] == "C1")
|
| 279 |
+
self.assertEqual(course["students"], [{"student_id": "student-1", "display_name": "新姓名"}])
|
| 280 |
+
current_submission = next(
|
| 281 |
+
item for item in classroom.submission_catalog()
|
| 282 |
+
if item["class_code"] == "C1"
|
| 283 |
+
)
|
| 284 |
+
self.assertEqual(current_submission["display_name"], "新姓名")
|
| 285 |
+
with classroom.sqlite3.connect(classroom.DB) as db:
|
| 286 |
+
version_name = db.execute(
|
| 287 |
+
"SELECT display_name FROM classroom_versions WHERE class_code='C1' AND student_id='student-1'"
|
| 288 |
+
).fetchone()[0]
|
| 289 |
+
self.assertEqual(version_name, "新姓名")
|
| 290 |
+
restored = classroom.load("C1", "student-1", "新姓名", "week02")
|
| 291 |
+
self.assertEqual(restored["final_files"], files)
|
| 292 |
+
finally:
|
| 293 |
+
classroom.DATA, classroom.TEMPLATES, classroom.CHECKPOINTS, classroom.VERSIONS, classroom.REPAIRS = old_paths
|
| 294 |
+
|
| 295 |
def test_course_is_available_even_when_repo_persistence_is_unavailable(self):
|
| 296 |
old_password, old_token = classroom.PASSWORD, classroom.TOKEN
|
| 297 |
try:
|
|
|
|
| 359 |
course = next(item for item in classroom.catalog()["courses"] if item["class_code"] == "L6Sat")
|
| 360 |
submissions = [item for item in classroom.submission_catalog() if item["class_code"] == "L6Sat"]
|
| 361 |
self.assertEqual(len(course["students"]), 8)
|
| 362 |
+
self.assertEqual(len(submissions), 8)
|
| 363 |
restored = classroom.get_submission("L6Sat", "胡惟媗", "week02")
|
| 364 |
self.assertEqual(restored["project_name"], "校園謎案調查局")
|
| 365 |
self.assertEqual(set(restored["final_files"]), {"main.py", "case_data.json"})
|
|
|
|
| 395 |
self.assertEqual(progress[0]["source"], "published")
|
| 396 |
self.assertIn("已發布", progress[0]["files"]["main.py"])
|
| 397 |
|
| 398 |
+
def test_version_write_rate_limit_is_persisted_per_student_and_lesson(self):
|
| 399 |
+
old_limit, old_cooldown = classroom.CHECKPOINTS_PER_HOUR, classroom.VERSION_WRITE_COOLDOWN_MS
|
| 400 |
+
try:
|
| 401 |
+
classroom.CHECKPOINTS_PER_HOUR = 1
|
| 402 |
+
classroom.VERSION_WRITE_COOLDOWN_MS = 0
|
| 403 |
+
classroom.consume_version_write("L6A", "student-1", "week02", "checkpoint")
|
| 404 |
+
with self.assertRaises(classroom.VersionLimitError):
|
| 405 |
+
classroom.consume_version_write("L6A", "student-1", "week02", "checkpoint")
|
| 406 |
+
classroom.consume_version_write("L6A", "student-2", "week02", "checkpoint")
|
| 407 |
+
finally:
|
| 408 |
+
classroom.CHECKPOINTS_PER_HOUR, classroom.VERSION_WRITE_COOLDOWN_MS = old_limit, old_cooldown
|
| 409 |
+
|
| 410 |
+
def test_version_retention_prunes_only_oldest_versions_of_same_kind(self):
|
| 411 |
+
old_limit = classroom.MAX_VERSIONS_PER_KIND
|
| 412 |
+
try:
|
| 413 |
+
classroom.MAX_VERSIONS_PER_KIND = 2
|
| 414 |
+
state = classroom.empty("L6A", "student-1", "Alex", "week02")
|
| 415 |
+
for stamp in (100, 200, 300):
|
| 416 |
+
state["files"] = {"main.py": f"print({stamp})\n"}
|
| 417 |
+
state["checkpoint_files"] = dict(state["files"])
|
| 418 |
+
state["checkpoint_at"] = stamp
|
| 419 |
+
classroom.record_version(state, "checkpoint")
|
| 420 |
+
state["final_files"] = {"main.py": "print('submission')\n"}
|
| 421 |
+
state["submitted_at"] = 400
|
| 422 |
+
classroom.record_version(state, "submit")
|
| 423 |
+
finally:
|
| 424 |
+
classroom.MAX_VERSIONS_PER_KIND = old_limit
|
| 425 |
+
|
| 426 |
+
history = classroom.version_history("L6A", "student-1")
|
| 427 |
+
checkpoints = [item for item in history if item["source"] == "checkpoint"]
|
| 428 |
+
submissions = [item for item in history if item["source"] == "submit"]
|
| 429 |
+
self.assertEqual([item["submitted_at"] for item in checkpoints], [300, 200])
|
| 430 |
+
self.assertEqual(len(submissions), 1)
|
| 431 |
+
|
| 432 |
+
def test_course_quota_rejects_before_inserting_version(self):
|
| 433 |
+
old_quota = classroom.COURSE_STORAGE_QUOTA_BYTES
|
| 434 |
+
try:
|
| 435 |
+
classroom.COURSE_STORAGE_QUOTA_BYTES = 1
|
| 436 |
+
state = classroom.empty("L6A", "student-1", "Alex", "week02")
|
| 437 |
+
state["files"] = {"main.py": "print('too large')\n"}
|
| 438 |
+
state["checkpoint_files"] = dict(state["files"])
|
| 439 |
+
state["checkpoint_at"] = 123
|
| 440 |
+
with self.assertRaises(classroom.VersionLimitError):
|
| 441 |
+
classroom.record_version(state, "checkpoint")
|
| 442 |
+
finally:
|
| 443 |
+
classroom.COURSE_STORAGE_QUOTA_BYTES = old_quota
|
| 444 |
+
self.assertEqual(classroom.version_history("L6A", "student-1"), [])
|
| 445 |
+
|
| 446 |
+
def test_websocket_connection_and_message_budgets_are_shared_across_reconnects(self):
|
| 447 |
+
old_connections, old_messages = classroom.STUDENT_WS_CONNECTIONS_PER_MINUTE, classroom.STUDENT_WS_MESSAGES_PER_MINUTE
|
| 448 |
+
try:
|
| 449 |
+
classroom.STUDENT_WS_CONNECTIONS_PER_MINUTE = 2
|
| 450 |
+
classroom.STUDENT_WS_MESSAGES_PER_MINUTE = 2
|
| 451 |
+
self.assertTrue(classroom.allow_student_connection("198.51.100.7"))
|
| 452 |
+
self.assertTrue(classroom.allow_student_connection("198.51.100.7"))
|
| 453 |
+
self.assertFalse(classroom.allow_student_connection("198.51.100.7"))
|
| 454 |
+
key = ("L6A", "student-1", "week02")
|
| 455 |
+
self.assertTrue(classroom.allow_student_message(key))
|
| 456 |
+
self.assertTrue(classroom.allow_student_message(key))
|
| 457 |
+
self.assertFalse(classroom.allow_student_message(key))
|
| 458 |
+
finally:
|
| 459 |
+
classroom.STUDENT_WS_CONNECTIONS_PER_MINUTE, classroom.STUDENT_WS_MESSAGES_PER_MINUTE = old_connections, old_messages
|
| 460 |
+
|
| 461 |
|
| 462 |
if __name__ == "__main__":
|
| 463 |
unittest.main()
|
tests/test_security.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import tempfile
|
| 2 |
+
import unittest
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
from unittest.mock import patch
|
| 5 |
+
|
| 6 |
+
import server
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
class ServerSecurityTests(unittest.TestCase):
|
| 10 |
+
def setUp(self):
|
| 11 |
+
server._runner_start_windows.clear()
|
| 12 |
+
|
| 13 |
+
def test_only_absolute_https_links_are_exposed(self):
|
| 14 |
+
self.assertEqual(server._safe_external_url("https://arcade.makecode.com/_abc"), "https://arcade.makecode.com/_abc")
|
| 15 |
+
self.assertEqual(server._safe_external_url("javascript:alert(1)"), "")
|
| 16 |
+
self.assertEqual(server._safe_external_url("https://user:pass@example.com/"), "")
|
| 17 |
+
self.assertEqual(server._safe_external_url("http://example.com/"), "")
|
| 18 |
+
|
| 19 |
+
def test_public_runner_starts_are_limited_per_ip(self):
|
| 20 |
+
old_limit, old_cooldown = server.RUNNER_STARTS_PER_IP_PER_HOUR, server.RUNNER_START_COOLDOWN_SEC
|
| 21 |
+
try:
|
| 22 |
+
server.RUNNER_STARTS_PER_IP_PER_HOUR = 2
|
| 23 |
+
server.RUNNER_START_COOLDOWN_SEC = 0.5
|
| 24 |
+
with patch("server.time.monotonic", side_effect=[100, 101, 102, 103]):
|
| 25 |
+
self.assertTrue(server.allow_runner_start("198.51.100.8"))
|
| 26 |
+
self.assertTrue(server.allow_runner_start("198.51.100.8"))
|
| 27 |
+
self.assertFalse(server.allow_runner_start("198.51.100.8"))
|
| 28 |
+
self.assertTrue(server.allow_runner_start("198.51.100.9"))
|
| 29 |
+
finally:
|
| 30 |
+
server.RUNNER_STARTS_PER_IP_PER_HOUR = old_limit
|
| 31 |
+
server.RUNNER_START_COOLDOWN_SEC = old_cooldown
|
| 32 |
+
|
| 33 |
+
def test_runner_staging_skips_credentials_but_keeps_unrelated_named_files(self):
|
| 34 |
+
with tempfile.TemporaryDirectory() as source_temp, tempfile.TemporaryDirectory() as scratch_temp:
|
| 35 |
+
source = Path(source_temp)
|
| 36 |
+
(source / "main.py").write_text("print('ok')\n", encoding="utf-8")
|
| 37 |
+
(source / ".env").write_text("HF_TOKEN=should-not-copy\n", encoding="utf-8")
|
| 38 |
+
(source / "credentials.json").write_text("{}", encoding="utf-8")
|
| 39 |
+
(source / "secret_notes.py").write_text("NOTE = 'ordinary project file'\n", encoding="utf-8")
|
| 40 |
+
with patch.object(server, "RUNNER_TMP_DIR", scratch_temp):
|
| 41 |
+
session_root, workspace = server._stage_runner_project(str(source))
|
| 42 |
+
try:
|
| 43 |
+
self.assertTrue((Path(workspace) / "main.py").is_file())
|
| 44 |
+
self.assertTrue((Path(workspace) / "secret_notes.py").is_file())
|
| 45 |
+
self.assertFalse((Path(workspace) / ".env").exists())
|
| 46 |
+
self.assertFalse((Path(workspace) / "credentials.json").exists())
|
| 47 |
+
finally:
|
| 48 |
+
import shutil
|
| 49 |
+
shutil.rmtree(session_root, ignore_errors=True)
|
| 50 |
+
|
| 51 |
+
def test_sandbox_refuses_non_linux_execution(self):
|
| 52 |
+
with patch.object(server.sys, "platform", "darwin"):
|
| 53 |
+
with self.assertRaisesRegex(RuntimeError, "拒絕未隔離執行"):
|
| 54 |
+
server._runner_sandbox_command("/tmp/project", "/tmp/session/runner.sock")
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
if __name__ == "__main__":
|
| 58 |
+
unittest.main()
|