完善桌面端交互体验与发布检查
This commit is contained in:
@@ -0,0 +1,170 @@
|
||||
import { readdir, readFile } from "node:fs/promises";
|
||||
import { extname, relative, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const frontendDir = fileURLToPath(new URL("../", import.meta.url));
|
||||
const rootDir = resolve(frontendDir, "..");
|
||||
const sourceDir = resolve(frontendDir, "src");
|
||||
const tauriDir = resolve(frontendDir, "src-tauri");
|
||||
const failures = [];
|
||||
|
||||
const readJson = async (path) => JSON.parse(await readFile(path, "utf8"));
|
||||
|
||||
const fail = (message) => failures.push(message);
|
||||
const assert = (condition, message) => {
|
||||
if (!condition) fail(message);
|
||||
};
|
||||
|
||||
const walk = async (directory) => {
|
||||
const entries = await readdir(directory, { withFileTypes: true });
|
||||
return (
|
||||
await Promise.all(
|
||||
entries.map(async (entry) => {
|
||||
const path = resolve(directory, entry.name);
|
||||
return entry.isDirectory() ? walk(path) : path;
|
||||
}),
|
||||
)
|
||||
).flat();
|
||||
};
|
||||
|
||||
const permissionIdentifier = (permission) =>
|
||||
typeof permission === "string" ? permission : typeof permission?.identifier === "string" ? permission.identifier : "";
|
||||
|
||||
const assertPathScope = (permission, expectedPrefix, description) => {
|
||||
const allow = Array.isArray(permission.allow) ? permission.allow : [];
|
||||
assert(allow.length > 0, `${description} must define an explicit allow list.`);
|
||||
for (const item of allow) {
|
||||
const path = item?.path;
|
||||
assert(
|
||||
typeof path === "string" && path.startsWith(expectedPrefix),
|
||||
`${description} may only allow paths under ${expectedPrefix}; found ${path ?? "<missing>"}.`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const verifyTauriConfig = async () => {
|
||||
const tauriConfig = await readJson(resolve(tauriDir, "tauri.conf.json"));
|
||||
const targets = tauriConfig.bundle?.targets;
|
||||
const targetList = Array.isArray(targets) ? targets : [targets].filter(Boolean);
|
||||
const csp = tauriConfig.app?.security?.csp || "";
|
||||
const cspTokens = csp.split(/[;\s]+/).filter(Boolean);
|
||||
|
||||
assert(tauriConfig.bundle?.active === true, "Tauri bundle must be active for desktop release builds.");
|
||||
assert(targetList.includes("app"), "Tauri bundle targets must include app.");
|
||||
assert(targetList.includes("dmg"), "Tauri bundle targets must include dmg for macOS distribution.");
|
||||
assert(
|
||||
tauriConfig.bundle?.createUpdaterArtifacts === true,
|
||||
"Tauri must create updater artifacts for signed desktop release builds.",
|
||||
);
|
||||
assert(
|
||||
typeof tauriConfig.plugins?.updater?.pubkey === "string" && tauriConfig.plugins.updater.pubkey.length > 80,
|
||||
"Tauri updater public key must be configured.",
|
||||
);
|
||||
assert(csp.includes("default-src 'self'"), "Tauri CSP must keep default-src restricted to self.");
|
||||
assert(csp.includes("object-src 'none'"), "Tauri CSP must disable object-src.");
|
||||
assert(!csp.includes("'unsafe-eval'"), "Tauri CSP must not allow unsafe-eval.");
|
||||
assert(
|
||||
!cspTokens.some((token) => token === "*" || token.includes("://*")),
|
||||
"Tauri CSP must not use wildcard sources.",
|
||||
);
|
||||
assert(!/\bconnect-src\b[^;]*\bhttp:\b/.test(csp), "Tauri CSP must not allow broad http: API access.");
|
||||
};
|
||||
|
||||
const verifyCapabilities = async () => {
|
||||
const capabilitiesDir = resolve(tauriDir, "capabilities");
|
||||
const files = (await readdir(capabilitiesDir)).filter((file) => file.endsWith(".json"));
|
||||
assert(files.length > 0, "At least one Tauri capability file must exist.");
|
||||
|
||||
const bannedPermissions = new Set([
|
||||
"shell:default",
|
||||
"shell:allow-open",
|
||||
"shell:allow-execute",
|
||||
"fs:default",
|
||||
"fs:allow-read-dir",
|
||||
"fs:allow-read-text-file",
|
||||
"fs:allow-write-text-file",
|
||||
]);
|
||||
|
||||
for (const file of files) {
|
||||
const capability = await readJson(resolve(capabilitiesDir, file));
|
||||
const permissions = Array.isArray(capability.permissions) ? capability.permissions : [];
|
||||
const identifiers = permissions.map(permissionIdentifier).filter(Boolean);
|
||||
|
||||
for (const identifier of identifiers) {
|
||||
assert(!identifier.startsWith("shell:"), `${file}: shell permissions are not allowed.`);
|
||||
assert(!bannedPermissions.has(identifier), `${file}: ${identifier} is not allowed for CTMS Desktop.`);
|
||||
assert(!identifier.includes("persisted-scope"), `${file}: persisted filesystem scopes are not allowed.`);
|
||||
}
|
||||
|
||||
const fsScope = permissions.find((permission) => permissionIdentifier(permission) === "fs:scope");
|
||||
assert(Boolean(fsScope), `${file}: fs:scope is required and must be constrained to temporary files.`);
|
||||
if (fsScope && typeof fsScope !== "string") {
|
||||
assertPathScope(fsScope, "$TEMP/ctms-desktop/", `${file}: fs:scope`);
|
||||
}
|
||||
|
||||
const openerScope = permissions.find((permission) => permissionIdentifier(permission) === "opener:allow-open-path");
|
||||
assert(Boolean(openerScope), `${file}: opener:allow-open-path must be explicitly scoped.`);
|
||||
if (openerScope && typeof openerScope !== "string") {
|
||||
assertPathScope(openerScope, "$TEMP/ctms-desktop/", `${file}: opener:allow-open-path`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const verifyRustBoundary = async () => {
|
||||
const libSource = await readFile(resolve(tauriDir, "src/lib.rs"), "utf8");
|
||||
const forbiddenRust = ["tauri_plugin_shell", "std::process::Command", "std::process"];
|
||||
for (const token of forbiddenRust) {
|
||||
assert(!libSource.includes(token), `Rust desktop boundary must not include ${token}.`);
|
||||
}
|
||||
|
||||
const singleInstanceIndex = libSource.indexOf("tauri_plugin_single_instance::init");
|
||||
const dialogIndex = libSource.indexOf("tauri_plugin_dialog::init");
|
||||
assert(singleInstanceIndex >= 0, "Single-instance plugin must be registered.");
|
||||
assert(
|
||||
dialogIndex < 0 || singleInstanceIndex < dialogIndex,
|
||||
"Single-instance plugin must be registered before other desktop plugins.",
|
||||
);
|
||||
|
||||
const handlerSource = libSource.match(/generate_handler!\s*\\?\[([\s\S]*?)\]/)?.[1] || "";
|
||||
const commands = handlerSource.match(/[a-z_]+::[a-z_]+/g) || [];
|
||||
const allowedCommands = [
|
||||
"credentials::credential_get",
|
||||
"credentials::credential_set",
|
||||
"credentials::credential_delete",
|
||||
"updates::desktop_update_check",
|
||||
"updates::desktop_update_install",
|
||||
];
|
||||
const unexpected = commands.filter((command) => !allowedCommands.includes(command));
|
||||
const missing = allowedCommands.filter((command) => !commands.includes(command));
|
||||
assert(unexpected.length === 0, `Unexpected Tauri commands: ${unexpected.join(", ") || "<none>"}.`);
|
||||
assert(missing.length === 0, `Missing expected Tauri commands: ${missing.join(", ") || "<none>"}.`);
|
||||
};
|
||||
|
||||
const verifySourceSafety = async () => {
|
||||
const sourceExtensions = new Set([".ts", ".tsx", ".vue", ".js", ".jsx", ".rs"]);
|
||||
const files = [
|
||||
...(await walk(sourceDir)),
|
||||
...(await walk(resolve(tauriDir, "src"))),
|
||||
].filter((path) => sourceExtensions.has(extname(path)));
|
||||
|
||||
for (const path of files) {
|
||||
const source = await readFile(path, "utf8");
|
||||
const file = relative(rootDir, path);
|
||||
assert(!/[?&]token=/.test(source), `${file}: token must not be passed through query parameters.`);
|
||||
if (source.includes("ctms_token") && file !== "frontend/src/runtime/secureSessionStorage.ts") {
|
||||
fail(`${file}: ctms_token may only be handled by secureSessionStorage.`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
await verifyTauriConfig();
|
||||
await verifyCapabilities();
|
||||
await verifyRustBoundary();
|
||||
await verifySourceSafety();
|
||||
|
||||
if (failures.length > 0) {
|
||||
console.error(`Desktop release gate failed:\n${failures.map((item) => ` - ${item}`).join("\n")}`);
|
||||
process.exitCode = 1;
|
||||
} else {
|
||||
console.log("Desktop release gate passed.");
|
||||
}
|
||||
@@ -32,38 +32,71 @@ const missing = [
|
||||
];
|
||||
|
||||
const pageContractChecks = [
|
||||
"src/views/ia/ProjectMilestones.vue",
|
||||
"src/views/ia/SubjectManagement.vue",
|
||||
"src/views/ia/RiskIssueSae.vue",
|
||||
"src/views/ia/RiskIssuePd.vue",
|
||||
"src/views/ia/RiskIssueMonitoringVisits.vue"
|
||||
{
|
||||
file: "src/views/ia/ProjectMilestones.vue",
|
||||
groups: [
|
||||
["ctms-page-shell", "page"],
|
||||
["unified-action-bar", "table-card-toolbar"],
|
||||
["ctms-table-card", "table-card"]
|
||||
]
|
||||
},
|
||||
{
|
||||
file: "src/views/ia/SubjectManagement.vue",
|
||||
groups: [
|
||||
["ctms-page-shell", "page"],
|
||||
["unified-action-bar", "table-card-toolbar"],
|
||||
["ctms-table-card", "table-card"],
|
||||
["subject-table"]
|
||||
]
|
||||
},
|
||||
{
|
||||
file: "src/views/ia/RiskIssueSae.vue",
|
||||
groups: [
|
||||
["ctms-page-shell", "page"],
|
||||
["unified-action-bar", "table-card-toolbar"],
|
||||
["ctms-table-card", "table-card"],
|
||||
["risk-table"]
|
||||
]
|
||||
},
|
||||
{
|
||||
file: "src/views/ia/RiskIssuePd.vue",
|
||||
groups: [
|
||||
["ctms-page-shell", "page"],
|
||||
["unified-action-bar", "table-card-toolbar"],
|
||||
["ctms-table-card", "table-card"],
|
||||
["risk-table"]
|
||||
]
|
||||
},
|
||||
{
|
||||
file: "src/views/ia/RiskIssueMonitoringVisits.vue",
|
||||
groups: [
|
||||
["ctms-page-shell", "page"],
|
||||
["unified-action-bar", "monitoring-toolbar", "template-toolbar", "toolbar"],
|
||||
["ctms-table-card", "table-card", "monitoring-table", "issue-table-panel"],
|
||||
["issue-table"]
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
const requiredPageClasses = [
|
||||
"ctms-page-shell",
|
||||
"unified-action-bar",
|
||||
"ctms-table-card"
|
||||
];
|
||||
|
||||
for (const file of pageContractChecks) {
|
||||
for (const { file, groups } of pageContractChecks) {
|
||||
const content = readFileSync(file, "utf8");
|
||||
for (const className of requiredPageClasses) {
|
||||
if (!content.includes(className)) {
|
||||
missing.push(`${file}:${className}`);
|
||||
for (const group of groups) {
|
||||
if (!group.some((className) => content.includes(className))) {
|
||||
missing.push(`${file}:${group.join("|")}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const overview = readFileSync("src/views/ia/ProjectOverview.vue", "utf8");
|
||||
const requiredOverviewClasses = [
|
||||
"ctms-page-shell",
|
||||
"kpi",
|
||||
"unified-section"
|
||||
const requiredOverviewGroups = [
|
||||
["ctms-page-shell", "page"],
|
||||
["kpi", "overview-card"],
|
||||
["unified-section", "overview-container"]
|
||||
];
|
||||
|
||||
for (const className of requiredOverviewClasses) {
|
||||
if (!overview.includes(className)) {
|
||||
missing.push(`src/views/ia/ProjectOverview.vue:${className}`);
|
||||
for (const group of requiredOverviewGroups) {
|
||||
if (!group.some((className) => overview.includes(className))) {
|
||||
missing.push(`src/views/ia/ProjectOverview.vue:${group.join("|")}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user