Files
ClassHelper/packages/classisland-plugin/scripts/build.mjs
T
LoveChengke d0830f6830 feat(integrations,classisland,server,web,desktop): ClassIsland 联动插件——上报课表与上课状态、下发提醒并回执、教室端推送通道
教室机器上的 ClassIsland 现在可以直接和班级小助手互通:插件走 /api/integrations/classisland/*,用设备令牌(X-ClassIsland-Token,库里只存 sha256)独立鉴权,不占用 JWT 通道。

- 服务端:新增 integrations 模块(上报 / 待弹出 / 回执 / 课表镜像 / 回写),两个迁移(设备令牌表、Class.notificationChannel);通知、叫人、联动页下发统一复用 pushToClassIsland,避免"某个页面发的通知不联动"
- 插件:packages/classisland-plugin(.NET 8,独立于 pnpm workspace)——BridgeService 上报与提醒轮询、ScheduleMapper/ClassPlanWriter 课表镜像、Avalonia 设置页、ClassHelperNotificationProvider 在 ClassIsland 上弹出提醒;本地/内网地址强制直连,躲开系统代理返回的假 404
- 客户端:设置页可选提醒通道(both / client / classisland),并订阅相应实时事件
- Web 端:新增「ClassIsland 联动」页(设备管理与提醒下发)
- 校验与打包:verify:classisland(需后端)、verify:classisland-plugin(静态契约 54 项)、build/dist:classisland-plugin 产出 .cipx
- 文档:README 与 AGENTS.md 同步新增联动说明,docs/screenshots/classisland 留三张真机截图

门禁:pnpm lint 0 error、pnpm typecheck 四包通过、verify:classisland-plugin 54/54 通过。运行期仍需在装了 ClassIsland 的机器上实测(步骤见 AGENTS.md §8)。
2026-09-27 14:59:12 +08:00

175 lines
7.5 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* ClassIsland 联动插件构建脚本:`pnpm build:classisland-plugin` / `pnpm dist:classisland-plugin`
*
* 用法:
* node scripts/build.mjs # Release 构建,产物在 packages/classisland-plugin/bin/Release
* node scripts/build.mjs --cipx # 追加打包 .cipx(ClassIsland 插件包)并归集到 releases/classisland-plugin/
* 额外参数会原样透传给 `dotnet build`(例如 `-p:ClassIslandPluginSdkVersion=2.1.1.1`)
*
* 为什么需要这层包装(本机踩过的坑,写在这里免得换台机器又踩一遍):
* 1. `dotnet` 默认把 NuGet 缓存与临时文件写在 C 盘(`%USERPROFILE%\.nuget`、`%TEMP%`),
* 本机 C 盘紧张时会直接构建失败。这里统一改到仓库内 `.cache/`(已 gitignore)。
* 2. 插件包(.cipx)的最后一步要调 PowerShell 生成哈希清单,SDK 默认用 `pwsh`;
* 没有 pwsh 的机器会失败,因此这里探测一次并显式传给 MSBuild。
* 3. 构建完顺手校验"清单 / 程序集 / 版本号"三者一致,避免打出装不上的包。
*/
import { spawnSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const pluginDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const repoRoot = path.resolve(pluginDir, '..', '..');
const cacheDir = path.join(repoRoot, '.cache');
const releasesDir = path.join(repoRoot, 'releases', 'classisland-plugin');
const argv = process.argv.slice(2);
const withCipx = argv.includes('--cipx');
/** `--` 之后的参数原样透传给 dotnet build;其余以 - 开头的参数同样透传 */
const passthrough = argv.filter((item) => item !== '--cipx' && item !== '--');
const log = (message) => console.log(`[classisland-plugin] ${message}`);
/* ------------------------------------------------------------ 1. 环境准备 */
const env = { ...process.env };
for (const sub of ['nuget', 'tmp', 'dotnet-home']) {
fs.mkdirSync(path.join(cacheDir, sub), { recursive: true });
}
const setDefault = (key, value) => {
if (!env[key]) env[key] = value;
};
setDefault('NUGET_PACKAGES', path.join(cacheDir, 'nuget'));
setDefault('TEMP', path.join(cacheDir, 'tmp'));
setDefault('TMP', env.TEMP);
setDefault('DOTNET_CLI_HOME', path.join(cacheDir, 'dotnet-home'));
env.DOTNET_NOLOGO = '1';
env.DOTNET_CLI_TELEMETRY_OPTOUT = '1';
/** 可执行文件探测:先看显式配置,再问 PATH,最后试常见安装路径 */
function resolveExecutable(envKey, names, fallbacks) {
const candidates = [];
if (env[envKey]) candidates.push(env[envKey]);
const finder = process.platform === 'win32' ? 'where' : 'which';
for (const name of names) {
const found = spawnSync(finder, [name], { encoding: 'utf8', env });
if (found.status === 0) {
for (const line of String(found.stdout ?? '').split(/\r?\n/)) candidates.push(line.trim());
}
}
candidates.push(...fallbacks);
for (const candidate of candidates) {
if (!candidate) continue;
try {
if (fs.statSync(candidate).isFile()) return candidate;
} catch {
/* 继续试下一个 */
}
}
return null;
}
const dotnet = resolveExecutable(
'DOTNET_EXE',
['dotnet'],
process.platform === 'win32'
? [path.join(env.ProgramFiles ?? 'C:\\Program Files', 'dotnet', 'dotnet.exe')]
: ['/usr/local/bin/dotnet', '/usr/bin/dotnet'],
);
if (!dotnet) {
throw new Error('未找到 dotnet SDK(需要 .NET 8 SDK)。安装后用 DOTNET_EXE 指定完整路径。');
}
const powerShell = resolveExecutable(
'POWERSHELL_EXE',
['pwsh', 'powershell'],
process.platform === 'win32'
? [path.join(env.SystemRoot ?? 'C:\\Windows', 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe')]
: [],
);
/* ------------------------------------------------------------ 2. 构建 */
const buildArgs = ['build', '-c', 'Release'];
if (withCipx) {
buildArgs.push('-p:CreateCipx=true');
// SDK 的 cipx 目标默认调 pwsh;这里按探测结果显式指定,避免"没有 pwsh 就打包失败"
if (powerShell) buildArgs.push(`-p:PowershellBinaryName=${powerShell}`);
}
buildArgs.push(...passthrough);
log(`dotnet build(缓存目录 ${path.relative(repoRoot, cacheDir)})`);
const build = spawnSync(dotnet, buildArgs, { cwd: pluginDir, stdio: 'inherit', env });
if (build.error) throw new Error(`无法启动 dotnet:${build.error.message}`);
if (build.status !== 0) throw new Error(`dotnet build 失败(退出码 ${build.status})`);
/* ------------------------------------------------------------ 3. 产物自检 */
const outputDir = path.join(pluginDir, 'bin', 'Release');
const manifestPath = path.join(outputDir, 'manifest.yml');
const assemblyPath = path.join(outputDir, 'ClassHelper.ClassIslandPlugin.dll');
for (const required of [manifestPath, assemblyPath, path.join(outputDir, 'icon.png')]) {
if (!fs.existsSync(required)) {
throw new Error(`构建产物缺少 ${path.relative(pluginDir, required)},ClassIsland 会加载失败`);
}
}
const manifest = fs.readFileSync(manifestPath, 'utf8');
// prettier 会把 YAML 字符串统一成单引号,因此两种引号都要能剥掉
const entrance = /^entranceAssembly:\s*(.+?)\s*$/m.exec(manifest)?.[1]?.replace(/^['"]|['"]$/g, '');
if (entrance !== path.basename(assemblyPath)) {
throw new Error(
`manifest.yml 的 entranceAssembly(${entrance})与程序集(${path.basename(assemblyPath)})不一致`,
);
}
const manifestVersion = /^version:\s*([\d.]+)/m.exec(manifest)?.[1];
const bridgeSource = fs.readFileSync(path.join(pluginDir, 'src', 'Services', 'BridgeService.cs'), 'utf8');
const codeVersion = /PluginVersion = "([\d.]+)"/.exec(bridgeSource)?.[1];
if (!manifestVersion || manifestVersion !== codeVersion) {
throw new Error(
`版本号不一致:manifest.yml=${manifestVersion ?? '缺失'},BridgeService.cs=${codeVersion ?? '缺失'}`,
);
}
log(`产物自检通过(版本 ${manifestVersion},入口程序集 ${entrance})`);
/* ------------------------------------------------------------ 4. 打包归集 */
if (!withCipx) {
log('完成。加 --cipx 可同时打包成 ClassIsland 插件包(.cipx)');
process.exit(0);
}
const cipxDir = path.join(pluginDir, 'cipx');
const cipxFile = path.join(cipxDir, 'ClassHelper.ClassIslandPlugin.cipx');
if (!fs.existsSync(cipxFile)) {
throw new Error(`未生成插件包:${path.relative(pluginDir, cipxFile)}`);
}
// 归集到 releases/classisland-plugin/(与客户端、服务端交付产物同级;releases/ 不入库)
fs.rmSync(releasesDir, { recursive: true, force: true });
fs.mkdirSync(releasesDir, { recursive: true });
fs.copyFileSync(cipxFile, path.join(releasesDir, path.basename(cipxFile)));
// 免安装目录:解压即用,直接放进 ClassIsland 的 Plugins 目录也能加载
const portableDir = path.join(releasesDir, 'ClassHelper.ClassIslandPlugin');
fs.mkdirSync(portableDir, { recursive: true });
for (const name of fs.readdirSync(outputDir)) {
const from = path.join(outputDir, name);
if (!fs.statSync(from).isFile()) continue;
// 依赖清单与调试符号对加载没影响,但会让老师误以为"要装一堆东西",因此只带最小集合
if (!['ClassHelper.ClassIslandPlugin.dll', 'manifest.yml', 'icon.png', 'README.md'].includes(name)) {
continue;
}
fs.copyFileSync(from, path.join(portableDir, name));
}
for (const file of fs.readdirSync(cipxDir)) {
fs.copyFileSync(path.join(cipxDir, file), path.join(releasesDir, file));
}
log(`插件包已归集到 ${path.relative(repoRoot, releasesDir)}`);
for (const file of fs.readdirSync(releasesDir)) {
log(` ${file}`);
}