Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,22 @@ jobs:
chmod +x gradlew
./gradlew :app:assembleDebug :app:testDebugUnitTest --console=plain


android:
name: android-mobile
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: "17"
- name: Build and test phone app
working-directory: android
run: |
chmod +x gradlew
./gradlew :app:assembleDebug :app:testDebugUnitTest --console=plain

fnos:
name: fnos fpk 打包检查
runs-on: ubuntu-latest
Expand Down
58 changes: 57 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,56 @@ jobs:
name: watch-apk
path: RemoteCI.Watch-*.apk


build-android:
name: Phone APK
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: "17"
- name: Prepare signing key
env:
ANDROID_KEYSTORE_B64: ${{ secrets.ANDROID_KEYSTORE_B64 }}
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
run: |
for name in ANDROID_KEYSTORE_B64 ANDROID_KEYSTORE_PASSWORD ANDROID_KEY_ALIAS ANDROID_KEY_PASSWORD; do
if [ -z "${!name}" ]; then
echo "::error::missing $name"
exit 1
fi
done
mkdir -p android/keystore
echo "$ANDROID_KEYSTORE_B64" | base64 -d > android/keystore/remoteci-release.jks
{
echo "storeFile=keystore/remoteci-release.jks"
echo "storePassword=$ANDROID_KEYSTORE_PASSWORD"
echo "keyAlias=$ANDROID_KEY_ALIAS"
echo "keyPassword=$ANDROID_KEY_PASSWORD"
} > android/keystore.properties
- name: Build Release APK
working-directory: android
run: |
source ../.github/scripts/resolve-release-version.sh "$GITHUB_REF_NAME" >/dev/null
grep -Fq 'const val VERSION = 3' app/src/main/java/com/remoteci/mobile/data/Protocol.kt
chmod +x gradlew
./gradlew :app:assembleRelease -PreleaseVersion="$version" --console=plain
grep -Fq "\"versionName\": \"$version\"" app/build/outputs/apk/release/output-metadata.json
- name: Rename APK
run: |
source .github/scripts/resolve-release-version.sh "$GITHUB_REF_NAME" >/dev/null
cp android/app/build/outputs/apk/release/app-release.apk "RemoteCI.Mobile-${version}.apk"
apksigner="$(find "$ANDROID_HOME/build-tools" -type f -name apksigner | sort -V | tail -n 1)"
"$apksigner" verify --print-certs "RemoteCI.Mobile-${version}.apk"
- uses: actions/upload-artifact@v4
with:
name: mobile-apk
path: RemoteCI.Mobile-*.apk

build-fnos:
name: fnOS 应用 (镜像 + fpk)
runs-on: ubuntu-latest
Expand Down Expand Up @@ -301,7 +351,7 @@ jobs:

release:
name: 创建 GitHub Release
needs: [build-server, build-plugin, build-wearos, build-fnos]
needs: [build-server, build-plugin, build-wearos, build-android, build-fnos]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
Expand All @@ -321,6 +371,10 @@ jobs:
with:
name: watch-apk
path: artifacts/watch-apk
- uses: actions/download-artifact@v4
with:
name: mobile-apk
path: artifacts/mobile-apk
- uses: actions/download-artifact@v4
with:
name: fnos-fpk
Expand All @@ -334,6 +388,7 @@ jobs:
test -f artifacts/plugin-cipx/checksums.md
test -f artifacts/plugin-cipx/remoteci.plugin.yml
test -f "artifacts/watch-apk/RemoteCI.Watch-${version}.apk"
test -f "artifacts/mobile-apk/RemoteCI.Mobile-${version}.apk"
test -f "artifacts/fnos-fpk/RemoteCI-${version}.fpk"
test -f "artifacts/fnos-fpk/RemoteCI-${version}-fnos-x86_64-offline.fpk"
test -f "artifacts/fnos-fpk/RemoteCI-${version}-fnos-arm64-offline.fpk"
Expand All @@ -348,6 +403,7 @@ jobs:
artifacts/plugin-cipx/checksums.md
artifacts/plugin-cipx/remoteci.plugin.yml
artifacts/watch-apk/**
artifacts/mobile-apk/**
artifacts/fnos-fpk/**
body_path: artifacts/plugin-cipx/checksums.md
generate_release_notes: true
Expand Down
8 changes: 8 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -69,3 +69,11 @@ yarn-error.log*

# 运行时 DataProtection 密钥
server/RemoteCI.Server/Data/keys/

android/.gradle/
android/build/
android/**/build/
android/local.properties
android/.idea/
android/keystore.properties
android/keystore/
78 changes: 78 additions & 0 deletions .zcode/plans/plan-sess_2d4801a2-c711-4856-a907-f824e8711c02.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
# 服务端多班级管理 — 实施计划

