Compare commits

..
5 Commits
Author SHA1 Message Date
xuyueandClaude Opus 5.5 f242308a3b 部署:update.sh 改由 deploy/update-bg.sh 在服务器上后台跑(setsid),SSH 断了它照样跑完。2026-11-03 开发机上 FlClash 切换配置重建了 TUN 网卡,SSH 正好断在 docker compose up 中间:旧容器删了、新容器只创建没启动,网站停了 40 分钟。输出写到 /var/tmp/iot_sim-update.log,--follow <提交号> 接着看输出、退出码和 update.sh 一样,这个提交的部署还没开始时返回 75;上一次还在跑就等它结束。Gitea 的"起服务"ssh 返回 255 就 10 秒后重连 --follow,75 就重新开始,最多 6 次。GitHub 备用部署也改用 update-bg.sh。部署说明同步
Deploy / deploy (push) Successful in 1m28s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 13:32:21 +08:00
xuyueandClaude Opus 5.5 2aa267839f 导出 VS 工程:实验页工具栏的"导出 EXE"改成下拉按钮"导出 ▾",里面是"导出 EXE"和"导出 VS 工程"。导出的 zip 是教材那样的 VS 2012 窗体应用工程(.sln + .csproj,.NET Framework 4.5),在机房电脑上解压、双击 .sln 用 VS 打开接着改,按 F5 运行。导出前照机房环境编译一遍要放进 zip 的代码:C# 5(VS 2012 的编译器)、真机版设备类库,有错不导出;新版 C# 写法报 CS8026,按功能名给出改法(字符串插值改 string.Format、?. 改先判断 null、=> 改大括号等 12 种)。导出 EXE 仍按最新 C#。平台的 Properties/Resources.Designer.cs 换成 VS 生成的写法和 Resources.resx,图片放 Resources\;代码里按文件名用的素材(ring.mp3)复制到输出目录,运行时在 bin\Debug\Resources\;每个窗体带 .resx(媒体播放器 OcxState 用教材默认状态),SubType、DependentUpon 和 VS 一样;补上 AssemblyInfo、Settings、App.config。教材 dll 全放 lib\,只引用代码直接用到的(和教材工程一样),只带这些编译不过时引用全部;用到书上的 ledHelper.Close() 时带上 LedHelperCompat.cs。文本文件用带 BOM 的 UTF-8 和 CRLF(VS 2012 打开不带 BOM 的文件会把中文当 GBK);zip 文件名用 GBK,另附 Info-ZIP Unicode 文件名字段(0x7075),中文 Windows 自带解压和 7-Zip 都显示正确(ZipWriter.cs)。23 个示例都能导出,导出的工程在 mono 的 msbuild 里全部编译通过、输出目录和教材 bin\Debug 一样、程序能启动;VS 2012 还要在机房实测。接口 POST /api/export-project,代码有错时 422。export-exe.ts 改名 export.ts(服务器和网页各一个)。使用说明、设计方案、README、部署说明同步
Deploy / deploy (push) Failing after 1m8s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 12:42:13 +08:00
xuyueandClaude Opus 5.5 4c3ce31fe1 部署:push origin 就由开发机编好、测过再传到服务器,服务器不再自己编,push 后一两分钟上线。照 OJ2 的做法,开发机上的 gitea-runner(和 OJ2 共用,标签 iot-deploy,一次只跑一个任务)跑 .gitea/workflows/deploy.yml:编 C# 运行时(Release)、网页和 server.js,跑服务端测试(用要上线的 Release 版运行时)、网页端测试和类型检查,产物 rsync 到服务器的 prebuilt/(按内容比、传完才换上、断了重试),服务器上 deploy/update.sh --prebuilt 用现成产物构建镜像,只剩几条 COPY。以前 GitHub 上测试约 5 分钟、服务器 2 核再编约一分钟(编的时候学生程序会卡),push 后 6 分钟多才上线。host 模式用开发机自己的 ssh 和 git 访问,不需要 secret。Dockerfile 加构建参数 ARTIFACTS(build 默认在镜像里编 / prebuilt 用现成产物),服务器自己编的能力保留;prebuilt/COMMIT 和要部署的提交对不上时 update.sh 直接失败。服务端测试同时跑:文件之间 --parallel,评分测试标 describe.concurrent(--max-concurrency=8),本机 4 分钟降到半分钟,连跑三遍都通过。GitHub 的 ci.yml 改成 PR 跑测试、手动触发才部署(服务器自己编),开发机不在时用,push 不再两边各部署一遍。顺手:update.sh 每次部署拉一下 caddy:2-alpine(up 不会自己拉,一直是第一次部署时那份);app 和 caddy 的容器日志限 10MB × 3。部署说明、README、设计方案同步
Deploy / deploy (push) Successful in 1m38s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 12:08:14 +08:00
xuyueandClaude Opus 5.5 77bfaba7ff 导出 EXE:服务器上的教材 dll 读不了(root 拷进去的文件是 600,容器里的平台用户读不到)时提示中文原因和改法,不再把英文的系统报错给学生看;部署说明补上 chmod 755 目录、644 文件
CI/CD / test (push) Canceled after 0s
CI/CD / deploy (push) Canceled after 0s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 22:19:28 +08:00
xuyueandClaude Opus 5.5 5e5d29b2aa 导出 EXE:实验页工具栏"导出 EXE"把学生的程序编译成一个 exe,拷到机房电脑上双击就能控制真的实训台。编译服务照教材工程编译学生写的原始代码(.NET Framework 4.5 引用程序集,构建时只复制教材工程引用的 13 个到 netfx/;真机版设备类库),不做平台的仿真改写和 API 限制,和 VS 里一样。教材 dll(DeviceCtrlLibrary 及 API 下 7 个、WinFormsDeviceControlLibrary、媒体播放器的两个 Interop)作为资源嵌进 exe,模块初始化器注册 AssemblyResolve 从 exe 里加载,Program.cs 不用改;Properties.Resources 的图片、声音嵌进 exe,代码里按文件名用到的素材(项目 7 的 ring.mp3)启动时放到 exe 旁边的 Resources;媒体播放器的 OcxState 用教材 resx 的默认状态;书上的 ledHelper.Close() 在教材类库里没有,补一个空的扩展方法。23 个实验都能导出(约 0.9 MB),mono + Xvfb 里实测能启动、控件和图片正常。接口 POST /api/export-exe,代码有错时 422,实验页弹框列出报错、点击跳到代码。教材 dll 有版权、GitHub 仓库公开,不进仓库:本机放 textbook-libs/,服务器放 ~/IOT_SIM/textbook-libs/ 由 compose 只读挂载(deploy/README.md),没放时提示原因。使用说明、设计方案、README 同步
CI/CD / test (push) Canceled after 0s
CI/CD / deploy (push) Canceled after 0s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 22:10:07 +08:00
29 changed files with 2084 additions and 45 deletions

No files matched your search

