门禁加固与 Windows 可跑性
- 新增 scripts/check-generated-bindings.mjs 并接入 lint 与 ai-game-creator-shell:check:rust:crates:重跑 export_bindings 前后逐字节比对共享契约绑定。 - check:native-shells 的 AGC 边界改为负向门禁:渲染源码不得出现 fetch(/XHR/EventSource/sendBeacon/WebSocket/plugin-http,客户端 capability 不得授予 http:default。 - check-pingora-gateway-smoke 在 Windows 上找 pingora-gateway.exe,并修掉「API 并发保护」先关客户端连接再读响应的竞态。 - mobile-shell 三个 smoke 改成固定命令串启动 npm(绕开 npm.cmd EINVAL 与 DEP0190),desktop-shell 的 stage-release-binary 改用 fileURLToPath 修掉 F:\F:\… 路径拼接。 - vitest.config.ts 白名单补回现役根用例;三份 nginx 模板的 SPA allowlist 补回 /components 与 /design-system。
This commit is contained in:
@@ -0,0 +1,81 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* 校验 ts-rs 生成的共享契约绑定与 Rust 声明一致。
|
||||
*
|
||||
* 做法是「重新生成一遍并与工作区现有内容逐字节比较」:只比 git diff 会漏掉「Rust 改了但
|
||||
* 生成文件没重新跑」以外的情形,也很容易被本地未提交改动掩盖;比较生成前后快照更直接——
|
||||
* 只要重新生成后的内容与现有内容不同,就说明仓库里的绑定是陈旧的。
|
||||
*/
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const repoRoot = path.resolve(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
'..',
|
||||
);
|
||||
const generatedDir = path.join(
|
||||
repoRoot,
|
||||
'packages/shared/src/contracts/generated',
|
||||
);
|
||||
|
||||
function snapshot() {
|
||||
if (!fs.existsSync(generatedDir)) return new Map();
|
||||
return new Map(
|
||||
fs
|
||||
.readdirSync(generatedDir, { withFileTypes: true })
|
||||
.filter((entry) => entry.isFile())
|
||||
.map((entry) => [
|
||||
entry.name,
|
||||
fs.readFileSync(path.join(generatedDir, entry.name)),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
const before = snapshot();
|
||||
const result = spawnSync(
|
||||
'cargo',
|
||||
[
|
||||
'test',
|
||||
'--locked',
|
||||
'-p',
|
||||
'shared-contracts',
|
||||
'--features',
|
||||
'ts-bindings',
|
||||
'--manifest-path',
|
||||
'server-rs/Cargo.toml',
|
||||
'export_bindings',
|
||||
],
|
||||
{ cwd: repoRoot, encoding: 'utf8' },
|
||||
);
|
||||
if (result.status !== 0) {
|
||||
console.error('生成绑定校验无法运行:export_bindings 未通过');
|
||||
if (result.stdout) console.error(result.stdout.trim());
|
||||
if (result.stderr) console.error(result.stderr.trim());
|
||||
process.exit(result.status ?? 1);
|
||||
}
|
||||
const after = snapshot();
|
||||
|
||||
const problems = [];
|
||||
for (const [name, content] of after) {
|
||||
const previous = before.get(name);
|
||||
if (!previous) problems.push(`新增 ${name}`);
|
||||
else if (!previous.equals(content)) problems.push(`内容变化 ${name}`);
|
||||
}
|
||||
for (const name of before.keys()) {
|
||||
if (!after.has(name)) problems.push(`删除 ${name}`);
|
||||
}
|
||||
|
||||
if (problems.length > 0) {
|
||||
console.error('生成绑定与 Rust 声明不一致:');
|
||||
for (const problem of problems) console.error(` - ${problem}`);
|
||||
console.error(
|
||||
'请运行 `cargo test -p shared-contracts --features ts-bindings export_bindings`,并把重新生成的结果与 Rust 改动一并提交。',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`生成绑定校验通过:${after.size} 个文件与 Rust 声明一致(${path.relative(repoRoot, generatedDir)})。`,
|
||||
);
|
||||
@@ -2564,21 +2564,47 @@ function assertAiGameCreatorShellUserDevBoundary() {
|
||||
!aiGameCreatorClientHttpSource.includes(
|
||||
"'https://dev.genarrative.world'",
|
||||
) ||
|
||||
!aiGameCreatorClientHttpSource.includes("transport: 'tauri-http'") ||
|
||||
!aiGameCreatorClientHttpSource.includes('target.origin !==')
|
||||
!aiGameCreatorClientHttpSource.includes(
|
||||
"'https://www.genarrative.world'",
|
||||
) ||
|
||||
/\bfetch\s*\(/u.test(aiGameCreatorClientHttpSource) ||
|
||||
aiGameCreatorClientHttpSource.includes("transport: 'tauri-http'")
|
||||
) {
|
||||
throw new Error(
|
||||
'AI game creator release dev API transport boundary drifted',
|
||||
'AI game creator clientHttp must only resolve the server selection, not carry a transport',
|
||||
);
|
||||
}
|
||||
// 渲染层是离线前端:平台接口、OSS 直传、Provider 与更新清单的网络 IO 全部在 Rust 侧
|
||||
// (`src-tauri/src/account_api.rs` / `auth_session.rs` / `platform_asset_upload.rs` /
|
||||
// `game_distribution_publish.rs` 等 reqwest facade)。这里对整棵渲染源码加网络原语与
|
||||
// Tauri HTTP guest 的负向门禁,避免以后再长出第二条传输路径。
|
||||
const rendererNetworkPrimitivePatterns = [
|
||||
['fetch(', /\bfetch\s*\(/u],
|
||||
['XMLHttpRequest', /\bXMLHttpRequest\b/u],
|
||||
['EventSource', /\bEventSource\b/u],
|
||||
['sendBeacon(', /\bsendBeacon\s*\(/u],
|
||||
['new WebSocket(', /\bnew\s+WebSocket\s*\(/u],
|
||||
['@tauri-apps/plugin-http', /@tauri-apps\/plugin-http/u],
|
||||
];
|
||||
for (const rendererFilePath of collectFiles(
|
||||
'apps/ai-game-creator-shell/src',
|
||||
(entryPath) => /\.(ts|tsx)$/u.test(entryPath),
|
||||
)) {
|
||||
const rendererSource = fs.readFileSync(rendererFilePath, 'utf8');
|
||||
for (const [label, pattern] of rendererNetworkPrimitivePatterns) {
|
||||
if (pattern.test(rendererSource)) {
|
||||
throw new Error(
|
||||
`AI game creator renderer must stay offline, but ${normalizeScannedFilePath(rendererFilePath)} contains ${label}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (
|
||||
!aiGameCreatorCargoManifestSource.includes(
|
||||
'tauri-plugin-http = { version = "2.5.9", default-features = false, features = ["charset", "cookies", "http2", "rustls-tls"] }',
|
||||
) ||
|
||||
!aiGameCreatorShellTauriSource.includes('tauri_plugin_http::init()')
|
||||
aiGameCreatorCargoManifestSource.includes('tauri-plugin-http') ||
|
||||
aiGameCreatorShellTauriSource.includes('tauri_plugin_http::init()')
|
||||
) {
|
||||
throw new Error(
|
||||
'AI game creator release must register the native HTTP plugin without the OS automatic system-proxy feature',
|
||||
'AI game creator shell must not register a renderer HTTP plugin; native IO goes through the Rust reqwest facades',
|
||||
);
|
||||
}
|
||||
if (
|
||||
@@ -2588,27 +2614,18 @@ function assertAiGameCreatorShellUserDevBoundary() {
|
||||
'AI game creator monorepo build must dedupe React runtime packages',
|
||||
);
|
||||
}
|
||||
const httpPermission = (aiGameCreatorMainCapability.permissions ?? []).find(
|
||||
(permission) =>
|
||||
typeof permission === 'object' &&
|
||||
permission?.identifier === 'http:default',
|
||||
);
|
||||
if (
|
||||
!httpPermission ||
|
||||
JSON.stringify(httpPermission.allow ?? []) !==
|
||||
JSON.stringify([
|
||||
{ url: 'https://dev.genarrative.world/api/*' },
|
||||
{ url: 'https://www.genarrative.world/api/*' },
|
||||
{ url: 'https://*/api/*' },
|
||||
{ url: 'http://localhost:*/*' },
|
||||
{ url: 'http://127.0.0.1:*/*' },
|
||||
{ url: 'https://*.aliyuncs.com/*' },
|
||||
])
|
||||
(aiGameCreatorMainCapability.permissions ?? []).some(
|
||||
(permission) =>
|
||||
permission === 'http:default' ||
|
||||
(typeof permission === 'object' &&
|
||||
permission?.identifier === 'http:default'),
|
||||
)
|
||||
) {
|
||||
throw new Error(
|
||||
// 更新清单与安装包下载由 tauri-plugin-updater 在原生侧完成;
|
||||
// 封面与截图仍通过 webview 的 http 插件向凭证指定的 OSS 地址直传。
|
||||
'AI game creator native HTTP scope must match the release, dev, custom HTTPS, loopback API, and OSS media upload boundary',
|
||||
// 客户端窗口只保留剪贴板、图片、资源关闭、opener、updater 与原生对话框权限;
|
||||
// 更新清单下载由 tauri-plugin-updater 在原生侧完成,素材直传也不再经渲染层。
|
||||
'AI game creator client capability must not grant the renderer HTTP permission',
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1720,8 +1720,10 @@ async function expectConcurrencyLimit(baseUrl, api) {
|
||||
);
|
||||
} finally {
|
||||
api.state.releaseHold?.();
|
||||
hold.socket.end();
|
||||
// 先读完被放行的响应再关客户端连接:原先「释放 hold 后立刻 FIN」会让客户端半关闭
|
||||
// 与上游响应抢跑,Windows 上稳定表现为 `HTTP 响应提前关闭`(Linux 上只是偶尔更宽松)。
|
||||
const holdResponse = await hold.done.catch((error) => ({ error }));
|
||||
hold.socket.end();
|
||||
if (holdResponse?.error) {
|
||||
failures.push(
|
||||
`API 并发保护: hold 请求失败:${holdResponse.error.message}`,
|
||||
@@ -2519,9 +2521,12 @@ function resolveGatewayBinary() {
|
||||
return explicit;
|
||||
}
|
||||
|
||||
// Windows 上 cargo 产出的是 `pingora-gateway.exe`;Linux 侧名字保持不变。
|
||||
const binaryName =
|
||||
process.platform === 'win32' ? 'pingora-gateway.exe' : 'pingora-gateway';
|
||||
const candidates = [
|
||||
path.join(repoRoot, 'server-rs', 'target', 'debug', 'pingora-gateway'),
|
||||
path.join(repoRoot, 'target', 'debug', 'pingora-gateway'),
|
||||
path.join(repoRoot, 'server-rs', 'target', 'debug', binaryName),
|
||||
path.join(repoRoot, 'target', 'debug', binaryName),
|
||||
];
|
||||
const found = candidates.find((candidate) => existsSync(candidate));
|
||||
if (!found) {
|
||||
|
||||
Reference in New Issue
Block a user