## 已确认的设计决策(探索后按推荐方案定,可否决)

1. **成员模型**:新建 `ClassMembership` 多对多成员表,用户可属于多个班级且在每个班级有独立角色(引用现有 `AccountRole` 角色体系)。系统管理员(`UserRole.Admin`)自动可访问所有班级。切换班级 = 在自己有成员关系的班级间切换。
2. **班管理员**:新增内置角色"班管理员"(新 `AccountRoleKind.ClassAdministrator`,固定 GUID,名字锁定但权限可自定义,不授予系统管理员身份),默认权限 = 查看课表、访问 WebUI、拉取课表、发送通知、语音消息、教师来电、调用扩展(不含用户管理/电源控制/主菜单控制/系统设置)。
3. **范围**:服务端(RemoteCI.Server)+ Razor WebUI + shared 协议模型 + 测试。旧插件/手表/手机客户端保持基本可用:配对码创建时绑定班级(旧插件无感),未指定班级的 API 请求默认路由到默认班级/用户唯一成员班级。插件、Android、WearOS 的完整多班级 UI 为后续工作。
4. **批量操作**(系统管理员):批量开启/关闭访客功能、批量删除班级。
5. **访客开关迁移**:全局开关 → 每班级开关;`AutoEnterVisitorPage`(登录页自动进入访客页)保持全局。迁移时把原全局值落到默认班级。

## 1. 数据模型(`Data/Entities.cs`、`Data/AppDbContext.cs`、EF 迁移)

- 新实体 `Classroom { Id, Name, VisitorAccessEnabled, CreatedAt, UpdatedAt }`,固定默认班级 GUID `33333333-…`(静态 `Classroom.DefaultId`,模式同 `AccountRole.StudentId`)。
- 新实体 `ClassMembership { UserId, ClassroomId, RoleDefinitionId }`,复合主键,级联删除(删班级/删用户连带删成员关系)。
- `PluginCredential` 增加 `ClassroomId`(插件归属班级);删除班级时级联删除其插件凭据(需重新配对)。
- `SystemMetadata.VisitorAccessEnabled` 列删除(迁移内先把值写入默认班级);`AutoEnterVisitorPage` 保留。
- 新迁移 `AddClassrooms`:建表 + 数据回填(`INSERT OR IGNORE` 默认班级;`UPDATE PluginCredentials SET ClassroomId=默认 WHERE NULL`;为全部现有用户按其 `RoleDefinitionId` 在默认班级建成员关系)。
- `AppDbContext`:新增 DbSet、索引(ClassMemberships 按 ClassroomId/UserId)、唯一约束(班级名唯一)。
- `IdentityCoordinator.BootstrapAsync`:幂等种子默认班级与"班管理员"角色(GUID `44444444-…`);新用户创建时(`CreateUserAsync`)同时在其默认班级建成员关系(保持单班级部署的现有体验)。
- `AccountRoleService`:`ClassAdministrator` 与 Administrator 一样不可删除/改名,权限可改(同 Student 的规则)。

## 2. 按班级分桶的状态(`Services/IStateStore.cs`、`StateStore.cs`)

- `IStateStore` 全部方法加 `Guid classId` 参数(`SaveSnapshot(classId, …)` / `GetLatestSnapshot(classId)` 等 4 组),实现改为 `ConcurrentDictionary<Guid, Bucket>`(Bucket 含现有 4 个字段,保持 Volatile 原子换引用模式)。更新所有调用点:`WebSocketHub`、`Program.cs` 各端点、`SchedulePullWorker`、`Visitor/Index/Schedule/Control` 页面。

## 3. 鉴权与按班级权限

