steveyehtw commited on
Commit
7547388
·
1 Parent(s): 773dbc3

Harden demo runtime and add browser Pygame runner

Browse files
.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.txt
30
 
31
  COPY . .
 
 
 
 
 
 
 
32
 
33
- RUN mkdir -p /app/projects /app/models /app/static \
34
- && chmod -R 777 /app
 
 
 
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
- CMD uvicorn server:app --host 0.0.0.0 --port $PORT
 
 
 
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
- with lock,sqlite3.connect(DB) as db: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"],json.dumps(record["files"],ensure_ascii=False),created_at))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- content=(json.dumps(record,ensure_ascii=False,indent=2)+"\n").encode()
 
 
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
- with persist_lock:HfApi(token=TOKEN).create_commit(repo_id=REPO,repo_type="space",operations=[CommitOperationAdd(path_in_repo=path,path_or_fileobj=content)],commit_message=f"{label} {record['class_code']} {record['student_id']} {record['week_id']}")
 
 
 
 
 
 
 
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();kind=m.get("type");version=None
 
 
 
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
- 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")
378
- elif kind=="submit":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")
 
 
 
 
 
 
 
 
 
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-7">
8
- <link rel="stylesheet" href="/classroom-assets/admin.css?v=2026fall-6">
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-7"></script>
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 => `<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="remove-file" data-remove-student="${esc(member.student_id)}" aria-label="刪除 ${esc(member.display_name)}">×</button></div>`).join("") : '<div class="admin-empty">這個班級尚無學生。</div>';
 
 
 
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,width 160ms ease; }
160
- .panel-resizer:hover::after,.panel-resizer:focus-visible::after,.panel-resizer.dragging::after { width:2px;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,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, .panel-heading h2 { margin: 0; font-size: 1rem; }
 
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-stream { min-height:0;display:grid;grid-template-rows:minmax(0,1fr) auto;background:var(--editor); }
 
 
 
 
 
 
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
- .project-bar{align-items:stretch;flex-direction:column;padding:var(--space-md)}.project-actions{overflow-x:auto;flex-wrap:nowrap}.project-actions>*{flex:none}
 
 
 
 
353
  .editor-toggle { display:none; }
354
  .panel-resizer { display:none; }
355
- .student-layout { display:block;min-height:0; }
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
- .brand-copy span, .connection-state span { display: none; }
366
- .app-header { padding-inline: var(--space-md); }
367
- .btn { padding-inline: var(--space-md); }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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("https://cdn.jsdelivr.net/pyodide/v0.27.7/full/pyodide.js");
8
- pyodide = await loadPyodide({ indexURL: "https://cdn.jsdelivr.net/pyodide/v0.27.7/full/" });
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><html lang="zh-Hant"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="color-scheme" content="light"><title>Python 專案教室|學生工作區</title><link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/codemirror.min.css"><link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/theme/material-darker.min.css"><link rel="stylesheet" href="/classroom-assets/classroom.css?v=2026fall-9"></head><body>
2
- <header class="app-header"><div class="brand"><div class="brand-mark">Py</div><div class="brand-copy"><strong>Python 專案教室</strong><span id="studentIdentity">尚未進入教室</span></div></div><div class="header-actions"><div class="connection-state"><i class="status-dot" id="connectionDot"></i><span id="connectionText">未連線</span></div><button class="btn help compact" id="helpButton">請老師協助</button><button class="btn secondary compact" id="identityButton">更換身分</button></div></header>
3
- <section class="project-bar"><div class="project-context"><label for="weekSelect">Lesson</label><select id="weekSelect"></select><label for="projectName">專案</label><input id="projectName" maxlength="36"></div><div class="project-actions"><button class="copy-button" id="templateButton">匯入專案起始套版</button><button class="copy-button" id="progressButton">選擇舊進度</button><button class="copy-button" id="importButton">匯入 .py / ZIP</button><button class="copy-button" id="exportButton">匯出專案</button><button class="copy-button checkpoint-action" id="checkpointButton">暫存本週進度</button><button class="btn compact" id="submitButton">提交正式版本</button><input id="fileImport" type="file" accept=".py,.zip" hidden></div></section>
4
- <nav class="mobile-tabs" role="tablist"><button class="mobile-tab" data-panel="referencePanel">專案/語法</button><button class="mobile-tab" data-panel="editorPanel" aria-selected="true">程式</button><button class="mobile-tab" data-panel="consolePanel">執行結果</button></nav>
5
- <main class="student-layout" id="studentLayout"><aside class="panel reference-panel" id="referencePanel"><div class="side-tabs"><button class="side-tab active" data-side="filesView">專案檔案</button><button class="side-tab" data-side="syntaxView">語法查詢</button></div><section class="side-view" id="filesView"><div class="panel-heading"><h1>檔案夾</h1><button class="copy-button" id="newFileButton">+新增</button></div><div class="file-list" id="fileList"></div><p class="side-note">入口固定為 <code>main.py</code>,可用 <code>import</code> 引入其他 Python 檔。</p></section><section class="side-view" id="syntaxView" hidden><div class="panel-heading"><h1>Python 語法查詢</h1><span id="snippetCount">0 項</span></div><div class="search-wrap"><label class="field-label" for="syntaxSearch">輸入語法或用途</label><input class="text-field" id="syntaxSearch" type="search"><div class="chip-row" id="categoryFilters"></div></div><div class="snippet-list" id="snippetList"></div></section></aside>
6
- <div class="panel-resizer reference-resizer" id="referenceResizer" role="separator" aria-label="調整專案側欄寬度" aria-orientation="vertical" tabindex="0" title="拖曳調整寬度;雙擊恢復預設"></div>
7
- <section class="panel editor-panel mobile-active" id="editorPanel"><div class="panel-heading dark"><span class="file-name" id="activeFileLabel">main.py</span><div class="editor-toolbar"><button class="editor-toggle" id="toggleReferenceButton" aria-pressed="false">隱藏側欄</button><button class="editor-toggle" id="toggleConsoleButton" aria-pressed="false">隱藏 Console</button><button class="btn stop compact" id="stopButton" disabled>停止</button><button class="btn compact" id="runButton" disabled>載入 Python…</button></div></div><div class="editor-host"><textarea id="codeEditor" aria-label="程式碼"></textarea></div></section>
8
- <div class="panel-resizer console-resizer" id="consoleResizer" role="separator" aria-label="調整 Console 寬度" aria-orientation="vertical" tabindex="0" title="拖曳調整寬度;雙擊恢復預設"></div>
9
- <aside class="panel console-panel" id="consolePanel"><div class="panel-heading"><h2>互動式 Console</h2><button class="copy-button" id="clearOutputButton">清除</button></div><div class="console-body"><div class="console-stream"><pre class="console-output" id="consoleOutput">Python 執行環境載入中…</pre><form class="console-input-row" id="consoleInputForm" hidden><span aria-hidden="true">›</span><label class="sr-only" for="consoleInput">Python 程式要求輸入</label><input id="consoleInput" autocomplete="off"><button class="btn compact" type="submit">送出</button></form></div><p class="console-hint">執行 <code>main.py</code>;遇到 <code>input()</code> 時會在這裡詢問。</p><div class="console-status"><i class="status-dot" id="runStatusDot"></i><span id="runStatusText">準備環境</span><span>·</span><span id="saveStatus">尚未同步</span><span>·</span><span id="submitStatus">尚未暫存或提交</span></div></div></aside></main>
10
- <dialog id="identityDialog"><form class="dialog-content form-stack" id="identityForm"><div><h1>進入程式教室</h1><p>選擇班級並輸入自己在名單上的姓名。</p></div><div><label class="field-label" for="classCode">課程</label><select class="text-field" id="classCode" required></select></div><div><label class="field-label" for="displayName">姓名</label><input class="text-field" id="displayName" autocomplete="name" required><p class="field-help">姓名必須與老師建立的名單一致。</p></div><p class="form-error" id="identityError" role="alert"></p><button class="btn" type="submit">進入教室</button></form></dialog>
11
- <dialog id="progressDialog"><div class="dialog-content"><h2>選擇已儲存版本</h2><p>每次暫存與正式提交都會保留;載入只會建立工作副本,不會刪除其他版本。</p><div class="progress-list" id="progressList"></div><button class="btn secondary dialog-close">關閉</button></div></dialog><div class="toast" id="toast"></div>
12
- <script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/codemirror.min.js"></script><script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/mode/python/python.min.js"></script><script src="https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js"></script><script src="/classroom-assets/student.js?v=2026fall-11"></script></body></html>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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(["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(m.checkpoint_at)checkpointAt=m.checkpoint_at;if(m.submitted_at)submittedAt=m.submitted_at;renderState();if(m.type==="ack")E.saveStatus.textContent="已同步";if(m.version_warning)toast(m.version_warning)}
 
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 localhost TCP, monkeypatches display/input
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("--port", type=int, required=True, help="Internal TCP communication port")
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
- # ----------------- TCP threads -----------------
105
  def reader_thread_func(sock):
106
  global shared_webcam_frame, shared_keys, shared_mouse_pos, shared_mouse_buttons
107
- print("[Runner-TCP] Reader thread started.")
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-TCP] Reader error: {e}", file=sys.__stderr__)
217
  finally:
218
- print("[Runner-TCP] Reader thread finished.", file=sys.__stderr__)
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-TCP] Writer thread started.")
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-TCP] Writer error: {e}", file=sys.__stderr__)
242
  finally:
243
- print("[Runner-TCP] Writer thread finished.", file=sys.__stderr__)
244
  try:
245
  sock.close()
246
  except Exception:
247
  pass
248
 
249
 
250
- sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
251
  try:
252
- sock.connect(("127.0.0.1", args.port))
253
  except Exception as e:
254
- print(f"Error connecting to server on port {args.port}: {e}")
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 StreamToTCP:
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 = StreamToTCP(sys.stdout)
310
- sys.stderr = StreamToTCP(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
- """Enable SharedArrayBuffer for interactive input inside the Python worker."""
 
 
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
- appetize_key = f.read().strip()
 
 
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().strip()
 
 
476
  except OSError:
477
  pass
478
 
@@ -571,10 +787,9 @@ async def play_project_ws_internal(
571
  await websocket.close()
572
  return
573
 
574
- project_path = os.path.join(
575
- PROJECTS_DIR, real_class, real_student, project_name, "main.py"
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
- tcp_server = None
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
- tcp_server = await asyncio.start_server(handle_runner_conn, "127.0.0.1", 0)
638
- tcp_port = tcp_server.sockets[0].getsockname()[1]
639
-
640
- runner_script = os.path.join(BASE_DIR, "runner.py")
641
- cmd = [
642
- sys.executable,
643
- runner_script,
644
- "--project_path",
645
- project_path,
646
- "--port",
647
- str(tcp_port),
648
- "--models_dir",
649
- MODELS_DIR,
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 TCP", process.pid)
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": "伺服器錯誤: 執行模擬器連線超時!\n"}
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 tcp_server is not None:
882
- tcp_server.close()
883
- await tcp_server.wait_closed()
 
 
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: #6366f1;
7
- --secondary: #0ea5e9;
8
- --accent: #a855f7;
9
  --text-main: #f9fafb;
10
  --text-muted: #9ca3af;
11
  }
@@ -76,7 +76,7 @@
76
  }
77
 
78
  .brand-logo:hover .brand-name {
79
- color: #818cf8;
80
  }
81
 
82
  .nav-links {
@@ -120,7 +120,7 @@
120
  }
121
 
122
  .badge-top span {
123
- color: #a5b4fc;
124
  font-weight: 600;
125
  font-size: 0.75rem;
126
  letter-spacing: 1px;
@@ -142,10 +142,8 @@
142
  }
143
  }
144
 
145
- header h1 span.gradient-text {
146
- background: linear-gradient(135deg, #6366f1 30%, #a855f7 90%);
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 h3 {
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: #ffffff;
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(99, 102, 241, 0.05);
391
- border-color: rgba(99, 102, 241, 0.15);
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(168, 85, 247, 0.05);
399
- border-color: rgba(168, 85, 247, 0.15);
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(99, 102, 241, 0.15);
425
  }
426
 
427
  .project-card.hover-scratch:hover {
@@ -430,8 +426,8 @@
430
  }
431
 
432
  .project-card.hover-makecode:hover {
433
- border-left-color: #0ea5e9;
434
- border-color: rgba(14, 165, 233, 0.15);
435
  }
436
 
437
  .project-card.hover-html:hover {
@@ -440,8 +436,8 @@
440
  }
441
 
442
  .project-card.hover-apk:hover {
443
- border-left-color: #a855f7;
444
- border-color: rgba(168, 85, 247, 0.15);
445
  }
446
 
447
  .project-meta {
@@ -469,9 +465,9 @@
469
  }
470
 
471
  .tag-python {
472
- background: rgba(99, 102, 241, 0.08);
473
- color: #a5b4fc;
474
- border: 1px solid rgba(99, 102, 241, 0.15);
475
  }
476
 
477
  .tag-scratch {
@@ -481,9 +477,9 @@
481
  }
482
 
483
  .tag-makecode {
484
- background: rgba(14, 165, 233, 0.08);
485
- color: #bae6fd;
486
- border: 1px solid rgba(14, 165, 233, 0.15);
487
  }
488
 
489
  .tag-html {
@@ -493,9 +489,9 @@
493
  }
494
 
495
  .tag-apk {
496
- background: rgba(168, 85, 247, 0.08);
497
- color: #d8b4fe;
498
- border: 1px solid rgba(168, 85, 247, 0.15);
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: #4f46e5;
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: #9333ea;
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: #6366f1;
7
- --secondary: #0ea5e9;
8
- --accent: #a855f7;
9
  --text-main: #f9fafb;
10
  --text-muted: #9ca3af;
11
  --success: #10b981;
@@ -99,9 +99,8 @@
99
  }
100
 
101
  .project-path {
102
- font-size: 0.72rem;
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(16, 185, 129, 0.06);
122
- color: var(--success);
123
  padding: 0.35rem 0.9rem;
124
  border-radius: 30px;
125
- border: 1px solid rgba(16, 185, 129, 0.15);
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(14, 165, 233, 0.3);
241
- background: rgba(14, 165, 233, 0.05);
242
  }
243
 
244
  /* Right Panel styling */
@@ -290,7 +309,7 @@
290
  }
291
 
292
  .btn-webcam.active {
293
- background: rgba(14, 165, 233, 0.06);
294
  border-color: var(--secondary);
295
  color: var(--secondary);
296
  }
@@ -318,7 +337,7 @@
318
 
319
  .btn-restart {
320
  background: var(--primary);
321
- color: #fff;
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: var(--danger);
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: #9333ea !important;
461
  transform: translateY(-1px);
462
- box-shadow: 0 4px 12px rgba(168, 85, 247, 0.25);
463
  }
464
 
465
  /* Smartphone Mockup Container */
@@ -534,7 +554,7 @@
534
 
535
  .phone-screen {
536
  flex-grow: 1;
537
- background: linear-gradient(135deg, #1e1b4b 0%, #0f172a 100%);
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(168, 85, 247, 0.15);
562
- border: 2px solid rgba(168, 85, 247, 0.3);
563
  border-radius: 50%;
564
  display: flex;
565
  align-items: center;
566
  justify-content: center;
567
- animation: pulse-glow 2s infinite ease-in-out;
568
  }
569
 
570
- @keyframes pulse-glow {
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.05);
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(168, 85, 247, 0.15);
667
- color: #c084fc;
668
  width: 22px;
669
  height: 22px;
670
  border-radius: 50%;
@@ -683,7 +701,7 @@
683
  }
684
 
685
  .guide-link {
686
- color: #c084fc;
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(99, 102, 241, 0.2);
726
- border-top-color: #6366f1;
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: 1px solid rgba(168, 85, 247, 0.35);
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: #7c3aed;
811
- color: #fff;
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: #8b5cf6;
820
- outline: 3px solid rgba(167, 139, 250, 0.35);
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
- card.innerHTML = `
181
- <span>${cls}</span>
182
- <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>
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
- card.innerHTML = `
220
- <span>${std}</span>
221
- <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>
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
- card.innerHTML = `
292
- <div class="project-meta">
293
- <span class="project-title">${proj.name}</span>
294
- <span class="project-tag ${tagClass}">${tagText}</span>
295
- </div>
296
- <button class="btn-launch ${btnClass}" onclick="launchProject('${proj.name}')">
297
- <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>
298
- 啟動專題專區
299
- </button>
300
- `;
 
 
 
 
 
 
301
  projectList.appendChild(card);
302
  });
303
  }
