Compare commits
14 Commits
f204109a84
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| d355174f5a | |||
| 7ee15f8eac | |||
| b8d62e01d9 | |||
| 76129469a1 | |||
| 655f8b78fd | |||
| ca0ca6fe7e | |||
| 9f28eabd2d | |||
| a2318f2244 | |||
| f7892dd31b | |||
| b02a182bf1 | |||
| 687a8347f8 | |||
| 6fc651ad72 | |||
| 7d682cce8d | |||
| e4c811f51d |
12
.cursor/hooks.json
Normal file
12
.cursor/hooks.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"version": 1,
|
||||
"hooks": {
|
||||
"afterFileEdit": [
|
||||
{
|
||||
"command": ".cursor/hooks/dev-reload-touch.sh",
|
||||
"timeout": 5,
|
||||
"matcher": "Write|TabWrite"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
8
.cursor/hooks/dev-reload-touch.sh
Executable file
8
.cursor/hooks/dev-reload-touch.sh
Executable file
@@ -0,0 +1,8 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Consume hook JSON input (not needed for this hook).
|
||||
cat >/dev/null || true
|
||||
|
||||
mkdir -p "src/static"
|
||||
date +%s%3N > "src/static/.reload-token"
|
||||
14
.dockerignore
Normal file
14
.dockerignore
Normal file
@@ -0,0 +1,14 @@
|
||||
.git
|
||||
.gitignore
|
||||
.venv
|
||||
__pycache__/
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
.ruff_cache/
|
||||
.cursor/
|
||||
tests/
|
||||
agent-transcripts/
|
||||
data/
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
12
.env.example
12
.env.example
@@ -8,11 +8,21 @@
|
||||
|
||||
# --- User accounts (SQLite) ---
|
||||
# AUTH_ENABLED=true # require sign-in for /api/* (except /api/auth/*)
|
||||
# AUTH_REGISTER_OPEN=true # allow POST /api/auth/register
|
||||
# AUTH_REGISTER_OPEN=false # allow POST /api/auth/register
|
||||
# AUTH_INVITE_ONLY=true # default: require invite token; set false for open signup
|
||||
# AUTH_DATABASE_PATH=./data/editor.db
|
||||
# AUTH_SESSION_DAYS=14
|
||||
# BOOTSTRAP_ADMIN_USERNAME=admin # first-run only: create superuser if DB has zero users
|
||||
# BOOTSTRAP_ADMIN_PASSWORD=change-me-in-production
|
||||
|
||||
# Optional invite email (used by POST /api/users/invites)
|
||||
# PUBLIC_BASE_URL=http://127.0.0.1:8080
|
||||
# SMTP_HOST=smtp.example.com
|
||||
# SMTP_PORT=587
|
||||
# SMTP_USER=mailer@example.com
|
||||
# SMTP_PASSWORD=app-password
|
||||
# SMTP_FROM=Python Editor <mailer@example.com>
|
||||
# SMTP_TLS=true
|
||||
|
||||
# Base URL for `pipenv run test-selenium` (app must be running separately)
|
||||
# SELENIUM_BASE_URL=http://127.0.0.1:8080
|
||||
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -15,7 +15,6 @@ dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
/lib/
|
||||
/lib64/
|
||||
parts/
|
||||
sdist/
|
||||
@@ -177,3 +176,9 @@ cython_debug/
|
||||
# PyPI configuration file
|
||||
.pypirc
|
||||
|
||||
# Editor runtime state — `workspace/` holds user files (auth-mode per-user
|
||||
# folders, no-auth dev's `code/`); the canonical demo source lives under
|
||||
# `src/static/bundled-demos/` and is what gets seeded into `workspace/`
|
||||
# on startup. Nothing under `workspace/` should ever be committed.
|
||||
/workspace/
|
||||
src/static/.reload-token
|
||||
|
||||
22
Dockerfile
Normal file
22
Dockerfile
Normal file
@@ -0,0 +1,22 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PIP_NO_CACHE_DIR=1
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN pip install --no-cache-dir pipenv
|
||||
|
||||
COPY Pipfile Pipfile.lock ./
|
||||
RUN pipenv install --system --deploy
|
||||
|
||||
COPY src ./src
|
||||
COPY lib ./lib
|
||||
RUN mkdir -p src/static/bundled-lib && cp -f lib/*.py src/static/bundled-lib/
|
||||
# `workspace/` is runtime/user state (gitignored) and is created on demand
|
||||
# at app startup — the image does not need to ship it.
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
CMD ["uvicorn", "app:app", "--app-dir", "src", "--host", "0.0.0.0", "--port", "8080"]
|
||||
75
LED_TUTORIAL.md
Normal file
75
LED_TUTORIAL.md
Normal file
@@ -0,0 +1,75 @@
|
||||
# Python LED Tutorial (NeoPixel Focus)
|
||||
|
||||
This tutorial is for the browser editor's ESP32-style mocks:
|
||||
|
||||
- `machine.Pin`
|
||||
- `neopixel.NeoPixel`
|
||||
|
||||
Open `code/led_tutorial.py` in the editor while reading this guide. (Source of truth: `src/static/bundled-demos/led_tutorial.py` — the editor's `code/` folder is seeded from there on first run.)
|
||||
|
||||
## 1) Basic setup
|
||||
|
||||
```python
|
||||
from machine import Pin
|
||||
import neopixel
|
||||
|
||||
np = neopixel.NeoPixel(Pin(4), 12)
|
||||
```
|
||||
|
||||
- `Pin(4)` means data pin 4 (matching common ESP32 examples).
|
||||
- `12` is the number of LEDs in the strip/ring.
|
||||
- `np` is your LED strip object.
|
||||
|
||||
## 2) Set one LED color
|
||||
|
||||
```python
|
||||
np[0] = (255, 0, 0) # red
|
||||
np.write()
|
||||
```
|
||||
|
||||
- Colors are `(red, green, blue)` from `0` to `255`.
|
||||
- Nothing updates visually until `np.write()`.
|
||||
|
||||
## 3) Fill all LEDs
|
||||
|
||||
```python
|
||||
np.fill((0, 0, 255)) # all blue
|
||||
np.write()
|
||||
```
|
||||
|
||||
## 4) Clear LEDs (turn off)
|
||||
|
||||
```python
|
||||
np.fill((0, 0, 0))
|
||||
np.write()
|
||||
```
|
||||
|
||||
## 5) Animate over time
|
||||
|
||||
```python
|
||||
import time
|
||||
|
||||
for step in range(20):
|
||||
np.fill((step * 10, 0, 255 - step * 10))
|
||||
np.write()
|
||||
time.sleep(0.08)
|
||||
```
|
||||
|
||||
`time.sleep(...)` controls animation speed.
|
||||
|
||||
## 6) Moving pixel example
|
||||
|
||||
```python
|
||||
for i in range(len(np)):
|
||||
np.fill((0, 0, 0))
|
||||
np[i] = (255, 120, 0)
|
||||
np.write()
|
||||
time.sleep(0.06)
|
||||
```
|
||||
|
||||
## 7) Tips
|
||||
|
||||
- Keep color values in `0..255`.
|
||||
- Use helper functions for repeated color logic.
|
||||
- Start with short loops, then increase frames once behavior looks good.
|
||||
- If the simulator is closed, run your script again to show updates.
|
||||
3
Pipfile
3
Pipfile
@@ -8,6 +8,7 @@ pytest = "*"
|
||||
pytest-cov = "*"
|
||||
httpx = "*"
|
||||
selenium = "*"
|
||||
playwright = "*"
|
||||
|
||||
[packages]
|
||||
fastapi = "*"
|
||||
@@ -19,7 +20,7 @@ bcrypt = "*"
|
||||
python_version = "3.12"
|
||||
|
||||
[scripts]
|
||||
dev = "uvicorn app:app --app-dir src --reload --port 8080"
|
||||
dev = "uvicorn app:app --app-dir src --reload --host 0.0.0.0 --port 8080"
|
||||
test = "bash -lc 'cd src && PYTHONPATH=. pytest ../tests'"
|
||||
test-integration = "bash -lc 'cd src && PYTHONPATH=. pytest ../tests -m integration'"
|
||||
test-selenium = "bash -lc 'cd src && PYTHONPATH=. pytest ../tests -m selenium -v'"
|
||||
|
||||
90
Pipfile.lock
generated
90
Pipfile.lock
generated
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"_meta": {
|
||||
"hash": {
|
||||
"sha256": "f9de958b9982b3a30c107c2c02746aece031522dc429be84e4f9589c73404282"
|
||||
"sha256": "ce177325185c5a9e04401b9368766f419921dfc5f9de934820507dae79998082"
|
||||
},
|
||||
"pipfile-spec": 6,
|
||||
"requires": {
|
||||
@@ -586,6 +586,71 @@
|
||||
"markers": "python_version >= '3.10'",
|
||||
"version": "==7.13.5"
|
||||
},
|
||||
"greenlet": {
|
||||
"hashes": [
|
||||
"sha256:0ecec963079cd58cbd14723582384f11f166fd58883c15dcbfb342e0bc9b5846",
|
||||
"sha256:0ed006e4b86c59de7467eb2601cd1b77b5a7d657d1ee55e30fe30d76451edba4",
|
||||
"sha256:0ff251e9a0279522e62f6176412869395a64ddf2b5c5f782ff609a8216a4e662",
|
||||
"sha256:1aa4ce8debcd4ea7fb2e150f3036588c41493d1d52c43538924ae1819003f4ce",
|
||||
"sha256:1bae92a1dd94c5f9d9493c3a212dd874c202442047cf96446412c862feca83a2",
|
||||
"sha256:1eb67d5adefb5bd2e182d42678a328979a209e4e82eb93575708185d31d1f588",
|
||||
"sha256:2094acd54b272cb6eae8c03dd87b3fa1820a4cef18d6889c378d503500a1dc13",
|
||||
"sha256:2628d6c86f6cb0cb45e0c3c54058bbec559f57eaae699447748cb3928150577e",
|
||||
"sha256:29ea813b2e1f45fa9649a17853b2b5465c4072fbcb072e5af6cd3a288216574a",
|
||||
"sha256:362624e6a8e5bca3b8233e45eef33903a100e9539a2b995c364d595dbc4018b3",
|
||||
"sha256:3a717fbc46d8a354fa675f7c1e813485b6ba3885f9bef0cd56e5ba27d758ff5b",
|
||||
"sha256:3bc59be3945ae9750b9e7d45067d01ae3fe90ea5f9ade99239dabdd6e28a5033",
|
||||
"sha256:3ec9ea74e7268ace7f9aab1b1a4e730193fc661b39a993cd91c606c32d4a3628",
|
||||
"sha256:41353ec2ecedf7aa8f682753a41919f8718031a6edac46b8d3dc7ed9e1ceb136",
|
||||
"sha256:47422135b1d308c14b2c6e758beedb1acd33bb91679f5670edf77bf46244722b",
|
||||
"sha256:4964101b8585c144cbda5532b1aa644255126c08a265dae90c16e7a0e63aaa9d",
|
||||
"sha256:4a448128607be0de65342dc9b31be7f948ef4cc0bc8832069350abefd310a8f2",
|
||||
"sha256:4b28037cb07768933c54d81bfe47a85f9f402f57d7d69743b991a713b63954eb",
|
||||
"sha256:4d0eadc7e4d9ffb2af4247b606cae307be8e448911e5a0d0b16d72fc3d224cfd",
|
||||
"sha256:54d243512da35485fc7a6bf3c178fdda6327a9d6506fcdd62b1abd1e41b2927b",
|
||||
"sha256:55fa7ea52771be44af0de27d8b80c02cd18c2c3cddde6c847ecebdf72418b6a1",
|
||||
"sha256:57a43c6079a89713522bc4bcb9f75070ecf5d3dbad7792bfe42239362cbf2a16",
|
||||
"sha256:58c1c374fe2b3d852f9b6b11a7dff4c85404e51b9a596fd9e89cf904eb09866d",
|
||||
"sha256:5a5ed18de6a0f6cc7087f1563f6bd93fc7df1c19165ca01e9bde5a5dc281d106",
|
||||
"sha256:5e05ba267789ea87b5a155cf0e810b1ab88bf18e9e8740813945ceb8ee4350ba",
|
||||
"sha256:5ecd83806b0f4c2f53b1018e0005cd82269ea01d42befc0368730028d850ed1c",
|
||||
"sha256:64d6ac45f7271f48e45f67c95b54ef73534c52ec041fcda8edf520c6d811f4bc",
|
||||
"sha256:680bd0e7ad5e8daa8a4aa89f68fd6adc834b8a8036dc256533f7e08f4a4b01f7",
|
||||
"sha256:6c18dfb59c70f5a94acd271c72e90128c3c776e41e5f07767908c8c1b74ad339",
|
||||
"sha256:6d874e79afd41a96e11ff4c5d0bc90a80973e476fda1c2c64985667397df432b",
|
||||
"sha256:7022615368890680e67b9965d33f5773aade330d5343bbe25560135aaa849eae",
|
||||
"sha256:703cb211b820dbffbbc55a16bfc6e4583a6e6e990f33a119d2cc8b83211119c8",
|
||||
"sha256:728a73687e39ae9ca34e4694cbf2f049d3fbc7174639468d0f67200a97d8f9e2",
|
||||
"sha256:728d9667d8f2f586644b748dbd9bb67e50d6a9381767d1357714ea6825bb3bf5",
|
||||
"sha256:762612baf1161ccb8437c0161c668a688223cba28e1bf038f4eb47b13e39ccdf",
|
||||
"sha256:7fc391b1566f2907d17aaebe78f8855dc45675159a775fcf9e61f8ee0078e87f",
|
||||
"sha256:804a70b328e706b785c6ef16187051c394a63dd1a906d89be24b6ad77759f13f",
|
||||
"sha256:83ed9f27f1680b50e89f40f6df348a290ea234b249a4003d366663a12eab94f2",
|
||||
"sha256:884f649de075b84739713d41dd4dfd41e2b910bfb769c4a3ea02ec1da52cd9bb",
|
||||
"sha256:8f1cc966c126639cd152fdaa52624d2655f492faa79e013fea161de3e6dda082",
|
||||
"sha256:8f52a464e4ed91780bdfbbdd2b97197f3accaa629b98c200f4dffada759f3ae7",
|
||||
"sha256:9c615f869163e14bb1ced20322d8038fb680b08236521ac3f30cd4c1288785a0",
|
||||
"sha256:9d280a7f5c331622c69f97eb167f33577ff2d1df282c41cd15907fc0a3ca198c",
|
||||
"sha256:a10a732421ab4fec934783ce3e54763470d0181db6e3468f9103a275c3ed1853",
|
||||
"sha256:a96fcee45e03fe30a62669fd16ab5c9d3c172660d3085605cb1e2d1280d3c988",
|
||||
"sha256:a97e4821aa710603f94de0da25f25096454d78ffdace5dc77f3a006bc01abba3",
|
||||
"sha256:ba8f0bdc2fae6ce915dfd0c16d2d00bca7e4247c1eae4416e06430e522137858",
|
||||
"sha256:bf2d8a80bec89ab46221ae45c5373d5ba0bd36c19aa8508e85c6cd7e5106cd37",
|
||||
"sha256:cda05425526240807408156b6960a17a79a0c760b813573b67027823be760977",
|
||||
"sha256:d419647372241bc68e957bf38d5c1f98852155e4146bd1e4121adea81f4f01e4",
|
||||
"sha256:d4d9f0624c775f2dfc56ba54d515a8c771044346852a918b405914f6b19d7fd8",
|
||||
"sha256:d60097128cb0a1cab9ea541186ea13cd7b847b8449a7787c2e2350da0cb82d86",
|
||||
"sha256:db2910d3c809444e0a20147361f343fe2798e106af8d9d8506f5305302655a9f",
|
||||
"sha256:ddb36c7d6c9c0a65f18c7258634e0c416c6ab59caac8c987b96f80c2ebda0112",
|
||||
"sha256:ddc090c5c1792b10246a78e8c2163ebbe04cf877f9d785c230a7b27b39ad038e",
|
||||
"sha256:e5ddf316ced87539144621453c3aef229575825fe60c604e62bedc4003f372b2",
|
||||
"sha256:f35807464c4c58c55f0d31dfa83c541a5615d825c2fe3d2b95360cf7c4e3c0a8",
|
||||
"sha256:f8c30c2225f40dd76c50790f0eb3b5c7c18431efb299e2782083e1981feed243",
|
||||
"sha256:fa94cb2288681e3a11645958f1871d48ee9211bd2f66628fdace505927d6e564"
|
||||
],
|
||||
"markers": "platform_machine == 'aarch64' or (platform_machine == 'ppc64le' or (platform_machine == 'x86_64' or (platform_machine == 'amd64' or (platform_machine == 'AMD64' or (platform_machine == 'win32' or platform_machine == 'WIN32')))))",
|
||||
"version": "==3.5.0"
|
||||
},
|
||||
"h11": {
|
||||
"hashes": [
|
||||
"sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1",
|
||||
@@ -643,6 +708,21 @@
|
||||
"markers": "python_version >= '3.8'",
|
||||
"version": "==26.2"
|
||||
},
|
||||
"playwright": {
|
||||
"hashes": [
|
||||
"sha256:4a4a2d4842b0e4120de3fa48636e4b69085a05b81d8a35ad4353f530ade72ed6",
|
||||
"sha256:6989c476be2b9cd3e24a18cc9dcf202e266fb3d91e3e5395cd668c54ea54b119",
|
||||
"sha256:8c881a19377d2b900af855fb525b5f22a27bf3cfbecba6d1edb36766d56cb100",
|
||||
"sha256:93581ad515728cadc8af39b288a5633ba6d36e7d72048e79d890ce01ea2156f9",
|
||||
"sha256:af068143a0c045ec11608b67d6c42e58db7e9cf65a742dd21fddedc1a9802c47",
|
||||
"sha256:bfc6940100b57423175c819ce2422ec5880d55fa2769987f62ab7a1f5fe6783e",
|
||||
"sha256:c5792aad9e22b91a09264b9edbc18553cf05ea5a39404d65dc19a012c6b2e51d",
|
||||
"sha256:d5a5cc064b82ca92996080025710844e417f44df8fda9001102c28f44174171c"
|
||||
],
|
||||
"index": "pypi",
|
||||
"markers": "python_version >= '3.9'",
|
||||
"version": "==1.59.0"
|
||||
},
|
||||
"pluggy": {
|
||||
"hashes": [
|
||||
"sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3",
|
||||
@@ -651,6 +731,14 @@
|
||||
"markers": "python_version >= '3.9'",
|
||||
"version": "==1.6.0"
|
||||
},
|
||||
"pyee": {
|
||||
"hashes": [
|
||||
"sha256:0b931f7c14535667ed4c7e0d531716368715e860b988770fc7eb8578d1f67fc8",
|
||||
"sha256:af2f8fede4171ef667dfded53f96e2ed0d6e6bd7ee3bb46437f77e3b57689228"
|
||||
],
|
||||
"markers": "python_version >= '3.8'",
|
||||
"version": "==13.0.1"
|
||||
},
|
||||
"pygments": {
|
||||
"hashes": [
|
||||
"sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f",
|
||||
|
||||
104
README.md
104
README.md
@@ -1,6 +1,6 @@
|
||||
# python-editor
|
||||
|
||||
Browser-based Python editing: **FastAPI** serves static assets, stores workspace files, and optional **API key auth**. **Pyodide** runs your scripts and **Jedi** (inside Pyodide) powers completions — no server-side Python execution or Jedi.
|
||||
Browser-based Python editing: **FastAPI** serves static assets, stores workspace files, and optional **API key auth**. **Pyodide** runs your scripts and **Jedi** (inside Pyodide) powers completions and syntax diagnostics — no server-side Python execution or LSP process.
|
||||
|
||||
## Run
|
||||
|
||||
@@ -48,13 +48,111 @@ If nothing is listening, the smoke test **skips** with a short message instead o
|
||||
|
||||
Open [http://localhost:8080](http://localhost:8080).
|
||||
|
||||
**User accounts** — Set `AUTH_ENABLED=true` in `.env` to require sign-in for workspace APIs. Users live in a SQLite file (`AUTH_DATABASE_PATH`, default `./data/editor.db`). Use `/register` (if `AUTH_REGISTER_OPEN=true`) or `BOOTSTRAP_ADMIN_USERNAME` / `BOOTSTRAP_ADMIN_PASSWORD` for the first superuser. Superusers can **GET/POST/DELETE `/api/users`** to list, create, or remove accounts.
|
||||
### Editor runtime controls
|
||||
|
||||
- `Run Python` runs the active open `.py` tab.
|
||||
- Enable `Run main.py` to always run `code/main.py` instead.
|
||||
- Pressing `Run Python` while a script is running will stop and restart with the selected target.
|
||||
- `LSP` badge in the header shows in-browser Jedi syntax status (`n/a`, `checking...`, `OK`, or issue count).
|
||||
|
||||
## Deploy with Docker
|
||||
|
||||
Build and run with Docker Compose:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
mkdir -p data
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
Then open [http://localhost:8080](http://localhost:8080).
|
||||
|
||||
Notes:
|
||||
|
||||
- `workspace/` is mounted to `/app/workspace` so your code persists locally.
|
||||
- `data/` is mounted to `/app/data` for the SQLite auth DB.
|
||||
- In container mode, `WORKSPACE_ROOT` and `AUTH_DATABASE_PATH` are set by `docker-compose.yml`.
|
||||
|
||||
**User accounts** — Set `AUTH_ENABLED=true` in `.env` to require sign-in for workspace APIs. Users live in a SQLite file (`AUTH_DATABASE_PATH`, default `./data/editor.db`). Use `/register` with an invite link (unless you opt into open signup) or `BOOTSTRAP_ADMIN_USERNAME` / `BOOTSTRAP_ADMIN_PASSWORD` for the first superuser. Superusers can **GET `/api/users`**, **PATCH `/api/users/{id}`** (username, password reset, admin flag — renames workspace folder when the username changes), or **DELETE `/api/users/{id}`** to manage accounts. New accounts are added only through **invite links** (**`POST /api/users/invites`**) plus self-service registration (`/register?invite=…`).
|
||||
|
||||
Email invite signup:
|
||||
|
||||
- By default **`AUTH_INVITE_ONLY=true`**: registrations need a valid invite token. Set **`AUTH_INVITE_ONLY=false`** to allow open signup whenever **`AUTH_REGISTER_OPEN=true`**.
|
||||
- Superusers can create invites via `POST /api/users/invites` with `{ "email": "...", "expires_days": 7 }`.
|
||||
- Response includes `invite_url`; if SMTP is configured the invite email is sent automatically.
|
||||
- Registration page accepts invite links like `/register?invite=<token>`.
|
||||
|
||||
When auth is enabled, file APIs use a per-user workspace under `WORKSPACE_ROOT/users/<username-id>/` for **isolated `code/`**. The `lib/` tree is shared and read-only for all users. When auth is disabled, the shared workspace root is used for everything.
|
||||
|
||||
Admins can open another user's workspace from the home page user management panel (links to `/editor?workspace_user_id=<id>`). Only superusers may use this override.
|
||||
|
||||
**API key** — If `EDITOR_API_KEY` is set, requests may use `Authorization: Bearer …` instead of a session (useful for automation). When `AUTH_ENABLED=true`, a valid session *or* API key is accepted.
|
||||
|
||||
The home page can store the API key in `sessionStorage` when you are not using cookie login, or use `?api_key=` on `/editor`.
|
||||
|
||||
**Local mode (no login)** — Click *Use locally* on the home page (or open `/editor?local=1`) to run the editor without any FastAPI auth. The boot-time auth probe is skipped when local mode is active, so this works even on a host that has `AUTH_ENABLED=true`. Files default to the browser's **IndexedDB**; inside the editor the workspace badge has a **Folder…** button that opens `window.showDirectoryPicker()` so you can save straight to any folder on disk (Chromium-only — Firefox/Safari stay on IndexedDB). The picked directory handle is persisted across reloads in IndexedDB; if browser permission lapses a *Reconnect* button reappears in the badge. Nothing is sent to the server for file reads/writes. The MicroPython stubs are loaded from **`/static/bundled-lib/*.py`** (files under `src/static/bundled-lib/` in the repo) so a plain static file server is enough; if those requests fail, the app falls back to `GET /api/public/lib-bundle` when FastAPI is available. For static-only hosting, run `python scripts/serve_static_editor.py` from the repo root — it serves `src/static/` with the same `/static/…` URLs the HTML expects (it strips the `/static` prefix when resolving files), rewrites `/editor` → `index.html`, and sends the same COOP/COEP headers as the full app so **ADC sliders, pin toggles, and serial I/O** keep using `SharedArrayBuffer` on mobile Safari and Chrome where supported. An *Exit* button in the editor's workspace badge clears the local-mode flag (your IndexedDB files stay until you wipe browser storage).
|
||||
|
||||
## Layout
|
||||
|
||||
- `src/` — FastAPI app and static UI (`src/static/`)
|
||||
- `workspace/` — default tree: `code/` (editable), `lib/` (read-only via API)
|
||||
- `lib/` — bundled MicroPython stubs, served read-only as `lib/` in the editor and merged into Pyodide at run time (single source of truth)
|
||||
- `workspace/` — default `WORKSPACE_ROOT`: `code/` samples and per-user folders (editable); the editor surfaces the repo `lib/` alongside it without copying anything to disk
|
||||
|
||||
## ESP32 / NeoPixel mock
|
||||
|
||||
The browser runtime ships MicroPython-style stubs in repo `lib/` (they appear as `lib/` in the editor and are read-only via the APIs):
|
||||
|
||||
- `machine.Pin` — `value/on/off/toggle/high/low/init/__call__/irq` plus a live "Pins" panel: OUT pins show an indicator, IN pins expose a clickable toggle button (its value is what `Pin.value()` returns), `irq()` fires on rising / falling edges as you click
|
||||
- `machine.PWM` — `freq()` / `duty()` / `duty_u16()` / `duty_ns()` with a duty-cycle bar in the Pins panel
|
||||
- `machine.ADC` — backed by a live slider in the editor UI (one slider per pin, `read_u16()` returns 0..65535)
|
||||
- `machine.UART` — opens a Serial Monitor pane; `write()` text appears there, what you type is delivered via `read()` / `readline()`
|
||||
- `neopixel.NeoPixel`
|
||||
- `utime` — `ticks_ms`, `ticks_diff`, `ticks_add`, `sleep_ms`, `sleep_us`, `sleep`
|
||||
- `micropython.const` — no-op helper for ported constant declarations
|
||||
|
||||
Use them from scripts in `code/` (your editor workspace, populated on first run from `src/static/bundled-demos/`) like typical ESP32 / MicroPython examples:
|
||||
|
||||
```python
|
||||
from machine import Pin
|
||||
import neopixel
|
||||
|
||||
np = neopixel.NeoPixel(Pin(4), 8)
|
||||
np[0] = (255, 0, 0)
|
||||
np.write()
|
||||
```
|
||||
|
||||
`write()` updates the NeoPixel simulator so you can verify behavior visually.
|
||||
|
||||
Simulator modes:
|
||||
|
||||
- Default: in-app LED strip/panel section under the editor.
|
||||
- `16x16 panel` checkbox: opens a dedicated popup with 16x16 serpentine mapping:
|
||||
- first LED at top-right
|
||||
- first row goes right -> left
|
||||
- rows zig-zag left/right.
|
||||
- The 16x16 popup closes automatically on **Stop** or when script execution finishes.
|
||||
|
||||
Tutorial files (canonical source — committed under `src/static/bundled-demos/`; copies appear in your editor's `code/` folder on first run):
|
||||
|
||||
- `LED_TUTORIAL.md` - step-by-step NeoPixel tutorial
|
||||
- `led_tutorial.py` - runnable guided LED example
|
||||
- `led_patterns.py` - shared pattern helpers (used by automated tests); each `pattern_*_demo.py` duplicates what it needs and uses only Python stdlib + `machine` / `neopixel` / `time`
|
||||
- `pattern_rainbow_demo.py` - rainbow animation (self-contained)
|
||||
- `pattern_chase_demo.py` - Knight Rider–style bouncing scanner (self-contained)
|
||||
- `pattern_twinkle_demo.py` - twinkle animation (self-contained)
|
||||
- `panel16_utils.py` - helpers for 16x16 serpentine mapping
|
||||
- `panel16_rainbow_wave.py` - 16x16 rainbow wave
|
||||
- `panel16_bounce.py` - 16x16 bouncing pixel with trail
|
||||
- `panel16_matrix_rain.py` - 16x16 matrix rain effect
|
||||
|
||||
> `workspace/` is gitignored runtime state. To edit the **shipped** demo source, edit `src/static/bundled-demos/<file>.py` and re-run "Reset demos" in the editor (or restart the dev server with an empty `workspace/code/`).
|
||||
|
||||
## Dev auto-reload hook
|
||||
|
||||
Project hook files are included in `.cursor/`:
|
||||
|
||||
- `.cursor/hooks.json`
|
||||
- `.cursor/hooks/dev-reload-touch.sh`
|
||||
|
||||
When files are edited through Cursor tools, the hook updates `src/static/.reload-token`.
|
||||
The editor (on localhost) polls that token and auto-reloads the browser when it changes.
|
||||
|
||||
16
docker-compose.yml
Normal file
16
docker-compose.yml
Normal file
@@ -0,0 +1,16 @@
|
||||
services:
|
||||
python-editor:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
ports:
|
||||
- "8080:8080"
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
- WORKSPACE_ROOT=/app/workspace
|
||||
- AUTH_DATABASE_PATH=/app/data/editor.db
|
||||
volumes:
|
||||
- ./workspace:/app/workspace
|
||||
- ./data:/app/data
|
||||
restart: unless-stopped
|
||||
445
lib/machine.py
Normal file
445
lib/machine.py
Normal file
@@ -0,0 +1,445 @@
|
||||
"""Minimal MicroPython-style machine module mock for browser simulation."""
|
||||
|
||||
import json
|
||||
|
||||
|
||||
_PIN_MODE_OUT = 1
|
||||
_PIN_MODE_IN = 2
|
||||
_PIN_MODE_PWM = 4
|
||||
|
||||
|
||||
def _pin_view_get(name):
|
||||
try:
|
||||
import js
|
||||
|
||||
return getattr(js, name, None)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _sab_isolated():
|
||||
"""True when the worker has a real SharedArrayBuffer for pin/ADC views.
|
||||
|
||||
Set by `pyodide-worker.js` at init time. When false, output updates
|
||||
must travel through `print("[pin-out]…")` markers because the main
|
||||
thread can't read worker-local arrays."""
|
||||
try:
|
||||
import js
|
||||
|
||||
return bool(getattr(js, "__sab_isolated", False))
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _pin_out_publish(pin_id, ui_mode, value):
|
||||
"""Pack [ui_mode (high byte) | value (low 24 bits)] for the UI panel."""
|
||||
view = _pin_view_get("__pin_out_view")
|
||||
if view is not None:
|
||||
packed = ((int(ui_mode) & 0xFF) << 24) | (int(value) & 0x00FFFFFF)
|
||||
try:
|
||||
import js
|
||||
|
||||
js.Atomics.store(view, int(pin_id), packed)
|
||||
except Exception:
|
||||
try:
|
||||
view[int(pin_id)] = packed
|
||||
except Exception:
|
||||
pass
|
||||
if not _sab_isolated():
|
||||
# Fallback for non-isolated contexts (e.g. mobile over LAN IP):
|
||||
# the main thread reads `[pin-out]` lines from stdout and updates
|
||||
# the Pins panel from there. Slower than SAB but works everywhere.
|
||||
try:
|
||||
print(
|
||||
"[pin-out]"
|
||||
+ json.dumps(
|
||||
{"pin": int(pin_id), "mode": int(ui_mode), "value": int(value)}
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _pin_in_read(pin_id):
|
||||
view = _pin_view_get("__pin_in_view")
|
||||
if view is None:
|
||||
return 0
|
||||
try:
|
||||
import js
|
||||
|
||||
return int(js.Atomics.load(view, int(pin_id))) & 1
|
||||
except Exception:
|
||||
try:
|
||||
return int(view[int(pin_id)]) & 1
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
|
||||
def _pin_register(pin_id, ui_mode, **extra):
|
||||
payload = {"pin": int(pin_id), "mode": int(ui_mode)}
|
||||
payload.update({k: v for k, v in extra.items() if v is not None})
|
||||
try:
|
||||
print("[pin-register]" + json.dumps(payload))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
class Pin:
|
||||
"""Browser-simulated `machine.Pin`.
|
||||
|
||||
A control row appears in the editor UI when a pin is constructed:
|
||||
* `Pin.OUT` pins show a live indicator that mirrors `value()`.
|
||||
* `Pin.IN` pins show a toggle button you can click — the next call
|
||||
to `value()` returns 0 or 1 accordingly.
|
||||
"""
|
||||
|
||||
IN = 0
|
||||
OUT = 1
|
||||
PULL_UP = 2
|
||||
PULL_DOWN = 3
|
||||
OPEN_DRAIN = 7
|
||||
|
||||
IRQ_FALLING = 1
|
||||
IRQ_RISING = 2
|
||||
|
||||
def __init__(self, pin_id, mode=OUT, pull=-1, value=None, **_kwargs):
|
||||
self.id = int(pin_id)
|
||||
self.mode = int(mode)
|
||||
self.pull = int(pull) if pull != -1 else -1
|
||||
self._value = 1 if value else 0
|
||||
self._irq_handler = None
|
||||
self._irq_trigger = 0
|
||||
self._last_input = 0
|
||||
self._publish()
|
||||
|
||||
def _ui_mode(self):
|
||||
if self.mode == self.IN:
|
||||
return _PIN_MODE_IN
|
||||
return _PIN_MODE_OUT
|
||||
|
||||
def _publish(self):
|
||||
ui_mode = self._ui_mode()
|
||||
_pin_register(self.id, ui_mode, pull=self.pull if self.pull != -1 else None)
|
||||
if ui_mode == _PIN_MODE_OUT:
|
||||
_pin_out_publish(self.id, ui_mode, self._value)
|
||||
|
||||
def init(self, mode=-1, pull=-1, value=None):
|
||||
if mode != -1:
|
||||
self.mode = int(mode)
|
||||
if pull != -1:
|
||||
self.pull = int(pull)
|
||||
if value is not None:
|
||||
self._value = 1 if int(value) else 0
|
||||
self._publish()
|
||||
|
||||
def value(self, new_value=None):
|
||||
if new_value is None:
|
||||
if self.mode == self.IN:
|
||||
v = _pin_in_read(self.id)
|
||||
if self._irq_handler is not None:
|
||||
if v and not self._last_input and (self._irq_trigger & self.IRQ_RISING):
|
||||
self._fire_irq()
|
||||
elif not v and self._last_input and (self._irq_trigger & self.IRQ_FALLING):
|
||||
self._fire_irq()
|
||||
self._last_input = v
|
||||
return v
|
||||
return self._value
|
||||
v = 1 if int(new_value) else 0
|
||||
self._value = v
|
||||
if self._ui_mode() == _PIN_MODE_OUT:
|
||||
_pin_out_publish(self.id, _PIN_MODE_OUT, v)
|
||||
return v
|
||||
|
||||
def on(self):
|
||||
return self.value(1)
|
||||
|
||||
def off(self):
|
||||
return self.value(0)
|
||||
|
||||
def high(self):
|
||||
return self.value(1)
|
||||
|
||||
def low(self):
|
||||
return self.value(0)
|
||||
|
||||
def toggle(self):
|
||||
return self.value(0 if (self._value if self.mode == self.OUT else _pin_in_read(self.id)) else 1)
|
||||
|
||||
def irq(self, handler=None, trigger=IRQ_RISING | IRQ_FALLING, **_kwargs):
|
||||
self._irq_handler = handler
|
||||
self._irq_trigger = int(trigger)
|
||||
return self
|
||||
|
||||
def _fire_irq(self):
|
||||
if self._irq_handler is None:
|
||||
return
|
||||
try:
|
||||
self._irq_handler(self)
|
||||
except Exception as exc:
|
||||
try:
|
||||
print(f"[pin] irq handler raised: {exc!r}")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def __call__(self, *args):
|
||||
return self.value(*args) if args else self.value()
|
||||
|
||||
|
||||
class PWM:
|
||||
"""Browser-simulated `machine.PWM`.
|
||||
|
||||
Visualises the configured duty cycle as a bar in the Pins panel.
|
||||
`freq()` and `duty()`/`duty_u16()`/`duty_ns()` mirror MicroPython's API.
|
||||
"""
|
||||
|
||||
def __init__(self, pin, freq=1000, duty=None, duty_u16=None, duty_ns=None):
|
||||
self._pin_id = _adc_pin_id(pin)
|
||||
self._freq = int(freq) if freq else 1000
|
||||
self._duty_u16 = 0
|
||||
if duty_u16 is not None:
|
||||
self._duty_u16 = max(0, min(65535, int(duty_u16)))
|
||||
elif duty is not None:
|
||||
self._duty_u16 = max(0, min(65535, int(duty) * 64))
|
||||
elif duty_ns is not None:
|
||||
self._set_duty_ns(int(duty_ns))
|
||||
_pin_register(self._pin_id, _PIN_MODE_PWM, freq=self._freq)
|
||||
_pin_out_publish(self._pin_id, _PIN_MODE_PWM, self._duty_u16)
|
||||
|
||||
def _set_duty_ns(self, ns):
|
||||
period_ns = 1_000_000_000 // max(1, self._freq)
|
||||
if period_ns <= 0:
|
||||
self._duty_u16 = 0
|
||||
return
|
||||
self._duty_u16 = max(0, min(65535, int(ns) * 65535 // period_ns))
|
||||
|
||||
def freq(self, value=None):
|
||||
if value is None:
|
||||
return self._freq
|
||||
self._freq = max(1, int(value))
|
||||
_pin_register(self._pin_id, _PIN_MODE_PWM, freq=self._freq)
|
||||
|
||||
def duty(self, value=None):
|
||||
if value is None:
|
||||
return self._duty_u16 // 64
|
||||
self._duty_u16 = max(0, min(65535, int(value) * 64))
|
||||
_pin_out_publish(self._pin_id, _PIN_MODE_PWM, self._duty_u16)
|
||||
|
||||
def duty_u16(self, value=None):
|
||||
if value is None:
|
||||
return self._duty_u16
|
||||
self._duty_u16 = max(0, min(65535, int(value)))
|
||||
_pin_out_publish(self._pin_id, _PIN_MODE_PWM, self._duty_u16)
|
||||
|
||||
def duty_ns(self, value=None):
|
||||
period_ns = 1_000_000_000 // max(1, self._freq)
|
||||
if value is None:
|
||||
return (self._duty_u16 * period_ns) // 65535
|
||||
self._set_duty_ns(int(value))
|
||||
_pin_out_publish(self._pin_id, _PIN_MODE_PWM, self._duty_u16)
|
||||
|
||||
def deinit(self):
|
||||
_pin_out_publish(self._pin_id, 0, 0)
|
||||
|
||||
|
||||
def _adc_pin_id(pin):
|
||||
if isinstance(pin, int):
|
||||
return pin
|
||||
return int(getattr(pin, "id", 0))
|
||||
|
||||
|
||||
def _adc_read_raw(pin_id: int) -> int:
|
||||
"""Read the live slider value (0..65535) shared from the editor UI.
|
||||
|
||||
Backed by a SharedArrayBuffer so updates from the browser slider propagate
|
||||
instantly without the script having to yield. Falls back to 0 when the
|
||||
runtime is not cross-origin isolated (e.g. older browsers).
|
||||
"""
|
||||
try:
|
||||
import js # only available inside Pyodide
|
||||
|
||||
view = getattr(js, "__adc_view", None)
|
||||
if view is None:
|
||||
return 0
|
||||
try:
|
||||
return int(js.Atomics.load(view, pin_id)) & 0xFFFF
|
||||
except Exception:
|
||||
return int(view[pin_id]) & 0xFFFF
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
|
||||
class ADC:
|
||||
"""Browser-simulated `machine.ADC`.
|
||||
|
||||
Exposes a slider in the editor UI for the given pin. `read_u16()` returns
|
||||
the slider value in the 0..65535 range (matching MicroPython's API);
|
||||
`read()` scales to the configured `width` (defaults to 12-bit, 0..4095).
|
||||
"""
|
||||
|
||||
WIDTH_9BIT = 9
|
||||
WIDTH_10BIT = 10
|
||||
WIDTH_11BIT = 11
|
||||
WIDTH_12BIT = 12
|
||||
ATTN_0DB = 0
|
||||
ATTN_2_5DB = 1
|
||||
ATTN_6DB = 2
|
||||
ATTN_11DB = 3
|
||||
|
||||
def __init__(self, pin):
|
||||
self._pin = _adc_pin_id(pin)
|
||||
self._atten = self.ATTN_11DB
|
||||
self._width = self.WIDTH_12BIT
|
||||
try:
|
||||
print("[adc-register]" + json.dumps({"pin": self._pin}))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def atten(self, attn):
|
||||
self._atten = int(attn)
|
||||
|
||||
def width(self, width):
|
||||
self._width = int(width)
|
||||
|
||||
def read_u16(self):
|
||||
return _adc_read_raw(self._pin)
|
||||
|
||||
def read(self):
|
||||
bits = self._width if self._width in (9, 10, 11, 12) else 12
|
||||
max_val = (1 << bits) - 1
|
||||
return (_adc_read_raw(self._pin) * max_val) // 65535
|
||||
|
||||
def read_uv(self):
|
||||
"""Return microvolts assuming a 0..3.3V range (rough simulation)."""
|
||||
return (_adc_read_raw(self._pin) * 3_300_000) // 65535
|
||||
|
||||
|
||||
def _serial_drain_pending(buf):
|
||||
"""Pull any bytes the editor's serial-monitor has pushed into the SAB ring."""
|
||||
try:
|
||||
import js
|
||||
import base64 # noqa: F401 (kept for symmetry with write path)
|
||||
|
||||
indices = getattr(js, "__serial_in_indices", None)
|
||||
data = getattr(js, "__serial_in_data", None)
|
||||
if indices is None or data is None:
|
||||
return
|
||||
cap = int(getattr(js, "__serial_in_capacity", 0))
|
||||
if cap <= 0:
|
||||
return
|
||||
r = int(js.Atomics.load(indices, 0))
|
||||
w = int(js.Atomics.load(indices, 1))
|
||||
while r != w:
|
||||
buf.append(int(data[r]) & 0xFF)
|
||||
r = (r + 1) % cap
|
||||
js.Atomics.store(indices, 0, r)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
class UART:
|
||||
"""Browser-simulated `machine.UART`.
|
||||
|
||||
A serial monitor pane appears in the editor when this is constructed.
|
||||
`write()` text shows up there; characters typed into the input box arrive
|
||||
via `read()` / `readline()` / `any()`.
|
||||
"""
|
||||
|
||||
INV_TX = 1
|
||||
INV_RX = 2
|
||||
INV_RTS = 4
|
||||
INV_CTS = 8
|
||||
|
||||
def __init__(self, id=0, baudrate=115200, bits=8, parity=None, stop=1,
|
||||
tx=None, rx=None, timeout=0, **_kwargs):
|
||||
self.id = int(id) if id is not None else 0
|
||||
self.baudrate = int(baudrate)
|
||||
self.bits = int(bits)
|
||||
self.parity = parity
|
||||
self.stop = int(stop)
|
||||
self.tx = tx
|
||||
self.rx = rx
|
||||
self.timeout = int(timeout) if timeout is not None else 0
|
||||
self._buf = bytearray()
|
||||
try:
|
||||
print("[serial-register]" + json.dumps({
|
||||
"id": self.id,
|
||||
"baudrate": self.baudrate,
|
||||
}))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def init(self, *args, **kwargs):
|
||||
if args:
|
||||
try:
|
||||
self.baudrate = int(args[0])
|
||||
except Exception:
|
||||
pass
|
||||
if "baudrate" in kwargs:
|
||||
self.baudrate = int(kwargs["baudrate"])
|
||||
|
||||
def deinit(self):
|
||||
self._buf = bytearray()
|
||||
|
||||
def any(self):
|
||||
_serial_drain_pending(self._buf)
|
||||
return len(self._buf)
|
||||
|
||||
def read(self, nbytes=None):
|
||||
_serial_drain_pending(self._buf)
|
||||
if not self._buf:
|
||||
return None
|
||||
if nbytes is None:
|
||||
out = bytes(self._buf)
|
||||
self._buf = bytearray()
|
||||
return out
|
||||
n = min(int(nbytes), len(self._buf))
|
||||
if n <= 0:
|
||||
return None
|
||||
out = bytes(self._buf[:n])
|
||||
del self._buf[:n]
|
||||
return out
|
||||
|
||||
def readinto(self, buf, nbytes=None):
|
||||
n = int(nbytes) if nbytes is not None else len(buf)
|
||||
data = self.read(n)
|
||||
if not data:
|
||||
return None
|
||||
for i, b in enumerate(data):
|
||||
buf[i] = b
|
||||
return len(data)
|
||||
|
||||
def readline(self):
|
||||
_serial_drain_pending(self._buf)
|
||||
if not self._buf:
|
||||
return None
|
||||
nl = self._buf.find(b"\n")
|
||||
if nl == -1:
|
||||
return None
|
||||
out = bytes(self._buf[: nl + 1])
|
||||
del self._buf[: nl + 1]
|
||||
return out
|
||||
|
||||
def write(self, data):
|
||||
if isinstance(data, str):
|
||||
buf = data.encode("utf-8")
|
||||
elif isinstance(data, (bytes, bytearray, memoryview)):
|
||||
buf = bytes(data)
|
||||
else:
|
||||
buf = bytes([int(data) & 0xFF])
|
||||
try:
|
||||
import base64
|
||||
|
||||
encoded = base64.b64encode(buf).decode("ascii")
|
||||
print(f"[serial-out]{encoded}")
|
||||
except Exception:
|
||||
pass
|
||||
return len(buf)
|
||||
|
||||
def sendbreak(self):
|
||||
pass
|
||||
|
||||
def flush(self):
|
||||
pass
|
||||
|
||||
def txdone(self):
|
||||
return True
|
||||
67
lib/neopixel.py
Normal file
67
lib/neopixel.py
Normal file
@@ -0,0 +1,67 @@
|
||||
"""NeoPixel mock for Pyodide/browser execution.
|
||||
|
||||
Supports a useful subset of MicroPython's neopixel.NeoPixel API:
|
||||
- NeoPixel(pin, n, bpp=3, timing=1)
|
||||
- __setitem__, __getitem__, __len__
|
||||
- fill(color)
|
||||
- write() # prints current pixel buffer snapshot
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
|
||||
def _normalize_color(value, bpp: int):
|
||||
if not hasattr(value, "__iter__"):
|
||||
raise TypeError("Color must be an iterable, e.g. (r, g, b)")
|
||||
parts = [int(v) for v in value]
|
||||
if len(parts) != bpp:
|
||||
raise ValueError(f"Expected {bpp} color channels, got {len(parts)}")
|
||||
out = []
|
||||
for channel in parts:
|
||||
out.append(max(0, min(255, channel)))
|
||||
return tuple(out)
|
||||
|
||||
|
||||
class NeoPixel:
|
||||
def __init__(self, pin, n: int, bpp: int = 3, timing: int = 1):
|
||||
self.pin = pin
|
||||
self.n = int(n)
|
||||
self.bpp = int(bpp)
|
||||
self.timing = int(timing)
|
||||
self._buf = [tuple([0] * self.bpp) for _ in range(self.n)]
|
||||
# Tell the editor UI that this pin is the NeoPixel data line so its
|
||||
# row in the Pins panel goes away (it would just look noisy — every
|
||||
# `pixels.write()` flips it).
|
||||
try:
|
||||
pin_id = getattr(self.pin, "id", self.pin)
|
||||
print(
|
||||
"[pin-claim]"
|
||||
+ json.dumps({"pin": int(pin_id), "by": "neopixel"})
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def __len__(self):
|
||||
return self.n
|
||||
|
||||
def __getitem__(self, index):
|
||||
return self._buf[int(index)]
|
||||
|
||||
def __setitem__(self, index, color):
|
||||
idx = int(index)
|
||||
self._buf[idx] = _normalize_color(color, self.bpp)
|
||||
|
||||
def fill(self, color):
|
||||
c = _normalize_color(color, self.bpp)
|
||||
for i in range(self.n):
|
||||
self._buf[i] = c
|
||||
|
||||
def write(self):
|
||||
pin_id = getattr(self.pin, "id", self.pin)
|
||||
payload = {
|
||||
"type": "neopixel",
|
||||
"pin": pin_id,
|
||||
"pixels": [list(pixel) for pixel in self._buf],
|
||||
"bpp": self.bpp,
|
||||
}
|
||||
print("[neopixel-json]" + json.dumps(payload))
|
||||
74
scripts/serve_static_editor.py
Normal file
74
scripts/serve_static_editor.py
Normal file
@@ -0,0 +1,74 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Serve only the contents of `src/static/` (HTML, CSS, JS, bundled stubs).
|
||||
|
||||
Use this with **local mode** in the editor (`?local=1`): files live in IndexedDB,
|
||||
so no FastAPI file API is required. Maps `/` and `/editor` to `home.html` /
|
||||
`index.html` so links from the home page keep working.
|
||||
|
||||
Sends ``Cross-Origin-Opener-Policy: same-origin`` and
|
||||
``Cross-Origin-Embedder-Policy: credentialless`` on every response so the page
|
||||
can become cross-origin isolated (SharedArrayBuffer for live ADC / pins /
|
||||
serial), matching the full FastAPI app.
|
||||
|
||||
Example:
|
||||
|
||||
python scripts/serve_static_editor.py
|
||||
# open http://127.0.0.1:8765/ then "Use locally" → /editor?local=1
|
||||
|
||||
Note: Pyodide and CodeMirror still load from CDNs; you need network access.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from functools import partial
|
||||
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
|
||||
parser.add_argument("--host", default="127.0.0.1", help="Bind address (default 127.0.0.1)")
|
||||
parser.add_argument("--port", type=int, default=8765, help="Port (default 8765)")
|
||||
args = parser.parse_args()
|
||||
|
||||
static_root = (Path(__file__).resolve().parent.parent / "src" / "static").resolve()
|
||||
if not static_root.is_dir():
|
||||
raise SystemExit(f"Static directory not found: {static_root}")
|
||||
|
||||
class Handler(SimpleHTTPRequestHandler):
|
||||
def __init__(self, *a, **kw):
|
||||
super().__init__(*a, directory=str(static_root), **kw)
|
||||
|
||||
def end_headers(self):
|
||||
self.send_header("Cross-Origin-Opener-Policy", "same-origin")
|
||||
self.send_header("Cross-Origin-Embedder-Policy", "credentialless")
|
||||
super().end_headers()
|
||||
|
||||
def translate_path(self, path: str) -> str:
|
||||
clean = path.split("?", 1)[0].split("#", 1)[0]
|
||||
if clean.startswith("/static/"):
|
||||
clean = clean[len("/static") :] # e.g. /styles.css, /bundled-lib/machine.py
|
||||
if clean in ("/", ""):
|
||||
clean = "/home.html"
|
||||
elif clean == "/editor" or clean.startswith("/editor/"):
|
||||
clean = "/index.html"
|
||||
elif clean == "/tutorial" or clean.startswith("/tutorial/"):
|
||||
clean = "/tutorial.html"
|
||||
elif clean == "/login" or clean.startswith("/login/"):
|
||||
clean = "/login.html"
|
||||
elif clean == "/register" or clean.startswith("/register/"):
|
||||
clean = "/register.html"
|
||||
return super().translate_path(clean)
|
||||
|
||||
httpd = ThreadingHTTPServer((args.host, args.port), Handler)
|
||||
print(f"Serving {static_root} at http://{args.host}:{args.port}/")
|
||||
print("Open / then use “Use locally” for IndexedDB workspace.")
|
||||
try:
|
||||
httpd.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
print("\nStopped.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -28,3 +28,10 @@ load_env_file(PROJECT_ROOT / ".env")
|
||||
|
||||
_default_workspace = PROJECT_ROOT / "workspace"
|
||||
WORKSPACE_ROOT = Path(os.environ.get("WORKSPACE_ROOT", str(_default_workspace))).resolve()
|
||||
|
||||
# Canonical demo source. Files here are the single source of truth for the
|
||||
# editor's "Reset demos" button and per-user account seeding. They ship with
|
||||
# the static bundle (`/static/bundled-demos/...`) so a static-only host
|
||||
# also exposes them. `workspace/` is intentionally NOT used for canonical
|
||||
# data — it is treated as runtime/user state and is gitignored.
|
||||
BUNDLED_DEMOS_DIR = STATIC_DIR / "bundled-demos"
|
||||
|
||||
@@ -38,3 +38,21 @@ class AuthSession(Base):
|
||||
created_at: Mapped[dt.datetime] = mapped_column(DateTime, default=_utc_naive)
|
||||
|
||||
user: Mapped[User] = relationship("User", back_populates="sessions")
|
||||
|
||||
|
||||
class InviteToken(Base):
|
||||
__tablename__ = "invite_tokens"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
email: Mapped[str] = mapped_column(String(320), index=True)
|
||||
token: Mapped[str] = mapped_column(String(128), unique=True, index=True)
|
||||
expires_at: Mapped[dt.datetime] = mapped_column(DateTime)
|
||||
used_at: Mapped[dt.datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
invited_by_user_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True
|
||||
)
|
||||
consumed_by_user_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True
|
||||
)
|
||||
grants_superuser: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
created_at: Mapped[dt.datetime] = mapped_column(DateTime, default=_utc_naive)
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import Cookie, Depends, Header, HTTPException
|
||||
from fastapi import Cookie, Depends, Header, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from editor_app.db.session import get_db
|
||||
from editor_app.db.models import User
|
||||
from editor_app import config
|
||||
from editor_app.services import accounts
|
||||
from editor_app.services import user_workspace
|
||||
|
||||
|
||||
def api_key_valid(authorization: str | None) -> bool:
|
||||
@@ -61,3 +64,28 @@ async def require_superuser(
|
||||
if not user.is_superuser:
|
||||
raise HTTPException(status_code=403, detail="Superuser required")
|
||||
return user
|
||||
|
||||
|
||||
def _seed_user_workspace(user_root: Path) -> None:
|
||||
user_workspace.ensure_default_code_main(user_root)
|
||||
|
||||
|
||||
async def get_workspace_root(
|
||||
user: User | None = Depends(get_current_user_optional),
|
||||
workspace_user_id: int | None = Query(default=None),
|
||||
db: Session = Depends(get_db),
|
||||
) -> Path:
|
||||
root = config.WORKSPACE_ROOT.resolve()
|
||||
if not accounts.auth_enabled() or user is None:
|
||||
return root
|
||||
target_user = user
|
||||
if workspace_user_id is not None:
|
||||
if not user.is_superuser:
|
||||
raise HTTPException(status_code=403, detail="Superuser required for workspace override")
|
||||
lookup = accounts.get_user_by_id(db, int(workspace_user_id))
|
||||
if lookup is None:
|
||||
raise HTTPException(status_code=404, detail="Workspace user not found")
|
||||
target_user = lookup
|
||||
user_root = user_workspace.user_workspace_root(target_user.id, target_user.username, workspace_root=root)
|
||||
_seed_user_workspace(user_root)
|
||||
return user_root
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import os
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import Depends, FastAPI
|
||||
from fastapi import Depends, FastAPI, Request
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from editor_app.config import STATIC_DIR, WORKSPACE_ROOT
|
||||
@@ -13,14 +14,30 @@ from editor_app.routers.auth_routes import router as auth_router
|
||||
from editor_app.routers.files import router as files_router
|
||||
from editor_app.routers.frontend import router as frontend_router
|
||||
from editor_app.routers.users_admin import router as users_admin_router
|
||||
from editor_app.services import accounts
|
||||
from editor_app.services import accounts, user_workspace
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(_app: FastAPI):
|
||||
(WORKSPACE_ROOT / "lib").mkdir(parents=True, exist_ok=True)
|
||||
# `workspace/` is gitignored runtime state. On a fresh clone it doesn't
|
||||
# exist, and in no-auth dev mode the file tree's `code/` would otherwise
|
||||
# be empty — seed every bundled demo so `pipenv run dev` after `git
|
||||
# clone` Just Works without needing user accounts. Files already in
|
||||
# `code/` are left alone (user edits are preserved across restarts).
|
||||
WORKSPACE_ROOT.mkdir(parents=True, exist_ok=True)
|
||||
user_workspace.seed_all_bundled_demos(WORKSPACE_ROOT)
|
||||
|
||||
engine = get_engine()
|
||||
Base.metadata.create_all(bind=engine)
|
||||
with engine.begin() as conn:
|
||||
cols = conn.execute(text("PRAGMA table_info(invite_tokens)")).fetchall()
|
||||
column_names = {row[1] for row in cols}
|
||||
if column_names and "grants_superuser" not in column_names:
|
||||
conn.execute(
|
||||
text(
|
||||
"ALTER TABLE invite_tokens ADD COLUMN grants_superuser BOOLEAN NOT NULL DEFAULT 0"
|
||||
)
|
||||
)
|
||||
factory = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
db = factory()
|
||||
try:
|
||||
@@ -36,6 +53,18 @@ async def lifespan(_app: FastAPI):
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
app = FastAPI(lifespan=lifespan)
|
||||
|
||||
@app.middleware("http")
|
||||
async def cross_origin_isolation(request: Request, call_next):
|
||||
"""Turn on `crossOriginIsolated` so the editor worker can use SharedArrayBuffer
|
||||
(powers the live ADC slider). `credentialless` keeps no-credential cross-origin
|
||||
imports (esm.sh, jsdelivr) loading without requiring CORP everywhere.
|
||||
"""
|
||||
response = await call_next(request)
|
||||
response.headers.setdefault("Cross-Origin-Opener-Policy", "same-origin")
|
||||
response.headers.setdefault("Cross-Origin-Embedder-Policy", "credentialless")
|
||||
return response
|
||||
|
||||
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
|
||||
app.include_router(frontend_router)
|
||||
app.include_router(auth_router)
|
||||
|
||||
@@ -33,7 +33,11 @@ def _clear_session_cookie(response: Response, request: Request) -> None:
|
||||
|
||||
@router.get("/status", response_model=AuthStatusResponse)
|
||||
async def auth_status() -> AuthStatusResponse:
|
||||
return AuthStatusResponse(auth_enabled=accounts.auth_enabled(), register_open=accounts.register_open())
|
||||
return AuthStatusResponse(
|
||||
auth_enabled=accounts.auth_enabled(),
|
||||
register_open=accounts.register_open(),
|
||||
invite_required=accounts.invite_required(),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/me")
|
||||
@@ -57,9 +61,16 @@ async def register(
|
||||
) -> UserPublic:
|
||||
if not accounts.auth_enabled():
|
||||
raise HTTPException(status_code=400, detail="Set AUTH_ENABLED=true to use accounts")
|
||||
if not accounts.register_open():
|
||||
if not accounts.register_open() and not body.invite_token:
|
||||
raise HTTPException(status_code=403, detail="Registration is disabled (AUTH_REGISTER_OPEN=false)")
|
||||
try:
|
||||
if accounts.invite_required():
|
||||
if not body.invite_token:
|
||||
raise HTTPException(status_code=403, detail="Invite token is required")
|
||||
user = accounts.register_user_with_invite(db, body.username, body.password, body.invite_token)
|
||||
elif body.invite_token:
|
||||
user = accounts.register_user_with_invite(db, body.username, body.password, body.invite_token)
|
||||
else:
|
||||
user = accounts.register_user(db, body.username, body.password)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
from fastapi import APIRouter
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from editor_app.deps import get_workspace_root
|
||||
from editor_app.models import FileContent, FolderOperation, MoveFileRequest
|
||||
from editor_app.services import filesystem
|
||||
|
||||
@@ -7,50 +10,51 @@ router = APIRouter(prefix="/api")
|
||||
|
||||
|
||||
@router.get("/files")
|
||||
async def list_files(path: str = ""):
|
||||
files = filesystem.list_files(path)
|
||||
async def list_files(path: str = "", workspace_root: Path = Depends(get_workspace_root)):
|
||||
files = filesystem.list_files(path, workspace_root=workspace_root)
|
||||
return {"files": files}
|
||||
|
||||
|
||||
@router.get("/workspace/py-sources")
|
||||
async def workspace_python_sources():
|
||||
return {"files": filesystem.collect_python_sources()}
|
||||
async def workspace_python_sources(workspace_root: Path = Depends(get_workspace_root)):
|
||||
return {"files": filesystem.collect_python_sources(workspace_root=workspace_root)}
|
||||
|
||||
|
||||
@router.get("/file/{file_path:path}")
|
||||
async def read_file(file_path: str):
|
||||
content, filename = filesystem.read_text_file(file_path)
|
||||
async def read_file(file_path: str, workspace_root: Path = Depends(get_workspace_root)):
|
||||
content, filename = filesystem.read_text_file(file_path, workspace_root=workspace_root)
|
||||
return {"content": content, "filename": filename}
|
||||
|
||||
|
||||
@router.post("/file/{file_path:path}")
|
||||
async def save_file(file_path: str, file_data: FileContent):
|
||||
filename = filesystem.save_text_file(file_path, file_data.content)
|
||||
async def save_file(file_path: str, file_data: FileContent, workspace_root: Path = Depends(get_workspace_root)):
|
||||
filename = filesystem.save_text_file(file_path, file_data.content, workspace_root=workspace_root)
|
||||
return {"message": "File saved successfully", "filename": filename}
|
||||
|
||||
|
||||
@router.post("/file-move")
|
||||
async def move_file(move_data: MoveFileRequest):
|
||||
async def move_file(move_data: MoveFileRequest, workspace_root: Path = Depends(get_workspace_root)):
|
||||
new_path, moved_type = filesystem.move_path(
|
||||
source_path=move_data.source_path,
|
||||
destination_folder=move_data.destination_folder,
|
||||
workspace_root=workspace_root,
|
||||
)
|
||||
return {"message": "Path moved successfully", "new_path": new_path, "moved_type": moved_type}
|
||||
|
||||
|
||||
@router.delete("/file/{file_path:path}")
|
||||
async def delete_file(file_path: str):
|
||||
filesystem.delete_file(file_path)
|
||||
async def delete_file(file_path: str, workspace_root: Path = Depends(get_workspace_root)):
|
||||
filesystem.delete_file(file_path, workspace_root=workspace_root)
|
||||
return {"message": "File deleted successfully"}
|
||||
|
||||
|
||||
@router.post("/folder/new/{folder_path:path}")
|
||||
async def create_folder(folder_path: str, folder_data: FolderOperation):
|
||||
folder_name = filesystem.create_folder(folder_path)
|
||||
async def create_folder(folder_path: str, folder_data: FolderOperation, workspace_root: Path = Depends(get_workspace_root)):
|
||||
folder_name = filesystem.create_folder(folder_path, workspace_root=workspace_root)
|
||||
return {"message": "Folder created successfully", "folder": folder_name}
|
||||
|
||||
|
||||
@router.delete("/folder/{folder_path:path}")
|
||||
async def delete_folder(folder_path: str):
|
||||
filesystem.delete_folder(folder_path)
|
||||
async def delete_folder(folder_path: str, workspace_root: Path = Depends(get_workspace_root)):
|
||||
filesystem.delete_folder(folder_path, workspace_root=workspace_root)
|
||||
return {"message": "Folder deleted successfully"}
|
||||
|
||||
@@ -1,11 +1,38 @@
|
||||
from fastapi import APIRouter
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
from editor_app.config import STATIC_DIR
|
||||
from editor_app.config import PROJECT_ROOT, STATIC_DIR
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/api/public/lib-bundle")
|
||||
async def serve_lib_bundle():
|
||||
"""Public, unauthenticated dump of the read-only `lib/` stubs.
|
||||
|
||||
Local-mode browsers (no login, files in IndexedDB) need access to the
|
||||
MicroPython mocks so completion, diagnostics, and `runpy` can resolve
|
||||
`from machine import …`. Reading these is read-only and contains no
|
||||
user data, so it is safe to expose without auth."""
|
||||
bundle_root = (PROJECT_ROOT / "lib").resolve()
|
||||
files: dict[str, str] = {}
|
||||
if bundle_root.is_dir():
|
||||
for path in sorted(bundle_root.rglob("*.py")):
|
||||
if not path.is_file():
|
||||
continue
|
||||
try:
|
||||
rel = path.relative_to(bundle_root)
|
||||
except ValueError:
|
||||
continue
|
||||
if any(part.startswith(".") for part in rel.parts):
|
||||
continue
|
||||
try:
|
||||
files[str(rel).replace("\\", "/")] = path.read_text(encoding="utf-8")
|
||||
except (UnicodeDecodeError, OSError):
|
||||
continue
|
||||
return {"files": files}
|
||||
|
||||
|
||||
@router.get("/")
|
||||
async def serve_home():
|
||||
return FileResponse(STATIC_DIR / "home.html")
|
||||
@@ -16,6 +43,11 @@ async def serve_frontend():
|
||||
return FileResponse(STATIC_DIR / "index.html")
|
||||
|
||||
|
||||
@router.get("/tutorial")
|
||||
async def serve_tutorial():
|
||||
return FileResponse(STATIC_DIR / "tutorial.html")
|
||||
|
||||
|
||||
@router.get("/login")
|
||||
async def serve_login():
|
||||
return FileResponse(STATIC_DIR / "login.html")
|
||||
@@ -24,3 +56,10 @@ async def serve_login():
|
||||
@router.get("/register")
|
||||
async def serve_register():
|
||||
return FileResponse(STATIC_DIR / "register.html")
|
||||
|
||||
|
||||
@router.get("/users")
|
||||
async def serve_users():
|
||||
"""Admin panel for managing accounts and invites. Auth is enforced by
|
||||
the underlying `/api/users*` endpoints, not the static page itself."""
|
||||
return FileResponse(STATIC_DIR / "users.html")
|
||||
|
||||
@@ -6,7 +6,7 @@ from sqlalchemy.orm import Session
|
||||
from editor_app.db.session import get_db
|
||||
from editor_app.db.models import User
|
||||
from editor_app.deps import require_superuser
|
||||
from editor_app.schemas.users import UserCreateAdmin, UserPublic
|
||||
from editor_app.schemas.users import InviteCreateRequest, InviteCreateResponse, UserPublic, UserUpdateAdmin
|
||||
from editor_app.services import accounts
|
||||
|
||||
router = APIRouter(prefix="/api/users", tags=["users"])
|
||||
@@ -20,21 +20,26 @@ async def list_users(
|
||||
return [UserPublic.model_validate(u) for u in accounts.list_users(db)]
|
||||
|
||||
|
||||
@router.post("", response_model=UserPublic)
|
||||
async def create_user_admin(
|
||||
body: UserCreateAdmin,
|
||||
admin: User = Depends(require_superuser),
|
||||
@router.patch("/{user_id}", response_model=UserPublic)
|
||||
async def patch_user_admin(
|
||||
user_id: int,
|
||||
body: UserUpdateAdmin,
|
||||
_admin: User = Depends(require_superuser),
|
||||
db: Session = Depends(get_db),
|
||||
) -> UserPublic:
|
||||
if accounts.get_user_by_username(db, body.username):
|
||||
raise HTTPException(status_code=409, detail="Username already taken")
|
||||
user = accounts.create_user(
|
||||
try:
|
||||
updated = accounts.update_user(
|
||||
db,
|
||||
body.username,
|
||||
body.password,
|
||||
user_id,
|
||||
username=body.username,
|
||||
password=body.password,
|
||||
is_superuser=body.is_superuser,
|
||||
)
|
||||
return UserPublic.model_validate(user)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
if updated is None:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
return UserPublic.model_validate(updated)
|
||||
|
||||
|
||||
@router.delete("/{user_id}")
|
||||
@@ -48,3 +53,26 @@ async def delete_user_admin(
|
||||
if not accounts.delete_user(db, user_id):
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
return {"message": "User deleted"}
|
||||
|
||||
|
||||
@router.post("/invites", response_model=InviteCreateResponse)
|
||||
async def create_invite_admin(
|
||||
body: InviteCreateRequest,
|
||||
admin: User = Depends(require_superuser),
|
||||
db: Session = Depends(get_db),
|
||||
) -> InviteCreateResponse:
|
||||
email = (body.email or "").strip().lower()
|
||||
invite = accounts.create_invite(
|
||||
db,
|
||||
email,
|
||||
invited_by_user_id=admin.id,
|
||||
expires_days=body.expires_days,
|
||||
)
|
||||
invite_url = accounts.build_invite_url(invite.token)
|
||||
delivered = False
|
||||
if email:
|
||||
try:
|
||||
delivered = accounts.send_invite_email(email, invite_url)
|
||||
except Exception:
|
||||
delivered = False
|
||||
return InviteCreateResponse(email=invite.email, invite_url=invite_url, delivered=delivered)
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from pydantic import BaseModel, Field, field_validator, model_validator
|
||||
|
||||
|
||||
class RegisterRequest(BaseModel):
|
||||
username: str = Field(min_length=3, max_length=64)
|
||||
password: str = Field(min_length=8, max_length=128)
|
||||
invite_token: str | None = Field(default=None, min_length=8, max_length=256)
|
||||
|
||||
@field_validator("invite_token", mode="before")
|
||||
@classmethod
|
||||
def normalize_invite_token(cls, v: object) -> object:
|
||||
if isinstance(v, str):
|
||||
s = v.strip()
|
||||
return s if s else None
|
||||
return v
|
||||
|
||||
@field_validator("username")
|
||||
@classmethod
|
||||
@@ -27,20 +36,59 @@ class UserPublic(BaseModel):
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class UserCreateAdmin(BaseModel):
|
||||
username: str = Field(min_length=3, max_length=64)
|
||||
password: str = Field(min_length=8, max_length=128)
|
||||
is_superuser: bool = False
|
||||
class UserUpdateAdmin(BaseModel):
|
||||
username: str | None = Field(default=None, min_length=3, max_length=64)
|
||||
password: str | None = Field(default=None, min_length=8, max_length=128)
|
||||
is_superuser: bool | None = None
|
||||
|
||||
@field_validator("username", "password", mode="before")
|
||||
@classmethod
|
||||
def empty_str_to_none(cls, v: object) -> object:
|
||||
if isinstance(v, str) and not v.strip():
|
||||
return None
|
||||
return v
|
||||
|
||||
@field_validator("username")
|
||||
@classmethod
|
||||
def username_chars(cls, v: str) -> str:
|
||||
def username_chars(cls, v: str | None) -> str | None:
|
||||
if v is None:
|
||||
return None
|
||||
s = v.strip()
|
||||
if not s.replace("_", "").isalnum():
|
||||
raise ValueError("Username may only contain letters, numbers, and underscores")
|
||||
return s
|
||||
|
||||
@model_validator(mode="after")
|
||||
def none_empty_patch(self):
|
||||
if self.username is None and self.password is None and self.is_superuser is None:
|
||||
raise ValueError("Provide username, password, and/or superuser changes")
|
||||
return self
|
||||
|
||||
|
||||
class AuthStatusResponse(BaseModel):
|
||||
auth_enabled: bool
|
||||
register_open: bool
|
||||
invite_required: bool = False
|
||||
|
||||
|
||||
class InviteCreateRequest(BaseModel):
|
||||
email: str | None = Field(default=None, max_length=320)
|
||||
expires_days: int = Field(default=7, ge=1, le=30)
|
||||
|
||||
@field_validator("email")
|
||||
@classmethod
|
||||
def email_sane(cls, v: str | None) -> str | None:
|
||||
if v is None:
|
||||
return None
|
||||
s = v.strip().lower()
|
||||
if not s:
|
||||
return None
|
||||
if "@" not in s or "." not in s.split("@")[-1]:
|
||||
raise ValueError("Invalid email address")
|
||||
return s
|
||||
|
||||
|
||||
class InviteCreateResponse(BaseModel):
|
||||
email: str
|
||||
invite_url: str
|
||||
delivered: bool
|
||||
|
||||
@@ -3,13 +3,16 @@ from __future__ import annotations
|
||||
import datetime as dt
|
||||
import os
|
||||
import secrets
|
||||
import smtplib
|
||||
from email.message import EmailMessage
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import bcrypt
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from editor_app.db.models import AuthSession, User
|
||||
from editor_app.db.models import AuthSession, InviteToken, User
|
||||
from editor_app.services import user_workspace
|
||||
|
||||
if TYPE_CHECKING:
|
||||
pass
|
||||
@@ -65,6 +68,10 @@ def create_user(db: Session, username: str, password: str, *, is_superuser: bool
|
||||
db.add(user)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
if auth_enabled():
|
||||
user_workspace.ensure_default_code_main(
|
||||
user_workspace.user_workspace_root(user.id, user.username)
|
||||
)
|
||||
return user
|
||||
|
||||
|
||||
@@ -75,6 +82,20 @@ def register_user(db: Session, username: str, password: str) -> User:
|
||||
return create_user(db, username, password, is_superuser=first)
|
||||
|
||||
|
||||
def register_user_with_invite(db: Session, username: str, password: str, invite_token: str) -> User:
|
||||
invite = get_valid_invite(db, invite_token)
|
||||
if invite is None:
|
||||
raise ValueError("Invite is invalid or expired")
|
||||
if get_user_by_username(db, username):
|
||||
raise ValueError("Username already taken")
|
||||
user = create_user(db, username, password, is_superuser=bool(invite.grants_superuser))
|
||||
invite.used_at = _utc_naive()
|
||||
invite.consumed_by_user_id = user.id
|
||||
db.add(invite)
|
||||
db.commit()
|
||||
return user
|
||||
|
||||
|
||||
def authenticate(db: Session, username: str, password: str) -> User | None:
|
||||
user = get_user_by_username(db, username.strip())
|
||||
if not user or not verify_password(password, user.password_hash):
|
||||
@@ -126,3 +147,116 @@ def delete_user(db: Session, user_id: int) -> bool:
|
||||
db.delete(user)
|
||||
db.commit()
|
||||
return True
|
||||
|
||||
|
||||
def update_user(
|
||||
db: Session,
|
||||
user_id: int,
|
||||
*,
|
||||
username: str | None = None,
|
||||
password: str | None = None,
|
||||
is_superuser: bool | None = None,
|
||||
) -> User | None:
|
||||
user = get_user_by_id(db, user_id)
|
||||
if user is None:
|
||||
return None
|
||||
|
||||
old_username = user.username
|
||||
|
||||
if username is not None:
|
||||
normalized = username.strip()
|
||||
if normalized != old_username:
|
||||
dup = db.scalars(select(User).where(User.username == normalized, User.id != user_id)).first()
|
||||
if dup is not None:
|
||||
raise ValueError("Username already taken")
|
||||
user_workspace.rename_user_workspace_leaf(user_id, old_username, normalized)
|
||||
user.username = normalized
|
||||
|
||||
if password is not None:
|
||||
user.password_hash = hash_password(password)
|
||||
|
||||
if is_superuser is not None:
|
||||
if user.is_superuser and not is_superuser:
|
||||
remaining = db.scalar(
|
||||
select(func.count())
|
||||
.select_from(User)
|
||||
.where(User.is_superuser.is_(True), User.id != user_id)
|
||||
)
|
||||
if not remaining:
|
||||
raise ValueError("Cannot demote the last administrator")
|
||||
user.is_superuser = is_superuser
|
||||
|
||||
db.add(user)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
def invite_required() -> bool:
|
||||
return os.environ.get("AUTH_INVITE_ONLY", "true").strip().lower() in ("1", "true", "yes", "on")
|
||||
|
||||
|
||||
def create_invite(
|
||||
db: Session,
|
||||
email: str,
|
||||
invited_by_user_id: int | None = None,
|
||||
expires_days: int = 7,
|
||||
) -> InviteToken:
|
||||
token = secrets.token_urlsafe(36)
|
||||
expires = _utc_naive() + dt.timedelta(days=max(1, min(30, int(expires_days))))
|
||||
row = InviteToken(
|
||||
email=email.strip().lower(),
|
||||
token=token,
|
||||
expires_at=expires,
|
||||
invited_by_user_id=invited_by_user_id,
|
||||
)
|
||||
db.add(row)
|
||||
db.commit()
|
||||
db.refresh(row)
|
||||
return row
|
||||
|
||||
|
||||
def get_valid_invite(db: Session, token: str | None) -> InviteToken | None:
|
||||
if not token:
|
||||
return None
|
||||
row = db.scalars(select(InviteToken).where(InviteToken.token == token.strip())).one_or_none()
|
||||
if row is None:
|
||||
return None
|
||||
if row.used_at is not None:
|
||||
return None
|
||||
if row.expires_at < _utc_naive():
|
||||
return None
|
||||
return row
|
||||
|
||||
|
||||
def build_invite_url(token: str) -> str:
|
||||
base = (os.environ.get("PUBLIC_BASE_URL") or "http://127.0.0.1:8080").rstrip("/")
|
||||
return f"{base}/register?invite={token}"
|
||||
|
||||
|
||||
def send_invite_email(email: str, invite_url: str) -> bool:
|
||||
host = (os.environ.get("SMTP_HOST") or "").strip()
|
||||
if not host:
|
||||
return False
|
||||
port = int((os.environ.get("SMTP_PORT") or "587").strip())
|
||||
user = (os.environ.get("SMTP_USER") or "").strip()
|
||||
password = os.environ.get("SMTP_PASSWORD") or ""
|
||||
sender = (os.environ.get("SMTP_FROM") or user or "noreply@python-editor.local").strip()
|
||||
use_tls = (os.environ.get("SMTP_TLS", "true").strip().lower() in ("1", "true", "yes", "on"))
|
||||
|
||||
msg = EmailMessage()
|
||||
msg["Subject"] = "Your Python Editor invite"
|
||||
msg["From"] = sender
|
||||
msg["To"] = email
|
||||
msg.set_content(
|
||||
"You have been invited to Python Editor.\n\n"
|
||||
f"Use this link to sign up:\n{invite_url}\n\n"
|
||||
"If you did not expect this invite, you can ignore this message.\n"
|
||||
)
|
||||
with smtplib.SMTP(host, port, timeout=10) as smtp:
|
||||
if use_tls:
|
||||
smtp.starttls()
|
||||
if user:
|
||||
smtp.login(user, password)
|
||||
smtp.send_message(msg)
|
||||
return True
|
||||
|
||||
@@ -10,6 +10,20 @@ LIB_DIR_NAME = "lib"
|
||||
WRITABLE_ROOTS = {"code"}
|
||||
|
||||
|
||||
def _workspace_root(workspace_root: Path | None = None) -> Path:
|
||||
return (workspace_root or config.WORKSPACE_ROOT).resolve()
|
||||
|
||||
|
||||
def _shared_lib_root() -> Path:
|
||||
"""Shared MicroPython stubs ship in the repo `lib/` directory and are read directly.
|
||||
|
||||
Treating the bundle as the single source of truth means there is no on-disk
|
||||
`workspace/lib/` cache to keep in sync — updates to e.g. `machine.py` flow
|
||||
straight through to Pyodide and the file tree.
|
||||
"""
|
||||
return (config.PROJECT_ROOT / LIB_DIR_NAME).resolve()
|
||||
|
||||
|
||||
def normalize_relative_path(relative_path: str) -> str:
|
||||
cleaned = (relative_path or "").strip().lstrip("/")
|
||||
if not cleaned:
|
||||
@@ -22,33 +36,40 @@ def normalize_relative_path(relative_path: str) -> str:
|
||||
return "/".join(parts)
|
||||
|
||||
|
||||
def resolve_workspace_path(relative_path: str) -> Path:
|
||||
def resolve_workspace_path(relative_path: str, workspace_root: Path | None = None) -> Path:
|
||||
relative_path = normalize_relative_path(relative_path)
|
||||
target_path = (config.WORKSPACE_ROOT / relative_path).resolve()
|
||||
root = _workspace_root(workspace_root)
|
||||
if relative_path == LIB_DIR_NAME or relative_path.startswith(f"{LIB_DIR_NAME}/"):
|
||||
suffix = relative_path[len(LIB_DIR_NAME) :].lstrip("/")
|
||||
target_path = (_shared_lib_root() / suffix).resolve()
|
||||
try:
|
||||
target_path.relative_to(config.WORKSPACE_ROOT.resolve())
|
||||
target_path.relative_to(_shared_lib_root())
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail="Path escapes shared lib") from exc
|
||||
return target_path
|
||||
target_path = (root / relative_path).resolve()
|
||||
try:
|
||||
target_path.relative_to(root)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail="Path escapes workspace") from exc
|
||||
return target_path
|
||||
|
||||
|
||||
def _is_path_in_lib(target_path: Path) -> bool:
|
||||
workspace = config.WORKSPACE_ROOT.resolve()
|
||||
lib_root = (workspace / LIB_DIR_NAME).resolve()
|
||||
def _is_path_in_lib(target_path: Path, workspace_root: Path | None = None) -> bool:
|
||||
try:
|
||||
target_path.resolve().relative_to(lib_root)
|
||||
target_path.resolve().relative_to(_shared_lib_root())
|
||||
return True
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
def _ensure_not_lib_path(target_path: Path) -> None:
|
||||
if _is_path_in_lib(target_path):
|
||||
def _ensure_not_lib_path(target_path: Path, workspace_root: Path | None = None) -> None:
|
||||
if _is_path_in_lib(target_path, workspace_root):
|
||||
raise HTTPException(status_code=403, detail="lib is read-only")
|
||||
|
||||
|
||||
def _is_writable_path(target_path: Path) -> bool:
|
||||
workspace = config.WORKSPACE_ROOT.resolve()
|
||||
def _is_writable_path(target_path: Path, workspace_root: Path | None = None) -> bool:
|
||||
workspace = _workspace_root(workspace_root)
|
||||
resolved = target_path.resolve()
|
||||
try:
|
||||
relative = resolved.relative_to(workspace)
|
||||
@@ -59,17 +80,18 @@ def _is_writable_path(target_path: Path) -> bool:
|
||||
return relative.parts[0] in WRITABLE_ROOTS
|
||||
|
||||
|
||||
def _ensure_writable_path(target_path: Path) -> None:
|
||||
if not _is_writable_path(target_path):
|
||||
def _ensure_writable_path(target_path: Path, workspace_root: Path | None = None) -> None:
|
||||
if not _is_writable_path(target_path, workspace_root):
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Only code/ is writable (lib is read-only)",
|
||||
)
|
||||
|
||||
|
||||
def list_files(path: str = "") -> list[FileInfo]:
|
||||
def list_files(path: str = "", workspace_root: Path | None = None) -> list[FileInfo]:
|
||||
path = normalize_relative_path(path)
|
||||
target_path = config.WORKSPACE_ROOT / path if path else config.WORKSPACE_ROOT
|
||||
root = _workspace_root(workspace_root)
|
||||
target_path = resolve_workspace_path(path, root) if path else root
|
||||
if not target_path.exists() or not target_path.is_dir():
|
||||
raise HTTPException(status_code=404, detail="Directory not found")
|
||||
|
||||
@@ -77,6 +99,8 @@ def list_files(path: str = "") -> list[FileInfo]:
|
||||
for item in sorted(target_path.iterdir()):
|
||||
if item.name.startswith("."):
|
||||
continue
|
||||
if not path and item.name == "users":
|
||||
continue
|
||||
files.append(
|
||||
FileInfo(
|
||||
name=item.name,
|
||||
@@ -84,11 +108,15 @@ def list_files(path: str = "") -> list[FileInfo]:
|
||||
size=item.stat().st_size if item.is_file() else None,
|
||||
)
|
||||
)
|
||||
if not path:
|
||||
shared_lib = _shared_lib_root()
|
||||
if shared_lib.exists() and not any(f.name == LIB_DIR_NAME for f in files):
|
||||
files.append(FileInfo(name=LIB_DIR_NAME, is_directory=True, size=None))
|
||||
return files
|
||||
|
||||
|
||||
def read_text_file(file_path: str) -> tuple[str, str]:
|
||||
target_path = resolve_workspace_path(file_path)
|
||||
def read_text_file(file_path: str, workspace_root: Path | None = None) -> tuple[str, str]:
|
||||
target_path = resolve_workspace_path(file_path, workspace_root)
|
||||
if not target_path.exists():
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
if target_path.is_dir():
|
||||
@@ -100,19 +128,19 @@ def read_text_file(file_path: str) -> tuple[str, str]:
|
||||
return content, target_path.name
|
||||
|
||||
|
||||
def save_text_file(file_path: str, content: str) -> str:
|
||||
target_path = resolve_workspace_path(file_path)
|
||||
_ensure_not_lib_path(target_path)
|
||||
_ensure_writable_path(target_path)
|
||||
def save_text_file(file_path: str, content: str, workspace_root: Path | None = None) -> str:
|
||||
target_path = resolve_workspace_path(file_path, workspace_root)
|
||||
_ensure_not_lib_path(target_path, workspace_root)
|
||||
_ensure_writable_path(target_path, workspace_root)
|
||||
target_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
target_path.write_text(content, encoding="utf-8")
|
||||
return target_path.name
|
||||
|
||||
|
||||
def delete_file(file_path: str) -> None:
|
||||
target_path = resolve_workspace_path(file_path)
|
||||
_ensure_not_lib_path(target_path)
|
||||
_ensure_writable_path(target_path)
|
||||
def delete_file(file_path: str, workspace_root: Path | None = None) -> None:
|
||||
target_path = resolve_workspace_path(file_path, workspace_root)
|
||||
_ensure_not_lib_path(target_path, workspace_root)
|
||||
_ensure_writable_path(target_path, workspace_root)
|
||||
if not target_path.exists():
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
if target_path.is_dir():
|
||||
@@ -120,20 +148,21 @@ def delete_file(file_path: str) -> None:
|
||||
target_path.unlink()
|
||||
|
||||
|
||||
def move_path(source_path: str, destination_folder: str) -> tuple[str, str]:
|
||||
source = resolve_workspace_path(source_path)
|
||||
_ensure_not_lib_path(source)
|
||||
_ensure_writable_path(source)
|
||||
def move_path(source_path: str, destination_folder: str, workspace_root: Path | None = None) -> tuple[str, str]:
|
||||
root = _workspace_root(workspace_root)
|
||||
source = resolve_workspace_path(source_path, root)
|
||||
_ensure_not_lib_path(source, root)
|
||||
_ensure_writable_path(source, root)
|
||||
if not source.exists():
|
||||
raise HTTPException(status_code=404, detail="Source path not found")
|
||||
|
||||
destination_dir = (
|
||||
resolve_workspace_path(destination_folder)
|
||||
resolve_workspace_path(destination_folder, root)
|
||||
if destination_folder
|
||||
else config.WORKSPACE_ROOT
|
||||
else root
|
||||
)
|
||||
_ensure_not_lib_path(destination_dir)
|
||||
_ensure_writable_path(destination_dir)
|
||||
_ensure_not_lib_path(destination_dir, root)
|
||||
_ensure_writable_path(destination_dir, root)
|
||||
if not destination_dir.exists() or not destination_dir.is_dir():
|
||||
raise HTTPException(status_code=404, detail="Destination folder not found")
|
||||
|
||||
@@ -157,23 +186,23 @@ def move_path(source_path: str, destination_folder: str) -> tuple[str, str]:
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
source.rename(destination)
|
||||
moved_type = "folder" if destination.is_dir() else "file"
|
||||
return str(destination.relative_to(config.WORKSPACE_ROOT)), moved_type
|
||||
return str(destination.relative_to(root)), moved_type
|
||||
|
||||
|
||||
def create_folder(folder_path: str) -> str:
|
||||
target_path = resolve_workspace_path(folder_path)
|
||||
_ensure_not_lib_path(target_path)
|
||||
_ensure_writable_path(target_path)
|
||||
def create_folder(folder_path: str, workspace_root: Path | None = None) -> str:
|
||||
target_path = resolve_workspace_path(folder_path, workspace_root)
|
||||
_ensure_not_lib_path(target_path, workspace_root)
|
||||
_ensure_writable_path(target_path, workspace_root)
|
||||
if target_path.exists():
|
||||
raise HTTPException(status_code=400, detail="Folder already exists")
|
||||
target_path.mkdir(parents=True, exist_ok=False)
|
||||
return target_path.name
|
||||
|
||||
|
||||
def delete_folder(folder_path: str) -> None:
|
||||
target_path = resolve_workspace_path(folder_path)
|
||||
_ensure_not_lib_path(target_path)
|
||||
_ensure_writable_path(target_path)
|
||||
def delete_folder(folder_path: str, workspace_root: Path | None = None) -> None:
|
||||
target_path = resolve_workspace_path(folder_path, workspace_root)
|
||||
_ensure_not_lib_path(target_path, workspace_root)
|
||||
_ensure_writable_path(target_path, workspace_root)
|
||||
if not target_path.exists():
|
||||
raise HTTPException(status_code=404, detail="Folder not found")
|
||||
if not target_path.is_dir():
|
||||
@@ -181,12 +210,11 @@ def delete_folder(folder_path: str) -> None:
|
||||
shutil.rmtree(target_path)
|
||||
|
||||
|
||||
def collect_python_sources() -> dict[str, str]:
|
||||
"""Return all UTF-8 .py files under the workspace for browser-side Pyodide sync."""
|
||||
def collect_python_sources(workspace_root: Path | None = None) -> dict[str, str]:
|
||||
"""Return UTF-8 `.py` under the scoped workspace plus shared stubs from `WORKSPACE_ROOT/lib/` (bundled Micropython mocks for Jedi/completion and Pyodide)."""
|
||||
result: dict[str, str] = {}
|
||||
workspace = config.WORKSPACE_ROOT.resolve()
|
||||
if not workspace.exists():
|
||||
return result
|
||||
workspace = _workspace_root(workspace_root)
|
||||
if workspace.exists():
|
||||
for path in workspace.rglob("*.py"):
|
||||
try:
|
||||
rel = path.relative_to(workspace)
|
||||
@@ -199,4 +227,18 @@ def collect_python_sources() -> dict[str, str]:
|
||||
result[key] = path.read_text(encoding="utf-8")
|
||||
except (UnicodeDecodeError, OSError):
|
||||
continue
|
||||
shared_lib = _shared_lib_root()
|
||||
if shared_lib.is_dir():
|
||||
for path in shared_lib.rglob("*.py"):
|
||||
try:
|
||||
rel = path.relative_to(shared_lib)
|
||||
except ValueError:
|
||||
continue
|
||||
if any(part.startswith(".") for part in rel.parts):
|
||||
continue
|
||||
try:
|
||||
key = str(Path(LIB_DIR_NAME) / rel).replace("\\", "/")
|
||||
result[key] = path.read_text(encoding="utf-8")
|
||||
except (UnicodeDecodeError, OSError):
|
||||
continue
|
||||
return result
|
||||
|
||||
112
src/editor_app/services/user_workspace.py
Normal file
112
src/editor_app/services/user_workspace.py
Normal file
@@ -0,0 +1,112 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
from editor_app import config
|
||||
|
||||
DEFAULT_MAIN_PY = 'print("Hello, World!")\n'
|
||||
|
||||
# Self-contained demos copied from shipped `workspace/code/` (stdlib + machine/neopixel/time only).
|
||||
# New accounts get a copy of each one in their own `code/` folder so the
|
||||
# editor has something to show on first login. They're treated as
|
||||
# starting points — users can edit/delete freely without affecting the
|
||||
# shipped originals.
|
||||
_CANONICAL_DEMO_FILENAMES = (
|
||||
"pattern_rainbow_demo.py",
|
||||
"pattern_twinkle_demo.py",
|
||||
"pattern_chase_demo.py",
|
||||
"adc_slider_demo.py",
|
||||
"pin_demo.py",
|
||||
"serial_demo.py",
|
||||
)
|
||||
|
||||
|
||||
def safe_workspace_leaf(username: str, user_id: int) -> str:
|
||||
base = re.sub(r"[^a-zA-Z0-9._-]+", "-", username.strip()).strip("-").lower() or "user"
|
||||
return f"{base}-{user_id}"
|
||||
|
||||
|
||||
def user_workspace_root(user_id: int, username: str, workspace_root: Path | None = None) -> Path:
|
||||
root = (workspace_root or config.WORKSPACE_ROOT).resolve()
|
||||
return root / "users" / safe_workspace_leaf(username, user_id)
|
||||
|
||||
|
||||
def _seed_canonical_demos_into_code(code_dir: Path) -> None:
|
||||
"""Copy bundled demos into a user's `code/` if missing.
|
||||
|
||||
Reads from `BUNDLED_DEMOS_DIR` (single source of truth, ships under
|
||||
`src/static/bundled-demos/`), never from `workspace/`, so this works
|
||||
even when `workspace/` is empty (gitignored runtime directory).
|
||||
"""
|
||||
src_root = config.BUNDLED_DEMOS_DIR.resolve()
|
||||
for filename in _CANONICAL_DEMO_FILENAMES:
|
||||
dst = code_dir / filename
|
||||
if dst.exists():
|
||||
continue
|
||||
src = src_root / filename
|
||||
if src.is_file():
|
||||
dst.write_text(src.read_text(encoding="utf-8"), encoding="utf-8")
|
||||
|
||||
|
||||
def ensure_default_code_main(user_root: Path) -> None:
|
||||
"""Ensure code/ has main.py and the canonical NeoPixel demos.
|
||||
|
||||
Demos are sourced from `BUNDLED_DEMOS_DIR` (the single committed home
|
||||
for sample scripts). Only files listed in `_CANONICAL_DEMO_FILENAMES`
|
||||
get auto-seeded — the rest are available via the editor's "Reset
|
||||
demos" button or a manual copy."""
|
||||
code_dir = user_root / "code"
|
||||
code_dir.mkdir(parents=True, exist_ok=True)
|
||||
main_py = code_dir / "main.py"
|
||||
if not main_py.exists():
|
||||
main_py.write_text(DEFAULT_MAIN_PY, encoding="utf-8")
|
||||
_seed_canonical_demos_into_code(code_dir)
|
||||
|
||||
|
||||
def seed_all_bundled_demos(user_root: Path) -> None:
|
||||
"""Copy *every* file in `BUNDLED_DEMOS_DIR` into `<user_root>/code/`.
|
||||
|
||||
Used at app startup to populate a fresh `workspace/code/` with the
|
||||
full sample set so a no-auth dev install (`pipenv run dev` after
|
||||
`git clone`) has something to play with. Existing files are not
|
||||
overwritten — user edits are preserved.
|
||||
"""
|
||||
code_dir = user_root / "code"
|
||||
code_dir.mkdir(parents=True, exist_ok=True)
|
||||
main_py = code_dir / "main.py"
|
||||
if not main_py.exists():
|
||||
main_py.write_text(DEFAULT_MAIN_PY, encoding="utf-8")
|
||||
src_root = config.BUNDLED_DEMOS_DIR.resolve()
|
||||
if not src_root.is_dir():
|
||||
return
|
||||
for src in sorted(src_root.iterdir()):
|
||||
if not src.is_file() or not src.name.endswith(".py"):
|
||||
continue
|
||||
dst = code_dir / src.name
|
||||
if dst.exists():
|
||||
continue
|
||||
try:
|
||||
dst.write_text(src.read_text(encoding="utf-8"), encoding="utf-8")
|
||||
except (UnicodeDecodeError, OSError):
|
||||
continue
|
||||
|
||||
|
||||
def rename_user_workspace_leaf(
|
||||
user_id: int, old_username: str, new_username: str, workspace_root: Path | None = None
|
||||
) -> None:
|
||||
"""Rename per-user workspace directory when login name changes."""
|
||||
root = (workspace_root or config.WORKSPACE_ROOT).resolve()
|
||||
users_dir = root / "users"
|
||||
src = users_dir / safe_workspace_leaf(old_username, user_id)
|
||||
dst = users_dir / safe_workspace_leaf(new_username, user_id)
|
||||
if src.resolve() == dst.resolve():
|
||||
return
|
||||
dst.parent.mkdir(parents=True, exist_ok=True)
|
||||
if dst.exists():
|
||||
raise ValueError("Workspace folder for new username already exists; pick another username.")
|
||||
if src.exists():
|
||||
shutil.move(str(src), str(dst))
|
||||
else:
|
||||
ensure_default_code_main(dst)
|
||||
55
src/static/bundled-demos/adc_slider_demo.py
Normal file
55
src/static/bundled-demos/adc_slider_demo.py
Normal file
@@ -0,0 +1,55 @@
|
||||
"""ADC slider demo — drag the sliders that appear under the editor.
|
||||
|
||||
Two simulated ADCs:
|
||||
* pin 34 — sets the base hue of a rainbow
|
||||
* pin 35 — sets overall brightness
|
||||
|
||||
The strip lights up while the script runs; the values update live (no need to
|
||||
restart the script when you move the slider).
|
||||
"""
|
||||
|
||||
import time
|
||||
|
||||
from machine import ADC, Pin
|
||||
from neopixel import NeoPixel
|
||||
|
||||
|
||||
NUM_LEDS = 16
|
||||
strip = NeoPixel(Pin(5, Pin.OUT), NUM_LEDS)
|
||||
|
||||
hue_pot = ADC(Pin(34))
|
||||
bri_pot = ADC(Pin(35))
|
||||
|
||||
|
||||
def hsv_to_rgb(h, s, v):
|
||||
h = h - int(h)
|
||||
i = int(h * 6)
|
||||
f = h * 6 - i
|
||||
p = v * (1 - s)
|
||||
q = v * (1 - f * s)
|
||||
t = v * (1 - (1 - f) * s)
|
||||
if i == 0:
|
||||
r, g, b = v, t, p
|
||||
elif i == 1:
|
||||
r, g, b = q, v, p
|
||||
elif i == 2:
|
||||
r, g, b = p, v, t
|
||||
elif i == 3:
|
||||
r, g, b = p, q, v
|
||||
elif i == 4:
|
||||
r, g, b = t, p, v
|
||||
else:
|
||||
r, g, b = v, p, q
|
||||
return int(r * 255), int(g * 255), int(b * 255)
|
||||
|
||||
|
||||
print("Move the ADC sliders below the editor while this runs.")
|
||||
|
||||
while True:
|
||||
base_hue = hue_pot.read_u16() / 65535
|
||||
brightness = bri_pot.read_u16() / 65535
|
||||
for i in range(NUM_LEDS):
|
||||
h = (base_hue + i / NUM_LEDS) % 1.0
|
||||
strip[i] = hsv_to_rgb(h, 1.0, brightness)
|
||||
strip.write()
|
||||
time.sleep(0.04)
|
||||
143
src/static/bundled-demos/led_patterns.py
Normal file
143
src/static/bundled-demos/led_patterns.py
Normal file
@@ -0,0 +1,143 @@
|
||||
"""LED pattern helpers inspired by embedded NeoPixel drivers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
|
||||
|
||||
Color = tuple[int, int, int]
|
||||
|
||||
|
||||
def _clamp(channel: int) -> int:
|
||||
return max(0, min(255, int(channel)))
|
||||
|
||||
|
||||
def wheel(pos: int) -> Color:
|
||||
"""Return a rainbow color for position 0-255."""
|
||||
pos = 255 - (pos & 255)
|
||||
if pos < 85:
|
||||
return (_clamp(255 - pos * 3), 0, _clamp(pos * 3))
|
||||
if pos < 170:
|
||||
pos -= 85
|
||||
return (0, _clamp(pos * 3), _clamp(255 - pos * 3))
|
||||
pos -= 170
|
||||
return (_clamp(pos * 3), _clamp(255 - pos * 3), 0)
|
||||
|
||||
|
||||
def rainbow_frame(led_count: int, frame: int, step: int = 4) -> list[Color]:
|
||||
"""Generate one rainbow frame across all LEDs."""
|
||||
if led_count <= 0:
|
||||
return []
|
||||
return [wheel((i * 256 // led_count + frame * step) & 255) for i in range(led_count)]
|
||||
|
||||
|
||||
def chase_frame(
|
||||
led_count: int,
|
||||
frame: int,
|
||||
color: Color = (255, 120, 0),
|
||||
tail: Color = (16, 0, 0),
|
||||
) -> list[Color]:
|
||||
"""Generate a two-pixel chase pattern."""
|
||||
if led_count <= 0:
|
||||
return []
|
||||
out: list[Color] = [(0, 0, 0) for _ in range(led_count)]
|
||||
head = frame % led_count
|
||||
trail = (head - 1) % led_count
|
||||
out[trail] = tuple(_clamp(v) for v in tail) # type: ignore[assignment]
|
||||
out[head] = tuple(_clamp(v) for v in color) # type: ignore[assignment]
|
||||
return out
|
||||
|
||||
|
||||
def _bounce_head_index(led_count: int, frame: int) -> int:
|
||||
"""Map frame to a triangular index sweep 0..N-1..0 (Ping-Pong position)."""
|
||||
if led_count <= 1:
|
||||
return 0
|
||||
span = led_count - 1
|
||||
cycle = span * 2
|
||||
if cycle <= 0:
|
||||
return 0
|
||||
t = frame % cycle
|
||||
return t if t <= span else 2 * span - t
|
||||
|
||||
|
||||
def _bounce_phase_tail_direction(led_count: int, frame: int) -> int:
|
||||
"""Extend tail opposite motion: -1 fades toward lower indices, +1 toward higher."""
|
||||
if led_count <= 1:
|
||||
return -1
|
||||
span = led_count - 1
|
||||
cycle = span * 2
|
||||
if cycle <= 0:
|
||||
return -1
|
||||
t = frame % cycle
|
||||
if t <= span:
|
||||
return -1
|
||||
return 1
|
||||
|
||||
|
||||
def knight_rider_scanner_frame(
|
||||
led_count: int,
|
||||
frame: int,
|
||||
head_color: Color = (220, 0, 28),
|
||||
tail_len: int = 8,
|
||||
falloff_gamma: float = 2.6,
|
||||
) -> list[Color]:
|
||||
"""KITT-style bouncing scanner: saturated head with exponential tail fading to off."""
|
||||
if led_count <= 0:
|
||||
return []
|
||||
out: list[Color] = [(0, 0, 0) for _ in range(led_count)]
|
||||
tl = max(1, tail_len)
|
||||
head = _bounce_head_index(led_count, frame)
|
||||
direc = _bounce_phase_tail_direction(led_count, frame)
|
||||
gamma = max(1.05, falloff_gamma)
|
||||
for rk in reversed(range(tl)):
|
||||
idx = head + direc * rk
|
||||
if idx < 0 or idx >= led_count:
|
||||
continue
|
||||
w = max(0.0, float(tl - rk) / float(tl))
|
||||
strength = w**gamma
|
||||
out[idx] = tuple(_clamp(int(head_color[ch] * strength)) for ch in range(3))
|
||||
return out
|
||||
|
||||
|
||||
def scanner_bounce_frame(
|
||||
led_count: int,
|
||||
frame: int,
|
||||
head_color: Color = (0, 220, 255),
|
||||
tail_color: Color = (0, 40, 90),
|
||||
tail_len: int = 5,
|
||||
) -> list[Color]:
|
||||
"""Ping-pong scanner: head reverses at both ends with a directional fading tail."""
|
||||
if led_count <= 0:
|
||||
return []
|
||||
out: list[Color] = [(0, 0, 0) for _ in range(led_count)]
|
||||
tl = max(1, tail_len)
|
||||
for rk in reversed(range(tl)):
|
||||
past = frame - rk
|
||||
if past < 0:
|
||||
continue
|
||||
idx = _bounce_head_index(led_count, past)
|
||||
strength = max(0.0, float(tl - rk) / float(tl))
|
||||
if rk == 0:
|
||||
out[idx] = tuple(_clamp(int(c)) for c in head_color)
|
||||
else:
|
||||
out[idx] = tuple(_clamp(int(tail_color[i] * strength)) for i in range(3))
|
||||
return out
|
||||
|
||||
|
||||
def twinkle_frame(
|
||||
led_count: int,
|
||||
frame: int,
|
||||
base: Color = (0, 0, 8),
|
||||
sparkle: Color = (255, 255, 180),
|
||||
sparkles: int = 3,
|
||||
seed: int = 1337,
|
||||
) -> list[Color]:
|
||||
"""Generate deterministic twinkle frames for testing/replay."""
|
||||
if led_count <= 0:
|
||||
return []
|
||||
out: list[Color] = [tuple(_clamp(v) for v in base) for _ in range(led_count)] # type: ignore[list-item]
|
||||
rng = random.Random(seed + frame)
|
||||
for _ in range(min(max(0, sparkles), led_count)):
|
||||
idx = rng.randrange(led_count)
|
||||
out[idx] = tuple(_clamp(v) for v in sparkle) # type: ignore[assignment]
|
||||
return out
|
||||
50
src/static/bundled-demos/led_tutorial.py
Normal file
50
src/static/bundled-demos/led_tutorial.py
Normal file
@@ -0,0 +1,50 @@
|
||||
"""LED tutorial script for NeoPixel in the browser editor.
|
||||
|
||||
Run this file and watch the in-app NeoPixel simulator panel.
|
||||
"""
|
||||
|
||||
from machine import Pin
|
||||
import neopixel
|
||||
import time
|
||||
|
||||
|
||||
LED_COUNT = 12
|
||||
np = neopixel.NeoPixel(Pin(4), LED_COUNT)
|
||||
|
||||
|
||||
def show_step(title: str):
|
||||
print(f"\n--- {title} ---")
|
||||
|
||||
|
||||
show_step("Step 1: single colors")
|
||||
np.fill((0, 0, 0))
|
||||
np.write()
|
||||
time.sleep(0.2)
|
||||
np[0] = (255, 0, 0) # red
|
||||
np[1] = (0, 255, 0) # green
|
||||
np[2] = (0, 0, 255) # blue
|
||||
np.write()
|
||||
time.sleep(0.8)
|
||||
|
||||
show_step("Step 2: fill strip")
|
||||
np.fill((40, 0, 120))
|
||||
np.write()
|
||||
time.sleep(0.6)
|
||||
|
||||
show_step("Step 3: moving pixel")
|
||||
for i in range(len(np)):
|
||||
np.fill((0, 0, 0))
|
||||
np[i] = (255, 120, 0)
|
||||
np.write()
|
||||
time.sleep(0.06)
|
||||
|
||||
show_step("Step 4: simple pulse")
|
||||
for level in list(range(0, 200, 20)) + list(range(200, -1, -20)):
|
||||
np.fill((level, 0, level // 3))
|
||||
np.write()
|
||||
time.sleep(0.05)
|
||||
|
||||
show_step("Done")
|
||||
np.fill((0, 0, 0))
|
||||
np.write()
|
||||
print("Tutorial complete.")
|
||||
10
src/static/bundled-demos/manifest.json
Normal file
10
src/static/bundled-demos/manifest.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"files": [
|
||||
"pattern_rainbow_demo.py",
|
||||
"pattern_twinkle_demo.py",
|
||||
"pattern_chase_demo.py",
|
||||
"adc_slider_demo.py",
|
||||
"pin_demo.py",
|
||||
"serial_demo.py"
|
||||
]
|
||||
}
|
||||
12
src/static/bundled-demos/neopixel_demo.py
Normal file
12
src/static/bundled-demos/neopixel_demo.py
Normal file
@@ -0,0 +1,12 @@
|
||||
"""Example ESP32 NeoPixel script using mock modules in browser."""
|
||||
|
||||
from machine import Pin
|
||||
import neopixel
|
||||
|
||||
|
||||
np = neopixel.NeoPixel(Pin(4), 8)
|
||||
np.fill((0, 0, 0))
|
||||
np[0] = (255, 0, 0)
|
||||
np[1] = (0, 255, 0)
|
||||
np[2] = (0, 0, 255)
|
||||
np.write()
|
||||
32
src/static/bundled-demos/neopixel_time_test.py
Normal file
32
src/static/bundled-demos/neopixel_time_test.py
Normal file
@@ -0,0 +1,32 @@
|
||||
"""NeoPixel time-based animation test for the browser simulator."""
|
||||
|
||||
from machine import Pin
|
||||
import neopixel
|
||||
import time
|
||||
|
||||
|
||||
np = neopixel.NeoPixel(Pin(4), 50)
|
||||
|
||||
|
||||
def wheel(pos: int) -> tuple[int, int, int]:
|
||||
"""Generate rainbow colors across 0-255 positions."""
|
||||
pos = 255 - (pos & 255)
|
||||
if pos < 85:
|
||||
return (255 - pos * 3, 0, pos * 3)
|
||||
if pos < 170:
|
||||
pos -= 85
|
||||
return (0, pos * 3, 255 - pos * 3)
|
||||
pos -= 170
|
||||
return (pos * 3, 255 - pos * 3, 0)
|
||||
|
||||
|
||||
print("Starting NeoPixel time test...")
|
||||
for frame in range(60):
|
||||
for i in range(len(np)):
|
||||
np[i] = wheel((i * 256 // len(np) + frame * 4) & 255)
|
||||
np.write()
|
||||
time.sleep(0.08)
|
||||
|
||||
np.fill((0, 0, 0))
|
||||
np.write()
|
||||
print("NeoPixel time test complete.")
|
||||
41
src/static/bundled-demos/panel16_bounce.py
Normal file
41
src/static/bundled-demos/panel16_bounce.py
Normal file
@@ -0,0 +1,41 @@
|
||||
"""16x16 bouncing pixel with fading trail."""
|
||||
|
||||
from machine import Pin
|
||||
import neopixel
|
||||
import time
|
||||
|
||||
from panel16_utils import PANEL_H, PANEL_W, clamp8, xy_to_index
|
||||
|
||||
|
||||
np = neopixel.NeoPixel(Pin(4), PANEL_W * PANEL_H)
|
||||
|
||||
trail = [[0 for _ in range(PANEL_W)] for _ in range(PANEL_H)]
|
||||
x = 0
|
||||
y = 0
|
||||
vx = 1
|
||||
vy = 1
|
||||
|
||||
for _frame in range(420):
|
||||
for yy in range(PANEL_H):
|
||||
for xx in range(PANEL_W):
|
||||
trail[yy][xx] = max(0, trail[yy][xx] - 14)
|
||||
|
||||
trail[y][x] = 255
|
||||
|
||||
for yy in range(PANEL_H):
|
||||
for xx in range(PANEL_W):
|
||||
v = trail[yy][xx]
|
||||
np[xy_to_index(xx, yy)] = (clamp8(v), clamp8(v // 2), clamp8(30))
|
||||
|
||||
np.write()
|
||||
time.sleep(0.02)
|
||||
|
||||
x += vx
|
||||
y += vy
|
||||
if x <= 0 or x >= PANEL_W - 1:
|
||||
vx *= -1
|
||||
if y <= 0 or y >= PANEL_H - 1:
|
||||
vy *= -1
|
||||
|
||||
np.fill((0, 0, 0))
|
||||
np.write()
|
||||
37
src/static/bundled-demos/panel16_matrix_rain.py
Normal file
37
src/static/bundled-demos/panel16_matrix_rain.py
Normal file
@@ -0,0 +1,37 @@
|
||||
"""16x16 matrix-style rain animation."""
|
||||
|
||||
from machine import Pin
|
||||
import neopixel
|
||||
import random
|
||||
import time
|
||||
|
||||
from panel16_utils import PANEL_H, PANEL_W, clamp8, xy_to_index
|
||||
|
||||
|
||||
np = neopixel.NeoPixel(Pin(4), PANEL_W * PANEL_H)
|
||||
rng = random.Random(42)
|
||||
|
||||
heads = [rng.randrange(-PANEL_H, 0) for _ in range(PANEL_W)]
|
||||
|
||||
for _frame in range(320):
|
||||
for y in range(PANEL_H):
|
||||
for x in range(PANEL_W):
|
||||
np[xy_to_index(x, y)] = (0, 0, 0)
|
||||
|
||||
for x in range(PANEL_W):
|
||||
heads[x] += 1
|
||||
if heads[x] > PANEL_H + 6:
|
||||
heads[x] = rng.randrange(-PANEL_H, 0)
|
||||
|
||||
head_y = heads[x]
|
||||
for tail in range(8):
|
||||
y = head_y - tail
|
||||
if 0 <= y < PANEL_H:
|
||||
brightness = clamp8(255 - tail * 36)
|
||||
np[xy_to_index(x, y)] = (0, brightness, 0)
|
||||
|
||||
np.write()
|
||||
time.sleep(0.045)
|
||||
|
||||
np.fill((0, 0, 0))
|
||||
np.write()
|
||||
33
src/static/bundled-demos/panel16_rainbow_wave.py
Normal file
33
src/static/bundled-demos/panel16_rainbow_wave.py
Normal file
@@ -0,0 +1,33 @@
|
||||
"""16x16 rainbow wave animation."""
|
||||
|
||||
from machine import Pin
|
||||
import neopixel
|
||||
import time
|
||||
|
||||
from panel16_utils import PANEL_H, PANEL_W, clamp8, xy_to_index
|
||||
|
||||
|
||||
np = neopixel.NeoPixel(Pin(4), PANEL_W * PANEL_H)
|
||||
|
||||
|
||||
def wheel(pos: int) -> tuple[int, int, int]:
|
||||
pos = 255 - (pos & 255)
|
||||
if pos < 85:
|
||||
return (clamp8(255 - pos * 3), 0, clamp8(pos * 3))
|
||||
if pos < 170:
|
||||
pos -= 85
|
||||
return (0, clamp8(pos * 3), clamp8(255 - pos * 3))
|
||||
pos -= 170
|
||||
return (clamp8(pos * 3), clamp8(255 - pos * 3), 0)
|
||||
|
||||
|
||||
for frame in range(240):
|
||||
for y in range(PANEL_H):
|
||||
for x in range(PANEL_W):
|
||||
color_pos = ((x * 10) + (y * 6) + frame * 5) & 255
|
||||
np[xy_to_index(x, y)] = wheel(color_pos)
|
||||
np.write()
|
||||
time.sleep(0.04)
|
||||
|
||||
np.fill((0, 0, 0))
|
||||
np.write()
|
||||
26
src/static/bundled-demos/panel16_utils.py
Normal file
26
src/static/bundled-demos/panel16_utils.py
Normal file
@@ -0,0 +1,26 @@
|
||||
"""Helpers for 16x16 serpentine NeoPixel panel animations.
|
||||
|
||||
Mapping matches the simulator's 16x16 mode:
|
||||
- first LED at top-right
|
||||
- row 0 goes right -> left
|
||||
- row 1 goes left -> right
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
PANEL_W = 16
|
||||
PANEL_H = 16
|
||||
|
||||
|
||||
def xy_to_index(x: int, y: int, width: int = PANEL_W) -> int:
|
||||
"""Map panel coordinate (x, y) to LED index."""
|
||||
x = int(x)
|
||||
y = int(y)
|
||||
if y % 2 == 0:
|
||||
return y * width + (width - 1 - x)
|
||||
return y * width + x
|
||||
|
||||
|
||||
def clamp8(v: int) -> int:
|
||||
return max(0, min(255, int(v)))
|
||||
83
src/static/bundled-demos/pattern_chase_demo.py
Normal file
83
src/static/bundled-demos/pattern_chase_demo.py
Normal file
@@ -0,0 +1,83 @@
|
||||
"""Knight Rider–style bouncing scanner — self-contained (stdlib + simulated hardware only)."""
|
||||
|
||||
import time
|
||||
|
||||
from machine import Pin
|
||||
import neopixel
|
||||
|
||||
# --- helpers
|
||||
|
||||
|
||||
def _clamp(channel: int) -> int:
|
||||
return max(0, min(255, int(channel)))
|
||||
|
||||
|
||||
def _bounce_head_index(led_count: int, frame: int) -> int:
|
||||
if led_count <= 1:
|
||||
return 0
|
||||
span = led_count - 1
|
||||
cycle = span * 2
|
||||
if cycle <= 0:
|
||||
return 0
|
||||
t = frame % cycle
|
||||
return t if t <= span else 2 * span - t
|
||||
|
||||
|
||||
def _bounce_phase_tail_direction(led_count: int, frame: int) -> int:
|
||||
if led_count <= 1:
|
||||
return -1
|
||||
span = led_count - 1
|
||||
cycle = span * 2
|
||||
if cycle <= 0:
|
||||
return -1
|
||||
t = frame % cycle
|
||||
if t <= span:
|
||||
return -1
|
||||
return 1
|
||||
|
||||
|
||||
def knight_rider_scanner_frame(
|
||||
led_count: int,
|
||||
frame: int,
|
||||
head_color=(220, 0, 28),
|
||||
tail_len: int = 8,
|
||||
falloff_gamma: float = 2.6,
|
||||
):
|
||||
if led_count <= 0:
|
||||
return []
|
||||
out = [(0, 0, 0) for _ in range(led_count)]
|
||||
tl = max(1, tail_len)
|
||||
head = _bounce_head_index(led_count, frame)
|
||||
direc = _bounce_phase_tail_direction(led_count, frame)
|
||||
gamma = max(1.05, falloff_gamma)
|
||||
for rk in reversed(range(tl)):
|
||||
idx = head + direc * rk
|
||||
if idx < 0 or idx >= led_count:
|
||||
continue
|
||||
w = max(0.0, float(tl - rk) / float(tl))
|
||||
strength = w**gamma
|
||||
out[idx] = tuple(_clamp(int(head_color[ch] * strength)) for ch in range(3))
|
||||
return out
|
||||
|
||||
|
||||
# --- demo
|
||||
|
||||
NUM_LEDS = 16
|
||||
|
||||
np = neopixel.NeoPixel(Pin(4), NUM_LEDS)
|
||||
|
||||
for frame in range(200):
|
||||
frame_colors = knight_rider_scanner_frame(
|
||||
len(np),
|
||||
frame,
|
||||
head_color=(220, 0, 36),
|
||||
tail_len=10,
|
||||
falloff_gamma=2.85,
|
||||
)
|
||||
for i, color in enumerate(frame_colors):
|
||||
np[i] = color
|
||||
np.write()
|
||||
time.sleep(0.05)
|
||||
|
||||
np.fill((0, 0, 0))
|
||||
np.write()
|
||||
47
src/static/bundled-demos/pattern_rainbow_demo.py
Normal file
47
src/static/bundled-demos/pattern_rainbow_demo.py
Normal file
@@ -0,0 +1,47 @@
|
||||
"""Rainbow NeoPixel sweep — self-contained (stdlib + simulated hardware only)."""
|
||||
|
||||
import time
|
||||
|
||||
from machine import Pin
|
||||
import neopixel
|
||||
|
||||
# --- helpers (same logic as bundled led_patterns.py, inlined here)
|
||||
|
||||
|
||||
def _clamp(channel: int) -> int:
|
||||
return max(0, min(255, int(channel)))
|
||||
|
||||
|
||||
def wheel(pos: int):
|
||||
"""Return rainbow RGB at position 0–255."""
|
||||
pos = 255 - (pos & 255)
|
||||
if pos < 85:
|
||||
return (_clamp(255 - pos * 3), 0, _clamp(pos * 3))
|
||||
if pos < 170:
|
||||
pos -= 85
|
||||
return (0, _clamp(pos * 3), _clamp(255 - pos * 3))
|
||||
pos -= 170
|
||||
return (_clamp(pos * 3), _clamp(255 - pos * 3), 0)
|
||||
|
||||
|
||||
def rainbow_frame(led_count: int, frame: int, step: int = 4):
|
||||
if led_count <= 0:
|
||||
return []
|
||||
return [wheel((i * 256 // led_count + frame * step) & 255) for i in range(led_count)]
|
||||
|
||||
|
||||
# --- demo
|
||||
|
||||
NUM_LEDS = 16
|
||||
|
||||
np = neopixel.NeoPixel(Pin(4), NUM_LEDS)
|
||||
|
||||
for frame in range(120):
|
||||
frame_colors = rainbow_frame(len(np), frame, step=5)
|
||||
for i, color in enumerate(frame_colors):
|
||||
np[i] = color
|
||||
np.write()
|
||||
time.sleep(0.05)
|
||||
|
||||
np.fill((0, 0, 0))
|
||||
np.write()
|
||||
54
src/static/bundled-demos/pattern_twinkle_demo.py
Normal file
54
src/static/bundled-demos/pattern_twinkle_demo.py
Normal file
@@ -0,0 +1,54 @@
|
||||
"""Twinkle NeoPixel demo — self-contained (stdlib + simulated hardware only)."""
|
||||
|
||||
import random
|
||||
import time
|
||||
|
||||
from machine import Pin
|
||||
import neopixel
|
||||
|
||||
# --- helpers
|
||||
|
||||
|
||||
def _clamp(channel: int) -> int:
|
||||
return max(0, min(255, int(channel)))
|
||||
|
||||
|
||||
def twinkle_frame(
|
||||
led_count: int,
|
||||
frame: int,
|
||||
base=(0, 0, 8),
|
||||
sparkle=(255, 255, 180),
|
||||
sparkles: int = 3,
|
||||
seed: int = 1337,
|
||||
):
|
||||
if led_count <= 0:
|
||||
return []
|
||||
out = [tuple(_clamp(v) for v in base) for _ in range(led_count)]
|
||||
rng = random.Random(seed + frame)
|
||||
for _ in range(min(max(0, sparkles), led_count)):
|
||||
idx = rng.randrange(led_count)
|
||||
out[idx] = tuple(_clamp(v) for v in sparkle)
|
||||
return out
|
||||
|
||||
|
||||
# --- demo
|
||||
|
||||
NUM_LEDS = 16
|
||||
|
||||
np = neopixel.NeoPixel(Pin(4), NUM_LEDS)
|
||||
|
||||
for frame in range(120):
|
||||
frame_colors = twinkle_frame(
|
||||
len(np),
|
||||
frame,
|
||||
base=(0, 0, 6),
|
||||
sparkle=(255, 210, 130),
|
||||
sparkles=3,
|
||||
)
|
||||
for i, color in enumerate(frame_colors):
|
||||
np[i] = color
|
||||
np.write()
|
||||
time.sleep(0.08)
|
||||
|
||||
np.fill((0, 0, 0))
|
||||
np.write()
|
||||
56
src/static/bundled-demos/pin_demo.py
Normal file
56
src/static/bundled-demos/pin_demo.py
Normal file
@@ -0,0 +1,56 @@
|
||||
"""Pin features demo.
|
||||
|
||||
A "Pins" panel appears below the editor while this script runs:
|
||||
|
||||
* Pin 2 (OUT) — blinks automatically every ~200 ms via ``.value(...)``.
|
||||
* Pin 4 (OUT) — stays put until you press the button, then flips
|
||||
(driven from the IRQ handler with ``.toggle()``).
|
||||
* Pin 0 (IN) — click the toggle button in the panel to drive a 0 → 1
|
||||
rising edge; the IRQ fires and flips pin 4.
|
||||
* Pin 13 (PWM) — duty sweeps up and down; the bar shows the live duty.
|
||||
"""
|
||||
|
||||
import time
|
||||
|
||||
from machine import Pin, PWM
|
||||
|
||||
|
||||
led_a = Pin(2, Pin.OUT)
|
||||
led_b = Pin(4, Pin.OUT)
|
||||
button = Pin(0, Pin.IN, Pin.PULL_UP)
|
||||
fader = PWM(Pin(13), freq=1000, duty_u16=0)
|
||||
|
||||
|
||||
def on_button(pin):
|
||||
# Pin 4 is IRQ-driven on purpose — its only source of change is the
|
||||
# button press, so when you see it flip you know the IRQ fired.
|
||||
led_b.toggle()
|
||||
|
||||
|
||||
button.irq(handler=on_button, trigger=Pin.IRQ_RISING)
|
||||
|
||||
|
||||
tick = 0
|
||||
duty = 0
|
||||
direction = 1024
|
||||
|
||||
while True:
|
||||
# Pin 2: fast on/off via direct .value(...) writes (no IRQ involvement).
|
||||
led_a.value(tick % 2)
|
||||
|
||||
# Pin 13: triangular duty sweep so the PWM bar visibly fills and drains.
|
||||
duty += direction
|
||||
if duty >= 65535:
|
||||
duty = 65535
|
||||
direction = -1024
|
||||
elif duty <= 0:
|
||||
duty = 0
|
||||
direction = 1024
|
||||
fader.duty_u16(duty)
|
||||
|
||||
# Poll the IN pin so its IRQ actually fires when the panel button changes.
|
||||
# (`.value()` reads the current state and dispatches any pending edge.)
|
||||
button.value()
|
||||
|
||||
tick += 1
|
||||
time.sleep(0.1)
|
||||
90
src/static/bundled-demos/serial_demo.py
Normal file
90
src/static/bundled-demos/serial_demo.py
Normal file
@@ -0,0 +1,90 @@
|
||||
"""Serial in/out demo.
|
||||
|
||||
When this script runs, a "Serial monitor" pane appears below the editor.
|
||||
|
||||
Try this:
|
||||
* type hello and press Enter -> Python echoes "echo: hello"
|
||||
* type color red -> the strip turns red
|
||||
* try color 0,128,255 -> any (r,g,b) tuple works
|
||||
* type off -> strip blanks
|
||||
* type bye -> script exits cleanly
|
||||
|
||||
Anything Python `write()`s to the UART shows up in green; what you type back
|
||||
is shown in white.
|
||||
"""
|
||||
|
||||
import time
|
||||
|
||||
from machine import Pin, UART
|
||||
from neopixel import NeoPixel
|
||||
|
||||
|
||||
NUM_LEDS = 16
|
||||
strip = NeoPixel(Pin(5, Pin.OUT), NUM_LEDS)
|
||||
uart = UART(0, baudrate=115200)
|
||||
|
||||
PALETTE = {
|
||||
"red": (255, 0, 0),
|
||||
"green": (0, 255, 0),
|
||||
"blue": (0, 0, 255),
|
||||
"white": (200, 200, 200),
|
||||
"purple": (160, 0, 200),
|
||||
"orange": (255, 110, 0),
|
||||
}
|
||||
|
||||
|
||||
def fill(color):
|
||||
strip.fill(color)
|
||||
strip.write()
|
||||
|
||||
|
||||
def parse_color(arg):
|
||||
arg = arg.strip().lower()
|
||||
if arg in PALETTE:
|
||||
return PALETTE[arg]
|
||||
parts = [p for p in arg.replace(",", " ").split() if p]
|
||||
if len(parts) == 3:
|
||||
try:
|
||||
return tuple(max(0, min(255, int(p))) for p in parts)
|
||||
except ValueError:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
uart.write("ready. commands: color <name|r,g,b> | off | bye\n")
|
||||
fill((0, 0, 0))
|
||||
|
||||
running = True
|
||||
while running:
|
||||
line = uart.readline()
|
||||
if line is None:
|
||||
time.sleep(0.05)
|
||||
continue
|
||||
|
||||
text = line.decode("utf-8", errors="replace").strip()
|
||||
if not text:
|
||||
continue
|
||||
|
||||
if text == "bye":
|
||||
uart.write("goodbye!\n")
|
||||
running = False
|
||||
break
|
||||
|
||||
if text == "off":
|
||||
fill((0, 0, 0))
|
||||
uart.write("strip off\n")
|
||||
continue
|
||||
|
||||
if text.startswith("color"):
|
||||
rest = text[len("color"):].strip()
|
||||
color = parse_color(rest) if rest else None
|
||||
if color is None:
|
||||
uart.write("usage: color <name> | color r,g,b\n")
|
||||
else:
|
||||
fill(color)
|
||||
uart.write(f"strip = {color}\n")
|
||||
continue
|
||||
|
||||
uart.write(f"echo: {text}\n")
|
||||
|
||||
fill((0, 0, 0))
|
||||
419
src/static/bundled-lib/machine.py
Normal file
419
src/static/bundled-lib/machine.py
Normal file
@@ -0,0 +1,419 @@
|
||||
"""Minimal MicroPython-style machine module mock for browser simulation."""
|
||||
|
||||
import json
|
||||
|
||||
|
||||
_PIN_MODE_OUT = 1
|
||||
_PIN_MODE_IN = 2
|
||||
_PIN_MODE_PWM = 4
|
||||
|
||||
|
||||
def _pin_view_get(name):
|
||||
try:
|
||||
import js
|
||||
|
||||
return getattr(js, name, None)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _pin_out_publish(pin_id, ui_mode, value):
|
||||
"""Pack [ui_mode (high byte) | value (low 24 bits)] for the UI panel."""
|
||||
view = _pin_view_get("__pin_out_view")
|
||||
if view is None:
|
||||
return
|
||||
packed = ((int(ui_mode) & 0xFF) << 24) | (int(value) & 0x00FFFFFF)
|
||||
try:
|
||||
import js
|
||||
|
||||
js.Atomics.store(view, int(pin_id), packed)
|
||||
except Exception:
|
||||
try:
|
||||
view[int(pin_id)] = packed
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _pin_in_read(pin_id):
|
||||
view = _pin_view_get("__pin_in_view")
|
||||
if view is None:
|
||||
return 0
|
||||
try:
|
||||
import js
|
||||
|
||||
return int(js.Atomics.load(view, int(pin_id))) & 1
|
||||
except Exception:
|
||||
try:
|
||||
return int(view[int(pin_id)]) & 1
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
|
||||
def _pin_register(pin_id, ui_mode, **extra):
|
||||
payload = {"pin": int(pin_id), "mode": int(ui_mode)}
|
||||
payload.update({k: v for k, v in extra.items() if v is not None})
|
||||
try:
|
||||
print("[pin-register]" + json.dumps(payload))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
class Pin:
|
||||
"""Browser-simulated `machine.Pin`.
|
||||
|
||||
A control row appears in the editor UI when a pin is constructed:
|
||||
* `Pin.OUT` pins show a live indicator that mirrors `value()`.
|
||||
* `Pin.IN` pins show a toggle button you can click — the next call
|
||||
to `value()` returns 0 or 1 accordingly.
|
||||
"""
|
||||
|
||||
IN = 0
|
||||
OUT = 1
|
||||
PULL_UP = 2
|
||||
PULL_DOWN = 3
|
||||
OPEN_DRAIN = 7
|
||||
|
||||
IRQ_FALLING = 1
|
||||
IRQ_RISING = 2
|
||||
|
||||
def __init__(self, pin_id, mode=OUT, pull=-1, value=None, **_kwargs):
|
||||
self.id = int(pin_id)
|
||||
self.mode = int(mode)
|
||||
self.pull = int(pull) if pull != -1 else -1
|
||||
self._value = 1 if value else 0
|
||||
self._irq_handler = None
|
||||
self._irq_trigger = 0
|
||||
self._last_input = 0
|
||||
self._publish()
|
||||
|
||||
def _ui_mode(self):
|
||||
if self.mode == self.IN:
|
||||
return _PIN_MODE_IN
|
||||
return _PIN_MODE_OUT
|
||||
|
||||
def _publish(self):
|
||||
ui_mode = self._ui_mode()
|
||||
_pin_register(self.id, ui_mode, pull=self.pull if self.pull != -1 else None)
|
||||
if ui_mode == _PIN_MODE_OUT:
|
||||
_pin_out_publish(self.id, ui_mode, self._value)
|
||||
|
||||
def init(self, mode=-1, pull=-1, value=None):
|
||||
if mode != -1:
|
||||
self.mode = int(mode)
|
||||
if pull != -1:
|
||||
self.pull = int(pull)
|
||||
if value is not None:
|
||||
self._value = 1 if int(value) else 0
|
||||
self._publish()
|
||||
|
||||
def value(self, new_value=None):
|
||||
if new_value is None:
|
||||
if self.mode == self.IN:
|
||||
v = _pin_in_read(self.id)
|
||||
if self._irq_handler is not None:
|
||||
if v and not self._last_input and (self._irq_trigger & self.IRQ_RISING):
|
||||
self._fire_irq()
|
||||
elif not v and self._last_input and (self._irq_trigger & self.IRQ_FALLING):
|
||||
self._fire_irq()
|
||||
self._last_input = v
|
||||
return v
|
||||
return self._value
|
||||
v = 1 if int(new_value) else 0
|
||||
self._value = v
|
||||
if self._ui_mode() == _PIN_MODE_OUT:
|
||||
_pin_out_publish(self.id, _PIN_MODE_OUT, v)
|
||||
return v
|
||||
|
||||
def on(self):
|
||||
return self.value(1)
|
||||
|
||||
def off(self):
|
||||
return self.value(0)
|
||||
|
||||
def high(self):
|
||||
return self.value(1)
|
||||
|
||||
def low(self):
|
||||
return self.value(0)
|
||||
|
||||
def toggle(self):
|
||||
return self.value(0 if (self._value if self.mode == self.OUT else _pin_in_read(self.id)) else 1)
|
||||
|
||||
def irq(self, handler=None, trigger=IRQ_RISING | IRQ_FALLING, **_kwargs):
|
||||
self._irq_handler = handler
|
||||
self._irq_trigger = int(trigger)
|
||||
return self
|
||||
|
||||
def _fire_irq(self):
|
||||
if self._irq_handler is None:
|
||||
return
|
||||
try:
|
||||
self._irq_handler(self)
|
||||
except Exception as exc:
|
||||
try:
|
||||
print(f"[pin] irq handler raised: {exc!r}")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def __call__(self, *args):
|
||||
return self.value(*args) if args else self.value()
|
||||
|
||||
|
||||
class PWM:
|
||||
"""Browser-simulated `machine.PWM`.
|
||||
|
||||
Visualises the configured duty cycle as a bar in the Pins panel.
|
||||
`freq()` and `duty()`/`duty_u16()`/`duty_ns()` mirror MicroPython's API.
|
||||
"""
|
||||
|
||||
def __init__(self, pin, freq=1000, duty=None, duty_u16=None, duty_ns=None):
|
||||
self._pin_id = _adc_pin_id(pin)
|
||||
self._freq = int(freq) if freq else 1000
|
||||
self._duty_u16 = 0
|
||||
if duty_u16 is not None:
|
||||
self._duty_u16 = max(0, min(65535, int(duty_u16)))
|
||||
elif duty is not None:
|
||||
self._duty_u16 = max(0, min(65535, int(duty) * 64))
|
||||
elif duty_ns is not None:
|
||||
self._set_duty_ns(int(duty_ns))
|
||||
_pin_register(self._pin_id, _PIN_MODE_PWM, freq=self._freq)
|
||||
_pin_out_publish(self._pin_id, _PIN_MODE_PWM, self._duty_u16)
|
||||
|
||||
def _set_duty_ns(self, ns):
|
||||
period_ns = 1_000_000_000 // max(1, self._freq)
|
||||
if period_ns <= 0:
|
||||
self._duty_u16 = 0
|
||||
return
|
||||
self._duty_u16 = max(0, min(65535, int(ns) * 65535 // period_ns))
|
||||
|
||||
def freq(self, value=None):
|
||||
if value is None:
|
||||
return self._freq
|
||||
self._freq = max(1, int(value))
|
||||
_pin_register(self._pin_id, _PIN_MODE_PWM, freq=self._freq)
|
||||
|
||||
def duty(self, value=None):
|
||||
if value is None:
|
||||
return self._duty_u16 // 64
|
||||
self._duty_u16 = max(0, min(65535, int(value) * 64))
|
||||
_pin_out_publish(self._pin_id, _PIN_MODE_PWM, self._duty_u16)
|
||||
|
||||
def duty_u16(self, value=None):
|
||||
if value is None:
|
||||
return self._duty_u16
|
||||
self._duty_u16 = max(0, min(65535, int(value)))
|
||||
_pin_out_publish(self._pin_id, _PIN_MODE_PWM, self._duty_u16)
|
||||
|
||||
def duty_ns(self, value=None):
|
||||
period_ns = 1_000_000_000 // max(1, self._freq)
|
||||
if value is None:
|
||||
return (self._duty_u16 * period_ns) // 65535
|
||||
self._set_duty_ns(int(value))
|
||||
_pin_out_publish(self._pin_id, _PIN_MODE_PWM, self._duty_u16)
|
||||
|
||||
def deinit(self):
|
||||
_pin_out_publish(self._pin_id, 0, 0)
|
||||
|
||||
|
||||
def _adc_pin_id(pin):
|
||||
if isinstance(pin, int):
|
||||
return pin
|
||||
return int(getattr(pin, "id", 0))
|
||||
|
||||
|
||||
def _adc_read_raw(pin_id: int) -> int:
|
||||
"""Read the live slider value (0..65535) shared from the editor UI.
|
||||
|
||||
Backed by a SharedArrayBuffer so updates from the browser slider propagate
|
||||
instantly without the script having to yield. Falls back to 0 when the
|
||||
runtime is not cross-origin isolated (e.g. older browsers).
|
||||
"""
|
||||
try:
|
||||
import js # only available inside Pyodide
|
||||
|
||||
view = getattr(js, "__adc_view", None)
|
||||
if view is None:
|
||||
return 0
|
||||
try:
|
||||
return int(js.Atomics.load(view, pin_id)) & 0xFFFF
|
||||
except Exception:
|
||||
return int(view[pin_id]) & 0xFFFF
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
|
||||
class ADC:
|
||||
"""Browser-simulated `machine.ADC`.
|
||||
|
||||
Exposes a slider in the editor UI for the given pin. `read_u16()` returns
|
||||
the slider value in the 0..65535 range (matching MicroPython's API);
|
||||
`read()` scales to the configured `width` (defaults to 12-bit, 0..4095).
|
||||
"""
|
||||
|
||||
WIDTH_9BIT = 9
|
||||
WIDTH_10BIT = 10
|
||||
WIDTH_11BIT = 11
|
||||
WIDTH_12BIT = 12
|
||||
ATTN_0DB = 0
|
||||
ATTN_2_5DB = 1
|
||||
ATTN_6DB = 2
|
||||
ATTN_11DB = 3
|
||||
|
||||
def __init__(self, pin):
|
||||
self._pin = _adc_pin_id(pin)
|
||||
self._atten = self.ATTN_11DB
|
||||
self._width = self.WIDTH_12BIT
|
||||
try:
|
||||
print("[adc-register]" + json.dumps({"pin": self._pin}))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def atten(self, attn):
|
||||
self._atten = int(attn)
|
||||
|
||||
def width(self, width):
|
||||
self._width = int(width)
|
||||
|
||||
def read_u16(self):
|
||||
return _adc_read_raw(self._pin)
|
||||
|
||||
def read(self):
|
||||
bits = self._width if self._width in (9, 10, 11, 12) else 12
|
||||
max_val = (1 << bits) - 1
|
||||
return (_adc_read_raw(self._pin) * max_val) // 65535
|
||||
|
||||
def read_uv(self):
|
||||
"""Return microvolts assuming a 0..3.3V range (rough simulation)."""
|
||||
return (_adc_read_raw(self._pin) * 3_300_000) // 65535
|
||||
|
||||
|
||||
def _serial_drain_pending(buf):
|
||||
"""Pull any bytes the editor's serial-monitor has pushed into the SAB ring."""
|
||||
try:
|
||||
import js
|
||||
import base64 # noqa: F401 (kept for symmetry with write path)
|
||||
|
||||
indices = getattr(js, "__serial_in_indices", None)
|
||||
data = getattr(js, "__serial_in_data", None)
|
||||
if indices is None or data is None:
|
||||
return
|
||||
cap = int(getattr(js, "__serial_in_capacity", 0))
|
||||
if cap <= 0:
|
||||
return
|
||||
r = int(js.Atomics.load(indices, 0))
|
||||
w = int(js.Atomics.load(indices, 1))
|
||||
while r != w:
|
||||
buf.append(int(data[r]) & 0xFF)
|
||||
r = (r + 1) % cap
|
||||
js.Atomics.store(indices, 0, r)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
class UART:
|
||||
"""Browser-simulated `machine.UART`.
|
||||
|
||||
A serial monitor pane appears in the editor when this is constructed.
|
||||
`write()` text shows up there; characters typed into the input box arrive
|
||||
via `read()` / `readline()` / `any()`.
|
||||
"""
|
||||
|
||||
INV_TX = 1
|
||||
INV_RX = 2
|
||||
INV_RTS = 4
|
||||
INV_CTS = 8
|
||||
|
||||
def __init__(self, id=0, baudrate=115200, bits=8, parity=None, stop=1,
|
||||
tx=None, rx=None, timeout=0, **_kwargs):
|
||||
self.id = int(id) if id is not None else 0
|
||||
self.baudrate = int(baudrate)
|
||||
self.bits = int(bits)
|
||||
self.parity = parity
|
||||
self.stop = int(stop)
|
||||
self.tx = tx
|
||||
self.rx = rx
|
||||
self.timeout = int(timeout) if timeout is not None else 0
|
||||
self._buf = bytearray()
|
||||
try:
|
||||
print("[serial-register]" + json.dumps({
|
||||
"id": self.id,
|
||||
"baudrate": self.baudrate,
|
||||
}))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def init(self, *args, **kwargs):
|
||||
if args:
|
||||
try:
|
||||
self.baudrate = int(args[0])
|
||||
except Exception:
|
||||
pass
|
||||
if "baudrate" in kwargs:
|
||||
self.baudrate = int(kwargs["baudrate"])
|
||||
|
||||
def deinit(self):
|
||||
self._buf = bytearray()
|
||||
|
||||
def any(self):
|
||||
_serial_drain_pending(self._buf)
|
||||
return len(self._buf)
|
||||
|
||||
def read(self, nbytes=None):
|
||||
_serial_drain_pending(self._buf)
|
||||
if not self._buf:
|
||||
return None
|
||||
if nbytes is None:
|
||||
out = bytes(self._buf)
|
||||
self._buf = bytearray()
|
||||
return out
|
||||
n = min(int(nbytes), len(self._buf))
|
||||
if n <= 0:
|
||||
return None
|
||||
out = bytes(self._buf[:n])
|
||||
del self._buf[:n]
|
||||
return out
|
||||
|
||||
def readinto(self, buf, nbytes=None):
|
||||
n = int(nbytes) if nbytes is not None else len(buf)
|
||||
data = self.read(n)
|
||||
if not data:
|
||||
return None
|
||||
for i, b in enumerate(data):
|
||||
buf[i] = b
|
||||
return len(data)
|
||||
|
||||
def readline(self):
|
||||
_serial_drain_pending(self._buf)
|
||||
if not self._buf:
|
||||
return None
|
||||
nl = self._buf.find(b"\n")
|
||||
if nl == -1:
|
||||
return None
|
||||
out = bytes(self._buf[: nl + 1])
|
||||
del self._buf[: nl + 1]
|
||||
return out
|
||||
|
||||
def write(self, data):
|
||||
if isinstance(data, str):
|
||||
buf = data.encode("utf-8")
|
||||
elif isinstance(data, (bytes, bytearray, memoryview)):
|
||||
buf = bytes(data)
|
||||
else:
|
||||
buf = bytes([int(data) & 0xFF])
|
||||
try:
|
||||
import base64
|
||||
|
||||
encoded = base64.b64encode(buf).decode("ascii")
|
||||
print(f"[serial-out]{encoded}")
|
||||
except Exception:
|
||||
pass
|
||||
return len(buf)
|
||||
|
||||
def sendbreak(self):
|
||||
pass
|
||||
|
||||
def flush(self):
|
||||
pass
|
||||
|
||||
def txdone(self):
|
||||
return True
|
||||
56
src/static/bundled-lib/neopixel.py
Normal file
56
src/static/bundled-lib/neopixel.py
Normal file
@@ -0,0 +1,56 @@
|
||||
"""NeoPixel mock for Pyodide/browser execution.
|
||||
|
||||
Supports a useful subset of MicroPython's neopixel.NeoPixel API:
|
||||
- NeoPixel(pin, n, bpp=3, timing=1)
|
||||
- __setitem__, __getitem__, __len__
|
||||
- fill(color)
|
||||
- write() # prints current pixel buffer snapshot
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
|
||||
def _normalize_color(value, bpp: int):
|
||||
if not hasattr(value, "__iter__"):
|
||||
raise TypeError("Color must be an iterable, e.g. (r, g, b)")
|
||||
parts = [int(v) for v in value]
|
||||
if len(parts) != bpp:
|
||||
raise ValueError(f"Expected {bpp} color channels, got {len(parts)}")
|
||||
out = []
|
||||
for channel in parts:
|
||||
out.append(max(0, min(255, channel)))
|
||||
return tuple(out)
|
||||
|
||||
|
||||
class NeoPixel:
|
||||
def __init__(self, pin, n: int, bpp: int = 3, timing: int = 1):
|
||||
self.pin = pin
|
||||
self.n = int(n)
|
||||
self.bpp = int(bpp)
|
||||
self.timing = int(timing)
|
||||
self._buf = [tuple([0] * self.bpp) for _ in range(self.n)]
|
||||
|
||||
def __len__(self):
|
||||
return self.n
|
||||
|
||||
def __getitem__(self, index):
|
||||
return self._buf[int(index)]
|
||||
|
||||
def __setitem__(self, index, color):
|
||||
idx = int(index)
|
||||
self._buf[idx] = _normalize_color(color, self.bpp)
|
||||
|
||||
def fill(self, color):
|
||||
c = _normalize_color(color, self.bpp)
|
||||
for i in range(self.n):
|
||||
self._buf[i] = c
|
||||
|
||||
def write(self):
|
||||
pin_id = getattr(self.pin, "id", self.pin)
|
||||
payload = {
|
||||
"type": "neopixel",
|
||||
"pin": pin_id,
|
||||
"pixels": [list(pixel) for pixel in self._buf],
|
||||
"bpp": self.bpp,
|
||||
}
|
||||
print("[neopixel-json]" + json.dumps(payload))
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Python Editor</title>
|
||||
<title>LED Editor</title>
|
||||
<link rel="icon" href="data:,">
|
||||
<style>
|
||||
* { box-sizing: border-box; }
|
||||
@@ -15,129 +15,152 @@
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
background: linear-gradient(135deg, #0f172a, #1e293b);
|
||||
color: #e2e8f0;
|
||||
padding: 1rem;
|
||||
}
|
||||
.home-card {
|
||||
width: min(560px, 92vw);
|
||||
width: min(420px, 100%);
|
||||
background: rgba(15, 23, 42, 0.78);
|
||||
border: 1px solid rgba(148, 163, 184, 0.25);
|
||||
border-radius: 14px;
|
||||
padding: 2rem;
|
||||
box-shadow: 0 20px 45px rgba(0, 0, 0, 0.35);
|
||||
text-align: center;
|
||||
}
|
||||
h1 {
|
||||
margin: 0 0 0.5rem 0;
|
||||
font-size: 1.9rem;
|
||||
color: #f8fafc;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
p.tagline {
|
||||
margin: 0 0 1.75rem 0;
|
||||
color: #94a3b8;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.actions {
|
||||
display: grid;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
h1 { margin: 0 0 0.75rem 0; font-size: 1.8rem; color: #f8fafc; }
|
||||
p { margin: 0 0 1rem 0; color: #cbd5e1; line-height: 1.5; }
|
||||
.btn {
|
||||
display: inline-block;
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-decoration: none;
|
||||
border-radius: 8px;
|
||||
padding: 0.65rem 1rem;
|
||||
border-radius: 10px;
|
||||
padding: 0.85rem 1rem;
|
||||
font-weight: 600;
|
||||
border: 1px solid transparent;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
.btn-primary { background: #3b82f6; color: #ffffff; }
|
||||
.btn-ghost { background: transparent; border-color: #64748b; color: #e2e8f0; }
|
||||
label { display: block; font-size: 0.85rem; color: #94a3b8; margin-bottom: 0.35rem; }
|
||||
input[type="password"] {
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.65rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #64748b;
|
||||
background: #0f172a;
|
||||
.btn-primary:hover { background: #2563eb; }
|
||||
.btn-ghost {
|
||||
background: transparent;
|
||||
border-color: #475569;
|
||||
color: #e2e8f0;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.note { font-size: 0.8rem; color: #94a3b8; margin-top: 1rem; }
|
||||
.nav { display: flex; flex-wrap: wrap; gap: 0.5rem; margin-bottom: 1rem; align-items: center; }
|
||||
.nav span { color: #94a3b8; font-size: 0.9rem; }
|
||||
.btn-ghost:hover { background: rgba(71, 85, 105, 0.25); }
|
||||
.btn-link {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #93c5fd;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
padding: 0.4rem;
|
||||
text-decoration: underline;
|
||||
}
|
||||
.signed-in {
|
||||
font-size: 0.85rem;
|
||||
color: #94a3b8;
|
||||
margin-bottom: 0.6rem;
|
||||
}
|
||||
.hidden { display: none !important; }
|
||||
.footnote {
|
||||
margin-top: 1.25rem;
|
||||
font-size: 0.78rem;
|
||||
color: #64748b;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.footnote a { color: #93c5fd; text-decoration: none; }
|
||||
.footnote a:hover { text-decoration: underline; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class="home-card">
|
||||
<h1>Python Editor</h1>
|
||||
<div id="auth-nav" class="nav">
|
||||
<span id="auth-greeting" class="hidden"></span>
|
||||
<a class="btn btn-ghost hidden" id="link-login" href="/login">Sign in</a>
|
||||
<a class="btn btn-ghost hidden" id="link-register" href="/register">Register</a>
|
||||
<button type="button" class="btn btn-ghost hidden" id="btn-logout">Sign out</button>
|
||||
<h1>LED Editor</h1>
|
||||
<p class="tagline">Run MicroPython in your browser. Drive simulated NeoPixels, pins, ADC, and serial.</p>
|
||||
|
||||
<div id="signed-in-row" class="signed-in hidden"></div>
|
||||
|
||||
<div class="actions">
|
||||
<a class="btn btn-primary hidden" id="btn-signin" href="/login">Sign in</a>
|
||||
<a class="btn btn-primary hidden" id="btn-open-editor" href="/editor">Open editor</a>
|
||||
<a class="btn btn-ghost" id="btn-use-locally" href="/editor?local=1">Use locally (no login)</a>
|
||||
<a class="btn-link hidden" id="btn-manage-users" href="/users">Manage users</a>
|
||||
<button type="button" class="btn-link hidden" id="btn-signout">Sign out</button>
|
||||
</div>
|
||||
<p>Edit and store files on the server. Python runs in your browser with <a href="https://pyodide.org/" style="color:#93c5fd">Pyodide</a>.</p>
|
||||
<div id="optional-api-key">
|
||||
<p>If you use <code style="color:#fcd34d">EDITOR_API_KEY</code> (without user login), store it here for API calls from this browser tab:</p>
|
||||
<label for="api-key">API key (optional)</label>
|
||||
<input id="api-key" type="password" autocomplete="off" placeholder="Leave blank if not used" />
|
||||
<p class="note">The key is kept in <code>sessionStorage</code>. You can also use <code>?api_key=…</code> on the editor URL.</p>
|
||||
</div>
|
||||
<a class="btn btn-primary" href="/editor" id="open-editor">Open Editor</a>
|
||||
|
||||
<p class="footnote">
|
||||
Local mode keeps files in this browser's <code>IndexedDB</code>; from the editor you can also pick a folder on disk
|
||||
(<a href="https://developer.mozilla.org/en-US/docs/Web/API/File_System_Access_API" target="_blank" rel="noopener">File System Access API</a>, Chromium-only).
|
||||
</p>
|
||||
</main>
|
||||
<script>
|
||||
const storageKey = 'python-editor.api_key';
|
||||
const input = document.getElementById('api-key');
|
||||
const openLink = document.getElementById('open-editor');
|
||||
async function init() {
|
||||
const signedInRow = document.getElementById('signed-in-row');
|
||||
const btnSignIn = document.getElementById('btn-signin');
|
||||
const btnOpenEditor = document.getElementById('btn-open-editor');
|
||||
const btnSignOut = document.getElementById('btn-signout');
|
||||
const btnManageUsers = document.getElementById('btn-manage-users');
|
||||
|
||||
async function refreshAuthNav() {
|
||||
let authEnabled = false;
|
||||
try {
|
||||
const st = await fetch('/api/auth/status');
|
||||
if (st.ok) {
|
||||
const status = await st.json();
|
||||
const loginEl = document.getElementById('link-login');
|
||||
const regEl = document.getElementById('link-register');
|
||||
const outEl = document.getElementById('btn-logout');
|
||||
const greet = document.getElementById('auth-greeting');
|
||||
const optionalKey = document.getElementById('optional-api-key');
|
||||
if (!status.auth_enabled) {
|
||||
loginEl.classList.add('hidden');
|
||||
regEl.classList.add('hidden');
|
||||
outEl.classList.add('hidden');
|
||||
greet.classList.add('hidden');
|
||||
authEnabled = Boolean(status.auth_enabled);
|
||||
}
|
||||
} catch (_e) {
|
||||
// No backend (static-only host) → behave like auth disabled.
|
||||
}
|
||||
|
||||
if (!authEnabled) {
|
||||
// No login wall, so "Sign in" makes no sense — go straight to editor.
|
||||
btnOpenEditor.classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
loginEl.classList.remove('hidden');
|
||||
if (status.register_open) {
|
||||
regEl.classList.remove('hidden');
|
||||
}
|
||||
const me = await fetch('/api/auth/me', { credentials: 'include' });
|
||||
if (me.ok) {
|
||||
const j = await me.json();
|
||||
greet.textContent = `Signed in as ${j.user.username}`;
|
||||
greet.classList.remove('hidden');
|
||||
loginEl.classList.add('hidden');
|
||||
regEl.classList.add('hidden');
|
||||
outEl.classList.remove('hidden');
|
||||
if (optionalKey) optionalKey.classList.add('hidden');
|
||||
} else {
|
||||
outEl.classList.add('hidden');
|
||||
greet.classList.add('hidden');
|
||||
}
|
||||
|
||||
let me = null;
|
||||
try {
|
||||
const meRes = await fetch('/api/auth/me', { credentials: 'include' });
|
||||
if (meRes.ok) me = (await meRes.json()).user;
|
||||
} catch (_e) {
|
||||
// ignore
|
||||
}
|
||||
|
||||
document.getElementById('btn-logout').addEventListener('click', async () => {
|
||||
if (!me) {
|
||||
btnSignIn.classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
signedInRow.textContent = `Signed in as ${me.username}${me.is_superuser ? ' (admin)' : ''}`;
|
||||
signedInRow.classList.remove('hidden');
|
||||
btnOpenEditor.classList.remove('hidden');
|
||||
btnSignOut.classList.remove('hidden');
|
||||
if (me.is_superuser) btnManageUsers.classList.remove('hidden');
|
||||
}
|
||||
|
||||
document.getElementById('btn-signout').addEventListener('click', async () => {
|
||||
try {
|
||||
await fetch('/api/auth/logout', { method: 'POST', credentials: 'include' });
|
||||
} catch (_e) {
|
||||
// ignore
|
||||
}
|
||||
window.location.reload();
|
||||
});
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const fromQuery = params.get('api_key');
|
||||
if (fromQuery) {
|
||||
sessionStorage.setItem(storageKey, fromQuery);
|
||||
}
|
||||
const existing = sessionStorage.getItem(storageKey);
|
||||
if (existing) {
|
||||
input.value = existing;
|
||||
}
|
||||
} catch (_e) {}
|
||||
openLink.addEventListener('click', () => {
|
||||
try {
|
||||
const v = input.value.trim();
|
||||
if (v) {
|
||||
sessionStorage.setItem(storageKey, v);
|
||||
} else {
|
||||
sessionStorage.removeItem(storageKey);
|
||||
}
|
||||
} catch (_e) {}
|
||||
});
|
||||
refreshAuthNav();
|
||||
init();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -2,14 +2,16 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Python Editor</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||||
<meta name="theme-color" content="#2d3748">
|
||||
<title>LED Editor</title>
|
||||
<link rel="icon" href="data:,">
|
||||
<link rel="stylesheet" href="/static/styles.css?v=6">
|
||||
<link rel="stylesheet" href="/static/styles.css?v=36">
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="sidebar">
|
||||
<div class="sidebar-backdrop" id="sidebar-backdrop" hidden></div>
|
||||
<div class="sidebar" id="sidebar">
|
||||
<div class="sidebar-header">
|
||||
<h3>Files</h3>
|
||||
<div class="sidebar-actions">
|
||||
@@ -26,19 +28,31 @@
|
||||
|
||||
<div class="main-content">
|
||||
<div class="editor-header">
|
||||
<button id="sidebar-toggle" class="sidebar-toggle" type="button" aria-label="Toggle file tree" aria-controls="sidebar" aria-expanded="true" title="Toggle file browser"><span class="sidebar-toggle-icon" aria-hidden="true">‹</span></button>
|
||||
<div class="file-info">
|
||||
<span id="save-status" class="save-status"></span>
|
||||
<span class="runtime-hint" title="Python runs locally in your browser via Pyodide; completions use Jedi in the same runtime.">Browser · Pyodide</span>
|
||||
</div>
|
||||
<div class="mode-toggle">
|
||||
<a id="home-btn" class="mode-btn active" href="/">Home</a>
|
||||
<span id="workspace-badge" class="runtime-hint hidden"></span>
|
||||
</div>
|
||||
<div class="editor-actions">
|
||||
<button id="run-btn" disabled>Run Python</button>
|
||||
<button id="stop-btn" disabled>Stop</button>
|
||||
<select id="run-file-select" title="Script to run">
|
||||
<option value="">Run active file</option>
|
||||
</select>
|
||||
<button id="run-btn" class="icon-btn" disabled aria-label="Run" title="Run">▶</button>
|
||||
<button id="stop-btn" class="icon-btn" disabled aria-label="Stop" title="Stop">■</button>
|
||||
<details class="header-menu" id="header-menu">
|
||||
<summary class="menu-toggle" aria-label="Editor options" title="Options">⋮</summary>
|
||||
<div class="menu-content" role="menu">
|
||||
<a href="/" class="menu-item" id="home-btn" role="menuitem">🏠 Home</a>
|
||||
<label class="menu-item menu-checkbox">
|
||||
<input type="checkbox" id="run-main-checkbox" />
|
||||
Run main.py
|
||||
</label>
|
||||
<label class="menu-item menu-checkbox">
|
||||
<input type="checkbox" id="panel-16x16-checkbox" />
|
||||
16×16 panel
|
||||
</label>
|
||||
<div class="menu-separator" role="separator"></div>
|
||||
<div class="menu-section-label" id="workspace-menu-label">Workspace</div>
|
||||
<div id="workspace-menu-actions" role="group"></div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -49,8 +63,48 @@
|
||||
<div id="completion-dropdown" class="completion-dropdown"></div>
|
||||
</div>
|
||||
|
||||
<div class="console-container">
|
||||
<div class="console-header">Console Output</div>
|
||||
<section id="led-sim-panel" class="led-sim-panel hidden" aria-label="NeoPixel Simulator">
|
||||
<div id="led-grid" class="led-grid"></div>
|
||||
</section>
|
||||
|
||||
<section id="pin-panel" class="pin-panel hidden" aria-label="Pins">
|
||||
<div class="pin-panel-header">
|
||||
<span class="pin-panel-title">Pins</span>
|
||||
<span class="pin-panel-hint">OUT shows live state · IN is clickable · PWM shows duty</span>
|
||||
</div>
|
||||
<div id="pin-rows" class="pin-rows"></div>
|
||||
</section>
|
||||
|
||||
<section id="adc-panel" class="adc-panel hidden" aria-label="ADC inputs">
|
||||
<div class="adc-panel-header">
|
||||
<span class="adc-panel-title">ADC inputs</span>
|
||||
<span class="adc-panel-hint">drag to set value (0–65535)</span>
|
||||
</div>
|
||||
<div id="adc-sliders" class="adc-sliders"></div>
|
||||
</section>
|
||||
|
||||
<section id="serial-panel" class="serial-panel hidden" aria-label="Serial monitor">
|
||||
<div class="serial-header">
|
||||
<span class="serial-title">Serial monitor</span>
|
||||
<span id="serial-meta" class="serial-meta"></span>
|
||||
<button type="button" id="serial-clear" class="serial-clear" title="Clear">Clear</button>
|
||||
</div>
|
||||
<div id="serial-output" class="serial-output" aria-live="polite"></div>
|
||||
<form id="serial-form" class="serial-form" autocomplete="off">
|
||||
<input id="serial-input" type="text" class="serial-input" placeholder="Type and press Enter to send" />
|
||||
<label class="serial-newline" title="Append \n on send">
|
||||
<input type="checkbox" id="serial-newline-checkbox" checked />
|
||||
<span>\n</span>
|
||||
</label>
|
||||
<button type="submit" class="serial-send">Send</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<div class="console-container" id="console-container">
|
||||
<button type="button" class="console-header" id="console-toggle" aria-expanded="true" aria-controls="console-output">
|
||||
<span class="chevron" aria-hidden="true">▾</span>
|
||||
<span>Console Output</span>
|
||||
</button>
|
||||
<pre id="console-output" class="console-output"></pre>
|
||||
</div>
|
||||
</div>
|
||||
@@ -67,6 +121,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module" src="/static/script.js?v=10"></script>
|
||||
<script type="module" src="/static/script.js?v=59"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
1032
src/static/local-workspace.js
Normal file
1032
src/static/local-workspace.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Sign in — Python Editor</title>
|
||||
<title>Sign in — LED Editor</title>
|
||||
<link rel="icon" href="data:,">
|
||||
<style>
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
@@ -34,7 +34,109 @@ await micropip.install("jedi")
|
||||
self.onmessage = async (event) => {
|
||||
const { id, type, payload } = event.data || {};
|
||||
try {
|
||||
/* Fast-path: fire-and-forget messages used by the SAB-less fallback
|
||||
for pin input + ADC slider values. We update the worker-local
|
||||
Int32Array so Python's next read sees the new value, with no
|
||||
Pyodide round-trip and no reply expected. */
|
||||
if (type === 'pinIn') {
|
||||
const view = self.__pin_in_view;
|
||||
const pin = payload && Number(payload.pin);
|
||||
if (view && Number.isFinite(pin) && pin >= 0 && pin < view.length) {
|
||||
try {
|
||||
view[pin] = (payload.value | 0) ? 1 : 0;
|
||||
} catch (_e) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (type === 'adcSet') {
|
||||
const view = self.__adc_view;
|
||||
const pin = payload && Number(payload.pin);
|
||||
if (view && Number.isFinite(pin) && pin >= 0 && pin < view.length) {
|
||||
const value = Math.max(0, Math.min(65535, payload.value | 0));
|
||||
try {
|
||||
view[pin] = value;
|
||||
} catch (_e) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === 'init') {
|
||||
/* The main thread shares an Int32Array-backed SAB so Python ADC.read*()
|
||||
can pick up live slider values without yielding. */
|
||||
const adcSab = payload && payload.adcSab;
|
||||
if (adcSab && typeof adcSab !== 'string') {
|
||||
try {
|
||||
self.__adc_view = new Int32Array(adcSab);
|
||||
} catch (_e) {
|
||||
self.__adc_view = null;
|
||||
}
|
||||
}
|
||||
/* No SAB? Fall back to a worker-local Int32Array; the main thread
|
||||
then delivers slider drags via `postMessage({type:'adcSet'})`. */
|
||||
if (!self.__adc_view) {
|
||||
try {
|
||||
self.__adc_view = new Int32Array(64);
|
||||
} catch (_e) {
|
||||
self.__adc_view = null;
|
||||
}
|
||||
}
|
||||
/* Pin output state: Int32Array[64] — Python writes packed
|
||||
[ui_mode << 24 | value (low 24 bits)] entries, the editor UI reads
|
||||
them every frame to drive the Pins panel indicators. */
|
||||
const pinOutSab = payload && payload.pinOutSab;
|
||||
if (pinOutSab && typeof pinOutSab !== 'string') {
|
||||
try {
|
||||
self.__pin_out_view = new Int32Array(pinOutSab);
|
||||
} catch (_e) {
|
||||
self.__pin_out_view = null;
|
||||
}
|
||||
}
|
||||
if (!self.__pin_out_view) {
|
||||
try {
|
||||
self.__pin_out_view = new Int32Array(64);
|
||||
} catch (_e) {
|
||||
self.__pin_out_view = null;
|
||||
}
|
||||
}
|
||||
/* Pin input state: Int32Array[64] — UI buttons write 0/1 per pin,
|
||||
Python's `Pin.value()` for IN mode reads the matching slot. */
|
||||
const pinInSab = payload && payload.pinInSab;
|
||||
if (pinInSab && typeof pinInSab !== 'string') {
|
||||
try {
|
||||
self.__pin_in_view = new Int32Array(pinInSab);
|
||||
} catch (_e) {
|
||||
self.__pin_in_view = null;
|
||||
}
|
||||
}
|
||||
if (!self.__pin_in_view) {
|
||||
try {
|
||||
self.__pin_in_view = new Int32Array(64);
|
||||
} catch (_e) {
|
||||
self.__pin_in_view = null;
|
||||
}
|
||||
}
|
||||
/* Tell Python whether SAB was actually wired up so machine.py can
|
||||
emit `[pin-out]` print markers as a fallback for the live UI. */
|
||||
self.__sab_isolated = Boolean(pinOutSab && typeof pinOutSab !== 'string');
|
||||
/* Serial-in ring buffer: first 8 bytes are [readIdx, writeIdx] (Int32),
|
||||
followed by `capacity` bytes of payload data. */
|
||||
const serialSab = payload && payload.serialSab;
|
||||
if (serialSab && typeof serialSab !== 'string') {
|
||||
try {
|
||||
self.__serial_in_indices = new Int32Array(serialSab, 0, 2);
|
||||
const capacity = serialSab.byteLength - 8;
|
||||
self.__serial_in_capacity = capacity;
|
||||
self.__serial_in_data = new Uint8Array(serialSab, 8, capacity);
|
||||
} catch (_e) {
|
||||
self.__serial_in_indices = null;
|
||||
self.__serial_in_data = null;
|
||||
self.__serial_in_capacity = 0;
|
||||
}
|
||||
}
|
||||
await ensurePyodide();
|
||||
self.postMessage({ id, type: 'init', ok: true });
|
||||
return;
|
||||
@@ -52,7 +154,7 @@ self.onmessage = async (event) => {
|
||||
p.globals.set('__cm_max', Math.min(100, Math.max(1, Number(payload.max_results) || 20)));
|
||||
p.globals.set('__cm_extra_json', JSON.stringify(payload.extra_files || {}));
|
||||
const raw = p.runPython(`
|
||||
import json, os
|
||||
import json, os, sys
|
||||
import jedi
|
||||
|
||||
extra = json.loads(__cm_extra_json)
|
||||
@@ -66,7 +168,13 @@ for rel_path, body in extra.items():
|
||||
os.makedirs(os.path.dirname(__cm_path), exist_ok=True)
|
||||
with open(__cm_path, "w", encoding="utf-8") as fh:
|
||||
fh.write(__cm_code)
|
||||
proj = jedi.Project("/workspace")
|
||||
for entry in ("/workspace/code", "/workspace/lib", "/workspace"):
|
||||
if entry not in sys.path:
|
||||
sys.path.insert(0, entry)
|
||||
proj = jedi.Project(
|
||||
"/workspace",
|
||||
added_sys_path=["/workspace/code", "/workspace/lib", "/workspace"],
|
||||
)
|
||||
s = jedi.Script(code=__cm_code, path=__cm_path, project=proj)
|
||||
items = s.complete(line=__cm_line, column=__cm_col)
|
||||
out = [{"name": i.name, "type": i.type, "complete": i.complete} for i in items[:__cm_max]]
|
||||
@@ -77,6 +185,44 @@ json.dumps(out)
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === 'diagnostics') {
|
||||
const rel = String(payload.path || 'scratch.py').replace(/^\/+/, '');
|
||||
const vpath = `/workspace/${rel}`;
|
||||
p.globals.set('__diag_code', String(payload.content ?? ''));
|
||||
p.globals.set('__diag_path', vpath);
|
||||
p.globals.set('__diag_extra_json', JSON.stringify(payload.extra_files || {}));
|
||||
const raw = p.runPython(`
|
||||
import json, os, sys
|
||||
import jedi
|
||||
|
||||
extra = json.loads(__diag_extra_json)
|
||||
os.makedirs("/workspace", exist_ok=True)
|
||||
for rel_path, body in extra.items():
|
||||
rel_path = str(rel_path).lstrip("/")
|
||||
full = os.path.join("/workspace", rel_path)
|
||||
os.makedirs(os.path.dirname(full), exist_ok=True)
|
||||
with open(full, "w", encoding="utf-8") as fh:
|
||||
fh.write(str(body))
|
||||
os.makedirs(os.path.dirname(__diag_path), exist_ok=True)
|
||||
with open(__diag_path, "w", encoding="utf-8") as fh:
|
||||
fh.write(__diag_code)
|
||||
for entry in ("/workspace/code", "/workspace/lib", "/workspace"):
|
||||
if entry not in sys.path:
|
||||
sys.path.insert(0, entry)
|
||||
proj = jedi.Project(
|
||||
"/workspace",
|
||||
added_sys_path=["/workspace/code", "/workspace/lib", "/workspace"],
|
||||
)
|
||||
s = jedi.Script(code=__diag_code, path=__diag_path, project=proj)
|
||||
errs = s.get_syntax_errors()
|
||||
out = [{"line": e.line, "column": e.column, "message": str(e.get_message())} for e in errs]
|
||||
json.dumps(out)
|
||||
`);
|
||||
const diagnostics = JSON.parse(String(raw));
|
||||
self.postMessage({ id, type: 'diagnostics', ok: true, diagnostics });
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === 'run') {
|
||||
const files = payload.files && typeof payload.files === 'object' ? payload.files : {};
|
||||
const mainRel = String(payload.mainPath || '').replace(/^\/+/, '');
|
||||
@@ -97,7 +243,7 @@ for rel, body in files.items():
|
||||
with open(full, "w", encoding="utf-8") as fh:
|
||||
fh.write(str(body))
|
||||
|
||||
for entry in ("/workspace/lib", "/workspace"):
|
||||
for entry in ("/workspace/code", "/workspace/lib", "/workspace"):
|
||||
if entry not in sys.path:
|
||||
sys.path.insert(0, entry)
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Register — Python Editor</title>
|
||||
<title>Register — LED Editor</title>
|
||||
<link rel="icon" href="data:,">
|
||||
<style>
|
||||
* { box-sizing: border-box; }
|
||||
@@ -54,6 +54,7 @@
|
||||
<body>
|
||||
<main class="card">
|
||||
<h1>Create account</h1>
|
||||
<p id="invite-banner" class="hint hidden"></p>
|
||||
<p class="hint">Username: letters, numbers, underscore (3–64). Password: at least 8 characters.</p>
|
||||
<div id="err" class="err"></div>
|
||||
<form id="form">
|
||||
@@ -61,11 +62,38 @@
|
||||
<input id="username" name="username" autocomplete="username" required />
|
||||
<label for="password">Password</label>
|
||||
<input id="password" type="password" name="password" autocomplete="new-password" required />
|
||||
<input id="invite-token" type="hidden" name="invite_token" />
|
||||
<button type="submit" id="submit">Register</button>
|
||||
</form>
|
||||
<p><a href="/login">Sign in</a> · <a href="/">Home</a></p>
|
||||
</main>
|
||||
<script>
|
||||
const inviteTokenRaw = new URLSearchParams(window.location.search).get("invite") || "";
|
||||
const inviteToken = inviteTokenRaw.trim();
|
||||
const inviteInput = document.getElementById("invite-token");
|
||||
if (inviteInput) inviteInput.value = inviteToken;
|
||||
if (inviteToken) {
|
||||
const banner = document.getElementById("invite-banner");
|
||||
if (banner) {
|
||||
banner.classList.remove("hidden");
|
||||
banner.textContent =
|
||||
"You're using an invite link. Choose a username and password — the link works for one signup only.";
|
||||
}
|
||||
}
|
||||
|
||||
function formatApiDetail(body) {
|
||||
if (!body || body.detail === undefined || body.detail === null) return "";
|
||||
const d = body.detail;
|
||||
if (typeof d === "string") return d;
|
||||
if (Array.isArray(d))
|
||||
return d
|
||||
.map((item) =>
|
||||
typeof item === "object" && item && item.msg ? String(item.msg) : JSON.stringify(item)
|
||||
)
|
||||
.join(" ");
|
||||
return String(d);
|
||||
}
|
||||
|
||||
(async function checkStatus() {
|
||||
try {
|
||||
const r = await fetch("/api/auth/status");
|
||||
@@ -73,9 +101,12 @@
|
||||
if (!s.auth_enabled) {
|
||||
document.getElementById("err").textContent = "Registration is disabled (AUTH_ENABLED is not set).";
|
||||
document.getElementById("form").style.display = "none";
|
||||
} else if (!s.register_open) {
|
||||
} else if (!s.register_open && !inviteToken) {
|
||||
document.getElementById("err").textContent = "Public registration is closed. Ask an administrator.";
|
||||
document.getElementById("form").style.display = "none";
|
||||
} else if (s.invite_required && !inviteToken) {
|
||||
document.getElementById("err").textContent = "This server requires an invite link to register.";
|
||||
document.getElementById("form").style.display = "none";
|
||||
}
|
||||
} catch (_e) {}
|
||||
})();
|
||||
@@ -90,6 +121,7 @@
|
||||
const body = {
|
||||
username: document.getElementById("username").value.trim(),
|
||||
password: document.getElementById("password").value,
|
||||
invite_token: inviteToken ? inviteToken : null,
|
||||
};
|
||||
const res = await fetch("/api/auth/register", {
|
||||
method: "POST",
|
||||
@@ -98,7 +130,7 @@
|
||||
});
|
||||
if (!res.ok) {
|
||||
const j = await res.json().catch(() => ({}));
|
||||
err.textContent = typeof j.detail === "string" ? j.detail : JSON.stringify(j.detail) || res.statusText;
|
||||
err.textContent = formatApiDetail(j) || res.statusText;
|
||||
return;
|
||||
}
|
||||
window.location.href = "/login?next=/editor";
|
||||
|
||||
1868
src/static/script.js
1868
src/static/script.js
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
250
src/static/tutorial.html
Normal file
250
src/static/tutorial.html
Normal file
@@ -0,0 +1,250 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Python Tutorial</title>
|
||||
<link rel="icon" href="data:,">
|
||||
<style>
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
background: #0f172a;
|
||||
color: #e2e8f0;
|
||||
min-height: 100dvh;
|
||||
}
|
||||
.page {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
padding: 1rem;
|
||||
display: grid;
|
||||
gap: 0.9rem;
|
||||
}
|
||||
.topbar {
|
||||
display: flex;
|
||||
gap: 0.6rem;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
.btn {
|
||||
border: 1px solid #334155;
|
||||
background: #1e293b;
|
||||
color: #e2e8f0;
|
||||
border-radius: 8px;
|
||||
text-decoration: none;
|
||||
padding: 0.45rem 0.7rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
.btn.primary {
|
||||
background: #2563eb;
|
||||
border-color: #2563eb;
|
||||
}
|
||||
.layout {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0.9rem;
|
||||
min-height: 70dvh;
|
||||
}
|
||||
.card {
|
||||
border: 1px solid #334155;
|
||||
border-radius: 12px;
|
||||
background: #111827;
|
||||
padding: 0.8rem;
|
||||
min-height: 0;
|
||||
}
|
||||
.hint { color: #94a3b8; font-size: 0.9rem; margin: 0.4rem 0 0.7rem; white-space: pre-wrap; }
|
||||
.guided-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 320px;
|
||||
gap: 0.8rem;
|
||||
margin-bottom: 0.9rem;
|
||||
}
|
||||
.demo-code {
|
||||
margin: 0.55rem 0 0;
|
||||
border: 1px solid #334155;
|
||||
background: #020617;
|
||||
border-radius: 8px;
|
||||
padding: 0.6rem;
|
||||
min-height: 280px;
|
||||
overflow: auto;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 0.84rem;
|
||||
line-height: 1.45;
|
||||
white-space: pre;
|
||||
}
|
||||
.demo-line {
|
||||
display: block;
|
||||
padding: 0 0.2rem;
|
||||
border-radius: 4px;
|
||||
color: #cbd5e1;
|
||||
}
|
||||
.demo-line.active {
|
||||
background: rgba(59, 130, 246, 0.18);
|
||||
color: #dbeafe;
|
||||
outline: 1px solid rgba(59, 130, 246, 0.35);
|
||||
}
|
||||
.demo-controls {
|
||||
display: grid;
|
||||
gap: 0.65rem;
|
||||
margin-top: 0.65rem;
|
||||
padding: 0.7rem;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 10px;
|
||||
background: #0b1220;
|
||||
}
|
||||
.demo-controls label {
|
||||
display: grid;
|
||||
gap: 0.2rem;
|
||||
color: #e2e8f0;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.demo-controls .control-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.demo-controls .value-badge {
|
||||
min-width: 52px;
|
||||
text-align: center;
|
||||
background: #1d4ed8;
|
||||
color: white;
|
||||
border: 1px solid rgba(255,255,255,0.2);
|
||||
border-radius: 999px;
|
||||
padding: 0.1rem 0.45rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.demo-controls input[type="range"] {
|
||||
width: 100%;
|
||||
accent-color: #3b82f6;
|
||||
height: 1.1rem;
|
||||
}
|
||||
.demo-controls select {
|
||||
width: 100%;
|
||||
border: 1px solid #475569;
|
||||
border-radius: 8px;
|
||||
background: #020617;
|
||||
color: #e2e8f0;
|
||||
padding: 0.5rem 0.55rem;
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
.guide-explain {
|
||||
border: 1px solid #334155;
|
||||
background: #020617;
|
||||
border-radius: 8px;
|
||||
padding: 0.65rem;
|
||||
color: #cbd5e1;
|
||||
min-height: 120px;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.guide-step {
|
||||
color: #93c5fd;
|
||||
font-size: 0.9rem;
|
||||
margin: 0.45rem 0;
|
||||
}
|
||||
.hidden { display: none !important; }
|
||||
.led-demo-panel {
|
||||
border: 1px solid #334155;
|
||||
background: #020617;
|
||||
border-radius: 8px;
|
||||
padding: 0.65rem;
|
||||
width: fit-content;
|
||||
display: inline-block;
|
||||
}
|
||||
.led-meta {
|
||||
color: #9ca3af;
|
||||
font-size: 0.82rem;
|
||||
margin-bottom: 0.45rem;
|
||||
display: none;
|
||||
}
|
||||
.led-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(16, 24px);
|
||||
grid-auto-rows: 24px;
|
||||
gap: 6px;
|
||||
max-height: none;
|
||||
overflow: visible;
|
||||
align-content: start;
|
||||
justify-content: start;
|
||||
width: fit-content;
|
||||
}
|
||||
.led {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
box-shadow: inset 0 0 6px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.guided-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class="page">
|
||||
<div class="topbar">
|
||||
<a class="btn" href="/">Home</a>
|
||||
</div>
|
||||
<div class="layout">
|
||||
<section class="card">
|
||||
<h3 style="margin:0">Guided LED Demo</h3>
|
||||
<div style="display:flex; gap:0.5rem; flex-wrap:wrap;">
|
||||
<button id="begin-demo-btn" class="btn primary" type="button">Let's Get Started</button>
|
||||
<button id="toggle-breakdown-btn" class="btn hidden" type="button">Show Breakdown</button>
|
||||
</div>
|
||||
<div class="guided-grid">
|
||||
<div>
|
||||
<div class="led-demo-panel">
|
||||
<div id="led-meta" class="led-meta">Press Begin Demo to render the panel animation.</div>
|
||||
<div id="led-grid" class="led-grid"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="demo-controls">
|
||||
<label>Pattern
|
||||
<select id="pattern-select">
|
||||
<option value="plasma">Plasma Swirl</option>
|
||||
<option value="rainbow">Rainbow Orbit</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span class="control-head">Speed <span id="speed-value" class="value-badge"></span></span>
|
||||
<input id="speed-slider" type="range" min="0.8" max="3.2" step="0.1" value="1.6" />
|
||||
</label>
|
||||
<label>
|
||||
<span class="control-head">Wave Width <span id="width-value" class="value-badge"></span></span>
|
||||
<input id="width-slider" type="range" min="0.2" max="0.8" step="0.02" value="0.45" />
|
||||
</label>
|
||||
<label>
|
||||
<span class="control-head">Brightness <span id="brightness-value" class="value-badge"></span></span>
|
||||
<input id="brightness-slider" type="range" min="80" max="230" step="5" value="170" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="learning-content" class="hidden">
|
||||
<div id="breakdown-panel" class="hidden">
|
||||
<div style="display:flex; gap:0.5rem; flex-wrap:wrap; margin-top:0.2rem;">
|
||||
<button id="prev-part-btn" class="btn" type="button">Prev Part</button>
|
||||
<button id="next-part-btn" class="btn" type="button">Next Part</button>
|
||||
</div>
|
||||
<div class="guided-grid" style="margin-top:0.55rem;">
|
||||
<div>
|
||||
<div id="guide-step" class="guide-step"></div>
|
||||
<div id="demo-code" class="demo-code"></div>
|
||||
</div>
|
||||
<div>
|
||||
<div id="guide-explain" class="guide-explain"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
<script type="module" src="/static/tutorial.js?v=2"></script>
|
||||
</body>
|
||||
</html>
|
||||
435
src/static/tutorial.js
Normal file
435
src/static/tutorial.js
Normal file
@@ -0,0 +1,435 @@
|
||||
const DEMO_PARTS = [
|
||||
{
|
||||
title: "Setup objects",
|
||||
start: 1,
|
||||
end: 6,
|
||||
explain:
|
||||
"We import timing/math and create a 16x16 NeoPixel object.\nThis gives us 256 LEDs to control every frame.",
|
||||
},
|
||||
{
|
||||
title: "Serpentine mapping",
|
||||
start: 8,
|
||||
end: 10,
|
||||
explain:
|
||||
"The panel wiring zig-zags by row.\nEven rows run right-to-left, odd rows run left-to-right.",
|
||||
},
|
||||
{
|
||||
title: "Frame loop",
|
||||
start: 12,
|
||||
end: 12,
|
||||
explain:
|
||||
"`for t in range(...)` is time.\nEach pass draws one frame of animation.",
|
||||
},
|
||||
{
|
||||
title: "Pixel color math",
|
||||
start: 13,
|
||||
end: 17,
|
||||
explain:
|
||||
"For each (x,y), we compute a sine wave value.\nThat value controls brightness and color to create flowing motion.",
|
||||
},
|
||||
{
|
||||
title: "Render + delay",
|
||||
start: 18,
|
||||
end: 19,
|
||||
explain:
|
||||
"`np.write()` pushes the full frame to LEDs.\nA tiny sleep controls animation speed.",
|
||||
},
|
||||
];
|
||||
|
||||
class TutorialApp {
|
||||
constructor() {
|
||||
this.pyWorker = null;
|
||||
this.pyWorkerMsgId = 0;
|
||||
this.pyWorkerHandlers = new Map();
|
||||
this.pyodideInited = false;
|
||||
this.outputBuffer = "";
|
||||
this.introMode = true;
|
||||
this.previewTimer = null;
|
||||
this.previewTick = 0;
|
||||
this.breakdownVisible = false;
|
||||
this.demoPartIndex = 0;
|
||||
this.demoVars = {
|
||||
pattern: "plasma",
|
||||
speed: 1.6,
|
||||
width: 0.45,
|
||||
brightness: 170,
|
||||
};
|
||||
this.bind();
|
||||
this.renderDemoControls();
|
||||
this.renderDemoCode();
|
||||
this.renderDemoPart();
|
||||
this.prewarm();
|
||||
this.startIdlePreview();
|
||||
}
|
||||
|
||||
bind() {
|
||||
document.getElementById("toggle-breakdown-btn").addEventListener("click", () => {
|
||||
this.breakdownVisible = !this.breakdownVisible;
|
||||
const panel = document.getElementById("breakdown-panel");
|
||||
const button = document.getElementById("toggle-breakdown-btn");
|
||||
if (panel) panel.classList.toggle("hidden", !this.breakdownVisible);
|
||||
if (button) button.textContent = this.breakdownVisible ? "Hide Breakdown" : "Show Breakdown";
|
||||
});
|
||||
document.getElementById("begin-demo-btn").addEventListener("click", async () => {
|
||||
if (this.introMode) {
|
||||
this.introMode = false;
|
||||
this.stopIdlePreview();
|
||||
const learning = document.getElementById("learning-content");
|
||||
const breakdownToggle = document.getElementById("toggle-breakdown-btn");
|
||||
if (learning) learning.classList.remove("hidden");
|
||||
if (breakdownToggle) breakdownToggle.classList.remove("hidden");
|
||||
}
|
||||
const src = this.buildDemoCode();
|
||||
await this.runSource(src, "code/tutorial_demo.py");
|
||||
this.setStatus("Demo ran. Use Show Breakdown to step through code.", "info");
|
||||
});
|
||||
document.getElementById("prev-part-btn").addEventListener("click", () => {
|
||||
this.demoPartIndex = (this.demoPartIndex - 1 + DEMO_PARTS.length) % DEMO_PARTS.length;
|
||||
this.renderDemoPart();
|
||||
});
|
||||
document.getElementById("next-part-btn").addEventListener("click", () => {
|
||||
this.demoPartIndex = (this.demoPartIndex + 1) % DEMO_PARTS.length;
|
||||
this.renderDemoPart();
|
||||
});
|
||||
document.getElementById("pattern-select").addEventListener("change", (event) => {
|
||||
this.demoVars.pattern = String(event.target.value || "plasma");
|
||||
this.renderDemoCode();
|
||||
this.renderDemoPart();
|
||||
});
|
||||
document.getElementById("speed-slider").addEventListener("input", (event) => {
|
||||
this.demoVars.speed = Number(event.target.value);
|
||||
this.renderDemoControls();
|
||||
this.renderDemoCode();
|
||||
this.renderDemoPart();
|
||||
});
|
||||
document.getElementById("width-slider").addEventListener("input", (event) => {
|
||||
this.demoVars.width = Number(event.target.value);
|
||||
this.renderDemoControls();
|
||||
this.renderDemoCode();
|
||||
this.renderDemoPart();
|
||||
});
|
||||
document.getElementById("brightness-slider").addEventListener("input", (event) => {
|
||||
this.demoVars.brightness = Number(event.target.value);
|
||||
this.renderDemoControls();
|
||||
this.renderDemoCode();
|
||||
this.renderDemoPart();
|
||||
});
|
||||
}
|
||||
|
||||
buildDemoCode() {
|
||||
const pattern = this.demoVars.pattern;
|
||||
const speed = this.demoVars.speed.toFixed(2);
|
||||
const width = this.demoVars.width.toFixed(2);
|
||||
const brightness = Math.round(this.demoVars.brightness);
|
||||
const bodyLines =
|
||||
pattern === "rainbow"
|
||||
? [
|
||||
" dx = x - 7.5",
|
||||
" dy = y - 7.5",
|
||||
" angle = math.atan2(dy, dx)",
|
||||
" radius = math.sqrt(dx * dx + dy * dy)",
|
||||
" hue = angle + (radius * WAVE_WIDTH) + (t * SPEED * 0.15)",
|
||||
" pulse = (math.sin((radius * 0.65) - (t * 0.10)) + 1.0) * 0.5",
|
||||
" scale = int((0.45 + pulse * 0.55) * BRIGHTNESS)",
|
||||
" r = int((math.sin(hue + 0.0) * 0.5 + 0.5) * scale)",
|
||||
" g = int((math.sin(hue + 2.09) * 0.5 + 0.5) * scale)",
|
||||
" u = int((math.sin(hue + 4.18) * 0.5 + 0.5) * scale)",
|
||||
" np[xy_to_index(x, y)] = (max(0,min(255,r)), max(0,min(255,g)), max(0,min(255,u)))",
|
||||
]
|
||||
: [
|
||||
" dx = x - 7.5",
|
||||
" dy = y - 7.5",
|
||||
" dist = math.sqrt(dx * dx + dy * dy)",
|
||||
" ring = math.sin(dist * (WAVE_WIDTH * 2.6) - t * (SPEED * 0.9))",
|
||||
` swirl = math.sin((x * WAVE_WIDTH) + (y * ${Math.max(0.1, width * 0.8).toFixed(2)}) + (t * SPEED * 0.55))`,
|
||||
" pulse = math.sin(t * SPEED * 0.12)",
|
||||
" b = int((ring * 0.45 + swirl * 0.35 + pulse * 0.2 + 1.0) * (BRIGHTNESS / 2))",
|
||||
" b = max(0, min(255, b))",
|
||||
" r_wave = math.sin((x * 0.38) + (t * 0.12))",
|
||||
" g_wave = math.sin((y * 0.41) - (t * 0.10))",
|
||||
" u_wave = math.sin(((x + y) * 0.27) + (t * 0.08))",
|
||||
" r = max(0, min(255, int(b * 0.45 + (r_wave + 1) * 80)))",
|
||||
" g = max(0, min(255, int(b * 0.30 + (g_wave + 1) * 95)))",
|
||||
" u = max(0, min(255, int(b * 0.55 + (u_wave + 1) * 85)))",
|
||||
" np[xy_to_index(x, y)] = (r, g, u)",
|
||||
];
|
||||
return [
|
||||
"from machine import Pin",
|
||||
"import neopixel",
|
||||
"import time",
|
||||
"import math",
|
||||
"",
|
||||
"np = neopixel.NeoPixel(Pin(4), 16 * 16)",
|
||||
`PATTERN = "${pattern}"`,
|
||||
`SPEED = ${speed}`,
|
||||
`WAVE_WIDTH = ${width}`,
|
||||
`BRIGHTNESS = ${brightness}`,
|
||||
"",
|
||||
"def xy_to_index(x, y):",
|
||||
" return y * 16 + (15 - x if y % 2 == 0 else x)",
|
||||
"",
|
||||
"frames = 130",
|
||||
"for t in range(frames):",
|
||||
" for y in range(16):",
|
||||
" for x in range(16):",
|
||||
...bodyLines,
|
||||
" np.write()",
|
||||
" time.sleep(0.03)",
|
||||
"",
|
||||
"print('DEMO_DONE')",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
escapeHtml(text) {
|
||||
return text
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
}
|
||||
|
||||
renderDemoControls() {
|
||||
const patternSelect = document.getElementById("pattern-select");
|
||||
if (patternSelect) patternSelect.value = this.demoVars.pattern;
|
||||
document.getElementById("speed-value").textContent = this.demoVars.speed.toFixed(1);
|
||||
document.getElementById("width-value").textContent = this.demoVars.width.toFixed(2);
|
||||
document.getElementById("brightness-value").textContent = String(Math.round(this.demoVars.brightness));
|
||||
}
|
||||
|
||||
renderDemoCode() {
|
||||
const code = this.buildDemoCode();
|
||||
const lines = code.split("\n");
|
||||
const active = DEMO_PARTS[this.demoPartIndex];
|
||||
const html = lines
|
||||
.map((line, idx) => {
|
||||
const n = idx + 1;
|
||||
const activeClass = n >= active.start && n <= active.end ? " active" : "";
|
||||
return `<span class="demo-line${activeClass}">${String(n).padStart(2, " ")} ${this.escapeHtml(line)}</span>`;
|
||||
})
|
||||
.join("");
|
||||
document.getElementById("demo-code").innerHTML = html;
|
||||
}
|
||||
|
||||
renderDemoPart() {
|
||||
const part = DEMO_PARTS[this.demoPartIndex];
|
||||
document.getElementById("guide-step").textContent = `Part ${this.demoPartIndex + 1}/${DEMO_PARTS.length}: ${part.title}`;
|
||||
document.getElementById("guide-explain").textContent = part.explain;
|
||||
this.renderDemoCode();
|
||||
}
|
||||
|
||||
buildPreviewFrame(tick) {
|
||||
const pixels = new Array(16 * 16).fill(0).map(() => [0, 0, 0]);
|
||||
const pattern = this.demoVars.pattern;
|
||||
const speed = this.demoVars.speed;
|
||||
const width = this.demoVars.width;
|
||||
const brightness = this.demoVars.brightness;
|
||||
for (let y = 0; y < 16; y += 1) {
|
||||
for (let x = 0; x < 16; x += 1) {
|
||||
const ledIndex = y % 2 === 0 ? y * 16 + (15 - x) : y * 16 + x;
|
||||
if (pattern === "rainbow") {
|
||||
const dx = x - 7.5;
|
||||
const dy = y - 7.5;
|
||||
const angle = Math.atan2(dy, dx);
|
||||
const radius = Math.sqrt(dx * dx + dy * dy);
|
||||
const hue = angle + (radius * width) + (tick * speed * 0.012);
|
||||
const pulse = (Math.sin((radius * 0.65) - (tick * 0.012)) + 1) * 0.5;
|
||||
const scale = (0.45 + pulse * 0.55) * brightness;
|
||||
const r = Math.max(0, Math.min(255, Math.floor((Math.sin(hue + 0.0) * 0.5 + 0.5) * scale)));
|
||||
const g = Math.max(0, Math.min(255, Math.floor((Math.sin(hue + 2.09) * 0.5 + 0.5) * scale)));
|
||||
const u = Math.max(0, Math.min(255, Math.floor((Math.sin(hue + 4.18) * 0.5 + 0.5) * scale)));
|
||||
pixels[ledIndex] = [r, g, u];
|
||||
} else {
|
||||
const dx = x - 7.5;
|
||||
const dy = y - 7.5;
|
||||
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||
const ring = Math.sin(dist * (width * 2.6) - tick * speed * 0.072);
|
||||
const swirl = Math.sin((x * width) + (y * Math.max(0.1, width * 0.8)) + (tick * speed * 0.044));
|
||||
const pulse = Math.sin(tick * speed * 0.01);
|
||||
const b = Math.max(0, Math.min(255, Math.floor((ring * 0.45 + swirl * 0.35 + pulse * 0.2 + 1) * (brightness / 2))));
|
||||
const rWave = Math.sin((x * 0.38) + (tick * 0.12));
|
||||
const gWave = Math.sin((y * 0.41) - (tick * 0.10));
|
||||
const uWave = Math.sin(((x + y) * 0.27) + (tick * 0.08));
|
||||
const r = Math.max(0, Math.min(255, Math.floor(b * 0.45 + (rWave + 1) * 80)));
|
||||
const g = Math.max(0, Math.min(255, Math.floor(b * 0.30 + (gWave + 1) * 95)));
|
||||
const u = Math.max(0, Math.min(255, Math.floor(b * 0.55 + (uWave + 1) * 85)));
|
||||
pixels[ledIndex] = [r, g, u];
|
||||
}
|
||||
}
|
||||
}
|
||||
return { type: "neopixel", pin: 4, bpp: 3, pixels };
|
||||
}
|
||||
|
||||
startIdlePreview() {
|
||||
this.stopIdlePreview();
|
||||
this.previewTick = 0;
|
||||
this.previewTimer = setInterval(() => {
|
||||
this.previewTick += 1;
|
||||
this.renderLedSimulation(this.buildPreviewFrame(this.previewTick));
|
||||
}, 50);
|
||||
}
|
||||
|
||||
stopIdlePreview() {
|
||||
if (this.previewTimer) {
|
||||
clearInterval(this.previewTimer);
|
||||
this.previewTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
setStatus(msg, kind = "error") {
|
||||
const status = document.getElementById("status");
|
||||
if (!status) return;
|
||||
status.textContent = msg;
|
||||
if (kind === "success") {
|
||||
status.style.color = "#86efac";
|
||||
} else if (kind === "info") {
|
||||
status.style.color = "#93c5fd";
|
||||
} else {
|
||||
status.style.color = "#fca5a5";
|
||||
}
|
||||
}
|
||||
|
||||
ensurePyWorker() {
|
||||
if (!this.pyWorker) {
|
||||
this.pyWorker = new Worker("/static/pyodide-worker.js?v=4");
|
||||
this.pyWorker.onmessage = (event) => this.onWorkerMessage(event);
|
||||
}
|
||||
return this.pyWorker;
|
||||
}
|
||||
|
||||
onWorkerMessage(event) {
|
||||
const data = event.data;
|
||||
if (!data || typeof data !== "object") return;
|
||||
if (data.type === "io") {
|
||||
const text = data.text || "";
|
||||
this.outputBuffer += text;
|
||||
this.processSimulationLines(text.split(/\r?\n/));
|
||||
return;
|
||||
}
|
||||
const pending = this.pyWorkerHandlers.get(data.id);
|
||||
if (!pending) return;
|
||||
this.pyWorkerHandlers.delete(data.id);
|
||||
if (data.ok) pending.resolve(data);
|
||||
else pending.reject(new Error(data.error || "Worker error"));
|
||||
}
|
||||
|
||||
callWorker(type, payload) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const id = ++this.pyWorkerMsgId;
|
||||
this.pyWorkerHandlers.set(id, { resolve, reject });
|
||||
this.ensurePyWorker().postMessage({ id, type, payload });
|
||||
});
|
||||
}
|
||||
|
||||
async ensurePyodideReady() {
|
||||
if (this.pyodideInited) return;
|
||||
await this.callWorker("init", {});
|
||||
this.pyodideInited = true;
|
||||
}
|
||||
|
||||
async prewarm() {
|
||||
try {
|
||||
await this.ensurePyodideReady();
|
||||
} catch (_err) {
|
||||
// ignore warm-up errors
|
||||
}
|
||||
}
|
||||
|
||||
renderLedSimulation(frame) {
|
||||
if (!frame) return;
|
||||
const meta = document.getElementById("led-meta");
|
||||
const grid = document.getElementById("led-grid");
|
||||
if (!meta || !grid) return;
|
||||
const pixels = Array.isArray(frame.pixels) ? frame.pixels : [];
|
||||
meta.textContent = `pin=${frame.pin ?? "?"} | leds=${pixels.length} | bpp=${frame.bpp ?? 3} | mode=16x16`;
|
||||
grid.innerHTML = "";
|
||||
const panelSize = 16 * 16;
|
||||
for (let panelIndex = 0; panelIndex < panelSize; panelIndex += 1) {
|
||||
const row = Math.floor(panelIndex / 16);
|
||||
const col = panelIndex % 16;
|
||||
const ledIndex = row % 2 === 0 ? row * 16 + (15 - col) : row * 16 + col;
|
||||
const px = pixels[ledIndex] || [0, 0, 0];
|
||||
const r = Number(px?.[0] ?? 0);
|
||||
const g = Number(px?.[1] ?? 0);
|
||||
const b = Number(px?.[2] ?? 0);
|
||||
const div = document.createElement("div");
|
||||
div.className = "led";
|
||||
div.title = `panel(${row},${col}) -> #${ledIndex} (${r}, ${g}, ${b})`;
|
||||
div.style.backgroundColor = `rgb(${r}, ${g}, ${b})`;
|
||||
div.style.boxShadow = `0 0 10px rgba(${r}, ${g}, ${b}, 0.55), inset 0 0 8px rgba(0,0,0,0.45)`;
|
||||
grid.appendChild(div);
|
||||
}
|
||||
}
|
||||
|
||||
processSimulationLines(lines) {
|
||||
for (const line of lines || []) {
|
||||
if (typeof line !== "string") continue;
|
||||
const marker = "[neopixel-json]";
|
||||
const idx = line.indexOf(marker);
|
||||
if (idx === -1) continue;
|
||||
const jsonPart = line.slice(idx + marker.length).trim();
|
||||
if (!jsonPart) continue;
|
||||
try {
|
||||
const payload = JSON.parse(jsonPart);
|
||||
if (payload && payload.type === "neopixel") {
|
||||
this.renderLedSimulation(payload);
|
||||
}
|
||||
} catch (_err) {
|
||||
// Ignore malformed simulation payloads.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async runSource(source, mainPath = "code/tutorial_challenge.py") {
|
||||
this.outputBuffer = "";
|
||||
const outputEl = document.getElementById("output");
|
||||
if (outputEl) outputEl.textContent = "Running...\n";
|
||||
try {
|
||||
const bundleResp = await fetch("/api/workspace/py-sources", { credentials: "include" });
|
||||
if (!bundleResp.ok) {
|
||||
throw new Error("Failed to load workspace sources for tutorial runtime.");
|
||||
}
|
||||
const bundle = await bundleResp.json();
|
||||
const files = { ...(bundle.files || {}) };
|
||||
files[mainPath] = source;
|
||||
await this.ensurePyodideReady();
|
||||
await this.callWorker("run", {
|
||||
files,
|
||||
mainPath,
|
||||
args: [],
|
||||
});
|
||||
const visible = this.outputBuffer
|
||||
.split(/\r?\n/)
|
||||
.filter((line) => line && !line.includes("[neopixel-json]"))
|
||||
.join("\n");
|
||||
if (outputEl) outputEl.textContent = visible || "[no output]";
|
||||
return { ok: true, output: this.outputBuffer };
|
||||
} catch (error) {
|
||||
const out = `${this.outputBuffer}\n${error.message}`.trim();
|
||||
if (outputEl) outputEl.textContent = out;
|
||||
return { ok: false, output: out };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function bootTutorial() {
|
||||
try {
|
||||
const st = await fetch("/api/auth/status");
|
||||
if (st.ok) {
|
||||
const status = await st.json();
|
||||
if (status.auth_enabled) {
|
||||
const me = await fetch("/api/auth/me", { credentials: "include" });
|
||||
if (!me.ok) {
|
||||
const next = encodeURIComponent(`${window.location.pathname}${window.location.search}`);
|
||||
window.location.replace(`/login?next=${next}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (_err) {
|
||||
// Continue even if auth check fails.
|
||||
}
|
||||
new TutorialApp();
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
bootTutorial();
|
||||
});
|
||||
484
src/static/users.html
Normal file
484
src/static/users.html
Normal file
@@ -0,0 +1,484 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Manage users — LED Editor</title>
|
||||
<link rel="icon" href="data:,">
|
||||
<style>
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
background: linear-gradient(135deg, #0f172a, #1e293b);
|
||||
color: #e2e8f0;
|
||||
}
|
||||
.home-card {
|
||||
width: min(640px, 92vw);
|
||||
background: rgba(15, 23, 42, 0.78);
|
||||
border: 1px solid rgba(148, 163, 184, 0.25);
|
||||
border-radius: 14px;
|
||||
padding: 2rem;
|
||||
box-shadow: 0 20px 45px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
h1 { margin: 0 0 0.5rem 0; font-size: 1.6rem; color: #f8fafc; }
|
||||
p { margin: 0 0 1rem 0; color: #cbd5e1; line-height: 1.5; }
|
||||
.btn {
|
||||
display: inline-block;
|
||||
text-decoration: none;
|
||||
border-radius: 8px;
|
||||
padding: 0.55rem 0.9rem;
|
||||
font-weight: 600;
|
||||
border: 1px solid transparent;
|
||||
cursor: pointer;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.btn-primary { background: #3b82f6; color: #ffffff; }
|
||||
.btn-ghost { background: transparent; border-color: #64748b; color: #e2e8f0; }
|
||||
.btn-danger { background: transparent; border-color: #f87171; color: #fecaca; }
|
||||
.btn-danger:hover:not(:disabled) { background: rgba(248, 113, 113, 0.15); }
|
||||
.btn:disabled { opacity: 0.45; cursor: not-allowed; }
|
||||
label { display: block; font-size: 0.85rem; color: #94a3b8; margin-bottom: 0.35rem; }
|
||||
.note { font-size: 0.85rem; color: #94a3b8; }
|
||||
.nav { display: flex; flex-wrap: wrap; gap: 0.5rem; margin-bottom: 1rem; align-items: center; }
|
||||
.hidden { display: none !important; }
|
||||
.invite-panel,
|
||||
.users-panel {
|
||||
margin: 1rem 0;
|
||||
padding: 0.9rem;
|
||||
border: 1px solid rgba(148, 163, 184, 0.35);
|
||||
border-radius: 10px;
|
||||
background: rgba(30, 41, 59, 0.45);
|
||||
}
|
||||
.invite-row {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 0.4rem;
|
||||
}
|
||||
.invite-row input[type="email"] {
|
||||
flex: 1 1 260px;
|
||||
padding: 0.5rem 0.65rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #64748b;
|
||||
background: #0f172a;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
.invite-result {
|
||||
margin-top: 0.55rem;
|
||||
font-size: 0.85rem;
|
||||
color: #cbd5e1;
|
||||
word-break: break-all;
|
||||
}
|
||||
.users-list { margin-top: 0.5rem; display: grid; gap: 0.45rem; }
|
||||
.user-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.user-row a {
|
||||
color: #93c5fd;
|
||||
text-decoration: none;
|
||||
border: 1px solid rgba(147, 197, 253, 0.35);
|
||||
border-radius: 6px;
|
||||
padding: 0.25rem 0.5rem;
|
||||
}
|
||||
.user-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
#admin-users-feedback:not(:empty) {
|
||||
padding: 0.5rem 0.65rem;
|
||||
border-radius: 8px;
|
||||
font-size: 0.85rem;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
#admin-users-feedback.ok {
|
||||
background: rgba(34, 197, 94, 0.12);
|
||||
border: 1px solid rgba(74, 222, 128, 0.35);
|
||||
color: #bbf7d0;
|
||||
}
|
||||
#admin-users-feedback.err {
|
||||
background: rgba(248, 113, 113, 0.1);
|
||||
border: 1px solid rgba(248, 113, 113, 0.35);
|
||||
color: #fecaca;
|
||||
}
|
||||
.user-edit-form {
|
||||
margin-top: 1rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid rgba(148, 163, 184, 0.25);
|
||||
}
|
||||
.user-edit-form label { display: block; margin-bottom: 0.65rem; }
|
||||
.user-edit-form input[type='text'],
|
||||
.user-edit-form input[type='password'] {
|
||||
width: 100%;
|
||||
max-width: 320px;
|
||||
padding: 0.45rem 0.55rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #64748b;
|
||||
background: #0f172a;
|
||||
color: #e2e8f0;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
.user-edit-form .edit-actions {
|
||||
display: flex; flex-wrap: wrap; gap: 0.5rem; margin-top: 0.65rem;
|
||||
}
|
||||
.user-edit-form .super-line {
|
||||
display: flex; align-items: center; gap: 0.5rem;
|
||||
font-size: 0.85rem; color: #cbd5e1; margin-bottom: 0.65rem;
|
||||
}
|
||||
.user-edit-form .super-line input { accent-color: #3b82f6; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class="home-card">
|
||||
<h1>Manage users</h1>
|
||||
<p class="note">Superuser-only. Add accounts via invite link, edit roles, or remove users.</p>
|
||||
<div class="nav">
|
||||
<a class="btn btn-ghost" href="/">← Home</a>
|
||||
<a class="btn btn-ghost" href="/editor">Open editor</a>
|
||||
<span id="auth-greeting"></span>
|
||||
<button type="button" class="btn btn-ghost hidden" id="btn-logout">Sign out</button>
|
||||
</div>
|
||||
|
||||
<div id="not-allowed" class="hidden">
|
||||
<p style="color:#fca5a5">You need to be signed in as a superuser to manage users.
|
||||
<a href="/login?next=/users" style="color:#93c5fd">Sign in</a></p>
|
||||
</div>
|
||||
|
||||
<section id="users-panel" class="users-panel hidden">
|
||||
<strong>Accounts</strong>
|
||||
<div id="admin-users-feedback" role="status" aria-live="polite"></div>
|
||||
<div id="users-list" class="users-list"></div>
|
||||
<div id="user-edit-form" class="user-edit-form hidden">
|
||||
<strong id="user-edit-heading">Edit account</strong>
|
||||
<p class="note" style="margin: 0.35rem 0 0.5rem">Change login name or admin role; set a password only when you mean to reset it.</p>
|
||||
<input type="hidden" id="edit-user-id" autocomplete="off" />
|
||||
<label for="edit-user-username">Username
|
||||
<input type="text" id="edit-user-username" name="username" autocomplete="username" minlength="3" maxlength="64" />
|
||||
</label>
|
||||
<label for="edit-user-password">New password
|
||||
<input type="password" id="edit-user-password" name="password" autocomplete="new-password" minlength="8" maxlength="128" placeholder="Leave blank to keep current" />
|
||||
</label>
|
||||
<label class="super-line">
|
||||
<input type="checkbox" id="edit-user-super" />
|
||||
Superuser (can manage accounts and invites)
|
||||
</label>
|
||||
<div class="edit-actions">
|
||||
<button type="button" class="btn btn-primary" id="edit-user-save">Save changes</button>
|
||||
<button type="button" class="btn btn-ghost" id="edit-user-cancel">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="invite-panel" class="invite-panel hidden">
|
||||
<strong>Add users via invite link</strong>
|
||||
<p class="note" style="margin-top:0.4rem">
|
||||
Each link lets <strong>one</strong> person create their own account at <code>/register?invite=…</code> with a password they choose. After it is used, create a new link for the next person.
|
||||
</p>
|
||||
<div class="invite-row">
|
||||
<input id="invite-email" type="email" placeholder="new.user@example.com" autocomplete="email" />
|
||||
<button type="button" class="btn btn-primary" id="invite-create-btn">Email invite</button>
|
||||
<button type="button" class="btn btn-ghost" id="invite-link-btn">Invite link only</button>
|
||||
</div>
|
||||
<div id="invite-result" class="invite-result"></div>
|
||||
<button type="button" class="btn btn-ghost hidden" id="invite-copy-btn" aria-label="Copy invite link">Copy invite link</button>
|
||||
</section>
|
||||
</main>
|
||||
<script>
|
||||
let currentAdminUserId = null;
|
||||
|
||||
function formatApiDetail(body) {
|
||||
if (!body || body.detail === undefined || body.detail === null) return '';
|
||||
const d = body.detail;
|
||||
if (typeof d === 'string') return d;
|
||||
if (Array.isArray(d))
|
||||
return d.map((item) => (typeof item === 'object' && item && item.msg ? String(item.msg) : JSON.stringify(item))).join(' ');
|
||||
return String(d);
|
||||
}
|
||||
|
||||
function setAdminUsersFeedback(kind, msg) {
|
||||
const el = document.getElementById('admin-users-feedback');
|
||||
if (!el) return;
|
||||
el.textContent = msg || '';
|
||||
el.classList.remove('ok', 'err');
|
||||
if (kind === 'ok') el.classList.add('ok');
|
||||
if (kind === 'err') el.classList.add('err');
|
||||
}
|
||||
|
||||
async function refreshUsersList(viewerId) {
|
||||
const usersList = document.getElementById('users-list');
|
||||
if (!usersList) return;
|
||||
usersList.textContent = 'Loading users...';
|
||||
try {
|
||||
const res = await fetch('/api/users', { credentials: 'include' });
|
||||
const users = await res.json().catch(() => []);
|
||||
if (!res.ok) {
|
||||
usersList.textContent = 'Unable to load users.';
|
||||
return;
|
||||
}
|
||||
usersList.innerHTML = '';
|
||||
for (const user of users) {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'user-row';
|
||||
const name = document.createElement('span');
|
||||
name.textContent = `${user.username}${user.is_superuser ? ' (admin)' : ''}`;
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'user-actions';
|
||||
const link = document.createElement('a');
|
||||
link.href = `/editor?workspace_user_id=${encodeURIComponent(String(user.id))}`;
|
||||
link.textContent = 'Open workspace';
|
||||
const editBtn = document.createElement('button');
|
||||
editBtn.type = 'button';
|
||||
editBtn.className = 'btn btn-ghost';
|
||||
editBtn.textContent = 'Edit';
|
||||
editBtn.title = `Edit ${user.username}`;
|
||||
editBtn.addEventListener('click', () => openUserEdit(user.id, user.username, user.is_superuser));
|
||||
const delBtn = document.createElement('button');
|
||||
delBtn.type = 'button';
|
||||
delBtn.className = 'btn btn-ghost btn-danger';
|
||||
delBtn.textContent = 'Remove';
|
||||
const isSelf = Number(user.id) === Number(viewerId);
|
||||
delBtn.disabled = isSelf;
|
||||
delBtn.title = isSelf ? 'You cannot delete your own account here' : 'Permanently remove this user';
|
||||
delBtn.addEventListener('click', async () => {
|
||||
const ok = confirm(`Remove user “${user.username}”? Their workspace folder stays on disk until you delete it manually.`);
|
||||
if (!ok) return;
|
||||
delBtn.disabled = true;
|
||||
setAdminUsersFeedback('', '');
|
||||
try {
|
||||
const dres = await fetch(`/api/users/${encodeURIComponent(String(user.id))}`, {
|
||||
method: 'DELETE', credentials: 'include',
|
||||
});
|
||||
const body = await dres.json().catch(() => ({}));
|
||||
if (!dres.ok) {
|
||||
setAdminUsersFeedback('err', formatApiDetail(body) || dres.statusText || 'Could not remove user');
|
||||
delBtn.disabled = false;
|
||||
return;
|
||||
}
|
||||
setAdminUsersFeedback('ok', `Removed ${user.username}.`);
|
||||
await refreshUsersList(viewerId);
|
||||
} catch (err) {
|
||||
setAdminUsersFeedback('err', String(err.message || err));
|
||||
delBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
actions.appendChild(editBtn);
|
||||
actions.appendChild(link);
|
||||
actions.appendChild(delBtn);
|
||||
row.appendChild(name);
|
||||
row.appendChild(actions);
|
||||
usersList.appendChild(row);
|
||||
}
|
||||
} catch (_err) {
|
||||
usersList.textContent = 'Unable to load users.';
|
||||
}
|
||||
}
|
||||
|
||||
function closeUserEdit() {
|
||||
const sheet = document.getElementById('user-edit-form');
|
||||
if (sheet) sheet.classList.add('hidden');
|
||||
}
|
||||
|
||||
function openUserEdit(userId, username, isSuperuser) {
|
||||
document.getElementById('edit-user-id').value = String(userId);
|
||||
document.getElementById('edit-user-username').value = username;
|
||||
document.getElementById('edit-user-password').value = '';
|
||||
document.getElementById('edit-user-super').checked = Boolean(isSuperuser);
|
||||
document.getElementById('user-edit-heading').textContent = `Edit @${username}`;
|
||||
document.getElementById('user-edit-form').classList.remove('hidden');
|
||||
}
|
||||
|
||||
document.getElementById('edit-user-cancel').addEventListener('click', () => closeUserEdit());
|
||||
|
||||
document.getElementById('edit-user-save').addEventListener('click', async () => {
|
||||
const id = document.getElementById('edit-user-id').value;
|
||||
const u = document.getElementById('edit-user-username').value.trim();
|
||||
const pw = document.getElementById('edit-user-password').value;
|
||||
const superU = document.getElementById('edit-user-super').checked;
|
||||
const saveBtn = document.getElementById('edit-user-save');
|
||||
setAdminUsersFeedback('', '');
|
||||
if (!u || u.length < 3) {
|
||||
setAdminUsersFeedback('err', 'Username must be at least 3 characters.');
|
||||
return;
|
||||
}
|
||||
const payload = { username: u, is_superuser: superU };
|
||||
const tpw = pw.trim();
|
||||
if (tpw.length > 0) {
|
||||
if (tpw.length < 8) {
|
||||
setAdminUsersFeedback('err', 'New password must be at least 8 characters (or leave blank).');
|
||||
return;
|
||||
}
|
||||
payload.password = tpw;
|
||||
}
|
||||
saveBtn.disabled = true;
|
||||
try {
|
||||
const res = await fetch(`/api/users/${encodeURIComponent(String(id))}`, {
|
||||
method: 'PATCH',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const body = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
setAdminUsersFeedback('err', formatApiDetail(body) || res.statusText || 'Update failed');
|
||||
saveBtn.disabled = false;
|
||||
return;
|
||||
}
|
||||
setAdminUsersFeedback('ok', `Updated @${body.username}.`);
|
||||
closeUserEdit();
|
||||
await refreshUsersList(currentAdminUserId);
|
||||
} catch (err) {
|
||||
setAdminUsersFeedback('err', String(err.message || err));
|
||||
}
|
||||
saveBtn.disabled = false;
|
||||
});
|
||||
|
||||
document.getElementById('btn-logout').addEventListener('click', async () => {
|
||||
await fetch('/api/auth/logout', { method: 'POST', credentials: 'include' });
|
||||
window.location.href = '/';
|
||||
});
|
||||
|
||||
function showInviteOutcome(inviteUrl, headline) {
|
||||
const result = document.getElementById('invite-result');
|
||||
const copyBtn = document.getElementById('invite-copy-btn');
|
||||
result.textContent = '';
|
||||
const line = document.createElement('p');
|
||||
line.style.margin = '0 0 0.35rem 0';
|
||||
line.textContent = headline;
|
||||
const a = document.createElement('a');
|
||||
a.href = inviteUrl;
|
||||
a.textContent = inviteUrl;
|
||||
a.style.color = '#93c5fd';
|
||||
a.style.wordBreak = 'break-all';
|
||||
result.appendChild(line);
|
||||
result.appendChild(a);
|
||||
copyBtn.classList.remove('hidden');
|
||||
copyBtn.dataset.inviteUrl = inviteUrl;
|
||||
}
|
||||
|
||||
function clearInviteOutcome() {
|
||||
const result = document.getElementById('invite-result');
|
||||
const copyBtn = document.getElementById('invite-copy-btn');
|
||||
result.textContent = '';
|
||||
copyBtn.classList.add('hidden');
|
||||
copyBtn.textContent = 'Copy invite link';
|
||||
delete copyBtn.dataset.inviteUrl;
|
||||
}
|
||||
|
||||
document.getElementById('invite-copy-btn').addEventListener('click', async () => {
|
||||
const copyBtn = document.getElementById('invite-copy-btn');
|
||||
const url = copyBtn.dataset.inviteUrl;
|
||||
if (!url) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(url);
|
||||
copyBtn.textContent = 'Copied!';
|
||||
setTimeout(() => { copyBtn.textContent = 'Copy invite link'; }, 2000);
|
||||
} catch (_e) {
|
||||
copyBtn.textContent = 'Copy failed — select the link above';
|
||||
setTimeout(() => { copyBtn.textContent = 'Copy invite link'; }, 2500);
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('invite-create-btn').addEventListener('click', async () => {
|
||||
const emailInput = document.getElementById('invite-email');
|
||||
const result = document.getElementById('invite-result');
|
||||
const email = (emailInput.value || '').trim();
|
||||
clearInviteOutcome();
|
||||
if (!email) {
|
||||
result.textContent = 'Enter an email address first.';
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await fetch('/api/users/invites', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, expires_days: 7 }),
|
||||
});
|
||||
const body = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
result.textContent = formatApiDetail(body) || res.statusText || 'Failed to create invite';
|
||||
return;
|
||||
}
|
||||
const headline = body.delivered
|
||||
? 'Email sent. They can also use this link to register:'
|
||||
: 'Email not sent (SMTP not configured). Share this registration link:';
|
||||
showInviteOutcome(body.invite_url, headline);
|
||||
} catch (err) {
|
||||
result.textContent = String(err.message || err);
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('invite-link-btn').addEventListener('click', async () => {
|
||||
const result = document.getElementById('invite-result');
|
||||
clearInviteOutcome();
|
||||
try {
|
||||
const res = await fetch('/api/users/invites', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email: null, expires_days: 7 }),
|
||||
});
|
||||
const body = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
result.textContent = formatApiDetail(body) || res.statusText || 'Failed to create invite link';
|
||||
return;
|
||||
}
|
||||
showInviteOutcome(body.invite_url, 'Share this link so they can create an account:');
|
||||
} catch (err) {
|
||||
result.textContent = String(err.message || err);
|
||||
}
|
||||
});
|
||||
|
||||
async function init() {
|
||||
const greet = document.getElementById('auth-greeting');
|
||||
const logout = document.getElementById('btn-logout');
|
||||
const usersPanel = document.getElementById('users-panel');
|
||||
const invitePanel = document.getElementById('invite-panel');
|
||||
const blocked = document.getElementById('not-allowed');
|
||||
|
||||
const st = await fetch('/api/auth/status').catch(() => null);
|
||||
if (!st || !st.ok) {
|
||||
blocked.classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
const status = await st.json();
|
||||
if (!status.auth_enabled) {
|
||||
// Auth disabled — anyone can poke /api/users; show panels regardless.
|
||||
usersPanel.classList.remove('hidden');
|
||||
invitePanel.classList.remove('hidden');
|
||||
await refreshUsersList(null);
|
||||
return;
|
||||
}
|
||||
const me = await fetch('/api/auth/me', { credentials: 'include' });
|
||||
if (!me.ok) {
|
||||
blocked.classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
const j = await me.json();
|
||||
if (!j.user || !j.user.is_superuser) {
|
||||
blocked.classList.remove('hidden');
|
||||
greet.textContent = `Signed in as ${j.user.username}`;
|
||||
logout.classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
currentAdminUserId = j.user.id;
|
||||
greet.textContent = `Signed in as ${j.user.username} (admin)`;
|
||||
logout.classList.remove('hidden');
|
||||
usersPanel.classList.remove('hidden');
|
||||
invitePanel.classList.remove('hidden');
|
||||
await refreshUsersList(j.user.id);
|
||||
}
|
||||
init();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
204
src/static/zip-utils.js
Normal file
204
src/static/zip-utils.js
Normal file
@@ -0,0 +1,204 @@
|
||||
/**
|
||||
* Tiny zero-dependency ZIP encoder. STORE-only (no compression) — fine for
|
||||
* Python source which is small, gzip would only save a few KB. Kept inline
|
||||
* here so local-mode export works on every browser, including the ones
|
||||
* (Firefox/Safari/Brave-without-flag) that have no folder picker.
|
||||
*
|
||||
* Format reference: APPNOTE.TXT 6.3.x
|
||||
*/
|
||||
|
||||
let _crcTable = null;
|
||||
function crcTable() {
|
||||
if (_crcTable) return _crcTable;
|
||||
const t = new Uint32Array(256);
|
||||
for (let i = 0; i < 256; i++) {
|
||||
let c = i;
|
||||
for (let j = 0; j < 8; j++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
||||
t[i] = c;
|
||||
}
|
||||
_crcTable = t;
|
||||
return t;
|
||||
}
|
||||
|
||||
function crc32(bytes) {
|
||||
const tbl = crcTable();
|
||||
let c = 0xffffffff;
|
||||
for (let i = 0; i < bytes.length; i++) c = tbl[(c ^ bytes[i]) & 0xff] ^ (c >>> 8);
|
||||
return (c ^ 0xffffffff) >>> 0;
|
||||
}
|
||||
|
||||
function dosTime(d) {
|
||||
return ((d.getHours() << 11) | (d.getMinutes() << 5) | (d.getSeconds() >> 1)) & 0xffff;
|
||||
}
|
||||
function dosDate(d) {
|
||||
return (((d.getFullYear() - 1980) << 9) | ((d.getMonth() + 1) << 5) | d.getDate()) & 0xffff;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an uncompressed ZIP from a list of `{ name, content }` entries.
|
||||
* `content` may be a string or a Uint8Array.
|
||||
*
|
||||
* Returns a Blob (`application/zip`).
|
||||
*/
|
||||
export function createZip(entries) {
|
||||
const enc = new TextEncoder();
|
||||
const date = new Date();
|
||||
const time = dosTime(date);
|
||||
const ddate = dosDate(date);
|
||||
const parts = [];
|
||||
const central = [];
|
||||
let offset = 0;
|
||||
|
||||
for (const entry of entries) {
|
||||
const data = typeof entry.content === 'string' ? enc.encode(entry.content) : entry.content;
|
||||
const nameBytes = enc.encode(entry.name);
|
||||
const crc = crc32(data);
|
||||
|
||||
const local = new ArrayBuffer(30 + nameBytes.length);
|
||||
const lv = new DataView(local);
|
||||
lv.setUint32(0, 0x04034b50, true);
|
||||
lv.setUint16(4, 20, true);
|
||||
lv.setUint16(6, 0x0800, true); // bit 11 = UTF-8 filename
|
||||
lv.setUint16(8, 0, true);
|
||||
lv.setUint16(10, time, true);
|
||||
lv.setUint16(12, ddate, true);
|
||||
lv.setUint32(14, crc, true);
|
||||
lv.setUint32(18, data.length, true);
|
||||
lv.setUint32(22, data.length, true);
|
||||
lv.setUint16(26, nameBytes.length, true);
|
||||
lv.setUint16(28, 0, true);
|
||||
new Uint8Array(local, 30).set(nameBytes);
|
||||
parts.push(local);
|
||||
parts.push(data);
|
||||
|
||||
const cd = new ArrayBuffer(46 + nameBytes.length);
|
||||
const cv = new DataView(cd);
|
||||
cv.setUint32(0, 0x02014b50, true);
|
||||
cv.setUint16(4, 0x031e, true); // made by: unix + zip 3.0
|
||||
cv.setUint16(6, 20, true);
|
||||
cv.setUint16(8, 0x0800, true);
|
||||
cv.setUint16(10, 0, true);
|
||||
cv.setUint16(12, time, true);
|
||||
cv.setUint16(14, ddate, true);
|
||||
cv.setUint32(16, crc, true);
|
||||
cv.setUint32(20, data.length, true);
|
||||
cv.setUint32(24, data.length, true);
|
||||
cv.setUint16(28, nameBytes.length, true);
|
||||
cv.setUint16(30, 0, true);
|
||||
cv.setUint16(32, 0, true);
|
||||
cv.setUint16(34, 0, true);
|
||||
cv.setUint16(36, 0, true);
|
||||
cv.setUint32(38, 0, true);
|
||||
cv.setUint32(42, offset, true);
|
||||
new Uint8Array(cd, 46).set(nameBytes);
|
||||
central.push(cd);
|
||||
|
||||
offset += local.byteLength + data.length;
|
||||
}
|
||||
|
||||
const cdOffset = offset;
|
||||
let cdSize = 0;
|
||||
for (const cd of central) {
|
||||
parts.push(cd);
|
||||
cdSize += cd.byteLength;
|
||||
}
|
||||
|
||||
const eocd = new ArrayBuffer(22);
|
||||
const ev = new DataView(eocd);
|
||||
ev.setUint32(0, 0x06054b50, true);
|
||||
ev.setUint16(4, 0, true);
|
||||
ev.setUint16(6, 0, true);
|
||||
ev.setUint16(8, entries.length, true);
|
||||
ev.setUint16(10, entries.length, true);
|
||||
ev.setUint32(12, cdSize, true);
|
||||
ev.setUint32(16, cdOffset, true);
|
||||
ev.setUint16(20, 0, true);
|
||||
parts.push(eocd);
|
||||
|
||||
return new Blob(parts, { type: 'application/zip' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse an uncompressed-or-DEFLATEd ZIP buffer into an array of
|
||||
* `{ name, content }` entries (text). Skips directory entries (those that
|
||||
* end with '/'). Throws if the buffer is not a valid ZIP archive.
|
||||
*
|
||||
* Compression methods supported:
|
||||
* - 0 (STORE)
|
||||
* - 8 (DEFLATE) — decompressed via the browser's `DecompressionStream`
|
||||
* ('deflate-raw'), available in Chrome/Edge/Brave 113+, Firefox 113+,
|
||||
* Safari 16.4+. If the browser is too old we surface a clear error.
|
||||
*/
|
||||
export async function readZip(arrayBuffer) {
|
||||
const buf = arrayBuffer instanceof ArrayBuffer ? arrayBuffer : arrayBuffer.buffer;
|
||||
const view = new DataView(buf);
|
||||
if (buf.byteLength < 22) throw new Error('Not a ZIP file (too small)');
|
||||
|
||||
// EOCD signature scan from the end (max 64 KiB comment).
|
||||
let eocd = -1;
|
||||
const minScan = Math.max(0, buf.byteLength - 22 - 65535);
|
||||
for (let i = buf.byteLength - 22; i >= minScan; i--) {
|
||||
if (view.getUint32(i, true) === 0x06054b50) {
|
||||
eocd = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (eocd === -1) throw new Error('Not a ZIP file (no end-of-central-directory)');
|
||||
const totalEntries = view.getUint16(eocd + 10, true);
|
||||
const cdOffset = view.getUint32(eocd + 16, true);
|
||||
|
||||
const dec = new TextDecoder();
|
||||
const out = [];
|
||||
let p = cdOffset;
|
||||
for (let i = 0; i < totalEntries; i++) {
|
||||
if (view.getUint32(p, true) !== 0x02014b50) {
|
||||
throw new Error('Bad central-directory entry');
|
||||
}
|
||||
const method = view.getUint16(p + 10, true);
|
||||
const compSize = view.getUint32(p + 20, true);
|
||||
const uncompSize = view.getUint32(p + 24, true);
|
||||
const nameLen = view.getUint16(p + 28, true);
|
||||
const extraLen = view.getUint16(p + 30, true);
|
||||
const commentLen = view.getUint16(p + 32, true);
|
||||
const localOff = view.getUint32(p + 42, true);
|
||||
const name = dec.decode(new Uint8Array(buf, p + 46, nameLen));
|
||||
p += 46 + nameLen + extraLen + commentLen;
|
||||
|
||||
// Directory entry — skip; createFolder happens implicitly when we
|
||||
// write a child file under that path.
|
||||
if (name.endsWith('/')) continue;
|
||||
|
||||
const lview = new DataView(buf, localOff, 30);
|
||||
if (lview.getUint32(0, true) !== 0x04034b50) {
|
||||
throw new Error(`Bad local header for "${name}"`);
|
||||
}
|
||||
const lNameLen = lview.getUint16(26, true);
|
||||
const lExtraLen = lview.getUint16(28, true);
|
||||
const dataOff = localOff + 30 + lNameLen + lExtraLen;
|
||||
|
||||
let bytes;
|
||||
if (method === 0) {
|
||||
bytes = new Uint8Array(buf, dataOff, uncompSize);
|
||||
} else if (method === 8) {
|
||||
if (typeof DecompressionStream === 'undefined') {
|
||||
throw new Error(
|
||||
`"${name}" is DEFLATE-compressed but this browser has no DecompressionStream`
|
||||
);
|
||||
}
|
||||
const compressed = new Uint8Array(buf, dataOff, compSize);
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(compressed);
|
||||
controller.close();
|
||||
},
|
||||
}).pipeThrough(new DecompressionStream('deflate-raw'));
|
||||
bytes = new Uint8Array(await new Response(stream).arrayBuffer());
|
||||
} else {
|
||||
throw new Error(`Unsupported compression method ${method} on "${name}"`);
|
||||
}
|
||||
|
||||
out.push({ name, content: dec.decode(bytes) });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -9,14 +9,23 @@ def test_root_serves_html(client):
|
||||
response = client.get("/")
|
||||
assert response.status_code == 200
|
||||
assert "text/html" in response.headers["content-type"]
|
||||
assert "Python Editor" in response.text
|
||||
assert "LED Editor" in response.text
|
||||
|
||||
|
||||
def test_editor_route_serves_editor_html(client):
|
||||
response = client.get("/editor")
|
||||
assert response.status_code == 200
|
||||
assert "text/html" in response.headers["content-type"]
|
||||
assert "Python Editor" in response.text
|
||||
assert "LED Editor" in response.text
|
||||
|
||||
|
||||
def test_lib_served_directly_from_repo_bundle(client, tmp_path):
|
||||
"""`lib/` reads come straight from repo `lib/`; no workspace cache is created."""
|
||||
resp = client.get("/api/file/lib/machine.py")
|
||||
assert resp.status_code == 200
|
||||
body = resp.json().get("content", "")
|
||||
assert "class Pin" in body and len(body) > 0
|
||||
assert not (tmp_path / "lib").exists()
|
||||
|
||||
|
||||
def test_list_files_hides_dotfiles_and_reports_sizes(client, tmp_path):
|
||||
@@ -57,17 +66,14 @@ def test_save_file_collapses_duplicate_scoped_prefix(client, tmp_path):
|
||||
|
||||
|
||||
def test_lib_folder_is_read_only_for_mutations(client, tmp_path):
|
||||
lib_dir = tmp_path / "lib"
|
||||
lib_dir.mkdir(exist_ok=True)
|
||||
(lib_dir / "helper.py").write_text("x = 1\n", encoding="utf-8")
|
||||
code_dir = tmp_path / "code"
|
||||
code_dir.mkdir()
|
||||
code_dir.mkdir(exist_ok=True)
|
||||
(code_dir / "main.py").write_text("print('ok')\n", encoding="utf-8")
|
||||
|
||||
save_blocked = client.post("/api/file/lib/new.txt", json={"content": "nope"})
|
||||
assert save_blocked.status_code == 403
|
||||
|
||||
delete_blocked = client.delete("/api/file/lib/helper.py")
|
||||
delete_blocked = client.delete("/api/file/lib/machine.py")
|
||||
assert delete_blocked.status_code == 403
|
||||
|
||||
move_blocked = client.post(
|
||||
@@ -109,7 +115,7 @@ def test_read_file_non_utf8_returns_400(client, tmp_path):
|
||||
|
||||
def test_delete_file_success_and_errors(client, tmp_path):
|
||||
target = tmp_path / "code" / "delete-me.txt"
|
||||
target.parent.mkdir()
|
||||
target.parent.mkdir(exist_ok=True)
|
||||
target.write_text("x", encoding="utf-8")
|
||||
|
||||
ok = client.delete("/api/file/code/delete-me.txt")
|
||||
@@ -213,7 +219,7 @@ def test_folder_create_and_delete(client, tmp_path):
|
||||
|
||||
|
||||
def test_create_folder_collapses_duplicate_scoped_prefix(client, tmp_path):
|
||||
(tmp_path / "code").mkdir()
|
||||
(tmp_path / "code").mkdir(exist_ok=True)
|
||||
create = client.post("/api/folder/new/code/code/nested", json={"path": "ignored"})
|
||||
assert create.status_code == 200
|
||||
assert (tmp_path / "code" / "nested").is_dir()
|
||||
@@ -224,23 +230,23 @@ def test_folder_delete_errors(client, tmp_path):
|
||||
missing = client.delete("/api/folder/code/missing")
|
||||
assert missing.status_code == 404
|
||||
|
||||
(tmp_path / "code").mkdir()
|
||||
(tmp_path / "code").mkdir(exist_ok=True)
|
||||
(tmp_path / "code" / "file.txt").write_text("x", encoding="utf-8")
|
||||
not_dir = client.delete("/api/folder/code/file.txt")
|
||||
assert not_dir.status_code == 400
|
||||
|
||||
|
||||
def test_workspace_py_sources_returns_python_files(client, tmp_path):
|
||||
(tmp_path / "code").mkdir()
|
||||
(tmp_path / "code").mkdir(exist_ok=True)
|
||||
(tmp_path / "code" / "app.py").write_text("x = 1\n", encoding="utf-8")
|
||||
(tmp_path / "lib").mkdir(exist_ok=True)
|
||||
(tmp_path / "lib" / "util.py").write_text("def f():\n pass\n", encoding="utf-8")
|
||||
|
||||
response = client.get("/api/workspace/py-sources")
|
||||
assert response.status_code == 200
|
||||
files = response.json()["files"]
|
||||
assert files["code/app.py"] == "x = 1\n"
|
||||
assert "lib/util.py" in files
|
||||
# Bundled stubs from repo `lib/` are merged in automatically.
|
||||
assert "lib/machine.py" in files
|
||||
assert "lib/neopixel.py" in files
|
||||
|
||||
|
||||
def test_api_requires_bearer_when_editor_api_key_set(tmp_path, monkeypatch):
|
||||
@@ -265,7 +271,9 @@ def test_api_requires_bearer_when_editor_api_key_set(tmp_path, monkeypatch):
|
||||
assert ok.status_code == 200
|
||||
|
||||
|
||||
def test_create_app_startup_creates_lib(tmp_path, monkeypatch):
|
||||
def test_create_app_startup_does_not_seed_workspace_lib(tmp_path, monkeypatch):
|
||||
"""Bundle stubs are read directly from repo `lib/`, so startup must not create
|
||||
a `WORKSPACE_ROOT/lib` cache."""
|
||||
import editor_app.config as config
|
||||
import editor_app.db.session as db_sess
|
||||
import editor_app.main as main
|
||||
@@ -283,4 +291,4 @@ def test_create_app_startup_creates_lib(tmp_path, monkeypatch):
|
||||
assert not (tmp_path / "lib").exists()
|
||||
with TestClient(main.app) as _client:
|
||||
_client.get("/api/auth/status")
|
||||
assert (tmp_path / "lib").is_dir()
|
||||
assert not (tmp_path / "lib").exists()
|
||||
|
||||
@@ -11,12 +11,14 @@ def _reload_app(tmp_path, monkeypatch, **env):
|
||||
|
||||
monkeypatch.setenv("WORKSPACE_ROOT", str(tmp_path))
|
||||
monkeypatch.setenv("AUTH_DATABASE_PATH", str(tmp_path / "auth.db"))
|
||||
monkeypatch.setenv("AUTH_REGISTER_OPEN", "true")
|
||||
monkeypatch.setenv("AUTH_INVITE_ONLY", "false")
|
||||
monkeypatch.delenv("EDITOR_API_KEY", raising=False)
|
||||
monkeypatch.delenv("BOOTSTRAP_ADMIN_USERNAME", raising=False)
|
||||
monkeypatch.delenv("BOOTSTRAP_ADMIN_PASSWORD", raising=False)
|
||||
for k, v in env.items():
|
||||
monkeypatch.setenv(k, v)
|
||||
config.WORKSPACE_ROOT = tmp_path
|
||||
monkeypatch.setattr(config, "WORKSPACE_ROOT", tmp_path)
|
||||
db_sess.reset_engine()
|
||||
importlib.reload(main)
|
||||
return main.app
|
||||
@@ -26,7 +28,33 @@ def test_auth_status_public(tmp_path, monkeypatch):
|
||||
with TestClient(_reload_app(tmp_path, monkeypatch, AUTH_ENABLED="false")) as client:
|
||||
r = client.get("/api/auth/status")
|
||||
assert r.status_code == 200
|
||||
assert r.json() == {"auth_enabled": False, "register_open": True}
|
||||
assert r.json() == {"auth_enabled": False, "register_open": True, "invite_required": False}
|
||||
|
||||
|
||||
def test_auth_invite_only_defaults_on(monkeypatch, tmp_path):
|
||||
"""When AUTH_INVITE_ONLY is unset, require invites (deployment-safe default)."""
|
||||
import editor_app.config as config
|
||||
import editor_app.db.session as db_sess
|
||||
import editor_app.main as main
|
||||
|
||||
monkeypatch.setenv("WORKSPACE_ROOT", str(tmp_path))
|
||||
monkeypatch.setenv("AUTH_DATABASE_PATH", str(tmp_path / "auth.db"))
|
||||
monkeypatch.setenv("AUTH_REGISTER_OPEN", "true")
|
||||
monkeypatch.setenv("AUTH_ENABLED", "true")
|
||||
monkeypatch.delenv("AUTH_INVITE_ONLY", raising=False)
|
||||
monkeypatch.delenv("EDITOR_API_KEY", raising=False)
|
||||
monkeypatch.delenv("BOOTSTRAP_ADMIN_USERNAME", raising=False)
|
||||
monkeypatch.delenv("BOOTSTRAP_ADMIN_PASSWORD", raising=False)
|
||||
config.WORKSPACE_ROOT = tmp_path
|
||||
db_sess.reset_engine()
|
||||
importlib.reload(main)
|
||||
|
||||
with TestClient(main.app) as client:
|
||||
st = client.get("/api/auth/status")
|
||||
assert st.status_code == 200
|
||||
assert st.json()["invite_required"] is True
|
||||
denied = client.post("/api/auth/register", json={"username": "noc", "password": "password99"})
|
||||
assert denied.status_code == 403
|
||||
|
||||
|
||||
def test_register_login_and_api_access(tmp_path, monkeypatch):
|
||||
@@ -56,6 +84,37 @@ def test_register_login_and_api_access(tmp_path, monkeypatch):
|
||||
assert client.get("/api/files").status_code == 401
|
||||
|
||||
|
||||
def test_new_user_workspace_has_default_main_py(tmp_path, monkeypatch):
|
||||
with TestClient(
|
||||
_reload_app(tmp_path, monkeypatch, AUTH_ENABLED="true", AUTH_REGISTER_OPEN="true")
|
||||
) as client:
|
||||
reg = client.post("/api/auth/register", json={"username": "alice", "password": "password99"})
|
||||
assert reg.status_code == 200
|
||||
assert reg.json()["username"] == "alice"
|
||||
uid = reg.json()["id"]
|
||||
code_root = tmp_path / "users" / f"alice-{uid}" / "code"
|
||||
on_disk = code_root / "main.py"
|
||||
assert on_disk.is_file()
|
||||
assert on_disk.read_text(encoding="utf-8") == 'print("Hello, World!")\n'
|
||||
canonical = ("pattern_rainbow_demo.py", "pattern_twinkle_demo.py", "pattern_chase_demo.py")
|
||||
for fname in canonical:
|
||||
cp = code_root / fname
|
||||
assert cp.is_file(), f"missing bundled copy {fname} (workspace/code must ship with app)"
|
||||
text = cp.read_text(encoding="utf-8")
|
||||
assert len(text.strip()) > 20
|
||||
assert "from led_patterns" not in text
|
||||
assert "import led_patterns" not in text
|
||||
|
||||
assert client.post("/api/auth/login", json={"username": "alice", "password": "password99"}).status_code == 200
|
||||
fetched = client.get("/api/file/code/main.py")
|
||||
assert fetched.status_code == 200
|
||||
assert fetched.json()["filename"] == "main.py"
|
||||
assert 'Hello, World!' in fetched.json()["content"]
|
||||
chase = client.get("/api/file/code/pattern_chase_demo.py")
|
||||
assert chase.status_code == 200
|
||||
assert "knight_rider_scanner_frame" in chase.json()["content"]
|
||||
|
||||
|
||||
def test_second_user_not_superuser(tmp_path, monkeypatch):
|
||||
with TestClient(
|
||||
_reload_app(tmp_path, monkeypatch, AUTH_ENABLED="true", AUTH_REGISTER_OPEN="true")
|
||||
@@ -85,7 +144,101 @@ def test_register_closed(tmp_path, monkeypatch):
|
||||
assert r.status_code == 403
|
||||
|
||||
|
||||
def test_superuser_lists_and_creates_users(tmp_path, monkeypatch):
|
||||
def test_admin_can_create_invite_and_register_with_token(tmp_path, monkeypatch):
|
||||
with TestClient(
|
||||
_reload_app(tmp_path, monkeypatch, AUTH_ENABLED="true", AUTH_REGISTER_OPEN="true")
|
||||
) as client:
|
||||
client.post("/api/auth/register", json={"username": "admin", "password": "password99", "invite_token": None})
|
||||
client.post("/api/auth/login", json={"username": "admin", "password": "password99"})
|
||||
invite = client.post("/api/users/invites", json={"email": "newuser@example.com", "expires_days": 7})
|
||||
assert invite.status_code == 200
|
||||
invite_url = invite.json()["invite_url"]
|
||||
token = invite_url.split("invite=", 1)[1]
|
||||
client.post("/api/auth/logout")
|
||||
|
||||
reg = client.post(
|
||||
"/api/auth/register",
|
||||
json={"username": "newuser", "password": "password99", "invite_token": token},
|
||||
)
|
||||
assert reg.status_code == 200
|
||||
assert reg.json()["username"] == "newuser"
|
||||
|
||||
|
||||
def test_admin_can_create_link_only_invite(tmp_path, monkeypatch):
|
||||
with TestClient(
|
||||
_reload_app(tmp_path, monkeypatch, AUTH_ENABLED="true", AUTH_REGISTER_OPEN="true")
|
||||
) as client:
|
||||
client.post("/api/auth/register", json={"username": "admin", "password": "password99", "invite_token": None})
|
||||
client.post("/api/auth/login", json={"username": "admin", "password": "password99"})
|
||||
invite = client.post("/api/users/invites", json={"email": None, "expires_days": 7})
|
||||
assert invite.status_code == 200
|
||||
body = invite.json()
|
||||
assert body["invite_url"].startswith("http://")
|
||||
assert body["delivered"] is False
|
||||
|
||||
|
||||
def test_invite_required_blocks_public_register(tmp_path, monkeypatch):
|
||||
with TestClient(
|
||||
_reload_app(
|
||||
tmp_path,
|
||||
monkeypatch,
|
||||
AUTH_ENABLED="true",
|
||||
AUTH_REGISTER_OPEN="true",
|
||||
AUTH_INVITE_ONLY="true",
|
||||
)
|
||||
) as client:
|
||||
blocked = client.post("/api/auth/register", json={"username": "plain", "password": "password99"})
|
||||
assert blocked.status_code == 403
|
||||
|
||||
|
||||
def test_superuser_can_patch_user_account(tmp_path, monkeypatch):
|
||||
with TestClient(
|
||||
_reload_app(tmp_path, monkeypatch, AUTH_ENABLED="true", AUTH_REGISTER_OPEN="true")
|
||||
) as client:
|
||||
client.post("/api/auth/register", json={"username": "admin", "password": "password99"})
|
||||
client.post("/api/auth/login", json={"username": "admin", "password": "password99"})
|
||||
invite = client.post("/api/users/invites", json={"email": None, "expires_days": 7})
|
||||
token = invite.json()["invite_url"].split("invite=", 1)[1]
|
||||
client.post("/api/auth/logout")
|
||||
sub = client.post(
|
||||
"/api/auth/register",
|
||||
json={"username": "subacc", "password": "original99", "invite_token": token},
|
||||
).json()
|
||||
uid = sub["id"]
|
||||
client.post("/api/auth/login", json={"username": "admin", "password": "password99"})
|
||||
|
||||
pat = client.patch(
|
||||
f"/api/users/{uid}",
|
||||
json={"username": "renamed", "is_superuser": True, "password": "renewedpw8"},
|
||||
)
|
||||
assert pat.status_code == 200
|
||||
assert pat.json()["username"] == "renamed"
|
||||
assert pat.json()["is_superuser"] is True
|
||||
|
||||
client.post("/api/auth/logout")
|
||||
assert client.post(
|
||||
"/api/auth/login",
|
||||
json={"username": "renamed", "password": "renewedpw8"},
|
||||
).status_code == 200
|
||||
|
||||
|
||||
def test_superuser_cannot_demote_last_admin(tmp_path, monkeypatch):
|
||||
with TestClient(
|
||||
_reload_app(tmp_path, monkeypatch, AUTH_ENABLED="true", AUTH_REGISTER_OPEN="true")
|
||||
) as client:
|
||||
client.post("/api/auth/register", json={"username": "solo_admin", "password": "password99"})
|
||||
client.post("/api/auth/login", json={"username": "solo_admin", "password": "password99"})
|
||||
uid = client.get("/api/auth/me").json()["user"]["id"]
|
||||
demote = client.patch(
|
||||
f"/api/users/{uid}",
|
||||
json={"username": "solo_admin", "is_superuser": False},
|
||||
)
|
||||
assert demote.status_code == 400
|
||||
detail = demote.json().get("detail") or ""
|
||||
assert "last" in detail.lower() or "administrator" in detail.lower()
|
||||
|
||||
|
||||
def test_superuser_lists_users_after_invite_signup(tmp_path, monkeypatch):
|
||||
with TestClient(
|
||||
_reload_app(tmp_path, monkeypatch, AUTH_ENABLED="true", AUTH_REGISTER_OPEN="true")
|
||||
) as client:
|
||||
@@ -96,14 +249,20 @@ def test_superuser_lists_and_creates_users(tmp_path, monkeypatch):
|
||||
assert listed.status_code == 200
|
||||
assert len(listed.json()) == 1
|
||||
|
||||
created = client.post(
|
||||
"/api/users",
|
||||
json={"username": "sub", "password": "password99", "is_superuser": False},
|
||||
)
|
||||
assert created.status_code == 200
|
||||
assert created.json()["username"] == "sub"
|
||||
assert created.json()["is_superuser"] is False
|
||||
invite = client.post("/api/users/invites", json={"email": None, "expires_days": 7})
|
||||
assert invite.status_code == 200
|
||||
token = invite.json()["invite_url"].split("invite=", 1)[1]
|
||||
|
||||
client.post("/api/auth/logout")
|
||||
reg = client.post(
|
||||
"/api/auth/register",
|
||||
json={"username": "sub", "password": "password99", "invite_token": token},
|
||||
)
|
||||
assert reg.status_code == 200
|
||||
assert reg.json()["username"] == "sub"
|
||||
assert reg.json()["is_superuser"] is False
|
||||
|
||||
client.post("/api/auth/login", json={"username": "admin", "password": "password99"})
|
||||
names = {u["username"] for u in client.get("/api/users").json()}
|
||||
assert names == {"admin", "sub"}
|
||||
|
||||
@@ -121,6 +280,107 @@ def test_non_superuser_cannot_list_users(tmp_path, monkeypatch):
|
||||
assert denied.status_code == 403
|
||||
|
||||
|
||||
def test_users_have_isolated_workspaces(tmp_path, monkeypatch):
|
||||
with TestClient(
|
||||
_reload_app(tmp_path, monkeypatch, AUTH_ENABLED="true", AUTH_REGISTER_OPEN="true")
|
||||
) as client:
|
||||
client.post("/api/auth/register", json={"username": "alice", "password": "password99"})
|
||||
client.post("/api/auth/logout")
|
||||
client.post("/api/auth/register", json={"username": "bob", "password": "password99"})
|
||||
client.post("/api/auth/logout")
|
||||
|
||||
client.post("/api/auth/login", json={"username": "alice", "password": "password99"})
|
||||
save = client.post("/api/file/code/only_alice.py", json={"content": "owner = 'alice'\n"})
|
||||
assert save.status_code == 200
|
||||
client.post("/api/auth/logout")
|
||||
|
||||
client.post("/api/auth/login", json={"username": "bob", "password": "password99"})
|
||||
missing = client.get("/api/file/code/only_alice.py")
|
||||
assert missing.status_code == 404
|
||||
listing = client.get("/api/files", params={"path": "code"})
|
||||
assert listing.status_code == 200
|
||||
names = {item["name"] for item in listing.json()["files"]}
|
||||
assert "only_alice.py" not in names
|
||||
|
||||
|
||||
def test_lib_is_shared_read_only_across_users(tmp_path, monkeypatch):
|
||||
"""`lib/` lives at the repo root (single source of truth), not under WORKSPACE_ROOT,
|
||||
so we patch `config.PROJECT_ROOT` to give the test its own isolated lib bundle."""
|
||||
import editor_app.config as config
|
||||
|
||||
shared_lib = tmp_path / "lib"
|
||||
shared_lib.mkdir(parents=True, exist_ok=True)
|
||||
(shared_lib / "shared.py").write_text("VALUE = 42\n", encoding="utf-8")
|
||||
# Mirror canonical demo files so `_seed_canonical_demos_into_code` still works.
|
||||
real_demos = config.PROJECT_ROOT / "workspace" / "code"
|
||||
fake_demos = tmp_path / "workspace" / "code"
|
||||
fake_demos.mkdir(parents=True, exist_ok=True)
|
||||
for fname in ("pattern_rainbow_demo.py", "pattern_twinkle_demo.py", "pattern_chase_demo.py"):
|
||||
src = real_demos / fname
|
||||
if src.is_file():
|
||||
(fake_demos / fname).write_text(src.read_text(encoding="utf-8"), encoding="utf-8")
|
||||
|
||||
monkeypatch.setattr(config, "PROJECT_ROOT", tmp_path)
|
||||
|
||||
with TestClient(
|
||||
_reload_app(tmp_path, monkeypatch, AUTH_ENABLED="true", AUTH_REGISTER_OPEN="true")
|
||||
) as client:
|
||||
client.post("/api/auth/register", json={"username": "alice", "password": "password99"})
|
||||
client.post("/api/auth/logout")
|
||||
client.post("/api/auth/register", json={"username": "bob", "password": "password99"})
|
||||
client.post("/api/auth/logout")
|
||||
|
||||
client.post("/api/auth/login", json={"username": "alice", "password": "password99"})
|
||||
lib_read = client.get("/api/file/lib/shared.py")
|
||||
assert lib_read.status_code == 200
|
||||
assert "VALUE = 42" in lib_read.json()["content"]
|
||||
lib_write = client.post("/api/file/lib/shared.py", json={"content": "VALUE = 0\n"})
|
||||
assert lib_write.status_code == 403
|
||||
client.post("/api/auth/logout")
|
||||
|
||||
client.post("/api/auth/login", json={"username": "bob", "password": "password99"})
|
||||
lib_read_bob = client.get("/api/file/lib/shared.py")
|
||||
assert lib_read_bob.status_code == 200
|
||||
assert "VALUE = 42" in lib_read_bob.json()["content"]
|
||||
|
||||
|
||||
def test_superuser_can_open_other_user_workspace(tmp_path, monkeypatch):
|
||||
with TestClient(
|
||||
_reload_app(tmp_path, monkeypatch, AUTH_ENABLED="true", AUTH_REGISTER_OPEN="true")
|
||||
) as client:
|
||||
client.post("/api/auth/register", json={"username": "admin", "password": "password99"})
|
||||
client.post("/api/auth/logout")
|
||||
client.post("/api/auth/register", json={"username": "bob", "password": "password99"})
|
||||
client.post("/api/auth/logout")
|
||||
|
||||
client.post("/api/auth/login", json={"username": "bob", "password": "password99"})
|
||||
me = client.get("/api/auth/me").json()
|
||||
bob_id = me["user"]["id"]
|
||||
client.post("/api/file/code/bob_only.py", json={"content": "owner='bob'\n"})
|
||||
client.post("/api/auth/logout")
|
||||
|
||||
client.post("/api/auth/login", json={"username": "admin", "password": "password99"})
|
||||
as_bob = client.get("/api/file/code/bob_only.py", params={"workspace_user_id": bob_id})
|
||||
assert as_bob.status_code == 200
|
||||
assert "owner='bob'" in as_bob.json()["content"]
|
||||
|
||||
|
||||
def test_non_admin_cannot_override_workspace_user(tmp_path, monkeypatch):
|
||||
with TestClient(
|
||||
_reload_app(tmp_path, monkeypatch, AUTH_ENABLED="true", AUTH_REGISTER_OPEN="true")
|
||||
) as client:
|
||||
client.post("/api/auth/register", json={"username": "admin", "password": "password99"})
|
||||
client.post("/api/auth/logout")
|
||||
client.post("/api/auth/register", json={"username": "alice", "password": "password99"})
|
||||
client.post("/api/auth/logout")
|
||||
client.post("/api/auth/register", json={"username": "bob", "password": "password99"})
|
||||
client.post("/api/auth/logout")
|
||||
|
||||
client.post("/api/auth/login", json={"username": "alice", "password": "password99"})
|
||||
denied = client.get("/api/files", params={"workspace_user_id": 1})
|
||||
assert denied.status_code == 403
|
||||
|
||||
|
||||
def test_login_serves_page(client):
|
||||
r = client.get("/login")
|
||||
assert r.status_code == 200
|
||||
|
||||
@@ -43,3 +43,4 @@ def test_collect_python_sources_skips_hidden_and_binary(tmp_path):
|
||||
out = filesystem.collect_python_sources()
|
||||
assert out["code/ok.py"] == "a = 1\n"
|
||||
assert "bad.py" not in out
|
||||
assert "lib/machine.py" in out, "shared lib stubs should always be merged for Jedi / Pyodide"
|
||||
|
||||
72
tests/test_led_patterns.py
Normal file
72
tests/test_led_patterns.py
Normal file
@@ -0,0 +1,72 @@
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _load_patterns_module():
|
||||
repo_root = Path(__file__).resolve().parents[1]
|
||||
# Canonical home for shipped demos — `workspace/` is gitignored.
|
||||
module_path = repo_root / "src" / "static" / "bundled-demos" / "led_patterns.py"
|
||||
spec = importlib.util.spec_from_file_location("led_patterns", module_path)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
assert spec is not None and spec.loader is not None
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def test_rainbow_frame_shape_and_bounds():
|
||||
patterns = _load_patterns_module()
|
||||
frame = patterns.rainbow_frame(12, 3)
|
||||
assert len(frame) == 12
|
||||
for color in frame:
|
||||
assert len(color) == 3
|
||||
assert all(0 <= c <= 255 for c in color)
|
||||
|
||||
|
||||
def test_chase_frame_has_head_and_tail():
|
||||
patterns = _load_patterns_module()
|
||||
frame = patterns.chase_frame(8, 5, color=(10, 20, 30), tail=(1, 2, 3))
|
||||
assert len(frame) == 8
|
||||
assert frame[5] == (10, 20, 30)
|
||||
assert frame[4] == (1, 2, 3)
|
||||
assert sum(1 for c in frame if c != (0, 0, 0)) == 2
|
||||
|
||||
|
||||
def test_bounce_head_pingpongs_off_ends():
|
||||
patterns = _load_patterns_module()
|
||||
bounce = patterns._bounce_head_index
|
||||
assert bounce(5, 0) == 0 and bounce(5, 4) == 4
|
||||
assert bounce(5, 5) == 3 and bounce(5, 8) == 1 and bounce(5, 16) == bounce(5, 0)
|
||||
|
||||
|
||||
def test_scanner_bounce_has_head_and_fading_tail():
|
||||
patterns = _load_patterns_module()
|
||||
frame = patterns.scanner_bounce_frame(
|
||||
8, 20, head_color=(80, 10, 10), tail_color=(20, 50, 90), tail_len=4
|
||||
)
|
||||
assert len(frame) == 8
|
||||
bright = max(range(8), key=lambda i: sum(frame[i]))
|
||||
assert sum(frame[bright]) >= 80
|
||||
|
||||
|
||||
def test_knight_rider_tail_falls_off():
|
||||
patterns = _load_patterns_module()
|
||||
fr = patterns.knight_rider_scanner_frame(16, 55, tail_len=8, falloff_gamma=3.0)
|
||||
assert len(fr) == 16
|
||||
reds_sorted = sorted(fr[i][0] for i in range(16) if sum(fr[i]) > 0)
|
||||
assert len(reds_sorted) >= 2
|
||||
assert reds_sorted[-1] >= 200
|
||||
assert reds_sorted[0] < reds_sorted[-1] * 0.5
|
||||
|
||||
|
||||
def test_twinkle_frame_is_deterministic_for_same_inputs():
|
||||
patterns = _load_patterns_module()
|
||||
a = patterns.twinkle_frame(20, frame=9, seed=777, sparkles=4)
|
||||
b = patterns.twinkle_frame(20, frame=9, seed=777, sparkles=4)
|
||||
assert a == b
|
||||
|
||||
|
||||
def test_twinkle_frame_varies_between_frames():
|
||||
patterns = _load_patterns_module()
|
||||
a = patterns.twinkle_frame(20, frame=1, seed=777, sparkles=4)
|
||||
b = patterns.twinkle_frame(20, frame=2, seed=777, sparkles=4)
|
||||
assert a != b
|
||||
@@ -7,6 +7,9 @@ import urllib.error
|
||||
import urllib.request
|
||||
|
||||
import pytest
|
||||
from selenium.webdriver.common.by import By
|
||||
from selenium.webdriver.support import expected_conditions as ec
|
||||
from selenium.webdriver.support.ui import WebDriverWait
|
||||
|
||||
pytest.importorskip("selenium.webdriver")
|
||||
|
||||
@@ -46,3 +49,24 @@ def test_home_page_title(driver):
|
||||
)
|
||||
driver.get(f"{base}/")
|
||||
assert "Python Editor" in driver.title
|
||||
|
||||
|
||||
@pytest.mark.selenium
|
||||
def test_editor_page_loads_core_controls(driver):
|
||||
base = os.environ.get("SELENIUM_BASE_URL", "http://127.0.0.1:8080").rstrip("/")
|
||||
if not _server_reachable(base):
|
||||
pytest.skip(
|
||||
f"No server at {base}. In another terminal run: "
|
||||
"pipenv run dev (then re-run this test, or set SELENIUM_BASE_URL)."
|
||||
)
|
||||
|
||||
driver.get(f"{base}/editor")
|
||||
|
||||
# If the app is configured with AUTH_ENABLED=true, unauthenticated requests redirect to /login.
|
||||
if "/login" in (driver.current_url or ""):
|
||||
pytest.skip("Editor requires login; set AUTH_ENABLED=false for this Selenium smoke test.")
|
||||
|
||||
wait = WebDriverWait(driver, 10)
|
||||
wait.until(ec.presence_of_element_located((By.ID, "run-btn")))
|
||||
wait.until(ec.presence_of_element_located((By.ID, "stop-btn")))
|
||||
wait.until(ec.presence_of_element_located((By.ID, "file-tree")))
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
"""Shared helpers (read-only on server; copied into Pyodide when you run)."""
|
||||
|
||||
|
||||
def greet(name: str) -> str:
|
||||
return f"Hello, {name}!"
|
||||
Reference in New Issue
Block a user