- `AuthPrincipal` 增加插件侧 `ClassId`(`ValidatePluginTokenAsync` 从凭据读取)与用户侧 `AccessibleClassIds`(登录/刷新/WS 校验时计算;管理员=全部班级,普通用户=成员班级;`PeerAuthorizationRefreshWorker` 周期刷新,沿用现有机制)。
- 新服务 `ClassAccessService`(scoped,请求内缓存):
- `GetAccessibleClassesAsync(user)`:管理员→全部;普通用户→成员班级(含每班角色)。
- `EffectiveClassPermissions(user, classId)`:管理员→`All`;成员→`ViewCurrentCourse | 成员角色.DefaultPermissions | user.GrantedPermissions`;非成员→`None`。
- `Program.cs` 端点:`/api/state`、`/api/schedule`、`/api/commands`、`/api/extensions` 增加可选 `?classId=`,缺省→用户唯一/首个成员班级(管理员→默认班级);权限判断从全局 `principal.User.Permissions` 改为按班级计算。
- `Pages/WebPageModel.RequireAsync` 扩展:解析 Cookie `RemoteCI.Class`(校验可访问性,否则回退首个可访问班级),暴露 `CurrentClass`、`ClassPermissions`、`AccessibleClasses` 供页面/切换器使用。

## 4. WebSocket 路由(`WebSocketHub.cs`、`Services/PeerRegistry.cs`、`ScheduleSyncService`)

- 插件连接按班级注册:`PeerRegistry` 增加按班取插件连接(`HasPlugin(classId)`、`SendToPluginAsync(classId, …)`、`PrimaryPluginSupports(classId, capability)`);同班多插件沿用现有"取最早连接+警告"策略。
- 插件推送(state/schedule/event/extensions)写入对应班级桶,并只扇出给 `AccessibleClassIds` 包含该班的 watches/mobile。
- 用户命令:`CommandMessage` 增加**可选** `classId`(shared 协议,向后兼容;缺省同上),校验按班级权限后路由到该班插件;扩展校验用该班 `GetLatestExtensions`。
- `AccountSync` 按班级生成(`CreateSyncAsync(classId)`:该班成员 ∪ 启用的系统管理员,权限按成员角色计算),发给该班插件;`AuthorizationSyncService`/`ScheduleSyncService`/`SchedulePullWorker` 改为按班级遍历在线插件。

## 5. 班级管理 API(`Program.cs` + 新 `Services/ClassroomService.cs`)

- `GET /api/me/classes`:当前用户可访问班级(id、名称、角色、有效权限)。
- `GET /api/classes`(管理员):全量列表(成员数、插件在线数、访客开关)。
- `POST /api/classes {name}`、`PUT /api/classes/{id} {name}`、`DELETE /api/classes/{id}`(默认班级不可删,409)。
- `PUT /api/classes/{id}/visitor {enabled}`(每班访客开关)。
- `GET/PUT /api/classes/{id}/members`(成员与角色编辑,管理员)。
- `POST /api/classes/batch {classIds, operation: enableVisitor|disableVisitor|delete}`(管理员;返回逐班结果)。
- `POST /api/plugin/pairing-code` 增加可选 `classId`(缺省默认班级)。
- `/api/visitor` 改为只管理全局 `autoEnter`;`/api/admin/status` 增加分班插件在线统计;插件凭据列表带班级名。
- shared 新增 `Models/ClassModels.cs`(`ClassSummary`/`ClassDetail`/`ClassMemberInput`/批量请求响应);`UserProfile` 增加可选 `classes` 字段(additive,旧客户端忽略)。

## 6. WebUI(Razor Pages)

- 新页 `Pages/Classes.cshtml`(管理员):班级列表(名称、成员数、插件在线、访客开关)、新建/改名/删除、多选批量开关访客与批量删除、每班成员管理(添加用户+选角色)、创建绑定班级的配对码入口;侧边栏加入口。
- 布局头部班级切换器:列出可访问班级,POST 设置 `RemoteCI.Class` Cookie 后回跳(`Pages/ClassSwitch.cshtml` 或共享 handler)。
- 访客页 `Visitor`:`?class=<id>` 选择班级;服务端渲染"开启访客功能的班级"下拉/标签;无任何班级开启→跳 `/Login`(现状行为)。
- 登录落地:设置当前班级 Cookie(首个可访问班级);`AutoEnter` 逻辑改为"全局 autoEnter 且存在开启访客的班级"。
- `Index/Schedule/Control/Notifications` 改用当前班级数据与按班权限(Control 的按钮按 `ClassPermissions` 显隐);`SystemConfig` 的访客区改为 autoEnter 开关(班级级开关移到 Classes 页)。