@@ -310,5 +316,5 @@
310
  window.open(url, '_blank');
311
  }
312
 
313
- window.onload = fetchProjects;
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.add("status-stopped");
 
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
- img.innerHTML = `<h3 style="color:#fff;margin-bottom:0.75rem;">${projectName}</h3>
137
- <p>班級:${className}<br>學生:${studentName}</p>
138
- <p style="margin-top:1rem;font-size:0.9rem;">此連結為「僅預覽」模式,不會在伺服器上執行程式。<br>
139
- 若要實際操作,請使用不含 <code>?view=1</code> 的連結。</p>`;
 
 
 
 
 
 
 
 
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.getElementById("apk-qr-image").src = qrUrl;
 
 
 
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="#c084fc" stroke-width="1.8">
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);">${projectName}</h4>
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
- <img src="${qrUrl}" alt="QR Code" style="width: 100px; height: 100px; border-radius: 6px; border: 4px solid #ffffff; display: block;" />
223
  </div>
224
- <a href="${dlUrl}" class="btn-launch btn-launch-apk" style="text-decoration: none; width: 100%; font-size: 0.82rem;" download>
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.innerHTML = `⚠️ 因 Appetize.io 免費方案限制內嵌功能,若畫面顯示「未啟用內嵌」,請 <a href="https://appetize.io/app/${encodeURIComponent(appetizeKey)}" target="_blank" style="color: #c084fc; text-decoration: underline; font-weight: 600;">點此在新分頁中打開模擬器</a>。`;
 
 
 
 
 
 
 
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.allow = "autoplay; fullscreen; microphone; camera";
 
 
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: #a5b4fc; 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="gradient-text">展現無限可能</span></h1>
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: #a5b4fc;"><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
- <h3 style="margin-left: 8px;">分享連結產生器</h3>
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" onclick="copyGeneratedUrl()">複製連結</button>
60
  </div>
61
  </div>
62
  </div>
@@ -108,7 +108,7 @@
108
  <p>&copy; 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>&copy; 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" onclick="window.close()">
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" onclick="restartProject()">
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" onclick="stopProjectElegant()">
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" onclick="clearConsole()">清除</button>
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="#a855f7" 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,7 +111,7 @@
111
  <div class="qr-section">
112
  <h3 class="device-title">掃描二維碼安裝</h3>
113
  <div class="qr-code-wrapper">
114
- <img id="apk-qr-image" src="" alt="QR Code" />
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 test_bootstrap_keeps_new_checkpoint_ahead_of_legacy_repair(self):
75
  classroom.init_db()
76
  loaded = classroom.load("L6Sat", "李合盛", "李合盛", "week02")
77
 
78
- self.assertEqual(loaded["files"], loaded["checkpoint_files"])
79
- self.assertNotEqual(loaded["files"], loaded["final_files"])
 
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), 7)
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()