+1
View File
@@ -7,4 +7,5 @@
.git
docs
*.pdf
textbook-libs
.env
+135
View File
@@ -0,0 +1,135 @@
# push 到 origin(git.xuyue.cc)的 main 就部署:在开发机上编好产物、跑测试 → 产物 rsync 到服务器的 prebuilt/ →
# 服务器上 deploy/update.sh --prebuilt(拉代码、用现成产物构建镜像、重启)。
#
# 跑在哪:开发机上的 gitea-runner(host 模式,标签 iot-deploy;和 OJ2 共用一个 runner,一次只跑一个任务,
# 两边的部署排队,不会同时挤服务器)。runner 的装法见 deploy/README.md「自动部署」。
# - 服务器只有 2 核,学生程序也在上面跑,以前服务器自己编约一分钟,dotnet 和 vite 把两个核占满。
# - 以前在 GitHub 上测试(约 5 分钟)再 SSH 到服务器编,push 后 6 分钟多才上线。
# 代价:**push 时开发机得开着。** 没开的话任务在 Gitea 里排队,开机后 runner 连上就接着跑;
# 急着上线又不在开发机旁时,用 GitHub 上手动触发的备用部署(.github/workflows/ci.yml,服务器自己编)。
#
# host 模式直接用开发机自己的东西:PATH 里的 bun / dotnet / bwrap、~/.ssh/config 里的 `debian`(root@服务器)
# 和 git.xuyue.cc 的 SSH 访问。所以这里**不需要配任何 secret**,也不用 actions/checkout。
#
# 有了 .gitea/workflows 之后,Gitea 不再读 .github/workflows。
name: Deploy
on:
push:
branches: [main]
# 在 Actions 页面手动重新部署(比如改了服务器上的 .env 之后)
workflow_dispatch:
concurrency:
group: deploy
cancel-in-progress: false
env:
REMOTE: debian # 开发机 ~/.ssh/config 里的主机名
REMOTE_DIR: /root/IOT_SIM
DOTNET_CLI_TELEMETRY_OPTOUT: '1'
DOTNET_NOLOGO: '1'
jobs:
deploy:
runs-on: iot-deploy
timeout-minutes: 20
steps:
# 工作目录可能是上一次留下的,所以 fetch 之后强制切过去再清掉没跟踪的文件(node_modules 留着,bun install 会对齐它)
- name: 取代码
env:
SHA: ${{ gitea.sha }}
REPO: ${{ gitea.repository }}
run: |
git init -q .
git fetch -q --depth 1 "git@git.xuyue.cc:$REPO.git" "$SHA"
git checkout -q --force FETCH_HEAD
git clean -ffdxq -e node_modules
git log --oneline -1
# server.js 是开发机的 bun 打包的,到服务器上由镜像里的 bun(Dockerfile 里写死的版本)运行。
# 打包出来是普通 JS,不像 OJ2 的单二进制那样把 bun 本身带过去,版本差一点一般没事,所以只提醒不拦。
- name: 看 bun 版本
run: |
want=$(sed -nE 's|^FROM oven/bun:([0-9.]+).*|\1|p' Dockerfile | head -1)
have=$(bun --version)
echo "开发机 bun $have,镜像里 bun $want"
[ "$have" = "$want" ] || echo "::warning::开发机的 bun 是 $have,镜像里是 $want,升级 Dockerfile 和 ci.yml 里的版本,或者确认没有用到新版本才有的功能"
- name: 装依赖
run: bun install --frozen-lockfile
- name: 编 C# 运行时
run: dotnet build runtime/Runtime.slnx -c Release -v q
- name: 编网页、打包服务端
run: |
(cd web && bun run build)
bun build server/src/index.ts --target=bun --outfile=server.js
# 有一项不过就不部署。服务端测试用上一步编的 Release 版运行时,测的就是要上线的那份。
# --parallel 让 6 个测试文件同时跑;grading 里的测试标了 describe.concurrent,
# --max-concurrency 限制同时跑几个(太多时定时器来不及触发会误判)。挨个跑约 4 分钟,这样半分钟左右
- name: 测试
env:
RUNNER_DLL: runtime/Sim.Runner/bin/Release/net10.0/Sim.Runner.dll
run: |
(cd server && bun test --parallel --max-concurrency=8)
(cd web && bun test src && bunx vue-tsc --noEmit)
# 目录结构就是 Dockerfile 里 artifacts-prebuilt 阶段要的;COMMIT 让服务器确认产物和要部署的代码是同一个提交
- name: 收集产物
env:
SHA: ${{ gitea.sha }}
run: |
rm -rf prebuilt
mkdir -p prebuilt/runtime prebuilt/web
cp -a runtime/Sim.Runner/bin/Release/net10.0/. prebuilt/runtime/
cp -a web/dist/. prebuilt/web/
cp server.js prebuilt/
echo "$SHA" > prebuilt/COMMIT
du -sh prebuilt
# 只传产物,源码由服务器自己从 origin 拉(git.xuyue.cc 就在那台服务器上)。
# -c 按内容比:每次编出来的 mtime 都是「刚才」,按 mtime 比会把没变的文件也传一遍
# --delay-updates 全部传完才一起换上去,中途断了不会留下半新半旧的产物(反正 COMMIT 对不上也会被 update.sh 拦下)
# --timeout 60 秒没有数据进出就算断了,交给循环重试,半截文件接着用
- name: 同步到服务器
timeout-minutes: 10
run: |
for attempt in 1 2 3; do
rsync -a -c --no-owner --no-group --delete-delay --delay-updates \
--compress --compress-choice=zstd \
--timeout=60 --stats --human-readable \
-e "ssh -o BatchMode=yes -o ServerAliveInterval=15 -o ServerAliveCountMax=4" \
prebuilt/ "$REMOTE:$REMOTE_DIR/prebuilt/" && exit 0
echo "::warning::rsync 第 $attempt 次没传完,10 秒后重试"
sleep 10
done
exit 1
# 先把服务器上的代码切到这个提交再跑 update.sh:不然跑的是上一版的 update.sh,改了它要晚一次部署才生效。
# 重启会结束正在运行的学生程序,上课时间尽量不要推送 main。
#
# update.sh 由 update-bg.sh 在服务器上后台跑,SSH 断了(开发机切代理、换网络)它照样跑完;
# 这边 ssh 返回 255(连接出错)就重连,用 --follow 接着看、拿退出码。
# 75 = 服务器上这个提交的部署还没开始(断在 git fetch 那里),重新开始。
- name: 起服务
timeout-minutes: 10
env:
SHA: ${{ gitea.sha }}
run: |
remote() { ssh -o BatchMode=yes -o ConnectTimeout=20 -o ServerAliveInterval=15 -o ServerAliveCountMax=8 "$REMOTE" "$1"; }
start="cd $REMOTE_DIR && git fetch --quiet origin && git reset --quiet --hard $SHA && deploy/update-bg.sh $SHA --prebuilt"
follow="cd $REMOTE_DIR && deploy/update-bg.sh --follow $SHA"
code=0; remote "$start" || code=$?
for attempt in 1 2 3 4 5 6; do
case $code in
255) echo "::warning::和服务器的 SSH 断了,部署在服务器上接着跑,10 秒后重连接着看"
sleep 10; code=0; remote "$follow" || code=$? ;;
75) echo "::warning::服务器上的部署还没开始,重新开始"
code=0; remote "$start" || code=$? ;;
*) exit $code ;;
esac
done
exit $code
+10 -9
View File
@@ -1,13 +1,13 @@
# 每次推送和 PR 都跑测试;推送到 main 且测试通过后,SSH 到服务器执行 deploy/update.sh(服务器从 origin 拉代码、构建、重启)。
# 部署需要的 Secrets 见 deploy/README.md 的"自动部署"一节。
# 只在 github.com 上运行;仓库同时推到 git.xuyue.cc,那边的 Gitea Actions 也会读这个目录,用 if 跳过。
# 备用路线。平时 push origin(git.xuyue.cc)后,由开发机上的 gitea-runner 测试、编好、部署(.gitea/workflows/deploy.yml),
# 这里 push 不再触发,免得每次推送在两边各测一遍、各部署一遍。
# - PR:只跑测试。
# - 手动触发(Actions → CI/CD → Run workflow):测试通过后 SSH 到服务器执行 deploy/update.sh,服务器从 origin 拉代码、
# **自己编**、重启。开发机不在、又急着上线时用。部署需要的 Secrets 见 deploy/README.md 的"自动部署"一节。
# 只在 github.com 上运行(Gitea 有了 .gitea/workflows 后不再读这个目录,if 再保一道)。
name: CI/CD
on:
push:
branches: [main]
pull_request:
# 在 Actions 页面手动重新部署(比如改了服务器上的 .env 之后)
workflow_dispatch:
jobs:
@@ -37,9 +37,10 @@ jobs:
- name: 编译 C# 运行时
run: bun run build:runtime
# 文件之间同时跑,grading 里的测试也同时跑(describe.concurrent),见 .gitea/workflows/deploy.yml 同名一步
- name: 服务端测试(编译并运行示例、沙箱、编译检查)
working-directory: server
run: bun test
run: bun test --parallel --max-concurrency=8
- name: 网页端测试和类型检查
working-directory: web
@@ -49,7 +50,7 @@ jobs:
deploy:
needs: test
if: github.server_url == 'https://github.com' && github.ref == 'refs/heads/main' && github.event_name != 'pull_request'
if: github.server_url == 'https://github.com' && github.event_name == 'workflow_dispatch'
runs-on: ubuntu-24.04
# 服务器构建约几分钟;卡住时及时失败,不要一直占着
timeout-minutes: 20
@@ -99,4 +100,4 @@ jobs:
- name: 部署(在服务器上拉代码、构建、重启)
if: steps.check.outputs.configured == 'true'
run: ssh deploy-target "cd ${{ vars.DEPLOY_DIR || 'IOT_SIM' }} && deploy/update.sh ${{ github.sha }}"
run: ssh deploy-target "cd ${{ vars.DEPLOY_DIR || 'IOT_SIM' }} && deploy/update-bg.sh ${{ github.sha }}"
+5
View File
@@ -10,6 +10,8 @@ obj/
# 教材资料(体积大、有版权,不进仓库)
物联网编程与应用配套/
# 导出 EXE 用的教材设备类库(真机版 dll),部署时放在服务器上挂进容器
/textbook-libs/
*.pdf
# 编辑器
@@ -25,3 +27,6 @@ obj/
# 服务器上 deploy/backup.sh 做的备份
/backups/
# 开发机上的 gitea-runner 编好传到服务器的产物(.gitea/workflows/deploy.yml)
/prebuilt/
+23 -3
View File
@@ -1,5 +1,11 @@
# 物联网编程仿真平台:一个镜像里包含 Bun 服务端、打包好的网页、.NET 运行时和 bubblewrap 沙箱。
# 构建:docker compose build;部署说明见 deploy/README.md
#
# 产物(C# 运行时、网页、server.js)有两种来源,由构建参数 ARTIFACTS 选:
# build (默认)在镜像里编。只要有 docker 就能构建,但服务器只有 2 核,编一次约一分钟,期间学生程序会卡
# prebuilt 用 prebuilt/ 里现成的产物(开发机上的 gitea-runner 编好、测过后 rsync 过来,见 .gitea/workflows/deploy.yml),
# 镜像构建只剩几条 COPY。buildx 不会去跑用不到的 runtime-build / web-build 阶段
ARG ARTIFACTS=build
# ---- C# 运行时(编译服务 + 仿真库) ----
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS runtime-build
@@ -22,6 +28,20 @@ RUN cd web && bun run build
# 服务端打包成单个文件,运行镜像里不需要 node_modules
RUN bun build server/src/index.ts --target=bun --outfile=server.js
# ---- 产物:在镜像里编的 ----
FROM scratch AS artifacts-build
COPY --from=runtime-build /src/runtime/Sim.Runner/bin/Release/net10.0/ /runtime/
COPY --from=web-build /src/web/dist/ /web/
COPY --from=web-build /src/server.js /server.js
# ---- 产物:现成的(目录结构和上面一样,名字里不带 dist / bin,免得被 .dockerignore 挡掉) ----
FROM scratch AS artifacts-prebuilt
COPY prebuilt/runtime/ /runtime/
COPY prebuilt/web/ /web/
COPY prebuilt/server.js /server.js
FROM artifacts-${ARTIFACTS} AS artifacts
# ---- 运行镜像 ----
FROM mcr.microsoft.com/dotnet/runtime:10.0
RUN apt-get update \
@@ -29,9 +49,9 @@ RUN apt-get update \
&& rm -rf /var/lib/apt/lists/*
COPY --from=oven/bun:1.4.4 /usr/local/bin/bun /usr/local/bin/bun
WORKDIR /app
COPY --from=runtime-build /src/runtime/Sim.Runner/bin/Release/net10.0/ runtime/
COPY --from=web-build /src/web/dist/ web/dist/
COPY --from=web-build /src/server.js ./
COPY --from=artifacts /runtime/ runtime/
COPY --from=artifacts /web/ web/dist/
COPY --from=artifacts /server.js ./
COPY samples/ samples/
ENV APP_ROOT=/app \
RUNNER_DLL=runtime/Sim.Runner.dll \
+4 -3
View File
@@ -38,7 +38,7 @@ BASE=http://localhost:3000 bun test/classroom.ts 45 # 全班模拟:45 个学
## 部署
Docker Compose,HTTPS 由服务器上的 Nginx Proxy Manager 负责。推送到 GitHub 的 main 后,GitHub Actions 跑完测试,SSH 到服务器执行 `deploy/update.sh`:服务器从 git.xuyue.cc 拉代码、构建镜像、重启。详见 [deploy/README.md](deploy/README.md)。
Docker Compose,HTTPS 由服务器上的 Nginx Proxy Manager 负责。push 到 origin(git.xuyue.cc)的 main 后,开发机上的 gitea-runner 跑完测试、编好产物传到服务器,服务器拉代码、用现成产物构建镜像、重启(`deploy/update.sh --prebuilt`);GitHub 上手动触发是备用路线,由服务器自己编。详见 [deploy/README.md](deploy/README.md)。
## 运行器的四种模式
@@ -73,11 +73,12 @@ Docker Compose,HTTPS 由服务器上的 Nginx Proxy Manager 负责。推送到
- 代码编辑器(Monaco):C# 高亮、编译错误波浪线、点错误跳到对应行、F5 运行 / Shift+F5 停止、草稿自动保存在浏览器本地。
- 窗体设计器(仿 VS,界面中文):工具箱、拖放与改大小、属性窗口、事件窗口、双击生成事件处理方法、撤销 / 重做;读写 VS 格式的 Designer.cs。
- 多窗体:新建、删除窗体;运行时多个窗口可切换前后。
- 部署:bubblewrap 沙箱(断网、只读、看不到其他进程,限制内存、CPU 时间和运行时长)、编译时禁止网络 / 反射 / 启动进程等 API、Docker Compose、GitHub Actions 自动部署。
- 导出 EXE:学生的程序照教材工程(.NET Framework 4.5、真机版设备类库)编译成一个 exe,教材 dll 嵌在里面,在机房电脑上双击就能控制真的实训台(`runtime/Sim.Runner/ExeExporter.cs`);导出 VS 工程:打包成教材那样的 VS 2012 工程(zip),先照机房环境(C# 5)检查(`runtime/Sim.Runner/ProjectExporter.cs`)。教材 dll 有版权,不进仓库:本机放在 `textbook-libs/`,服务器上的放法见 [deploy/README.md](deploy/README.md)。
- 部署:bubblewrap 沙箱(断网、只读、看不到其他进程,限制内存、CPU 时间和运行时长)、编译时禁止网络 / 反射 / 启动进程等 API、Docker Compose、push origin 自动部署(开发机上的 gitea-runner 测试、编好再传到服务器)。
- 第 2 阶段(进行中):账号登录(本机开发时老师账号为 admin / admin)、班级管理与 CSV 名单导入、学生作品保存在服务器上;自动评分(每个示例的 `checks.yaml`);实验页"评价项"可以自己检查(不计分);布置作业、学生提交后自动评分;老师评阅(看代码和界面截图、运行学生的程序、老师评分项打分、评语)、AI 评阅建议(DeepSeek,老师采纳后生效)、全班成绩表与 CSV 导出。
- 第 3 阶段(教材项目 4–8 已全部完成):教材项目 5 用到的旧版线程 API(`Thread.Suspend` / `Resume` / `Abort`)编译时改写成平台的协作式实现(`runtime/Sim.Runner/LegacyThreadRewriter.cs`、`runtime/Sim.WinForms/Host/LegacyThreads.cs`);网络摄像机(画面帧由 `runtime/Sim.Devices/Camera/build-frames.ts` 从 `scene.svg` 生成,需要 rsvg-convert 和 ImageMagick)。
尚未完成:
- 一个班试用。
- 书上的扩展练习("边想边做""自主探究")、导出 VS 工程、校园街景场景、故障注入题库(第 4 阶段)。
- 书上的扩展练习("边想边做""自主探究")、校园街景场景、故障注入题库(第 4 阶段)。
+14 -2
View File
@@ -5,7 +5,11 @@ services:
init-data:
image: iot-sim:latest
# 和 app 同一个镜像(构建有缓存,不会重复构建);写上 build,新服务器上就不会去 Docker Hub 拉这个镜像
build: .
build: &build
context: .
# build:在镜像里编(默认);prebuilt:用 prebuilt/ 里开发机编好的产物,见 Dockerfile 开头
args:
ARTIFACTS: ${ARTIFACTS:-build}
user: root
command: ['sh', '-c', 'chown -R "$$APP_UID" /data']
volumes:
@@ -15,8 +19,13 @@ services:
app:
image: iot-sim:latest
build: .
build: *build
restart: unless-stopped
logging: &logging # 默认的 json-file 日志不限大小,会一直涨
driver: json-file
options:
max-size: 10m
max-file: '3'
depends_on:
init-data:
condition: service_completed_successfully
@@ -41,6 +50,8 @@ services:
# 数据库(账号、班级、学生作品),重新部署不会丢
volumes:
- data:/data
# 导出 EXE 用的教材设备类库(真机版 dll,有版权,不进仓库):放在服务器的 ~/IOT_SIM/textbook-libs/,见 deploy/README.md
- ./textbook-libs:/app/textbook-libs:ro
cap_drop: [ALL]
security_opt:
- no-new-privileges:true
@@ -58,6 +69,7 @@ services:
caddy:
image: caddy:2-alpine
restart: unless-stopped
logging: *logging
depends_on: [app]
expose:
- 80
+61 -9
View File
@@ -66,15 +66,35 @@
数据库(账号、班级、学生作品)在 Docker 数据卷 `data` 里,重新部署不会丢;`docker compose down -v` 会删除数据卷,不要加 `-v`。
## 自动部署(GitHub Actions)
## 自动部署(push origin 就部署)
`.github/workflows/ci.yml`:
`.gitea/workflows/deploy.yml`:push 到 origin(git.xuyue.cc)的 main 时,**开发机上的 gitea-runner** 编 C# 运行时、网页和 `server.js`,跑服务端测试(含沙箱)、网页端测试和类型检查(有一项不过就不部署),把产物 rsync 到服务器的 `~/IOT_SIM/prebuilt/`,再在服务器上执行 `deploy/update.sh <提交号> --prebuilt`:拉代码、用现成产物构建镜像(只剩几条 COPY)、重启,等到"编译服务已就绪"才算成功。push 后一两分钟上线,进度在 git.xuyue.cc/xuyue/IOT_SIM/actions。
- 每次推送和 PR:编译 C# 运行时,跑服务端测试(含沙箱)、网页端测试和类型检查。
- 推送到 GitHub 的 main 且测试通过:SSH 到服务器执行 `deploy/update.sh <提交号>`,服务器从 origin 拉代码、构建、重启,等到"编译服务已就绪"才算成功。**重启会结束正在运行的学生程序**,上课时间尽量不要推送 main。
- 服务器从 origin 拉代码,所以**两个仓库都要推送**(`git push origin main`、`git push github main`)。GitHub 先到时,脚本会等 origin 最多约 100 秒。
- 在 GitHub 的 Actions 页面点 Run workflow 可以手动重新部署。
- 只在 github.com 上运行;推到 git.xuyue.cc 不会触发。
- `update.sh` 由 `deploy/update-bg.sh` 在服务器上**后台**跑,SSH 断了(开发机切代理、换网络)它照样跑完,runner 重连后接着看输出、拿结果。以前它跑在 SSH 会话里,断线时正好在 `docker compose up` 中间,旧容器删了、新容器没启动,网站就停了。服务器上的部署输出在 `/var/tmp/iot_sim-update.log`。
- **重启会结束正在运行的学生程序**,上课时间尽量不要推送 main。
- **push 时开发机得开着。** 没开的话任务在 Gitea 里排队,开机后 runner 连上就接着跑;排队超过 24 小时会被作废,要在 Actions 页面重跑。
- 在 Gitea 的 Actions 页面点 Run workflow 可以手动重新部署(比如改了服务器上的 `.env` 之后)。
- `prebuilt/COMMIT` 记着产物是哪个提交编的,和要部署的提交对不上时 `update.sh` 直接失败,不会把旧产物配新代码。
为什么不在服务器或 GitHub 上编:服务器只有 2 核,学生程序也在上面跑,以前服务器自己编约一分钟,dotnet 和 vite 把两个核占满;GitHub 上测试约 5 分钟,push 后 6 分钟多才上线。开发机 16 核,服务端测试同时跑(`bun test --parallel`,评分测试标了 `describe.concurrent`)约半分钟。
**服务器自己编的能力还在**:`deploy/update.sh` 不带 `--prebuilt` 就是在镜像里编(`Dockerfile` 的构建参数 `ARTIFACTS` 默认 `build`),只要有 docker 就能部署,不依赖开发机。
### 部署 runner
和 OJ2 共用开发机上的同一个 runner(host 模式,注册在 git.xuyue.cc 的 `xuyue` 用户下,标签 `oj2-deploy`、`iot-deploy`,一次只跑一个任务,两边的部署排队)。装法、配置文件位置、换机器重新注册见 OJ2 仓库 `docs/deploy.md` 的"部署 runner"。
host 模式直接用开发机自己的东西,所以**不需要配任何 secret**:
- PATH 里的 bun、dotnet 10 SDK、bubblewrap(测试里学生程序在沙箱中运行)、rsync;
- `~/.ssh/config` 里的 `debian`(root@服务器,服务器目录 `/root/IOT_SIM`);
- 能 `git fetch git@git.xuyue.cc:xuyue/IOT_SIM.git`(都不能要密码)。
开发机的 bun 版本和 `Dockerfile` 里镜像的 bun 不一样时,工作流会给出警告(`server.js` 是开发机打包、镜像里的 bun 运行的)。
### 备用:GitHub 手动触发
`.github/workflows/ci.yml` 只在 PR 时跑测试,push 不触发(不然每次推送两边各部署一遍)。开发机不在、又急着上线时,在 github.com 的 Actions → CI/CD → Run workflow:测试通过后 SSH 到服务器执行 `deploy/update-bg.sh <提交号>`(后台跑 `update.sh`),**服务器自己编**。这个提交要已经推到 origin(服务器从 origin 拉代码,没到时脚本会等最多约 100 秒)。
一次性配置(在本机执行,需要已登录的 `gh`):
@@ -97,8 +117,8 @@
在服务器的 `~/IOT_SIM` 目录下:
| 做什么 | 命令 |
| ------------ | --------------------------------------------------------------------------------------- |
| 更新到新版本 | 推送到两个仓库的 main(GitHub 测试通过后自动部署),或在服务器上执行 `deploy/update.sh` |
| ------------ | --------------------------------------------------------------------------------------------------------- |
| 更新到新版本 | `git push origin main`(开发机测试、编好后自动部署),或在服务器上执行 `deploy/update.sh`(服务器自己编) |
| 改老师密码 | 修改 `.env` 后 `docker compose up -d` |
| 学生账号 | 老师登录后在"班级管理"里导入名单、查看和重置密码 |
| 看日志 | `docker compose logs -f app` |
@@ -106,6 +126,38 @@
| 备份数据 | `deploy/backup.sh`,见下文"备份与恢复" |
| 重启 / 停止 | `docker compose restart` / `docker compose down` |
## 导出 EXE、VS 工程用的教材类库
实验页的"导出 EXE"把学生的程序编译成 Windows 程序,嵌入教材的**真机版**设备类库,在机房电脑上双击就能控制真的实训台;"导出 VS 工程"把这些 dll 放在工程的 lib 文件夹里。这些 dll 是教材配套资料,有版权,不进仓库(GitHub 上的仓库是公开的),要单独拷到服务器的 `~/IOT_SIM/textbook-libs/`(compose 把它只读挂进容器)。没放时导出会提示"服务器上没有放教材的设备类库",其他功能不受影响。
放进同一个目录(不分子目录)的文件,都在教材配套资料里:
| 文件 | 在配套资料里的位置 |
| ------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------- |
| `DeviceCtrlLibrary.dll` | `公共库/设备控制类库/设备控制/`(**不是** `仿真/`) |
| `ComLibrary.dll`、`ICS.Acquisition.dll`、`ICS.Common.dll`、`ICS.Models.dll`、`IPCameraDll.dll`、`LEDLibrary.dll`、`MyCamer.dll` | `公共库/设备控制类库/设备控制/API/` |
| `WinFormsDeviceControlLibrary.dll` | `公共库/设备控件库/` |
| `AxInterop.WMPLib.dll`、`Interop.WMPLib.dll`(媒体播放器,项目 7 任务 2) | `项目7/任务二/寝室情景模式/寝室情景模式/bin/Debug/` |
在本机(配套资料解压在 `~/下载/物联网编程与应用配套/`)执行:
```bash
B=~/下载/物联网编程与应用配套/物联网编程与应用
ssh 服务器 mkdir -p IOT_SIM/textbook-libs
scp "$B"/公共库/设备控制类库/设备控制/DeviceCtrlLibrary.dll "$B"/公共库/设备控制类库/设备控制/API/*.dll \
"$B"/公共库/设备控件库/WinFormsDeviceControlLibrary.dll \
"$B"/项目7/任务二/寝室情景模式/寝室情景模式/bin/Debug/*Interop.WMPLib.dll 服务器:IOT_SIM/textbook-libs/
```
容器里的平台用普通用户运行,目录和文件要让其他用户能读(用 root 拷的文件常常是 `-rw-------`,导出时会提示"权限不够"):
```bash
chmod 755 ~/IOT_SIM/textbook-libs
chmod 644 ~/IOT_SIM/textbook-libs/*.dll
```
拷好后不用重启,下一次导出就能用。本机开发时同样放在仓库根目录的 `textbook-libs/`(已在 .gitignore 里),也可以用环境变量 `SIM_TEXTBOOK_LIBS` 指到别处。
## 备份与恢复
平台不自动备份,在服务器的 `~/IOT_SIM` 下手动执行:
+63
View File
@@ -0,0 +1,63 @@
#!/usr/bin/env bash
# 在服务器上执行:后台跑 deploy/update.sh,前台跟着输出,退出码和 update.sh 一样。
# 用法:deploy/update-bg.sh <提交号> [--prebuilt] 开始部署这个提交并跟着看
# deploy/update-bg.sh --follow <提交号> 接着看这个提交的部署(SSH 断线重连后用);
# 这个提交的部署还没开始过时退出码 75
#
# 为什么要后台跑:以前 update.sh 直接跑在 SSH 会话里,SSH 一断(开发机网络切换、代理重启)就跟着被杀。
# 2026-11-03 断在 docker compose up 中间:旧容器删了、新容器只创建没启动,网站停了 40 分钟。
# 现在 update.sh 在新会话里跑(setsid),SSH 断了它照样跑完,开发机重连后用 --follow 拿结果。
set -euo pipefail
# 包在函数里:update.sh 会 git reset,运行中替换本文件,bash 先读完函数再执行,不受影响
main() {
cd "$(dirname "$0")/.."
local state=/var/tmp/iot_sim-update
# state.sha:这次部署的提交 state.pid:后台进程 state.log:输出 state.exit:update.sh 的退出码(跑完才有)
if [[ ${1:-} == --follow ]]; then
local sha=${2:?用法:deploy/update-bg.sh --follow <提交号>}
if [[ $(cat $state.sha 2>/dev/null || true) != "$sha" ]]; then
echo "服务器上没有 $sha 的部署记录(还没开始)"
exit 75
fi
follow
fi
local sha=${1:?用法:deploy/update-bg.sh <提交号> [--prebuilt]}
# 上一次还在跑(比如它的 SSH 断了、又推了新的)就等它跑完,两次 compose up 不能同时跑
local old
old=$(cat $state.pid 2>/dev/null || true)
if [[ -n $old ]] && kill -0 "$old" 2>/dev/null; then
echo "== 上一次部署($(cat $state.sha 2>/dev/null))还在跑,等它结束"
while kill -0 "$old" 2>/dev/null; do sleep 2; done
fi
rm -f $state.pid $state.exit
echo "$sha" >$state.sha
# 后台进程自己写 pid($$),保证记下的就是会写退出码的那个进程
setsid bash -c 'echo $$ >"$0.pid"; deploy/update.sh "$@"; echo $? >"$0.exit"' \
"$state" "$@" >$state.log 2>&1 </dev/null &
for _ in $(seq 50); do [[ -s $state.pid ]] && break; sleep 0.1; done
follow
}
follow() {
local pid code
pid=$(cat $state.pid 2>/dev/null || true)
if [[ -n $pid ]]; then
# update 进程结束后 tail 自己退出
tail -n +1 -f --pid="$pid" $state.log
else
cat $state.log 2>/dev/null || true
fi
code=$(cat $state.exit 2>/dev/null || true)
if [[ -z $code ]]; then
echo "== 部署进程意外结束,没有留下退出码,看看 docker compose ps"
exit 1
fi
exit "$code"
}
main "$@"
+37 -5
View File
@@ -1,14 +1,27 @@
#!/usr/bin/env bash
# 在服务器上执行:拉取代码、构建镜像、重启,等到编译服务就绪才算成功。
# GitHub Actions 测试通过后通过 SSH 调用它,也可以登录服务器手动执行。
# 用法:deploy/update.sh [提交号,默认 origin/main]
# 用法:deploy/update.sh [提交号,默认 origin/main] [--prebuilt]
#
# 不带 --prebuilt:服务器自己编(只要有 docker 就能跑,不依赖开发机;2 核编一次约一分钟,期间学生程序会卡)。
# GitHub 上手动触发的备用部署、登录服务器手动部署都走这条。
# --prebuilt: 用 prebuilt/ 里现成的产物,镜像构建只剩几条 COPY。push origin 后开发机上的 gitea-runner
# 编好、测过、rsync 过来再调用(.gitea/workflows/deploy.yml)。prebuilt/COMMIT 必须就是要部署的提交,
# 对不上说明产物不是这次编的,直接失败,不会把旧产物配新代码。
#
# 第一次部署前,按 deploy/README.md 在服务器上克隆仓库、准备好 .env
set -euo pipefail
# 整个脚本包在函数里:git reset 会在运行中替换本文件,bash 先读完函数再执行,不受影响
main() {
cd "$(dirname "$0")/.."
local ref=${1:-origin/main}
local ref=origin/main prebuilt=0 arg
for arg in "$@"; do
case "$arg" in
--prebuilt) prebuilt=1 ;;
-*) echo "未知参数:$arg(可用:--prebuilt)" >&2; exit 2 ;;
*) ref=$arg ;;
esac
done
if [[ ! -f .env ]]; then
echo '还没有 .env:cp .env.example .env 后设置 TEACHER_PASSWORD'
@@ -16,8 +29,7 @@ main() {
fi
echo "== 拉取代码"
# 代码从 origin(git.xuyue.cc)拉取。Actions 由推送到 GitHub 触发,
# 两个仓库推送有先后,这个提交可能还没到 origin,多等一会儿
# 代码从 origin(git.xuyue.cc)拉取。GitHub 上手动触发时,这个提交可能还没推到 origin,多等一会儿
for attempt in $(seq 10); do
git fetch --quiet origin
if git rev-parse --quiet --verify "$ref^{commit}" >/dev/null; then break; fi
@@ -33,7 +45,27 @@ main() {
git reset --quiet --hard "$ref"
git log --oneline -1
if [[ $prebuilt == 1 ]]; then
local head built
head=$(git rev-parse HEAD)
built=$(cat prebuilt/COMMIT 2>/dev/null || true)
if [[ $built != "$head" ]]; then
echo "prebuilt/ 里的产物是 ${built:-(没有)} 编的,要部署的是 $head。"
echo '重新跑一次开发机上的部署,或者去掉 --prebuilt 让服务器自己编'
exit 1
fi
for f in prebuilt/runtime/Sim.Runner.dll prebuilt/web/index.html prebuilt/server.js; do
[[ -e $f ]] || { echo "prebuilt/ 不完整,缺 $f"; exit 1; }
done
export ARTIFACTS=prebuilt
echo "== 用开发机编好的产物($(du -sh prebuilt | cut -f1))"
else
export ARTIFACTS=build
fi
echo "== 构建镜像并重启"
# caddy 用的是现成镜像,up 不会去拉新版,不拉的话永远是第一次部署时那份
docker compose pull --quiet caddy
docker compose up -d --build --remove-orphans
docker image prune -f >/dev/null
+6 -4
View File
@@ -58,7 +58,7 @@
**教材要求的运行能力**:多窗体(`Show`、`Hide`、`Close`,项目 1 的登录程序就用到);控制台输出面板(`Console.WriteLine`);虚拟 COM 口(`SerialPort.GetPortNames()` 返回 COM2–COM5,对应实训台的四台设备,保留"选串口、点打开"的操作步骤;串口要选对,见"设备类库设计"的接线)。
**导入与导出 VS 工程**:设计器能读懂 VS 生成的 `Designer.cs`,教材配套的 19 个参考工程可直接导入,作为初始模板和参考答案;学生作品也能导出为 VS 工程,在机房真机上运行。
**导入 VS 工程、导出 EXE 和 VS 工程**:设计器能读懂 VS 生成的 `Designer.cs`,教材配套的 19 个参考工程可直接导入,作为初始模板和参考答案。学生作品能导出成一个 exe(实验页工具栏"导出 EXE"),在机房电脑上双击运行,控制真的实训台:服务器照教材工程(.NET Framework 4.5 窗体应用、真机版设备类库)编译学生的原始代码,教材 dll 嵌在 exe 里,不用另外拷文件;也能导出成教材那样的 VS 2012 工程(zip),在机房的 VS 里接着改。
## 设备类库设计(兼容教材)
@@ -155,7 +155,7 @@ IOT/
网站主体用 TypeScript;只有编译和运行学生 C# 代码的部分用 .NET。标"计划"的尚未引入。
| 层 | 用途 | 选择 |
| --------- | ------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| --------- | ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 前端 | 框架 | Vue 3 + TypeScript + Vite |
| 前端 | 状态管理 / 路由 | 计划:Pinia / Vue Router |
| 前端 | 组件库(网站外壳) | Naive UI |
@@ -183,7 +183,7 @@ IOT/
| 部署 | 沙箱 | bubblewrap:每次运行一个独立命名空间(断网、只读、看不到其他进程),加 .NET 堆上限、CPU 时间上限 |
| 部署 | HTTPS | Nginx Proxy Manager(服务器上已有),经外部网络 `npm_proxy` 转发 |
| 部署 | 反向代理 / 压缩 | Caddy(只收 HTTP),网页文件由 Bun 直接提供 |
| 部署 | 进程管理 | Docker Compose;GitHub Actions 测试通过后 SSH 到服务器,服务器从 git.xuyue.cc 拉代码并构建(`deploy/update.sh`)。不从国外传镜像:跨境上传 130 MB 镜像 40 分钟传不完 |
| 部署 | 进程管理 | Docker Compose;push origin 后开发机上的 gitea-runner 测试、编好产物(运行时 dll、网页、server.js)rsync 到服务器,服务器从 git.xuyue.cc 拉代码、用现成产物构建镜像(`deploy/update.sh --prebuilt`)。服务器只有 2 核,不在上面编;不从国外传镜像:跨境上传 130 MB 镜像 40 分钟传不完 |
| 部署 | 备份 | 计划:定时 `sqlite3 .backup` + 异地拷贝 |
## 安全与服务器资源
@@ -242,7 +242,7 @@ flowchart TB
P1["阶段 1 · MVP(已完成 2026-10-22)<br/>虚拟实训台 ✔、代码编辑器 ✔、窗体设计器 ✔、多窗体 ✔<br/>教材示例:项目 1–3 全部 ✔,项目 4–5 中平台已支持的 3 个任务 ✔<br/>部署(沙箱、Docker Compose、GitHub Actions)✔"]
P2["阶段 2 · 教学闭环(功能已完成,待一个班试用)<br/>账号与班级 ✔、作品保存在服务器 ✔、自动评分 ✔、布置与提交作业 ✔<br/>评阅(看代码、运行、老师评分、评语)✔、成绩表与导出 ✔<br/>一个班试用(2026-10-26 已部署上线,待试用)"]
P3["阶段 3 · 扩展(功能已完成 2026-10-23)<br/>教材项目 4–8:摄像头 ✔、线程 ✔、SQLite 模拟数据库 ✔、虚拟时钟 ✔、图表 ✔<br/>教材 8 个项目共 23 个示例 ✔、任务说明 ✔"]
P4["阶段 4 · 进阶(之后)<br/>校园街景场景、故障注入题库、导出 VS 工程<br/>可能扩展:物联网设备安装与调试课(学生自己接线、配置设备、排故)"]
P4["阶段 4 · 进阶(之后)<br/>校园街景场景、故障注入题库<br/>可能扩展:物联网设备安装与调试课(学生自己接线、配置设备、排故)"]
P0 -->|"关口:链路跑通,延迟和资源占用可接受"| P1
P1 -->|"关口:教材项目 1–3 能在平台上完整做完"| P2
P2 -->|"关口:一个班试用顺利"| P3
@@ -343,6 +343,8 @@ flowchart TB
- 2026-10-28:**运行中改了自动重新运行**。以前改了代码或设计器,运行区的窗体不变,要按 F5 才看得到。现在程序运行时(`WorkbenchPage.vue`)每次修改后等 1.5 秒没有新的改动,先用语言服务检查整个工程(和边写边检查同一个接口,不生成程序集):没有错误就重新编译运行;有错误就不重启,原来的程序接着运行,工具栏提示"代码有错误,改好后自动重新运行"。检查期间又改了、程序已经停了或换了工程,这次作废。只在运行中触发,停止后改动不会自动启动程序。
- 2026-10-28:**实训台接线**。讨论"ADAM 模块和传感器的虚拟连线"后决定:本课学的是 C# 界面和事件,学生不自己接线(那是安装调试课的内容,见路线图第 4 阶段),只做两件事。①**串口要选对**:以前选哪个 COM 口都能控制设备,现在按接线判断(接线随实验,项目 7 把 4150 并到 COM2),选错了返回值照反编译的真机版 dll,设备没反应;C# 端 `TrainingBoard.Reaches` 判断,`Open` / `Close` 报告给服务器(`adam4150/open` 等,没变不发),`BoardState.opened` 记下程序打开的串口,实训台、评分说明据此提示打错的串口。打开实验时浏览器发 `experiment` 消息,服务器按示例的 `meta.json` 换接线;老师看学生提交时也按作业的实验接线。②**只读接线展示**:鼠标移到实训台的设备上,标题栏写出线路、对应通道和串口高亮;串口服务器按接线列出每个口接的设备,程序打开的口亮绿点;程序里串口下拉框的灰字也按当前接线。顺带把"4017"统一写成 ADAM-4017+(代码里的 `adam4017Helper` 是教材 dll 的名字,不改)。另外核对真机版发现:4150 串口没打开时真机版 `SetData()` 返回 null(书上代码会出空引用异常),而书上照着写的仿真版返回全 false;平台保持全 false,改为在实训台和评分说明里提示"串口没有打开,程序却在读写它"(`adam4150/unopened` 等,每次打开后最多报一次)。
- 2026-10-28:**上课前检查 8 个项目**。23 个实验逐个在浏览器里照任务说明操作、"自己检查",全部通过。修了:①日期选择框在机房 Chrome 94 上点了没反应(项目 6 任务 3、项目 8 任务 5 选不了日期):以前靠 `showPicker()` 弹出,它 Chrome 99 才正式有,Chrome 94 的原型上有这个方法、调用却没反应;改成把浏览器的日期框透明地盖在下拉按钮上、日历图标撑满按钮,点按钮由浏览器自己弹出(Chromium 94 和新版 Chrome 实测都能选日期)。②运行时的长日期多了星期(2026年10月28日星期三,框窄被截掉):Linux 的 zh-CN 长日期带星期,中文 Windows 不带,运行器改成 Windows 的写法。③评分漏洞:项目 3 任务 1、项目 4 任务 3 的"能关闭"和项目 7 任务 2 的"熄灯模式关灯",程序什么都不做也能通过,补上先确认灯亮过。④串口红字提示挪到实训台最上方(原来在底部,要往下滚)。⑤评分说明的空格("期望 6 秒内""照明灯 1 打开")。⑥任务说明按实测补充:项目 2 任务 1(事件窗口叫"单击",先复位清 LED 屏)、项目 2 任务 2 和项目 3 任务 2 的控件表、项目 2 任务 3 的 `ledHelper.Close()`、项目 3 任务 2 的"计时到"、项目 4 任务 2(IP 不能空、可以放大)、项目 7 任务 1 第 5 步、项目 7 任务 3 的预约步骤和开关第一次点没反应、项目 8 任务 5 先恢复实验数据。没改的平台限制:`Control.Focus()` 不移动焦点,密码框显示圆点不是星号。
- 2026-11-02:**导出 EXE**。原计划"导出 VS 工程",讨论后改成直接导出一个 exe,学生下载后在机房电脑上双击就能控制真的实训台,更方便。①编译:编译服务收到带 `export` 的请求时(`runtime/Sim.Runner/ExeExporter.cs`),用 Roslyn 照教材工程编译学生写的原始代码:.NET Framework 4.5 的引用程序集(NuGet 包 `Microsoft.NETFramework.ReferenceAssemblies.net45`,构建时只复制教材工程引用的 13 个到 `netfx/`,9 MB),引用真机版设备类库;平台为仿真做的改写(虚拟时钟、旧线程 API、Windows 路径)和 API 限制都不用,和在 VS 里编译一样。窗体应用、AnyCPU 优先 32 位(和教材工程一致,教材 dll 也都是 AnyCPU),带默认清单和版本信息。②教材 dll 嵌进 exe:真机版 `DeviceCtrlLibrary.dll` 和它 `API/` 下的 7 个 dll、`WinFormsDeviceControlLibrary.dll`、媒体播放器的两个 Interop dll 都是托管程序集,作为资源嵌入;加进 exe 的模块初始化器在 Main 之前注册 `AssemblyResolve`,用到时从 exe 里加载,学生的 `Program.cs` 不用改。③资源:平台生成的 `Properties.Resources` 改成从 exe 里取图片、声音;代码里按文件名用到的素材(项目 7 任务 2 的 `Resources//ring.mp3`)启动时放到 exe 旁边的 Resources 文件夹,和 VS 输出目录一样;媒体播放器的 `OcxState` 用教材 Form1.resx 里的默认状态。④书上项目 2 任务 3 的 `ledHelper.Close()` 在教材类库里没有(真机版 LED 每次发送才用串口),平台补过这个方法,导出时也补一个什么都不做的扩展方法。23 个实验全部能导出,约 0.9 MB;在 mono + Xvfb 里实测能启动,教材控件库的灯泡控件、开关图片都显示出来;真机还要在机房电脑上实测。⑤教材 dll 有版权,GitHub 仓库是公开的,不进仓库:服务器上放在 `~/IOT_SIM/textbook-libs/`,compose 只读挂进容器(`deploy/README.md`),没放时导出提示原因。⑥接口 `POST /api/export-exe`:成功返回 exe(文件名取工程的命名空间,如"教室情景模式.exe"),代码有错误时 422 和报错,实验页弹框列出前 20 条,点击跳到出错的代码。注意:项目 6、8 导出的 exe 按学生写的连接字符串连真的 SQL Server,机房电脑上要有对应的库;exe 没有签名,从网上下载后第一次运行 Windows 可能提示"已保护你的电脑",点"更多信息"→"仍要运行"。
- 2026-11-03:**导出 VS 工程**。实验页工具栏的"导出 EXE"改成下拉按钮"导出 ▾",里面是"导出 EXE"和"导出 VS 工程"。讨论后定下:机房装的是 VS 2012;代码有错不让导出(和 EXE 一样);这次不做把工程导回平台。①检查:编译服务收到 `export.kind = "project"` 的请求时(`runtime/Sim.Runner/ProjectExporter.cs`),照机房的环境编译一遍要放进 zip 的代码:**C# 5**(VS 2012 的编译器)、.NET Framework 4.5、真机版设备类库。新版 C# 的写法报 CS8026,`CompileHints` 按报错里的功能名给出改法(字符串插值改 `string.Format`、`?.` 改先判断 null、`=>` 改大括号等 12 种)。导出 EXE 仍按最新的 C# 编译,不受影响。23 个示例都是 C# 5 的写法,全部能导出。②工程照教材的样子(VS 2012 窗体应用,`.sln` + `.csproj`,ToolsVersion 4.0):平台生成的 `Properties/Resources.Designer.cs` 从素材库取图,导出时换成 VS 的 ResXFileCodeGenerator 生成的写法和 `Resources.resx`(ResXFileRef 指向 `Resources\` 下的图片);代码里按文件名用到的素材(ring.mp3)设成复制到输出目录,运行时在 `bin\Debug\Resources\`,和教材一样;每个窗体、用户控件带 `.resx`(媒体播放器的 OcxState 用教材的默认状态),`SubType`、`DependentUpon` 和 VS 一样,VS 里能打开设计器;补上 AssemblyInfo、Settings、App.config。③引用和教材工程一样只加代码直接用到的教材 dll(一般是 DeviceCtrlLibrary 和 WinFormsDeviceControlLibrary,项目 7 寝室加媒体播放器的两个 Interop),全部教材 dll 放在工程的 `lib\`,HintPath 指过去,编译时 VS 把依赖一起复制到 `bin\Debug`;只带这些引用编译不过时引用全部。书上的 `ledHelper.Close()` 用到时带上 `LedHelperCompat.cs`。④文本文件用带 BOM 的 UTF-8 和 CRLF(VS 2012 打开不带 BOM 的文件会把中文当 GBK);zip 的文件名用 GBK(中文 Windows 资源管理器自带的解压、老的压缩软件都按 GBK 读,UTF-8 在 Windows 7 上会乱码),另附 Info-ZIP 的 Unicode 文件名字段(0x7075),7-Zip、Linux 的 unzip 在别的语言的系统上也显示正确(`ZipWriter.cs`)。⑤23 个示例导出的工程在 mono 的 msbuild 里全部编译通过,输出目录和教材工程的 `bin\Debug` 一样,程序能启动;VS 2012 还要在机房电脑上实测。接口 `POST /api/export-project`,返回 zip,代码有错时 422,弹框说明"照机房电脑上的 VS 2012 编译"。
## 技术风险与待确认事项
+26
View File
@@ -1,3 +1,4 @@
using System.Linq;
using Microsoft.CodeAnalysis;
namespace Sim.Runner;
@@ -23,7 +24,32 @@ internal static class CompileHints
("CS1002", _) => "C# 每条语句末尾要有英文分号 ;",
("CS1056", _) or ("CS1525", _) when source.Length == 1 && source[0] > 127 => "这里出现了中文标点,C# 代码里的符号要用英文半角。",
("CS0029", _) => "等号两边的类型不一致。如果要把文本框里的文字变成数字,可以用 int.Parse(...) 或 Convert.ToInt32(...)。",
("CS8026", _) => Vs2012(diagnostic),
_ => null,
};
}
/// <summary>导出工程时照机房的 VS 2012 只能用 C# 5,新版 C# 的写法(报错里给出功能名)说明怎么改</summary>
private static readonly (string Feature, string Fix)[] NewerFeatures =
{
("内插字符串", "$\"…{x}…\" 改成 string.Format(\"…{0}…\", x),或者用 + 把字符串连起来"),
("空传播运算符", "a?.b 改成先判断是否为 null,例如 a != null ? a.b : null"),
("nameof", "nameof(x) 直接写成字符串 \"x\""),
("expression-bodied", "=> 改成大括号,方法写成 { return …; },属性写成 { get { return …; } }"),
("自动属性初始值", "属性后面的 = 初始值 去掉,改到构造函数里赋值"),
("化出变量", "out var x 改成先声明变量(如 int x;),再写 out x"),
("字典初始值", "[\"键\"] = 值 改成 { \"键\", 值 }"),
("模式匹配", "x is 类型 y 改成先用 as 转换,再判断是否为 null"),
("本地函数", "方法里的方法挪到类里,写成一个 private 方法"),
("default 字面量", "default 写成 default(类型),例如 default(int)"),
("元组", "改成定义一个类,或者用 out 参数返回多个值"),
("异常筛选器", "catch (…) when (条件) 改成在 catch 里用 if 判断"),
};
private static string Vs2012(Diagnostic diagnostic)
{
var message = diagnostic.GetMessage(System.Globalization.CultureInfo.GetCultureInfo("zh-Hans"));
var fix = NewerFeatures.FirstOrDefault(f => message.Contains(f.Feature)).Fix;
return "机房的 VS 2012 只支持 C# 5,不认这种写法" + (fix == null ? ",换成老的写法。" : $":{fix}。");
}
}
+40 -1
View File
@@ -11,12 +11,16 @@ namespace Sim.Runner;
/// <summary>
/// 常驻编译服务:从标准输入读取 {"id":…,"dir":…},编译该目录下的学生工程,
/// 成功时把 StudentApp.dll / StudentApp.pdb 写到同一目录,回复 {"id":…,"type":"compiled",…}。
/// 请求带 "export":{"libs":…,"name":…} 时导出 EXE(见 ExeExporter),写到同一目录的 export.exe;
/// 带 "export":{"kind":"project",…} 时导出 VS 工程(见 ProjectExporter),写到同一目录的 export.zip。
/// 编译器常驻,第一次之后的编译不再有加载和预热开销。
/// </summary>
internal static class CompileServer
{
public const string AssemblyFile = "StudentApp.dll";
public const string PdbFile = "StudentApp.pdb";
public const string ExportFile = "export.exe";
public const string ExportProjectFile = "export.zip";
private static readonly JsonSerializerOptions JsonOptions = new() { Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping };
@@ -36,7 +40,10 @@ internal static class CompileServer
{
var request = JsonNode.Parse(line)!.AsObject();
id = request["id"]?.DeepClone();
reply = Compile((string?)request["dir"] ?? throw new ArgumentException("缺少 dir"));
var dir = (string?)request["dir"] ?? throw new ArgumentException("缺少 dir");
reply = request["export"] is JsonObject export
? Export(dir, (string?)export["kind"] == "project", (string?)export["libs"] ?? "", (string?)export["name"] ?? "")
: Compile(dir);
}
catch (Exception ex)
{
@@ -66,6 +73,38 @@ internal static class CompileServer
};
}
/// <summary>
/// 导出 EXE:照教材工程编译成 .NET Framework 程序,嵌入 libs 目录下的教材 dll,成功时写到工程目录的 ExportFile;
/// 导出工程(project):照机房的 VS 2012 检查后打包成 VS 工程,成功时写到 ExportProjectFile。
/// name 是程序集名(exe 的名字、工程名),由服务器按工程取好。
/// </summary>
private static JsonObject Export(string dir, bool project, string libs, string name)
{
var watch = Stopwatch.StartNew();
if (string.IsNullOrWhiteSpace(name)) name = "上位机程序";
bool ok;
JsonArray diagnostics;
if (project)
{
var result = ProjectExporter.Export(dir, libs, Sim.Host.ProjectResources.Directory, name);
if (result.Success) File.WriteAllBytes(Path.Combine(dir, ExportProjectFile), result.Zip);
(ok, diagnostics) = (result.Success, result.Diagnostics);
}
else
{
var result = ExeExporter.Export(dir, libs, Sim.Host.ProjectResources.Directory, name);
if (result.Success) File.WriteAllBytes(Path.Combine(dir, ExportFile), result.Exe);
(ok, diagnostics) = (result.Success, result.Diagnostics);
}
return new JsonObject
{
["type"] = "compiled",
["ok"] = ok,
["diagnostics"] = diagnostics,
["ms"] = watch.ElapsedMilliseconds,
};
}
/// <summary>启动时编译一个小程序,让 Roslyn 完成加载和 JIT,学生第一次点"运行"时就不用等</summary>
private static void WarmUp()
{
+313
View File
@@ -0,0 +1,313 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Text.Json.Nodes;
using System.Text.RegularExpressions;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Emit;
using Microsoft.CodeAnalysis.Text;
namespace Sim.Runner;
/// <summary>
/// 导出 EXE:把学生工程照教材工程的样子(.NET Framework 4.5 窗体应用,引用真机版设备类库)编译成一个 exe,
/// 在机房的 Windows 上双击就能运行,控制真的实训台。
/// <list type="bullet">
/// <item>编译的是学生写的原始代码:平台为仿真做的改写(虚拟时钟、旧线程 API、Windows 路径)和 API 限制都不用,和在 VS 里编译一样。</item>
/// <item>教材 dll(设备类库、设备控件库、媒体播放器的 Interop)嵌进 exe,启动时由模块初始化器从 exe 里加载,学生的 Program.cs 不用改。</item>
/// <item>Properties.Resources 的图片、声音嵌进 exe;代码里按文件名用到的素材(如 Resources\ring.mp3)启动时放到 exe 旁边的 Resources 文件夹,和 VS 输出目录一样。</item>
/// </list>
/// </summary>
internal static class ExeExporter
{
public sealed record Result(bool Success, byte[] Exe, JsonArray Diagnostics);
private const string LibPrefix = "IotSimExport.lib.";
private const string FilePrefix = "IotSimExport.file.";
private const string MediaPrefix = "IotSimExport.media.";
/// <summary>
/// VS 设计器给媒体播放器存的 OcxState(AxHost.State 的 BinaryFormatter 序列化数据,取自教材项目 7 的 Form1.resx,是控件的默认状态)。
/// 平台没有 .resx,导出时给每个 resources.GetObject("…OcxState") 都放这一份。
/// </summary>
internal const string MediaPlayerOcxState =
"AAEAAAD/////AQAAAAAAAAAMAgAAAFdTeXN0ZW0uV2luZG93cy5Gb3JtcywgVmVyc2lvbj00LjAuMC4w" +
"LCBDdWx0dXJlPW5ldXRyYWwsIFB1YmxpY0tleVRva2VuPWI3N2E1YzU2MTkzNGUwODkFAQAAACFTeXN0" +
"ZW0uV2luZG93cy5Gb3Jtcy5BeEhvc3QrU3RhdGUBAAAABERhdGEHAgIAAAAJAwAAAA8DAAAAtwAAAAIB" +
"AAAAAQAAAAAAAAAAAAAAAKIAAAAAAwAACAAAAAAABQAAAAAAAADwPwMAAAAAAAUAAAAAAAAAAAAIAAIA" +
"AAAAAAMAAQAAAAsA//8DAAAAAAALAP//CAACAAAAAAADADIAAAALAAAACAAKAAAAZgB1AGwAbAAAAAsA" +
"AAALAAAACwD//wsA//8LAAAACAACAAAAAAAIAAIAAAAAAAgAAgAAAAAACAACAAAAAAALAAAANAYAAOcB" +
"AAAL";
private const string AxHostStateType =
"System.Windows.Forms.AxHost+State, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089";
/// <summary>平台生成的 Properties/Resources.Designer.cs 从素材库取图片,导出时改成从 exe 里取</summary>
private const string PlatformResources = "global::Sim.Host.ProjectResources.";
private const string EmbeddedResources = "global::IotSimExport.EmbeddedFiles.";
private static readonly Regex ResourceFile = new(@"ProjectResources\.(?:Image|Bytes)\(""([^""]+)""\)");
internal static readonly Regex OcxStateKey = new(@"GetObject\(""([^""]+\.OcxState)""\)");
internal static readonly CSharpCompilationOptions Options = new CSharpCompilationOptions(OutputKind.WindowsApplication)
.WithPlatform(Platform.AnyCpu32BitPreferred)
.WithOptimizationLevel(OptimizationLevel.Release)
.WithNullableContextOptions(NullableContextOptions.Disable)
.WithAllowUnsafe(false)
.WithConcurrentBuild(true);
/// <summary>.NET Framework 4.5 的引用程序集(构建时复制到 netfx/,见 Sim.Runner.csproj)</summary>
internal static readonly Lazy<IReadOnlyList<MetadataReference>> Framework = new(() =>
Directory.EnumerateFiles(Path.Combine(AppContext.BaseDirectory, "netfx"), "*.dll")
.Select(path => (MetadataReference)MetadataReference.CreateFromFile(path))
.ToList());
/// <summary>libsDir:教材 dll 所在目录;galleryDir:平台素材库</summary>
public static Result Export(string projectDir, string libsDir, string galleryDir, string assemblyName)
{
var libs = TextbookLibs(libsDir, "导出 EXE");
var sources = StudentCompiler.SourceFiles(projectDir)
.Select(path => (Path: Path.GetRelativePath(projectDir, path), Text: File.ReadAllText(path)))
.ToList();
// 平台生成的资源类改成从 exe 里取,用到的素材文件嵌进去
var files = new SortedSet<string>(StringComparer.Ordinal);
for (var i = 0; i < sources.Count; i++)
{
if (!sources[i].Text.Contains(PlatformResources)) continue;
foreach (Match m in ResourceFile.Matches(sources[i].Text)) files.Add(Path.GetFileName(m.Groups[1].Value));
sources[i] = (sources[i].Path, sources[i].Text.Replace(PlatformResources, EmbeddedResources));
}
// 代码里按文件名用到的素材(如 BaseDirectory + "//Resources//ring.mp3"),在 VS 里是复制到输出目录的 Resources 文件夹
var media = ReferencedMedia(galleryDir, sources.Select(s => s.Text));
var trees = sources
.Select(s => CSharpSyntaxTree.ParseText(SourceText.From(s.Text, Encoding.UTF8), StudentCompiler.ParseOptions, path: s.Path))
.Append(CSharpSyntaxTree.ParseText(SourceText.From(Bootstrap(media), Encoding.UTF8), StudentCompiler.ParseOptions, path: "IotSimExport.cs"))
.ToList();
var references = Framework.Value.Concat(libs.Select(p => (MetadataReference)MetadataReference.CreateFromFile(p))).ToList();
var compilation = CSharpCompilation.Create(assemblyName, trees, references, Options);
var resources = new List<ResourceDescription>();
foreach (var lib in libs)
{
var path = lib;
resources.Add(new ResourceDescription(LibPrefix + Path.GetFileName(path), () => File.OpenRead(path), isPublic: false));
}
foreach (var file in files)
{
var path = Path.Combine(galleryDir, file);
if (File.Exists(path)) resources.Add(new ResourceDescription(FilePrefix + file, () => File.OpenRead(path), isPublic: false));
}
foreach (var file in media)
{
var path = Path.Combine(galleryDir, file);
resources.Add(new ResourceDescription(MediaPrefix + file, () => File.OpenRead(path), isPublic: false));
}
resources.AddRange(ControlResources(compilation, sources.SelectMany(s => OcxStateKey.Matches(s.Text).Select(m => m.Groups[1].Value)).ToHashSet()));
using var pe = new MemoryStream();
using var win32 = compilation.CreateDefaultWin32Resources(versionResource: true, noManifest: false, manifestContents: null, iconInIcoFormat: null);
var emit = compilation.Emit(pe, win32Resources: win32, manifestResources: resources,
options: new EmitOptions(debugInformationFormat: DebugInformationFormat.Embedded));
var diagnostics = new JsonArray();
// 和平台运行时一样按编译器给出的顺序:语法错误在前,真正的出错原因一般是第一条
foreach (var diagnostic in emit.Diagnostics.Where(d => d.Severity == DiagnosticSeverity.Error))
{
diagnostics.Add(StudentCompiler.ToJson(diagnostic));
}
return new Result(emit.Success, pe.ToArray(), diagnostics);
}
/// <summary>代码里按文件名用到的素材库文件(如 BaseDirectory + "//Resources//ring.mp3"),按文件名排序</summary>
internal static List<string> ReferencedMedia(string galleryDir, IEnumerable<string> texts)
{
if (!Directory.Exists(galleryDir)) return new List<string>();
var all = texts.ToList();
return Directory.GetFiles(galleryDir)
.Select(Path.GetFileName)
.OfType<string>()
.Where(name => !name.Contains(".2x.") && name != "index.json")
.Where(name => all.Any(text => text.Contains(name + "\"")))
.OrderBy(name => name, StringComparer.Ordinal)
.ToList();
}
/// <summary>
/// 教材 dll 的路径;没放时说明原因(what:"导出 EXE"或"导出工程")。目录或文件读不了时
/// (服务器上拷进去的文件权限是 600,容器里的平台用户读不到)也说明原因,不把英文的系统报错直接给学生看。
/// </summary>
internal static string[] TextbookLibs(string libsDir, string what)
{
var libs = ReadableLibs(libsDir, what);
if (!libs.Any(p => Path.GetFileName(p) == "DeviceCtrlLibrary.dll"))
throw new InvalidOperationException($"服务器上没有放教材的设备类库(真机版 DeviceCtrlLibrary.dll 等),不能{what},请联系老师");
return libs;
}
private static string[] ReadableLibs(string libsDir, string what)
{
if (!Directory.Exists(libsDir)) return Array.Empty<string>();
string? current = null;
try
{
var libs = Directory.GetFiles(libsDir, "*.dll").OrderBy(p => p, StringComparer.Ordinal).ToArray();
foreach (var lib in libs)
{
current = lib;
using var _ = File.OpenRead(lib);
}
return libs;
}
catch (UnauthorizedAccessException)
{
var item = current == null ? "目录" : Path.GetFileName(current);
throw new InvalidOperationException(
$"服务器上的教材设备类库读不了({item} 的权限不够),不能{what},请联系老师(服务器上执行 chmod 755 textbook-libs 和 chmod 644 textbook-libs/*.dll)");
}
}
/// <summary>
/// 每个窗体、控件类的 .resources:VS 生成的 Designer.cs 用 ComponentResourceManager 读窗体的 .resx,
/// 平台没有 .resx。媒体播放器的 OcxState 放默认状态,其他取到 null(和运行时一样)。
/// </summary>
private static List<ResourceDescription> ControlResources(CSharpCompilation compilation, HashSet<string> ocxKeys)
{
var stream = new MemoryStream();
using (var writer = new System.Resources.ResourceWriter(stream))
{
var state = Convert.FromBase64String(MediaPlayerOcxState);
foreach (var key in ocxKeys.OrderBy(k => k, StringComparer.Ordinal)) writer.AddResourceData(key, AxHostStateType, state);
writer.Generate();
}
var data = stream.ToArray();
var control = compilation.GetTypeByMetadataName("System.Windows.Forms.Control");
var resources = new List<ResourceDescription>();
if (control == null) return resources;
var names = new HashSet<string>();
foreach (var tree in compilation.SyntaxTrees)
{
var model = compilation.GetSemanticModel(tree);
foreach (var declaration in tree.GetRoot().DescendantNodes().OfType<ClassDeclarationSyntax>())
{
if (model.GetDeclaredSymbol(declaration) is not { } type) continue;
var derives = false;
for (var t = type.BaseType; t != null && !derives; t = t.BaseType) derives = SymbolEqualityComparer.Default.Equals(t, control);
var name = type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat).Replace("global::", "") + ".resources";
if (derives && names.Add(name)) resources.Add(new ResourceDescription(name, () => new MemoryStream(data), isPublic: true));
}
}
return resources;
}
/// <summary>
/// 加进 exe 的启动代码:模块初始化器在 Main 之前运行(.NET Framework 也支持),
/// 注册 AssemblyResolve,用到教材 dll 时从 exe 里加载;把代码里用到的素材文件放到 exe 旁边。
/// </summary>
private static string Bootstrap(IReadOnlyList<string> media) => $$"""
[assembly: global::System.Runtime.Versioning.TargetFramework(".NETFramework,Version=v4.5", FrameworkDisplayName = ".NET Framework 4.5")]
namespace System.Runtime.CompilerServices
{
[global::System.AttributeUsage(global::System.AttributeTargets.Method, Inherited = false)]
internal sealed class ModuleInitializerAttribute : global::System.Attribute { }
}
/// <summary>
/// 书上项目 2 任务 3 调用 DeviceCtrlHelper.ledHelper.Close(),教材的类库里没有这个方法(LED 屏每次发送才用串口,不占着),
/// 平台补上了它;导出时也补一个,书上的代码照样能编译。放在全局命名空间,不用 using 就能找到;类库自己有 Close 时用类库的。
/// </summary>
internal static class IotSimExportLedCompat
{
internal static void Close(this global::DeviceCtrlLibrary.LEDHelper helper) { }
}
namespace IotSimExport
{
using System;
using System.Collections.Generic;
using System.IO;
using System.Reflection;
internal static class Bootstrap
{
private static readonly Dictionary<string, Assembly> Loaded = new Dictionary<string, Assembly>(StringComparer.OrdinalIgnoreCase);
[global::System.Runtime.CompilerServices.ModuleInitializer]
internal static void Init()
{
AppDomain.CurrentDomain.AssemblyResolve += Resolve;
ExtractMedia();
}
private static Assembly Resolve(object sender, ResolveEventArgs args)
{
var name = new AssemblyName(args.Name).Name;
lock (Loaded)
{
Assembly assembly;
if (Loaded.TryGetValue(name, out assembly)) return assembly;
var bytes = EmbeddedFiles.Read("{{LibPrefix}}" + name + ".dll");
if (bytes == null) return null;
assembly = Assembly.Load(bytes);
Loaded[name] = assembly;
return assembly;
}
}
private static readonly string[] Media = { {{string.Join(", ", media.Select(m => SymbolDisplay.FormatLiteral(m, true)))}} };
private static void ExtractMedia()
{
foreach (var file in Media)
{
try
{
var dir = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Resources");
var path = Path.Combine(dir, file);
if (File.Exists(path)) continue;
Directory.CreateDirectory(dir);
File.WriteAllBytes(path, EmbeddedFiles.Read("{{MediaPrefix}}" + file));
}
catch (Exception)
{
// 文件夹不能写(如放在只读的 U 盘上)时不影响程序运行,只是用不到这个文件
}
}
}
}
internal static class EmbeddedFiles
{
internal static byte[] Read(string name)
{
using (var stream = typeof(EmbeddedFiles).Assembly.GetManifestResourceStream(name))
{
if (stream == null) return null;
var bytes = new byte[stream.Length];
var read = 0;
while (read < bytes.Length) read += stream.Read(bytes, read, bytes.Length - read);
return bytes;
}
}
internal static global::System.Drawing.Bitmap Image(string file)
{
return new global::System.Drawing.Bitmap(new MemoryStream(Bytes(file)));
}
internal static byte[] Bytes(string file)
{
return Read("{{FilePrefix}}" + Path.GetFileName(file));
}
}
}
""";
}
+625
View File
@@ -0,0 +1,625 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Security;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json.Nodes;
using System.Text.RegularExpressions;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Text;
namespace Sim.Runner;
/// <summary>
/// 导出 VS 工程:把学生工程打包成教材那样的 VS 2012 窗体应用工程(zip),在机房电脑上用 VS 打开接着改、按 F5 运行。
/// <list type="bullet">
/// <item>先照机房的环境检查:C# 5(VS 2012 的编译器)、.NET Framework 4.5、真机版设备类库,有错误就不导出。</item>
/// <item>平台生成的 Properties/Resources.Designer.cs 从素材库取图,导出时换成 VS 生成的写法和 Resources.resx,图片放在 Resources 文件夹;
/// 代码里按文件名用到的素材(如 ring.mp3)设成复制到输出目录,运行时在 bin\Debug\Resources 下,和教材一样。</item>
/// <item>教材 dll 放在工程的 lib 文件夹,用到的才加引用(和教材工程一样只引用设备类库、控件库),编译时 VS 把它们和依赖复制到 bin\Debug。</item>
/// <item>文本文件用带 BOM 的 UTF-8 和 CRLF 换行(VS 2012 打开不带 BOM 的文件会把中文当成 GBK)。</item>
/// </list>
/// </summary>
internal static class ProjectExporter
{
public sealed record Result(bool Success, byte[] Zip, JsonArray Diagnostics);
/// <summary>VS 2012 的编译器支持到 C# 5</summary>
internal static readonly CSharpParseOptions Vs2012 = new(LanguageVersion.CSharp5);
private const string PlatformResourcesFile = "Properties/Resources.Designer.cs";
private const string LedCompatFile = "LedHelperCompat.cs";
private const string LedCompatClass = "LedHelperCompat";
private static readonly Regex ResourceMember = new(
@"internal static (System\.Drawing\.Bitmap|byte\[\]) ([\p{L}\p{N}_]+) \{\s*get \{ return global::Sim\.Host\.ProjectResources\.(?:Image|Bytes)\(""([^""]+)""\); \}");
private static readonly Regex ResourcesNamespace = new(@"namespace\s+([\p{L}\p{N}_.]+)\.Properties\b");
private static readonly Regex FirstNamespace = new(@"^\s*namespace\s+([\p{L}\p{N}_.]+)", RegexOptions.Multiline);
/// <summary>VS 2012 窗体应用模板默认的引用(教材工程都有这些)</summary>
private static readonly string[] DefaultReferences =
{
"System", "System.Core", "System.Xml.Linq", "System.Data.DataSetExtensions", "Microsoft.CSharp",
"System.Data", "System.Deployment", "System.Drawing", "System.Windows.Forms", "System.Xml",
};
private const string BitmapType = "System.Drawing.Bitmap, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a";
private const string BytesType = "System.Byte[], mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089";
private sealed record ResourceItem(string Name, bool Image, string File);
private sealed record Source(string Path, string Text);
/// <summary>libsDir:教材 dll 所在目录;galleryDir:平台素材库;name:工程名(也是程序集名、文件夹名)</summary>
public static Result Export(string projectDir, string libsDir, string galleryDir, string name)
{
var libs = ExeExporter.TextbookLibs(libsDir, "导出工程");
var sources = StudentCompiler.SourceFiles(projectDir)
.Select(path => new Source(Path.GetRelativePath(projectDir, path).Replace('\\', '/'), File.ReadAllText(path)))
.ToList();
var platformResources = sources.FirstOrDefault(s => s.Path == PlatformResourcesFile)?.Text;
var items = platformResources == null
? new List<ResourceItem>()
: ResourceMember.Matches(platformResources)
.Select(m => new ResourceItem(m.Groups[2].Value, m.Groups[1].Value != "byte[]", Path.GetFileName(m.Groups[3].Value)))
.ToList();
var rootNamespace = RootNamespace(sources, platformResources, name);
sources.RemoveAll(s => s.Path == PlatformResourcesFile);
// VS 模板里有、平台上没有的文件(Properties 文件夹由平台管理,学生建不了同名文件)
var generated = new List<Source>
{
new("Properties/AssemblyInfo.cs", AssemblyInfo(name)),
new("Properties/Resources.Designer.cs", ResourcesDesigner(rootNamespace, items)),
new("Properties/Settings.Designer.cs", SettingsDesigner(rootNamespace)),
};
var compat = new Source(LedCompatFile, LedCompat);
// 照机房的环境编译一遍:C# 5、.NET Framework 4.5、真机版设备类库
var trees = sources.Concat(generated).Append(compat)
.Select(s => CSharpSyntaxTree.ParseText(SourceText.From(s.Text, Encoding.UTF8), Vs2012, path: s.Path))
.ToList();
var libReferences = libs.ToDictionary(p => (MetadataReference)MetadataReference.CreateFromFile(p), p => p);
var compilation = CSharpCompilation.Create(name, trees, ExeExporter.Framework.Value.Concat(libReferences.Keys), ExeExporter.Options);
using (var pe = new MemoryStream())
{
var emit = compilation.Emit(pe);
var diagnostics = new JsonArray();
foreach (var diagnostic in emit.Diagnostics.Where(d => d.Severity == DiagnosticSeverity.Error))
{
diagnostics.Add(StudentCompiler.ToJson(diagnostic));
}
if (!emit.Success) return new Result(false, Array.Empty<byte>(), diagnostics);
}
var studentTrees = trees.Take(sources.Count).ToList();
var code = sources.Concat(generated).ToList();
if (UsesLedCompat(compilation, studentTrees))
{
code.Add(compat);
studentTrees.Add(trees[^1]);
}
else
{
compilation = compilation.RemoveSyntaxTrees(trees[^1]);
}
// 引用:和教材工程一样,教材 dll 只引用代码里直接用到的(其他的作为依赖由 VS 复制到输出目录),
// 框架引用在 VS 模板默认的之外加上用到的(如图表控件)。只带这些引用编译不过时(用到的类型的基类在别的 dll 里等)引用全部教材 dll
var direct = DirectlyUsedAssemblies(compilation, studentTrees);
var frameworkReferences = DefaultReferences
.Concat(ExeExporter.Framework.Value.OfType<PortableExecutableReference>()
.Select(r => Path.GetFileNameWithoutExtension(r.FilePath)).OfType<string>()
.Where(n => direct.Contains(n) && n != "mscorlib" && !DefaultReferences.Contains(n))
.OrderBy(n => n, StringComparer.Ordinal))
.ToList();
var textbookReferences = libs.Where(p => direct.Contains(Path.GetFileNameWithoutExtension(p))).ToList();
var reduced = compilation.WithReferences(ExeExporter.Framework.Value.Concat(libReferences.Where(r => textbookReferences.Contains(r.Value)).Select(r => r.Key)));
if (reduced.GetDiagnostics().Any(d => d.Severity == DiagnosticSeverity.Error)) textbookReferences = libs.ToList();
textbookReferences = textbookReferences.Select(p => Path.GetFileName(p)).ToList();
var forms = Forms(compilation, sources, studentTrees);
var media = ExeExporter.ReferencedMedia(galleryDir, sources.Select(s => s.Text));
var images = items.Select(i => i.File).Where(f => File.Exists(Path.Combine(galleryDir, f))).Distinct().ToList();
var zip = new ZipWriter(DateTime.UtcNow.AddHours(8));
var root = $"{name}/";
var dir = $"{name}/{name}/";
zip.Add(root + name + ".sln", Text(Solution(name)));
zip.Add(dir + name + ".csproj", Text(Project(name, rootNamespace, code, forms, frameworkReferences, textbookReferences, images, media)));
zip.Add(dir + "App.config", Text(AppConfig));
zip.Add(dir + "Properties/Settings.settings", Text(SettingsFile));
zip.Add(dir + "Properties/Resources.resx", Text(Resx(items.Select(ResourceEntry))));
foreach (var source in code) zip.Add(dir + source.Path, Text(source.Text));
foreach (var form in forms.Where(f => f.Resx))
{
zip.Add(dir + Path.ChangeExtension(form.Path, ".resx"), Text(Resx(form.OcxKeys.Select(OcxEntry))));
}
foreach (var file in images.Union(media).OrderBy(f => f, StringComparer.Ordinal))
{
zip.Add(dir + "Resources/" + file, File.ReadAllBytes(Path.Combine(galleryDir, file)));
}
foreach (var lib in libs) zip.Add(dir + "lib/" + Path.GetFileName(lib), File.ReadAllBytes(lib));
return new Result(true, zip.Finish(), new JsonArray());
}
/// <summary>工程的默认命名空间:平台的资源类所在的命名空间,否则取 Program.cs(再其次其他文件)里的第一个命名空间</summary>
private static string RootNamespace(List<Source> sources, string? platformResources, string name)
{
if (platformResources != null && ResourcesNamespace.Match(platformResources) is { Success: true } r) return r.Groups[1].Value;
foreach (var source in sources.OrderBy(s => s.Path == "Program.cs" ? 0 : 1).ThenBy(s => s.Path, StringComparer.Ordinal))
{
var m = FirstNamespace.Match(source.Text);
if (m.Success) return m.Groups[1].Value;
}
return name;
}
/// <summary>代码里直接用到的类型、成员所在的程序集(程序集名)</summary>
private static HashSet<string> DirectlyUsedAssemblies(CSharpCompilation compilation, List<SyntaxTree> trees)
{
var names = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
void Add(ISymbol? symbol)
{
while (symbol is IArrayTypeSymbol array) symbol = array.ElementType;
if (symbol?.ContainingAssembly is { } assembly) names.Add(assembly.Identity.Name);
if (symbol is INamedTypeSymbol { IsGenericType: true } generic)
{
foreach (var argument in generic.TypeArguments) Add(argument);
}
}
foreach (var tree in trees)
{
var model = compilation.GetSemanticModel(tree);
foreach (var node in tree.GetRoot().DescendantNodes())
{
Add(model.GetSymbolInfo(node).Symbol);
Add(model.GetTypeInfo(node).Type);
}
}
return names;
}
/// <summary>学生代码是否用到了补上的 ledHelper.Close()(教材类库里没有)</summary>
private static bool UsesLedCompat(CSharpCompilation compilation, List<SyntaxTree> trees)
{
foreach (var tree in trees)
{
var model = compilation.GetSemanticModel(tree);
foreach (var invocation in tree.GetRoot().DescendantNodes().OfType<InvocationExpressionSyntax>())
{
if (model.GetSymbolInfo(invocation).Symbol is IMethodSymbol { ReducedFrom.ContainingType.Name: LedCompatClass }) return true;
}
}
return false;
}
/// <summary>窗体、用户控件:VS 里显示成可以打开设计器的文件,Designer.cs 和 .resx 挂在它下面</summary>
private sealed record Form(string Path, string SubType, bool Resx, List<string> OcxKeys);
private static List<Form> Forms(CSharpCompilation compilation, List<Source> sources, List<SyntaxTree> trees)
{
var form = compilation.GetTypeByMetadataName("System.Windows.Forms.Form");
var userControl = compilation.GetTypeByMetadataName("System.Windows.Forms.UserControl");
var result = new List<Form>();
for (var i = 0; i < sources.Count; i++)
{
var path = sources[i].Path;
if (path.EndsWith(".Designer.cs")) continue;
var designer = sources.FirstOrDefault(s => s.Path == path[..^3] + ".Designer.cs");
var model = compilation.GetSemanticModel(trees[i]);
var type = trees[i].GetRoot().DescendantNodes().OfType<ClassDeclarationSyntax>()
.Select(c => model.GetDeclaredSymbol(c)).FirstOrDefault(t => t != null);
string? subType = null;
for (var t = type?.BaseType; t != null && subType == null; t = t.BaseType)
{
if (SymbolEqualityComparer.Default.Equals(t, form)) subType = "Form";
else if (SymbolEqualityComparer.Default.Equals(t, userControl)) subType = "UserControl";
}
if (subType == null) continue;
var keys = designer == null
? new List<string>()
: ExeExporter.OcxStateKey.Matches(designer.Text).Select(m => m.Groups[1].Value).Distinct().OrderBy(k => k, StringComparer.Ordinal).ToList();
result.Add(new Form(path, subType, designer != null, keys));
}
return result;
}
private static string Project(string name, string rootNamespace, List<Source> code, List<Form> forms,
List<string> frameworkReferences, List<string> textbookReferences, List<string> images, List<string> media)
{
static string Win(string path) => path.Replace('/', '\\');
static string X(string text) => SecurityElement.Escape(text);
var s = new StringBuilder();
s.Append($"""
<?xml version="1.0" encoding="utf-8"?>
<Project ToolsVersion="4.0" DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<Import Project="$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props" Condition="Exists('$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props')" />
<PropertyGroup>
<Configuration Condition=" '$(Configuration)' == '' ">Debug</Configuration>
<Platform Condition=" '$(Platform)' == '' ">AnyCPU</Platform>
<ProjectGuid>{ProjectGuid(name)}</ProjectGuid>
<OutputType>WinExe</OutputType>
<AppDesignerFolder>Properties</AppDesignerFolder>
<RootNamespace>{X(rootNamespace)}</RootNamespace>
<AssemblyName>{X(name)}</AssemblyName>
<TargetFrameworkVersion>v4.5</TargetFrameworkVersion>
<FileAlignment>512</FileAlignment>
</PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Debug|AnyCPU' ">
<PlatformTarget>AnyCPU</PlatformTarget>
<DebugSymbols>true</DebugSymbols>
<DebugType>full</DebugType>
<Optimize>false</Optimize>
<OutputPath>bin\Debug\</OutputPath>
<DefineConstants>DEBUG;TRACE</DefineConstants>
<ErrorReport>prompt</ErrorReport>
<WarningLevel>4</WarningLevel>
</PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Release|AnyCPU' ">
<PlatformTarget>AnyCPU</PlatformTarget>
<DebugType>pdbonly</DebugType>
<Optimize>true</Optimize>
<OutputPath>bin\Release\</OutputPath>
<DefineConstants>TRACE</DefineConstants>
<ErrorReport>prompt</ErrorReport>
<WarningLevel>4</WarningLevel>
</PropertyGroup>
<ItemGroup>
""");
foreach (var lib in textbookReferences)
{
s.Append($"""
<Reference Include="{X(Path.GetFileNameWithoutExtension(lib))}">
<HintPath>lib\{X(lib)}</HintPath>
</Reference>
""");
}
foreach (var reference in frameworkReferences) s.Append($" <Reference Include=\"{reference}\" />\n");
s.Append(" </ItemGroup>\n <ItemGroup>\n");
var formPaths = forms.ToDictionary(f => f.Path);
// 和 VS 一样 Form1.cs 在 Form1.Designer.cs 前面
static string Order(string path) => path.EndsWith(".Designer.cs") ? path[..^12] + ".cs\u0001" : path;
foreach (var source in code.Where(c => !c.Path.StartsWith("Properties/")).OrderBy(c => Order(c.Path), StringComparer.Ordinal))
{
var path = source.Path;
if (formPaths.TryGetValue(path, out var form))
{
s.Append($" <Compile Include=\"{X(Win(path))}\">\n <SubType>{form.SubType}</SubType>\n </Compile>\n");
}
else if (path.EndsWith(".Designer.cs") && formPaths.ContainsKey(path[..^12] + ".cs"))
{
s.Append($" <Compile Include=\"{X(Win(path))}\">\n <DependentUpon>{X(Path.GetFileName(path[..^12] + ".cs"))}</DependentUpon>\n </Compile>\n");
}
else
{
s.Append($" <Compile Include=\"{X(Win(path))}\" />\n");
}
}
s.Append(" <Compile Include=\"Properties\\AssemblyInfo.cs\" />\n");
foreach (var form in forms.Where(f => f.Resx).OrderBy(f => f.Path, StringComparer.Ordinal))
{
s.Append($" <EmbeddedResource Include=\"{X(Win(Path.ChangeExtension(form.Path, ".resx")))}\">\n <DependentUpon>{X(Path.GetFileName(form.Path))}</DependentUpon>\n </EmbeddedResource>\n");
}
s.Append("""
<EmbeddedResource Include="Properties\Resources.resx">
<Generator>ResXFileCodeGenerator</Generator>
<LastGenOutput>Resources.Designer.cs</LastGenOutput>
<SubType>Designer</SubType>
</EmbeddedResource>
<Compile Include="Properties\Resources.Designer.cs">
<AutoGen>True</AutoGen>
<DependentUpon>Resources.resx</DependentUpon>
<DesignTime>True</DesignTime>
</Compile>
<None Include="Properties\Settings.settings">
<Generator>SettingsSingleFileGenerator</Generator>
<LastGenOutput>Settings.Designer.cs</LastGenOutput>
</None>
<Compile Include="Properties\Settings.Designer.cs">
<AutoGen>True</AutoGen>
<DependentUpon>Settings.settings</DependentUpon>
<DesignTimeSharedInput>True</DesignTimeSharedInput>
</Compile>
</ItemGroup>
<ItemGroup>
<None Include="App.config" />
</ItemGroup>
""");
var files = images.Union(media).OrderBy(f => f, StringComparer.Ordinal).ToList();
if (files.Count > 0)
{
s.Append(" <ItemGroup>\n");
foreach (var file in files)
{
// 代码里按文件名用的素材复制到 bin\Debug\Resources(和教材工程的输出目录一样);Properties.Resources 的图片编译进程序
s.Append(media.Contains(file)
? $" <Content Include=\"Resources\\{X(file)}\">\n <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>\n </Content>\n"
: $" <None Include=\"Resources\\{X(file)}\" />\n");
}
s.Append(" </ItemGroup>\n");
}
s.Append("""
<Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" />
</Project>
""");
return s.ToString();
}
private static string Solution(string name) => $$"""
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio 2012
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "{{name}}", "{{name}}\{{name}}.csproj", "{{ProjectGuid(name)}}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{{ProjectGuid(name)}}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{{ProjectGuid(name)}}.Debug|Any CPU.Build.0 = Debug|Any CPU
{{ProjectGuid(name)}}.Release|Any CPU.ActiveCfg = Release|Any CPU
{{ProjectGuid(name)}}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
EndGlobal
""";
/// <summary>同一个工程名每次导出的 GUID 相同(重新导出后覆盖旧的,VS 认成同一个工程)</summary>
private static string ProjectGuid(string name) => "{" + NameGuid("project:" + name).ToString().ToUpperInvariant() + "}";
private static Guid NameGuid(string text) => new(MD5.HashData(Encoding.UTF8.GetBytes(text)));
private static string AssemblyInfo(string name) => $$"""
using System.Reflection;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
// 有关程序集的常规信息通过以下
// 特性集控制。更改这些特性值可修改
// 与程序集关联的信息。
[assembly: AssemblyTitle({{SymbolDisplay.FormatLiteral(name, true)}})]
[assembly: AssemblyDescription("")]
[assembly: AssemblyConfiguration("")]
[assembly: AssemblyCompany("")]
[assembly: AssemblyProduct({{SymbolDisplay.FormatLiteral(name, true)}})]
[assembly: AssemblyCopyright("Copyright © {{DateTime.UtcNow.AddHours(8).Year}}")]
[assembly: AssemblyTrademark("")]
[assembly: AssemblyCulture("")]
// 将 ComVisible 设置为 false 使此程序集中的类型
// 对 COM 组件不可见。如果需要从 COM 访问此程序集中的类型,
// 则将该类型上的 ComVisible 特性设置为 true。
[assembly: ComVisible(false)]
// 如果此项目向 COM 公开,则下列 GUID 用于类型库的 ID
[assembly: Guid("{{NameGuid("assembly:" + name)}}")]
// 程序集的版本信息由下面四个值组成:
//
// 主版本
// 次版本
// 生成号
// 修订号
//
// 可以指定所有这些值,也可以使用“生成号”和“修订号”的默认值,
// 方法是按如下所示使用“*”:
// [assembly: AssemblyVersion("1.0.*")]
[assembly: AssemblyVersion("1.0.0.0")]
[assembly: AssemblyFileVersion("1.0.0.0")]
""";
/// <summary>和 VS 2012 的 ResXFileCodeGenerator 生成的一样(中文版 VS 的注释)</summary>
private static string ResourcesDesigner(string ns, List<ResourceItem> items)
{
var members = string.Concat(items.Select(item =>
{
var type = item.Image ? "System.Drawing.Bitmap" : "byte[]";
var what = item.Image ? "System.Drawing.Bitmap" : "System.Byte[]";
return $$"""
/// <summary>
/// 查找 {{what}} 类型的本地化资源。
/// </summary>
internal static {{type}} {{item.Name}} {
get {
object obj = ResourceManager.GetObject("{{item.Name}}", resourceCulture);
return (({{type}})(obj));
}
}
""";
}));
return $$"""
//------------------------------------------------------------------------------
// <auto-generated>
// 此代码由工具生成。
// 运行时版本:4.0.30319.42000
//
// 对此文件的更改可能会导致不正确的行为,并且如果
// 重新生成代码,这些更改将会丢失。
// </auto-generated>
//------------------------------------------------------------------------------
namespace {{ns}}.Properties {
using System;
/// <summary>
/// 一个强类型的资源类,用于查找本地化的字符串等。
/// </summary>
// 此类是由 StronglyTypedResourceBuilder
// 类通过类似于 ResGen 或 Visual Studio 的工具自动生成的。
// 若要添加或移除成员,请编辑 .ResX 文件,然后重新运行 ResGen
// (以 /str 作为命令选项),或重新生成 VS 项目。
[global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "4.0.0.0")]
[global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
[global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()]
internal class Resources {
private static global::System.Resources.ResourceManager resourceMan;
private static global::System.Globalization.CultureInfo resourceCulture;
[global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")]
internal Resources() {
}
/// <summary>
/// 返回此类使用的缓存的 ResourceManager 实例。
/// </summary>
[global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)]
internal static global::System.Resources.ResourceManager ResourceManager {
get {
if (object.ReferenceEquals(resourceMan, null)) {
global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("{{ns}}.Properties.Resources", typeof(Resources).Assembly);
resourceMan = temp;
}
return resourceMan;
}
}
/// <summary>
/// 使用此强类型资源类,为所有资源查找
/// 重写当前线程的 CurrentUICulture 属性。
/// </summary>
[global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)]
internal static global::System.Globalization.CultureInfo Culture {
get {
return resourceCulture;
}
set {
resourceCulture = value;
}
}
{{members}} }
}
""";
}
private static string SettingsDesigner(string ns) => $$"""
//------------------------------------------------------------------------------
// <auto-generated>
// This code was generated by a tool.
// Runtime Version:4.0.30319.42000
//
// Changes to this file may cause incorrect behavior and will be lost if
// the code is regenerated.
// </auto-generated>
//------------------------------------------------------------------------------
namespace {{ns}}.Properties
{
[global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()]
[global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.VisualStudio.Editors.SettingsDesigner.SettingsSingleFileGenerator", "11.0.0.0")]
internal sealed partial class Settings : global::System.Configuration.ApplicationSettingsBase
{
private static Settings defaultInstance = ((Settings)(global::System.Configuration.ApplicationSettingsBase.Synchronized(new Settings())));
public static Settings Default
{
get
{
return defaultInstance;
}
}
}
}
""";
private const string SettingsFile = """
<?xml version='1.0' encoding='utf-8'?>
<SettingsFile xmlns="http://schemas.microsoft.com/VisualStudio/2004/01/settings" CurrentProfile="(Default)">
<Profiles>
<Profile Name="(Default)" />
</Profiles>
<Settings />
</SettingsFile>
""";
private const string AppConfig = """
<?xml version="1.0" encoding="utf-8" ?>
<configuration>
<startup>
<supportedRuntime version="v4.0" sku=".NETFramework,Version=v4.5" />
</startup>
</configuration>
""";
/// <summary>
/// 书上项目 2 任务 3 调用 DeviceCtrlHelper.ledHelper.Close(),教材的类库里没有这个方法(LED 屏每次发送才用串口,不占着),
/// 平台补上了它。用到时导出工程里也带上这个文件,书上的代码照样能编译。
/// </summary>
private const string LedCompat = """
// 教材的设备类库里 LEDHelper 没有 Close 方法(LED 屏每次发送时才打开串口,发完就关),
// 书上的代码调用了 ledHelper.Close(),这里补一个什么都不做的方法,让代码能编译。
internal static class LedHelperCompat
{
internal static void Close(this global::DeviceCtrlLibrary.LEDHelper helper)
{
}
}
""";
private static readonly Lazy<string> ResxHeader = new(() =>
{
using var stream = typeof(ProjectExporter).Assembly.GetManifestResourceStream("ResxHeader.xml")!;
using var reader = new StreamReader(stream, Encoding.UTF8);
return reader.ReadToEnd().TrimEnd('\n');
});
private static string Resx(IEnumerable<string> entries)
{
var list = entries.ToList();
var s = new StringBuilder(ResxHeader.Value).Append('\n');
if (list.Any(e => e.Contains("ResXFileRef")))
{
s.Append(" <assembly alias=\"System.Windows.Forms\" name=\"System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089\" />\n");
}
foreach (var entry in list) s.Append(entry);
return s.Append("</root>").ToString();
}
private static string ResourceEntry(ResourceItem item) =>
$" <data name=\"{SecurityElement.Escape(item.Name)}\" type=\"System.Resources.ResXFileRef, System.Windows.Forms\">\n" +
$" <value>..\\Resources\\{SecurityElement.Escape(item.File)};{(item.Image ? BitmapType : BytesType)}</value>\n" +
" </data>\n";
/// <summary>媒体播放器的 OcxState:教材 Form1.resx 里的默认状态,和 VS 一样每行 80 个字符</summary>
private static string OcxEntry(string key)
{
var data = ExeExporter.MediaPlayerOcxState;
var lines = Enumerable.Range(0, (data.Length + 79) / 80).Select(i => " " + data.Substring(i * 80, Math.Min(80, data.Length - i * 80)));
return $" <data name=\"{SecurityElement.Escape(key)}\" mimetype=\"application/x-microsoft.net.object.binary.base64\">\n" +
" <value>\n" + string.Join("\n", lines) + "\n</value>\n </data>\n";
}
/// <summary>文本文件:带 BOM 的 UTF-8,CRLF 换行</summary>
private static byte[] Text(string text)
{
var crlf = text.Replace("\r\n", "\n").Replace("\n", "\r\n");
return Encoding.UTF8.GetPreamble().Concat(Encoding.UTF8.GetBytes(crlf)).ToArray();
}
}
@@ -0,0 +1,119 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<!--
Microsoft ResX Schema
Version 2.0
The primary goals of this format is to allow a simple XML format
that is mostly human readable. The generation and parsing of the
various data types are done through the TypeConverter classes
associated with the data types.
Example:
... ado.net/XML headers & schema ...
<resheader name="resmimetype">text/microsoft-resx</resheader>
<resheader name="version">2.0</resheader>
<resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader>
<resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader>
<data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data>
<data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data>
<data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64">
<value>[base64 mime encoded serialized .NET Framework object]</value>
</data>
<data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
<value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value>
<comment>This is a comment</comment>
</data>
There are any number of "resheader" rows that contain simple
name/value pairs.
Each data row contains a name, and value. The row also contains a
type or mimetype. Type corresponds to a .NET class that support
text/value conversion through the TypeConverter architecture.
Classes that don't support this are serialized and stored with the
mimetype set.
The mimetype is used for serialized objects, and tells the
ResXResourceReader how to depersist the object. This is currently not
extensible. For a given mimetype the value must be set accordingly:
Note - application/x-microsoft.net.object.binary.base64 is the format
that the ResXResourceWriter will generate, however the reader can
read any of the formats listed below.
mimetype: application/x-microsoft.net.object.binary.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Binary.BinaryFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.soap.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Soap.SoapFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.bytearray.base64
value : The object must be serialized into a byte array
: using a System.ComponentModel.TypeConverter
: and then encoded with base64 encoding.
-->
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
<xsd:element name="root" msdata:IsDataSet="true">
<xsd:complexType>
<xsd:choice maxOccurs="unbounded">
<xsd:element name="metadata">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" />
</xsd:sequence>
<xsd:attribute name="name" use="required" type="xsd:string" />
<xsd:attribute name="type" type="xsd:string" />
<xsd:attribute name="mimetype" type="xsd:string" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="assembly">
<xsd:complexType>
<xsd:attribute name="alias" type="xsd:string" />
<xsd:attribute name="name" type="xsd:string" />
</xsd:complexType>
</xsd:element>
<xsd:element name="data">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="resheader">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" />
</xsd:complexType>
</xsd:element>
</xsd:choice>
</xsd:complexType>
</xsd:element>
</xsd:schema>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>2.0</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
+18
View File
@@ -14,6 +14,24 @@
<!-- 语言服务(代码补全、悬停提示):和 VS 的 IntelliSense 用同一套引擎;只有 language-server 模式会加载 -->
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Features" Version="4.14.0" />
</ItemGroup>
<!-- 导出 EXE:照教材工程(.NET Framework 4.5)编译学生代码,要用 4.5 的引用程序集(只供编译,不含实现)。
只复制教材工程引用的那些(VS 窗体应用模板默认的引用加图表控件),放到输出目录的 netfx/ -->
<ItemGroup>
<PackageDownload Include="Microsoft.NETFramework.ReferenceAssemblies.net45" Version="[1.0.3]" />
</ItemGroup>
<Target Name="CopyNetFxReferences" AfterTargets="Build">
<PropertyGroup>
<NetFxDir>$(NuGetPackageRoot)microsoft.netframework.referenceassemblies.net45/1.0.3/build/.NETFramework/v4.5/</NetFxDir>
</PropertyGroup>
<ItemGroup>
<NetFxReference Include="$(NetFxDir)mscorlib.dll;$(NetFxDir)System.dll;$(NetFxDir)System.Core.dll;$(NetFxDir)System.Data.dll;$(NetFxDir)System.Data.DataSetExtensions.dll;$(NetFxDir)System.Deployment.dll;$(NetFxDir)System.Drawing.dll;$(NetFxDir)System.Net.Http.dll;$(NetFxDir)System.Windows.Forms.dll;$(NetFxDir)System.Windows.Forms.DataVisualization.dll;$(NetFxDir)System.Xml.dll;$(NetFxDir)System.Xml.Linq.dll;$(NetFxDir)Microsoft.CSharp.dll" />
</ItemGroup>
<Copy SourceFiles="@(NetFxReference)" DestinationFiles="@(NetFxReference->'$(OutDir)netfx/%(RecursiveDir)%(Filename)%(Extension)')" SkipUnchangedFiles="true" />
</Target>
<!-- 导出 VS 工程:.resx 文件开头的标准内容(VS 生成的架构说明和 resheader) -->
<ItemGroup>
<EmbeddedResource Include="ProjectTemplate/ResxHeader.xml" LogicalName="ResxHeader.xml" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../Sim.WinForms/Sim.WinForms.csproj" />
<ProjectReference Include="../Sim.Devices/Sim.Devices.csproj" />
+7 -3
View File
@@ -43,9 +43,7 @@ internal static class StudentCompiler
public static Result Compile(string projectDir)
{
var parseOptions = ParseOptions;
var trees = Directory.EnumerateFiles(projectDir, "*.cs", SearchOption.AllDirectories)
.Where(path => !IsBuildOutput(projectDir, path))
.OrderBy(path => path, StringComparer.Ordinal)
var trees = SourceFiles(projectDir)
.Select(path => CSharpSyntaxTree.ParseText(
SourceText.From(File.ReadAllText(path), Encoding.UTF8),
parseOptions,
@@ -123,6 +121,12 @@ internal static class StudentCompiler
return json;
}
/// <summary>学生工程里的源文件(不含 bin、obj 下的),按路径排序</summary>
internal static IEnumerable<string> SourceFiles(string projectDir) =>
Directory.EnumerateFiles(projectDir, "*.cs", SearchOption.AllDirectories)
.Where(path => !IsBuildOutput(projectDir, path))
.OrderBy(path => path, StringComparer.Ordinal);
private static bool IsBuildOutput(string projectDir, string path)
{
var relative = Path.GetRelativePath(projectDir, path).Replace('\\', '/');
+155
View File
@@ -0,0 +1,155 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.IO.Compression;
using System.Text;
namespace Sim.Runner;
/// <summary>
/// 写 zip 文件。文件名照中文 Windows 的习惯用 GBK 编码(资源管理器自带的解压、老版本的压缩软件都按 GBK 读,
/// 用 UTF-8 时 Windows 7 等会显示乱码),另附 Info-ZIP 的 Unicode 文件名扩展字段(0x7075),
/// 7-Zip、Linux 的 unzip 等认这个字段的软件在别的语言的系统上也能显示正确。
/// GBK 表示不了的文件名改用 UTF-8 并设置 UTF-8 标志位。
/// </summary>
internal sealed class ZipWriter
{
private static readonly Encoding Gbk;
private static readonly uint[] CrcTable = new uint[256];
static ZipWriter()
{
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
Gbk = Encoding.GetEncoding(936, EncoderFallback.ExceptionFallback, DecoderFallback.ExceptionFallback);
for (uint n = 0; n < 256; n++)
{
var c = n;
for (var k = 0; k < 8; k++) c = (c & 1) != 0 ? 0xEDB88320 ^ (c >> 1) : c >> 1;
CrcTable[n] = c;
}
}
private sealed record Entry(byte[] Name, byte[] Extra, ushort Flags, ushort Method, uint Crc, uint CompressedSize, uint Size, uint Offset);
private readonly MemoryStream output = new();
private readonly List<Entry> entries = new();
private readonly ushort dosTime;
private readonly ushort dosDate;
/// <param name="time">文件的修改时间(机房电脑上解压后看到的时间)</param>
public ZipWriter(DateTime time)
{
dosTime = (ushort)((time.Hour << 11) | (time.Minute << 5) | (time.Second / 2));
dosDate = (ushort)(((time.Year - 1980) << 9) | (time.Month << 5) | time.Day);
}
/// <summary>添加文件,path 用 / 分隔</summary>
public void Add(string path, byte[] data)
{
byte[] name;
byte[] extra = Array.Empty<byte>();
ushort flags = 0;
try
{
name = Gbk.GetBytes(path);
var utf8 = Encoding.UTF8.GetBytes(path);
if (utf8.Length != name.Length || !utf8.AsSpan().SequenceEqual(name))
{
// Unicode Path Extra Field:版本 1、GBK 文件名的 CRC、UTF-8 文件名
extra = new byte[4 + 1 + 4 + utf8.Length];
BitConverter.TryWriteBytes(extra.AsSpan(0), (ushort)0x7075);
BitConverter.TryWriteBytes(extra.AsSpan(2), (ushort)(1 + 4 + utf8.Length));
extra[4] = 1;
BitConverter.TryWriteBytes(extra.AsSpan(5), Crc32(name));
utf8.CopyTo(extra, 9);
}
}
catch (EncoderFallbackException)
{
name = Encoding.UTF8.GetBytes(path);
flags = 1 << 11;
}
var crc = Crc32(data);
var compressed = Deflate(data);
var method = (ushort)8;
if (compressed.Length >= data.Length)
{
compressed = data;
method = 0;
}
var entry = new Entry(name, extra, flags, method, crc, (uint)compressed.Length, (uint)data.Length, (uint)output.Position);
entries.Add(entry);
using var w = new BinaryWriter(output, Encoding.UTF8, leaveOpen: true);
w.Write(0x04034b50u);
w.Write((ushort)20);
w.Write(entry.Flags);
w.Write(entry.Method);
w.Write(dosTime);
w.Write(dosDate);
w.Write(entry.Crc);
w.Write(entry.CompressedSize);
w.Write(entry.Size);
w.Write((ushort)entry.Name.Length);
w.Write((ushort)entry.Extra.Length);
w.Write(entry.Name);
w.Write(entry.Extra);
w.Write(compressed);
}
/// <summary>写中央目录,返回整个 zip</summary>
public byte[] Finish()
{
var start = (uint)output.Position;
using (var w = new BinaryWriter(output, Encoding.UTF8, leaveOpen: true))
{
foreach (var e in entries)
{
w.Write(0x02014b50u);
w.Write((ushort)20); // 由 MS-DOS 兼容的系统生成:解压时不设置 Unix 权限
w.Write((ushort)20);
w.Write(e.Flags);
w.Write(e.Method);
w.Write(dosTime);
w.Write(dosDate);
w.Write(e.Crc);
w.Write(e.CompressedSize);
w.Write(e.Size);
w.Write((ushort)e.Name.Length);
w.Write((ushort)e.Extra.Length);
w.Write((ushort)0); // 注释长度
w.Write((ushort)0); // 磁盘号
w.Write((ushort)0); // 内部属性
w.Write(0u); // 外部属性
w.Write(e.Offset);
w.Write(e.Name);
w.Write(e.Extra);
}
var size = (uint)output.Position - start;
w.Write(0x06054b50u);
w.Write((ushort)0);
w.Write((ushort)0);
w.Write((ushort)entries.Count);
w.Write((ushort)entries.Count);
w.Write(size);
w.Write(start);
w.Write((ushort)0);
}
return output.ToArray();
}
private static byte[] Deflate(byte[] data)
{
using var stream = new MemoryStream();
using (var deflate = new DeflateStream(stream, CompressionLevel.Optimal, leaveOpen: true)) deflate.Write(data);
return stream.ToArray();
}
private static uint Crc32(byte[] data)
{
var c = 0xFFFFFFFFu;
foreach (var b in data) c = CrcTable[(c ^ b) & 0xFF] ^ (c >> 8);
return c ^ 0xFFFFFFFFu;
}
}
+31
View File
@@ -27,6 +27,7 @@ import { config } from './config'
import { deleteUserDatabases, restoreExperimentDatabases, userDatabaseDir } from './databases'
import { listSamples, loadChecks, loadSample, loadTask } from './samples'
import { SqlError, sqlService } from './sql-service'
import { type ExportKind, exportProgram, programName } from './export'
import { serveStatic } from './static'
import { deleteWork, getWork, saveWork, validFiles, validScreenshots } from './works'
@@ -129,6 +130,36 @@ export function createApp(db: Database, gradingQueue: GradingQueue, aiQueue: AiQ
app.get('/api/selfcheck', (c) => c.json(selfCheck.get(c.get('user').id)))
/**
* 导出 EXE、VS 工程:成功时返回 exe 文件或工程的 zip;代码有错误时 422 和报错
* (照真机版类库编译,和 VS 里一样;导出工程还照机房的 VS 2012 只能用 C# 5)
*/
const EXPORTS: Record<ExportKind, { ext: string; type: string }> = {
exe: { ext: 'exe', type: 'application/vnd.microsoft.portable-executable' },
project: { ext: 'zip', type: 'application/zip' },
}
for (const kind of ['exe', 'project'] as const) {
app.post(`/api/export-${kind}`, async (c) => {
const { files, name } = await body<{ files: unknown; name: unknown }>(c)
if (!validFiles(files)) return c.json({ message: '作品格式不对或太大' }, 400)
let result
try {
result = await exportProgram(files, programName(name), kind)
} catch (error) {
return c.json({ message: error instanceof Error ? error.message : String(error) }, 503)
}
if (!result.ok) return c.json({ message: '代码有错误,不能导出', diagnostics: result.diagnostics }, 422)
const { ext, type } = EXPORTS[kind]
return new Response(result.file, {
headers: {
'Content-Type': type,
'Content-Disposition': `attachment; filename="program.${ext}"; filename*=UTF-8''${encodeURIComponent(`${result.name}.${ext}`)}`,
'Cache-Control': 'no-store',
},
})
})
}
// 代码编辑器的语言服务:补全、悬停提示、参数提示、边写边检查
const LANGUAGE_REQUESTS = new Set(['complete', 'completionDetail', 'hover', 'signature', 'diagnostics'])
app.post('/api/language', async (c) => {
+26 -2
View File
@@ -9,6 +9,10 @@ export interface CompileResult {
ms: number
}
/** 导出的 exe、VS 工程(zip)在工程目录里的文件名(Sim.Runner 的 CompileServer.ExportFile、ExportProjectFile) */
export const EXPORT_FILE = 'export.exe'
export const EXPORT_PROJECT_FILE = 'export.zip'
type Reply = ({ type: 'compiled' } & CompileResult) | { type: 'compileFailed'; message: string }
/**
@@ -22,11 +26,31 @@ class CompileService {
private readonly pending = new Map<number, { resolve: (r: CompileResult) => void; reject: (e: Error) => void }>()
/** 编译工程目录;成功时程序集写到同一目录 */
async compile(dir: string): Promise<CompileResult> {
compile(dir: string): Promise<CompileResult> {
return this.request({ dir })
}
/**
* 导出 EXE:照教材工程编译成 .NET Framework 程序,嵌入教材的设备类库(config.textbookLibsDir),
* 成功时写到工程目录的 EXPORT_FILE。name 是 exe 的名字(不带 .exe)
*/
exportExe(dir: string, name: string): Promise<CompileResult> {
return this.request({ dir, export: { libs: config.textbookLibsDir, name } })
}
/**
* 导出 VS 工程:照机房的 VS 2012(C# 5、.NET Framework 4.5、真机版设备类库)检查,
* 打包成 zip 写到工程目录的 EXPORT_PROJECT_FILE。name 是工程名
*/
exportProject(dir: string, name: string): Promise<CompileResult> {
return this.request({ dir, export: { kind: 'project', libs: config.textbookLibsDir, name } })
}
private async request(payload: object): Promise<CompileResult> {
await this.start()
const id = this.nextId++
const result = new Promise<CompileResult>((resolve, reject) => this.pending.set(id, { resolve, reject }))
this.proc!.stdin.write(`${JSON.stringify({ id, dir })}\n`)
this.proc!.stdin.write(`${JSON.stringify({ id, ...payload })}\n`)
this.proc!.stdin.flush()
return result
}
+5
View File
@@ -13,6 +13,11 @@ export const config = {
/** 每次运行的临时工程目录放在这里 */
runsDir: resolve(ROOT, env.RUNS_DIR ?? '.runs'),
samplesDir: resolve(ROOT, env.SAMPLES_DIR ?? 'samples'),
/**
* 导出 EXE 用的教材设备类库(真机版 DeviceCtrlLibrary.dll 及其 API 目录下的 dll、WinFormsDeviceControlLibrary.dll、
* 媒体播放器的 AxInterop.WMPLib.dll / Interop.WMPLib.dll,放在同一个目录里)。有版权,不进仓库;没有时不能导出
*/
textbookLibsDir: resolve(ROOT, env.SIM_TEXTBOOK_LIBS ?? 'textbook-libs'),
/** 打包好的网页(web/dist);目录不存在时不提供网页,开发时由 Vite 提供 */
staticDir: resolve(ROOT, env.STATIC_DIR ?? 'web/dist'),
/** 学生程序的沙箱:bwrap(bubblewrap)或 none;默认装了 bwrap 就用 */
+36
View File
@@ -0,0 +1,36 @@
// 导出 EXE:学生工程照教材工程编译成 Windows 程序(嵌入教材的真机版设备类库),在机房双击就能控制真的实训台。
// 导出工程:打包成教材那样的 VS 2012 工程(zip),在机房电脑上用 VS 打开接着改。
import { mkdir, mkdtemp, rm } from 'node:fs/promises'
import { dirname, join } from 'node:path'
import type { Diagnostic, SourceFile } from '@iot/shared'
import { compileService, EXPORT_FILE, EXPORT_PROJECT_FILE } from './compile-service'
import { config } from './config'
import { safeJoin } from './run-session'
export type ExportResult = { ok: true; file: Uint8Array; name: string } | { ok: false; diagnostics: Diagnostic[] }
export type ExportKind = 'exe' | 'project'
/** exe、工程的名字:只留文字、数字、下划线和减号(也是程序集名),空了就叫"上位机程序" */
export function programName(name: unknown) {
const cleaned = typeof name === 'string' ? name.replace(/[^\p{L}\p{N}_-]/gu, '').slice(0, 40) : ''
return cleaned || '上位机程序'
}
/** 编译失败返回报错;服务器上没有教材 dll 等情况抛出异常(消息给学生看) */
export async function exportProgram(files: SourceFile[], name: string, kind: ExportKind): Promise<ExportResult> {
await mkdir(config.runsDir, { recursive: true })
const dir = await mkdtemp(join(config.runsDir, 'export-'))
try {
for (const file of files) {
const target = safeJoin(dir, file.path)
await mkdir(dirname(target), { recursive: true })
await Bun.write(target, file.content)
}
const result = kind === 'project' ? await compileService.exportProject(dir, name) : await compileService.exportExe(dir, name)
if (!result.ok) return { ok: false, diagnostics: result.diagnostics }
return { ok: true, file: await Bun.file(join(dir, kind === 'project' ? EXPORT_PROJECT_FILE : EXPORT_FILE)).bytes(), name }
} finally {
await rm(dir, { recursive: true, force: true })
}
}
+155
View File
@@ -1,5 +1,6 @@
// HTTP 接口测试:内存数据库,直接调用 app.fetch。
import { afterAll, beforeAll, beforeEach, describe, expect, test } from 'bun:test'
import { existsSync } from 'node:fs'
import { rm } from 'node:fs/promises'
import { createAiQueue, normalizeAiResult } from '../src/ai-review'
import { createApp } from '../src/app'
@@ -738,3 +739,157 @@ describe('AI 评阅', () => {
expect((await t.post(`/api/teacher/assignments/${assignmentId}/adopt-ai`)).body).toEqual({ adopted: 0 })
}, 60000)
})
describe('导出 EXE、VS 工程', () => {
const hasLibs = existsSync(`${config.textbookLibsDir}/DeviceCtrlLibrary.dll`)
const exportExe = (browser: Browser, json: unknown) => exportRequest(browser, 'exe', json)
async function exportRequest(browser: Browser, kind: 'exe' | 'project', json: unknown) {
const response = await app.fetch(
new Request(`http://localhost/api/export-${kind}`, {
method: 'POST',
headers: { authorization: `Bearer ${browser.accessToken}`, 'content-type': 'application/json', 'x-real-ip': browser.ip },
body: JSON.stringify(json),
}),
)
return response
}
test.skipIf(!hasLibs)(
'照真机版类库编译成 exe;书上的 ledHelper.Close() 也能编译;代码有错时返回报错',
async () => {
const t = await teacher()
const files = (await loadSample('p2-t3-LED推送模拟量'))!
const response = await exportExe(t, { files, name: 'LED推送 模拟量/..' })
expect(response.status).toBe(200)
expect(response.headers.get('content-disposition')).toContain(`filename*=UTF-8''${encodeURIComponent('LED推送模拟量.exe')}`)
const exe = new Uint8Array(await response.arrayBuffer())
expect(String.fromCharCode(exe[0]!, exe[1]!)).toBe('MZ')
// 教材 dll 嵌在 exe 里
expect(Buffer.from(exe).includes('IotSimExport.lib.DeviceCtrlLibrary.dll')).toBe(true)
const broken = files.map((f) => (f.path === 'frm_LED.cs' ? { ...f, content: f.content.replace('IsOpen = true;', 'IsOpen = true') } : f))
const failed = await exportExe(t, { files: broken, name: 'x' })
expect(failed.status).toBe(422)
expect(((await failed.json()) as any).diagnostics[0]).toMatchObject({ file: 'frm_LED.cs', severity: 'error' })
},
60000,
)
/** 读出 zip 里的文件:文件名是 GBK 编码,0x7075 扩展字段里是 UTF-8 文件名,两者要一致 */
function unzip(zip: Uint8Array<ArrayBuffer>) {
const view = new DataView(zip.buffer, zip.byteOffset, zip.byteLength)
let end = zip.length - 22
while (view.getUint32(end, true) !== 0x06054b50) end--
const count = view.getUint16(end + 10, true)
let p = view.getUint32(end + 16, true)
const files = new Map<string, Uint8Array<ArrayBuffer>>()
for (let i = 0; i < count; i++) {
expect(view.getUint32(p, true)).toBe(0x02014b50)
const flags = view.getUint16(p + 8, true)
const method = view.getUint16(p + 10, true)
const compressedSize = view.getUint32(p + 20, true)
const nameLength = view.getUint16(p + 28, true)
const extraLength = view.getUint16(p + 30, true)
const commentLength = view.getUint16(p + 32, true)
const offset = view.getUint32(p + 42, true)
const nameBytes = zip.subarray(p + 46, p + 46 + nameLength)
const extra = zip.subarray(p + 46 + nameLength, p + 46 + nameLength + extraLength)
expect(flags & 0x800).toBe(0)
const name = new TextDecoder('gbk').decode(nameBytes)
if (extra.length > 0) {
expect(extra[0]! | (extra[1]! << 8)).toBe(0x7075)
expect(new TextDecoder().decode(extra.subarray(9))).toBe(name)
} else {
expect(/^[\x20-\x7e]+$/.test(name)).toBe(true)
}
const local = offset + 30 + view.getUint16(offset + 26, true) + view.getUint16(offset + 28, true)
const data = zip.subarray(local, local + compressedSize)
files.set(name, method === 8 ? Bun.inflateSync(data) : data)
p += 46 + nameLength + extraLength + commentLength
}
return files
}
test.skipIf(!hasLibs)(
'导出 VS 2012 工程:资源换成 resx、素材复制到输出目录、教材 dll 放在 lib;照 C# 5 检查',
async () => {
const t = await teacher()
const response = await exportRequest(t, 'project', { files: (await loadSample('p7-t2-智慧寝室'))!, name: '寝室' })
expect(response.status).toBe(200)
expect(response.headers.get('content-type')).toBe('application/zip')
expect(response.headers.get('content-disposition')).toContain(`filename*=UTF-8''${encodeURIComponent('寝室.zip')}`)
const files = unzip(new Uint8Array(await response.arrayBuffer()))
const text = (name: string) => {
const bytes = files.get(name)
expect(bytes, name).toBeDefined()
// VS 2012 要带 BOM 才把文件当成 UTF-8;Windows 的换行
expect([...bytes!.subarray(0, 3)]).toEqual([0xef, 0xbb, 0xbf])
const content = new TextDecoder().decode(bytes!.subarray(3))
expect(content.replace(/\r\n/g, '')).not.toContain('\n')
return content
}
expect(text('寝室/寝室.sln')).toContain('# Visual Studio 2012\r\nProject("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "寝室", "寝室\\寝室.csproj"')
const csproj = text('寝室/寝室/寝室.csproj')
expect(csproj).toContain('<RootNamespace>寝室情景模式</RootNamespace>')
expect(csproj).toContain('<AssemblyName>寝室</AssemblyName>')
expect(csproj).toContain('<TargetFrameworkVersion>v4.5</TargetFrameworkVersion>')
// 和教材工程一样只引用直接用到的教材 dll,其他的作为依赖复制到输出目录
expect(csproj).toContain('<HintPath>lib\\DeviceCtrlLibrary.dll</HintPath>')
expect(csproj).toContain('<HintPath>lib\\AxInterop.WMPLib.dll</HintPath>')
expect(csproj).not.toContain('ICS.Models')
expect(files.has('寝室/寝室/lib/ICS.Models.dll')).toBe(true)
expect(csproj).toContain('<Compile Include="Form1.cs">\r\n <SubType>Form</SubType>')
expect(csproj).toContain('<Content Include="Resources\\ring.mp3">\r\n <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>')
expect(csproj).toContain('<None Include="Resources\\switch_on.png" />')
expect(files.has('寝室/寝室/Resources/ring.mp3')).toBe(true)
expect(text('寝室/寝室/Properties/Resources.resx')).toContain('<value>..\\Resources\\switch_on.png;System.Drawing.Bitmap, System.Drawing')
const resources = text('寝室/寝室/Properties/Resources.Designer.cs')
expect(resources).toContain('new global::System.Resources.ResourceManager("寝室情景模式.Properties.Resources"')
expect(resources).not.toContain('Sim.Host')
expect(text('寝室/寝室/Form1.resx')).toContain('<data name="axWindowsMediaPlayer1.OcxState" mimetype="application/x-microsoft.net.object.binary.base64">')
expect(text('寝室/寝室/Form1.cs')).toContain('namespace 寝室情景模式')
expect(files.has('寝室/寝室/LedHelperCompat.cs')).toBe(false)
// 书上的 ledHelper.Close() 教材类库里没有,用到时带上补的方法
const led = unzip(
new Uint8Array(await (await exportRequest(t, 'project', { files: (await loadSample('p2-t3-LED推送模拟量'))!, name: 'LED' })).arrayBuffer()),
)
expect(new TextDecoder().decode(led.get('LED/LED/LedHelperCompat.cs'))).toContain('Close(this global::DeviceCtrlLibrary.LEDHelper helper)')
expect(new TextDecoder().decode(led.get('LED/LED/LED.csproj'))).toContain('<Compile Include="LedHelperCompat.cs" />')
// 机房的 VS 2012 只支持 C# 5
const login = (await loadSample('p1-t1-登录'))!
const newer = login.map((f) =>
f.path === 'Program.cs' ? { ...f, content: f.content.replace('[STAThread]', 'static string S => $"{1}";\n [STAThread]') } : f,
)
const failed = await exportRequest(t, 'project', { files: newer, name: '登录' })
expect(failed.status).toBe(422)
const diagnostics = ((await failed.json()) as any).diagnostics
expect(diagnostics.map((d: any) => d.code)).toEqual(['CS8026', 'CS8026'])
expect(diagnostics[1]).toMatchObject({ file: 'Program.cs', hint: expect.stringContaining('string.Format') })
// 导出 EXE 不受这个限制
expect((await exportExe(t, { files: newer, name: '登录' })).status).toBe(200)
},
60000,
)
test('服务器上没有教材类库时说明原因;格式不对拒绝', async () => {
const t = await teacher()
const saved = config.textbookLibsDir
config.textbookLibsDir = '/nonexistent/textbook-libs'
try {
const response = await exportExe(t, { files: (await loadSample('p1-t1-登录'))!, name: '登录' })
expect(response.status).toBe(503)
expect(((await response.json()) as any).message).toContain('设备类库')
const project = await exportRequest(t, 'project', { files: (await loadSample('p1-t1-登录'))!, name: '登录' })
expect(project.status).toBe(503)
expect(((await project.json()) as any).message).toContain('不能导出工程')
} finally {
config.textbookLibsDir = saved
}
expect((await exportExe(t, { files: 'x' })).status).toBe(400)
expect((await exportRequest(t, 'project', { files: 'x' })).status).toBe(400)
}, 60000)
})
+4 -2
View File
@@ -33,7 +33,9 @@ async function prepare(id: string, name: string, edit?: { file: string; from: st
return dir
}
describe('参考答案全部通过', async () => {
// 每个检查大部分时间在等学生程序的定时器,挨个跑要 3 分钟;测试之间各用各的临时目录,可以同时跑(30 秒左右)。
// 同时跑的个数由 bun test 的 --max-concurrency 管,CI 里给的是 8:太多时 CPU 跟不上,定时器来不及触发会误判
describe.concurrent('参考答案全部通过', async () => {
const ids = (await Array.fromAsync(new Bun.Glob('*/checks.yaml').scan({ cwd: SAMPLES }))).map((p) => p.split('/')[0]).sort()
test('每个示例都有评分规则', () => expect(ids.length).toBe(23))
for (const id of ids) {
@@ -49,7 +51,7 @@ describe('参考答案全部通过', async () => {
}
})
describe('改坏的代码', () => {
describe.concurrent('改坏的代码', () => {
test('编译不通过时所有项都不通过', async () => {
const id = 'p2-t1-LED显示屏'
const dir = await prepare(id, 'compile-error', { file: 'Form1.cs', from: 'led.setLedText(res);', to: 'led.setLedText(res)' })
+11
View File
@@ -6,6 +6,8 @@ export class ApiError extends Error {
constructor(
message: string,
readonly status: number,
/** 服务器回复里的其他内容(如导出 EXE 时的编译报错) */
readonly data: Record<string, unknown> = {},
) {
super(message)
}
@@ -22,6 +24,15 @@ export async function api<T>(method: string, path: string, json?: unknown, init?
return data
}
/** 回复不是 JSON 的接口(如下载文件):成功时返回 Response,出错时和 api 一样抛出 ApiError */
export async function apiResponse(method: string, path: string, json?: unknown): Promise<Response> {
const response = await send(method, path, json, undefined)
if (response.ok) return response
const data = (await response.json().catch(() => ({}))) as Record<string, unknown> & { message?: string }
if (response.status === 401) apiHooks.onUnauthorized()
throw new ApiError(data.message ?? `请求失败(${response.status})`, response.status, data)
}
/** 发请求并带上访问令牌;令牌被拒绝(401)时刷新一次再重发 */
async function send(method: string, path: string, json: unknown, init: RequestInit | undefined, retried = false): Promise<Response> {
const headers: Record<string, string> = {}
+36
View File
@@ -0,0 +1,36 @@
// 导出:服务器照教材工程(真机版设备类库)编译,下载到机房电脑上用。
// EXE:一个 exe,双击就能控制真的实训台;VS 工程:教材那样的 VS 2012 工程(zip),用 VS 打开接着改。
import type { Diagnostic, SourceFile } from '@iot/shared'
import { apiResponse, ApiError } from '../api'
import { projectNamespace } from '../designer/templates'
export type ExportKind = 'exe' | 'project'
const EXT: Record<ExportKind, string> = { exe: 'exe', project: 'zip' }
/** 下载成功返回 null;代码有错误返回报错(照真机版类库编译,和 VS 里一样);其他问题抛出 ApiError */
export async function downloadExport(kind: ExportKind, files: SourceFile[]): Promise<Diagnostic[] | null> {
let response: Response
try {
response = await apiResponse('POST', `/api/export-${kind}`, { files, name: projectNamespace(files) })
} catch (error) {
if (error instanceof ApiError && error.status === 422 && Array.isArray(error.data.diagnostics)) return error.data.diagnostics as Diagnostic[]
throw error
}
const name = fileName(response.headers.get('Content-Disposition')) ?? `${projectNamespace(files)}.${EXT[kind]}`
const url = URL.createObjectURL(await response.blob())
const link = document.createElement('a')
link.href = url
link.download = name
document.body.append(link)
link.click()
link.remove()
setTimeout(() => URL.revokeObjectURL(url), 60_000)
return null
}
/** Content-Disposition 里的中文文件名(filename*=UTF-8''…) */
function fileName(header: string | null) {
const m = header && /filename\*=UTF-8''([^;]+)/i.exec(header)
return m ? decodeURIComponent(m[1]) : null
}
+20
View File
@@ -167,6 +167,26 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onKey))
<a href="/help-img/db.webp" title="点击放大" @click.prevent="zoom"><img src="/help-img/db.webp" alt="" loading="lazy" /></a>
<figcaption>数据库页面:左边是你的库和表(点表名编辑数据),右边写 SQL、按 F5 执行,下面是结果</figcaption>
</figure>
<h3>8. 导出 EXE 或 VS 工程,在机房电脑上运行</h3>
<ul>
<li>
点工具栏的<b>导出</b>→<b>导出 EXE</b>,下载一个
exe(名字是工程名,比如“教室情景模式.exe”)。拷到接着实训台的机房电脑上双击运行,选对串口、点打开,就能控制<b>真的</b>设备。
</li>
<li>导出时照机房电脑上的设备类库编译,和在 VS 里编译一样。代码有错误时弹出报错,点它跳到出错的地方。</li>
<li>设备类库已经装在 exe 里,不用另外拷 dll。项目 7 寝室的音乐第一次运行时会放到 exe 旁边的 Resources 文件夹。</li>
<li>项目 6、8 的程序连的是机房电脑上真的 SQL Server,那台电脑上要有书上建的库。</li>
<li>第一次运行时 Windows 可能提示“Windows 已保护你的电脑”:点<b>更多信息</b>→<b>仍要运行</b>。</li>
<li>
想在机房的 VS 里接着改,点<b>导出</b>→<b>导出 VS 工程</b>,下载一个 zip。解压后双击里面的 .sln 文件用 VS 打开,按 F5
运行。工程和书上的一样:设备类库在 lib 文件夹里,图片、声音在 Resources 文件夹里。
</li>
<li>
机房的 VS 2012 只认 C# 5 的写法。导出工程时照它检查,用了新写法(比如
<code>$"温度{t}"</code>)会弹出报错和改法,改好再导出。
</li>
</ul>
</section>
<section v-if="currentUser?.role === 'teacher'" id="teacher">
+98 -2
View File
@@ -4,8 +4,22 @@
// 老师运行学生提交的程序(/teacher/submissions/:id/run,改动只用于试运行,不保存)。
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { NButton, NInput, NModal, NPopconfirm, NPopover, NSelect, NTab, NTabs, NTag, useMessage, type SelectGroupOption } from 'naive-ui'
import type { Assignment, Review, SourceFile, Submission } from '@iot/shared'
import {
NButton,
NDropdown,
NInput,
NModal,
NPopconfirm,
NPopover,
NSelect,
NTab,
NTabs,
NTag,
useMessage,
type DropdownOption,
type SelectGroupOption,
} from 'naive-ui'
import type { Assignment, Diagnostic, Review, SourceFile, Submission } from '@iot/shared'
import FormDesigner from '../designer/FormDesigner.vue'
import { createDesignerHistory } from '../designer/history'
import { gallery, loadGallery, projectResources, resourcePrefix, resourcesFile, RESOURCES_FILE } from '../designer/resources'
@@ -25,6 +39,7 @@ import CodeEditor from '../editor/CodeEditor.vue'
import { checkProject } from '../editor/language'
import { api } from '../api'
import { currentUser, logout } from '../auth'
import { downloadExport, type ExportKind } from '../editor/export'
import { deleteWork, loadWork, sampleWorkKey, saveWork } from '../editor/works'
import RunArea from '../run/RunArea.vue'
import { createRunSession } from '../run/session'
@@ -478,6 +493,50 @@ async function showDiagnostic(file: string, line: number, column: number) {
editorRef.value?.reveal(file, line, column)
}
// ---------- 导出:照真机版类库编译,EXE 在机房电脑上双击运行,VS 工程用机房的 VS 2012 打开 ----------
const exporting = ref(false)
/** 导出时的编译报错(照真机版类库编译,和平台上运行时可能不同) */
const exportErrors = ref<Diagnostic[]>([])
const exportErrorsOpen = ref(false)
const exportKind = ref<ExportKind>('exe')
const EXPORT_OPTIONS: DropdownOption[] = [
{ key: 'exe', label: '导出 EXE', props: { title: '编译成 Windows 程序(.exe),在机房电脑上双击运行,控制真的实训台' } },
{ key: 'project', label: '导出 VS 工程', props: { title: '打包成 VS 2012 工程(.zip),在机房电脑上解压后用 VS 打开,接着改代码、按 F5 运行' } },
]
const EXPORT_DONE: Record<ExportKind, string> = {
exe: '已导出,在机房电脑上双击运行(要接真的实训台)',
project: '已导出,解压后双击 .sln 文件用 VS 打开,按 F5 运行(要接真的实训台)',
}
async function exportProgram(kind: ExportKind) {
if (exporting.value) return
exporting.value = true
exportKind.value = kind
try {
const errors = await downloadExport(
kind,
files.value.map((f) => ({ ...f })),
)
if (errors) {
exportErrors.value = errors
exportErrorsOpen.value = true
} else {
message.success(EXPORT_DONE[kind])
}
} catch (error) {
message.error(error instanceof Error ? error.message : String(error))
} finally {
exporting.value = false
}
}
function jumpToExportError(d: Diagnostic) {
exportErrorsOpen.value = false
void showDiagnostic(d.file, d.line, d.column)
}
function restoreOriginal() {
if (props.assignmentId) void loadAssignment(true)
else if (sampleId.value) void loadSample(sampleId.value, true)
@@ -600,6 +659,9 @@ onBeforeUnmount(() => clearTimeout(rerunTimer))
<NButton v-if="taskPanel && !viewing" size="small" title="任务说明、自动检查哪些功能;可以自己先检查一遍(不计分)" @click="taskOpen = !taskOpen">
任务
</NButton>
<NDropdown trigger="click" :options="EXPORT_OPTIONS" :disabled="exporting" @select="(key: ExportKind) => exportProgram(key)">
<NButton size="small" :loading="exporting" title="导出到机房电脑上用:EXE 双击运行,VS 工程用 VS 打开">导出 ▾</NButton>
</NDropdown>
<NPopconfirm v-if="modified && !viewing" positive-text="恢复" negative-text="取消" @positive-click="restoreOriginal">
<template #trigger>
<NButton quaternary size="small">{{ assignmentId ? '恢复到作业起点' : '恢复原始代码' }}</NButton>
@@ -623,6 +685,18 @@ onBeforeUnmount(() => clearTimeout(rerunTimer))
<NButton quaternary size="small" @click="signOut">退出</NButton>
</header>
<NModal v-model:show="exportErrorsOpen" preset="card" title="代码有错误,不能导出" style="width: 640px; max-width: calc(100vw - 32px)">
<p v-if="exportKind === 'project'" class="export-errors-note">
导出时照机房电脑上的 VS 2012 编译(只支持 C# 5,用真机版设备类库)。点击报错跳到出错的代码。
</p>
<p v-else class="export-errors-note">导出时照机房电脑上的设备类库编译,和在 VS 里编译一样。点击报错跳到出错的代码。</p>
<div v-for="(d, i) in exportErrors.slice(0, 20)" :key="i" class="export-error" @click="jumpToExportError(d)">
{{ d.file }}({{ d.line }},{{ d.column }}) {{ d.code }}:{{ d.message }}
<div v-if="d.hint" class="hint">提示:{{ d.hint }}</div>
</div>
<p v-if="exportErrors.length > 20" class="export-errors-note">还有 {{ exportErrors.length - 20 }} 个错误,先改上面的,后面的往往跟着就没了。</p>
</NModal>
<NModal
v-if="assignment && submission"
v-model:show="resultOpen"
@@ -794,6 +868,28 @@ onBeforeUnmount(() => clearTimeout(rerunTimer))
color: #dc2626;
}
.export-errors-note {
margin: 0 0 8px;
color: #6b7280;
font-size: 13px;
}
.export-error {
color: #b91c1c;
font-size: 13px;
cursor: pointer;
padding: 2px 0;
}
.export-error:hover {
text-decoration: underline;
}
.export-error .hint {
color: #2563eb;
padding-left: 1em;
}
.rerun-blocked {
color: #b45309;
font-size: 12px;