## 7. 备份/恢复(`ConfigurationArchiveService`)

- 快照版本 2→3:新增 `ClassroomSnapshot`/`MembershipSnapshot`,`PluginSnapshot` 带 `ClassroomId`,`MetadataSnapshot` 移除 `VisitorAccessEnabled`(导入 v2 旧包时映射到默认班级的访客开关,沿 `UpgradeImportedPermissions` 的升级模式)。`ApplyAsync` 恢复班级/成员关系。

## 8. 测试(server/tests/RemoteCI.Server.Tests)

- 新增 `ClassManagementTests`:班级 CRUD 鉴权(仅管理员)、每班访客开关、批量操作(含默认班级保护)、成员编辑、班级间权限隔离(A 班学生读不到 B 班 state;班管理员只能在本班发命令)、切换班级校验。
- 新增 StateStore 分桶单测;`WebSocketRelayTests` 扩展按班级路由(A 班插件状态不推给 B 班 watch)。
- 更新 `VisitorAccessTests`(多班级访客:下拉列出开启班级、`?class=` 切换、全关→跳登录)、`RoleAndBackupTests`(v3 备份往返 + v2 旧包导入)、受影响的 `ApiTests` 等。

## 9. 实施顺序

实体+迁移+Bootstrap → ClassAccessService/StateStore 分桶 → WS 路由 → API → WebUI → 备份 → 测试修补与新增。每步跑 `dotnet test`(server/tests)验证。

## 明确不做(后续工作)

ClassIsland 插件、Android、WearOS 客户端的多班级 UI 与按班数据流过滤(服务端已向后兼容);班内用户自助管理;批量通知/命令(批量端点预留 operation 扩展位)。
111 changes: 111 additions & 0 deletions .zcodeignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
# ── .NET ──────────────────────────────────────────────
bin/
obj/
*.user
*.suo
*.userosscache
*.sln.docstates
packages/
artifacts/
.tools/
.vs/
# ClassIsland 插件打包产物(可重新生成)
*.cipx
cipx/
plugin/RemoteCI.Plugin/publish/

# ── Android / Gradle ─────────────────────────────────
*.apk
*.aab
*.ap_
*.dex
wearos/.gradle/
wearos/build/
wearos/**/build/
wearos/local.properties
wearos/.idea/
wearos/keystore.properties
wearos/keystore/
*.iml
captures/
.externalNativeBuild/
.cxx/

# ── IDE / OS ─────────────────────────────────────────
.idea/
.vscode/
*.swp
*.swo
.DS_Store
Thumbs.db

# ── Runtime / logs ───────────────────────────────────
*.log
*.pid
*.seed
server/RemoteCI.Server/Data/*.db*
server/RemoteCI.Server/Data/updates/
server/RemoteCI.Server/Data/backups/
*.rcibak
# 防止把直接 publish 到项目目录产生的运行文件误提交。
server/RemoteCI.Server/*.dll
server/RemoteCI.Server/*.exe
server/RemoteCI.Server/*.pdb
server/RemoteCI.Server/*.deps.json
server/RemoteCI.Server/*.runtimeconfig.json
server/RemoteCI.Server/*.staticwebassets.endpoints.json
server/RemoteCI.Server/web.config
server/RemoteCI.Server/wwwroot/**/*.br
server/RemoteCI.Server/wwwroot/**/*.gz
# 直接以 dotnet 从仓库根目录运行服务端时产生的本地 SQLite 数据
/data/
*.pid.lock
npm-debug.log*
yarn-debug.log*
yarn-error.log*

# ── .codex 会话数据库 ─────────────────────────────────
.codex-design/*.db*

# 运行时 DataProtection 密钥
server/RemoteCI.Server/Data/keys/

android/.gradle/
android/build/
android/**/build/
android/local.properties
android/.idea/
android/keystore.properties
android/keystore/

# ===== ↑ 以上同步自 .gitignore(「从 .gitignore 同步」只重写以上部分)=====
.git/
.hg/
.svn/
node_modules/
bower_components/
jspm_packages/
__pycache__/
site-packages/
venv/
coverage/
htmlcov/
lcov-report/
cmakefiles/
cmake-build-*/
bazel-*/
pods/
deriveddata/
storybook-static/
playwright-report/
test-results/
allure-results/
allure-report/
cdk.out/
*.egg-info/
*.dist-info/
eggs/
pip-wheel-metadata/
wheels/
# ----- ↑ 以上为 ZCode 默认排除规则(自定义规则请写在本行下方,不会被同步/恢复改动)-----
# 自定义规则写在下方(本行提示可删除)
10 changes: 6 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,16 @@
[![Release](https://img.shields.io/github/v/release/Edge-HH/RemoteCI?include_prereleases)](https://github.com/Edge-HH/RemoteCI/releases)
[![License: GPL-3.0](https://img.shields.io/badge/license-GPL--3.0-blue.svg)](LICENSE)

> 把 ClassIsland 的课表、通知和受控操作,安全地延伸到 Web 与 Wear OS。
> 把 ClassIsland 的课表、通知和受控操作,安全地延伸到 Web、手机和 Wear OS。

RemoteCI 是面向 ClassIsland 2.x 的跨设备联动系统,由 ClassIsland 插件、ASP.NET Core 服务端、WebUI 和 Wear OS 应用组成。它让用户可以在浏览器或手表上查看当前课程与未来七日课表、接收课堂事件,并在权限允许时执行通知、换课和扩展操作。
RemoteCI 是面向 ClassIsland 2.x 的跨设备联动系统,由 ClassIsland 插件、ASP.NET Core 服务端、WebUI、Android 手机和 Wear OS 应用组成。它让用户可以在浏览器、手机或手表上查看当前课程与未来七日课表、接收课堂事件,并在权限允许时执行通知、换课和扩展操作。

当前稳定软件版本为 `3.2.1.4`,通信协议为 V3。稳定版使用 ClassIsland 要求的四段纯数字版本;保留的 Beta 使用 `v3.x.x-beta.y`,仅用于测试且不会进入插件市场。

## ✨ 主要能力

- **课程状态同步**:插件按秒推送当前课程状态,并独立同步未来七日课表。
- **Web 与手表联动**:在 WebUI 和 Wear OS 上查看课表、课堂事件与连接状态。
- **Web、手机与手表联动**:在 WebUI、Android 手机和 Wear OS 上查看课表、课堂事件与连接状态。
- **通知与“老师来了”**:从 WebUI 或手表触发 ClassIsland 正式通知,并把结果同步到在线设备。
- **课表操作**:支持手动拉取、自动拉取、换课与并发修订校验;多个入口共用任务锁,避免重复执行。
- **细粒度权限**:按账号分别控制概览、人员管理、通知、换课、扩展、主界面和电源等能力,命令会在服务端与插件端再次鉴权。
Expand Down Expand Up @@ -42,7 +42,7 @@ ClassIsland + RemoteCI 插件
⇅ WebSocket / 局域网
ASP.NET Core 服务端 ─── WebUI
⇅ 云端或局域网
Wear OS 应用
Android 手机 / Wear OS 应用
```

| 目录 | 职责 |
Expand All @@ -51,6 +51,7 @@ ASP.NET Core 服务端 ─── WebUI
| `server/` | ASP.NET Core 服务端、Razor WebUI、Identity 与 SQLite |
| `plugin/` | ClassIsland 2.x 插件、远程命令执行与 CIPX 构建 |
| `wearos/` | Kotlin / Compose for Wear OS 应用 |
| `android/` | Kotlin / Jetpack Compose Material 3 Expressive 手机应用 |
| `fnos/` | 飞牛 fnOS FPK 工程与打包脚本 |
| `docs/` | 部署、协议和平台说明 |

Expand All @@ -64,6 +65,7 @@ ASP.NET Core 服务端 ─── WebUI

- `RemoteCI.Plugin.cipx`:ClassIsland 插件市场使用的固定名称插件包。
- `RemoteCI.Watch-<版本>.apk`:Wear OS 应用。
- `RemoteCI.Mobile-<版本>.apk`:Android 手机应用。
- 服务端压缩包:Windows 或 Linux 部署。
- `RemoteCI-<版本>.fpk`:fnOS 在线多架构包,或 x86_64 / ARM64 单架构离线包。

Expand Down
Loading
Loading