release(main): 同步 dev 最新候选改动
Storage Persistence Guard / storage-persistence-audit (push) Has been cancelled
Client Quality Gates / Shared client and Web (push) Has been cancelled
Client Quality Gates / macOS Desktop (push) Has been cancelled
Client Quality Gates / Shared client and Web (pull_request) Has been cancelled
Client Quality Gates / macOS Desktop (pull_request) Has been cancelled
Storage Persistence Guard / storage-persistence-audit (pull_request) Has been cancelled

This commit is contained in:
Cheng Zhou
2026-07-16 17:15:50 +08:00
parent 32167fba02
commit d5279b124f
393 changed files with 51630 additions and 9711 deletions
@@ -0,0 +1,156 @@
import { copyFile, mkdir, readFile, stat, writeFile } from "node:fs/promises";
import { basename, resolve } from "node:path";
import { createHash } from "node:crypto";
import { fileURLToPath } from "node:url";
const frontendDir = fileURLToPath(new URL("../", import.meta.url));
const packageInfo = JSON.parse(await readFile(resolve(frontendDir, "package.json"), "utf8"));
const failures = [];
const args = process.argv.slice(2);
const values = (name) => {
const found = [];
for (let index = 0; index < args.length; index += 1) {
if (args[index] === name && args[index + 1]) {
found.push(args[index + 1]);
index += 1;
}
}
return found;
};
const value = (name) => values(name)[0];
const fail = (message) => failures.push(message);
const assert = (condition, message) => {
if (!condition) fail(message);
};
const artifactPath = value("--artifact") || process.env.DESKTOP_UPDATE_ARTIFACT;
const outputDir = resolve(
frontendDir,
value("--output-dir") || process.env.DESKTOP_UPDATE_OUTPUT_DIR || "src-tauri/target/release/desktop-update-feed",
);
const baseUrlRaw = value("--base-url") || process.env.DESKTOP_UPDATE_BASE_URL;
const pubDate = value("--date") || process.env.DESKTOP_UPDATE_PUB_DATE || new Date().toISOString();
const notes = value("--notes") || process.env.DESKTOP_UPDATE_NOTES;
const includes = values("--include").map((path) => resolve(frontendDir, path));
assert(Boolean(artifactPath), "--artifact or DESKTOP_UPDATE_ARTIFACT is required.");
assert(Boolean(baseUrlRaw), "--base-url or DESKTOP_UPDATE_BASE_URL is required.");
const resolvedArtifactPath = artifactPath ? resolve(frontendDir, artifactPath) : undefined;
const artifactName = resolvedArtifactPath ? basename(resolvedArtifactPath) : undefined;
const signaturePath = resolvedArtifactPath ? `${resolvedArtifactPath}.sig` : undefined;
const uploadFiles = [];
const readRequiredFile = async (path, description) => {
try {
return await readFile(path);
} catch (error) {
fail(`${description} cannot be read: ${path} (${error.message})`);
return undefined;
}
};
const sha256 = async (path) => {
const data = await readFile(path);
return createHash("sha256").update(data).digest("hex");
};
const normalizedBaseUrl = () => {
if (!baseUrlRaw) return undefined;
try {
const url = new URL(baseUrlRaw.endsWith("/") ? baseUrlRaw : `${baseUrlRaw}/`);
assert(url.protocol === "https:", "Desktop update artifact base URL must use HTTPS.");
assert(url.username === "" && url.password === "", "Desktop update artifact base URL must not include credentials.");
assert(!/[?&]token=/i.test(url.search), "Desktop update artifact base URL must not include token query parameters.");
assert(url.pathname.includes(packageInfo.version), `Desktop update artifact base URL must include version ${packageInfo.version}.`);
return url;
} catch (error) {
fail(`Desktop update artifact base URL is invalid: ${baseUrlRaw} (${error.message})`);
return undefined;
}
};
const copyIntoOutput = async (sourcePath) => {
const destination = resolve(outputDir, basename(sourcePath));
if (sourcePath !== destination) {
await copyFile(sourcePath, destination);
}
return destination;
};
if (resolvedArtifactPath && signaturePath) {
await readRequiredFile(resolvedArtifactPath, "Updater artifact");
const signature = await readRequiredFile(signaturePath, "Updater artifact signature");
if (signature) {
const signatureText = signature.toString("utf8").trim();
assert(signatureText.length > 80, "Updater artifact signature is unexpectedly short.");
}
}
for (const includePath of includes) {
try {
const metadata = await stat(includePath);
assert(metadata.isFile(), `Included release file must be a file: ${includePath}`);
} catch (error) {
fail(`Included release file cannot be read: ${includePath} (${error.message})`);
}
}
const baseUrl = normalizedBaseUrl();
if (failures.length === 0 && resolvedArtifactPath && signaturePath && artifactName && baseUrl) {
await mkdir(outputDir, { recursive: true });
const artifactUrl = new URL(artifactName, baseUrl).toString();
assert(!artifactUrl.endsWith("/latest.json"), "Updater artifact URL must not point at latest.json.");
assert(!/[?&]token=/i.test(new URL(artifactUrl).search), "Updater artifact URL must not include token query parameters.");
const signature = (await readFile(signaturePath, "utf8")).trim();
const latest = {
version: packageInfo.version,
pub_date: pubDate,
platforms: {
"darwin-aarch64": {
signature,
url: artifactUrl,
},
"darwin-x86_64": {
signature,
url: artifactUrl,
},
},
};
if (notes) {
latest.notes = notes;
}
uploadFiles.push(await copyIntoOutput(resolvedArtifactPath));
uploadFiles.push(await copyIntoOutput(signaturePath));
for (const includePath of includes) {
uploadFiles.push(await copyIntoOutput(includePath));
}
const latestPath = resolve(outputDir, "latest.json");
await writeFile(latestPath, `${JSON.stringify(latest, null, 2)}\n`);
uploadFiles.push(latestPath);
const uniqueFiles = [...new Map(uploadFiles.map((path) => [basename(path), path])).values()];
const checksumLines = [];
for (const filePath of uniqueFiles) {
checksumLines.push(`${await sha256(filePath)} ${basename(filePath)}`);
}
const checksumPath = resolve(outputDir, "SHA256SUMS.txt");
await writeFile(checksumPath, `${checksumLines.join("\n")}\n`);
console.log(`Desktop update feed created in ${outputDir}`);
console.log(` - ${uniqueFiles.map((path) => basename(path)).join("\n - ")}`);
console.log(` - SHA256SUMS.txt`);
}
if (failures.length > 0) {
console.error(`Desktop update feed creation failed:\n${failures.map((item) => ` - ${item}`).join("\n")}`);
process.exitCode = 1;
}
@@ -0,0 +1,101 @@
import { execFileSync } from "node:child_process";
import { readFile } from "node:fs/promises";
import { resolve } from "node:path";
import { fileURLToPath } from "node:url";
const frontendDir = fileURLToPath(new URL("../", import.meta.url));
const rootDir = resolve(frontendDir, "..");
const packageInfo = JSON.parse(await readFile(resolve(frontendDir, "package.json"), "utf8"));
const failures = [];
const env = process.env;
const fullShaPattern = /^[0-9a-f]{40}$/i;
const expectedTag = `v${packageInfo.version}`;
const requiredSecretLikeEnv = [
"TAURI_SIGNING_PRIVATE_KEY",
"TAURI_SIGNING_PRIVATE_KEY_PASSWORD",
"APPLE_ID",
"APPLE_PASSWORD",
"APPLE_TEAM_ID",
];
const fail = (message) => failures.push(message);
const assert = (condition, message) => {
if (!condition) fail(message);
};
const git = (args) =>
execFileSync("git", args, {
cwd: rootDir,
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
}).trim();
const gitMaybe = (args) => {
try {
return git(args);
} catch {
return undefined;
}
};
const requireEnv = (name) => {
assert(Boolean(env[name]), `${name} must be configured for signed desktop release readiness.`);
};
const validateBaseUrl = () => {
const raw = env.DESKTOP_UPDATE_BASE_URL;
requireEnv("DESKTOP_UPDATE_BASE_URL");
if (!raw) return;
let url;
try {
url = new URL(raw.endsWith("/") ? raw : `${raw}/`);
} catch (error) {
fail(`DESKTOP_UPDATE_BASE_URL is invalid: ${error.message}`);
return;
}
assert(url.protocol === "https:", "DESKTOP_UPDATE_BASE_URL must use HTTPS.");
assert(url.username === "" && url.password === "", "DESKTOP_UPDATE_BASE_URL must not include credentials.");
assert(!/[?&]token=/i.test(url.search), "DESKTOP_UPDATE_BASE_URL must not include token query parameters.");
assert(
url.pathname.includes(packageInfo.version),
`DESKTOP_UPDATE_BASE_URL must include the immutable version segment ${packageInfo.version}.`,
);
};
const headSha = gitMaybe(["rev-parse", "HEAD"]);
const exactTag = gitMaybe(["describe", "--tags", "--exact-match", "HEAD"]);
const status = gitMaybe(["status", "--porcelain"]);
assert(Boolean(headSha), "Current Git commit cannot be resolved.");
assert(exactTag === expectedTag, `Current commit must be exactly tagged ${expectedTag}; found ${exactTag || "<none>"}.`);
assert(status === "", "Release readiness requires a clean working tree.");
assert(env.VITE_BUILD_CHANNEL === "release", "VITE_BUILD_CHANNEL must be release.");
assert(fullShaPattern.test(env.VITE_BUILD_COMMIT || ""), "VITE_BUILD_COMMIT must be the full release commit SHA.");
if (headSha && env.VITE_BUILD_COMMIT) {
assert(env.VITE_BUILD_COMMIT === headSha, "VITE_BUILD_COMMIT must match the current release commit.");
}
for (const name of requiredSecretLikeEnv) {
requireEnv(name);
}
assert(
Boolean(env.APPLE_CERTIFICATE || env.APPLE_SIGNING_IDENTITY),
"APPLE_CERTIFICATE or APPLE_SIGNING_IDENTITY must be configured for macOS signing.",
);
if (env.APPLE_CERTIFICATE) {
requireEnv("APPLE_CERTIFICATE_PASSWORD");
}
validateBaseUrl();
if (failures.length > 0) {
console.error(`Desktop release readiness check failed:\n${failures.map((item) => ` - ${item}`).join("\n")}`);
process.exitCode = 1;
} else {
console.log("Desktop release readiness check passed.");
}
+304 -6
View File
@@ -29,6 +29,13 @@ const walk = async (directory) => {
const permissionIdentifier = (permission) =>
typeof permission === "string" ? permission : typeof permission?.identifier === "string" ? permission.identifier : "";
const toPosixPath = (path) => path.split("\\").join("/");
const cspDirective = (csp, name) =>
csp
.split(";")
.map((directive) => directive.trim().split(/\s+/))
.find(([directiveName]) => directiveName === name)
?.slice(1) || [];
const assertPathScope = (permission, expectedPrefix, description) => {
const allow = Array.isArray(permission.allow) ? permission.allow : [];
@@ -69,8 +76,31 @@ const verifyTauriConfig = async () => {
"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 scriptSources = cspDirective(csp, "script-src");
const frameSources = cspDirective(csp, "frame-src");
assert(
scriptSources.length === 1 && scriptSources[0] === "'self'",
"Tauri script-src must remain self-only when ONLYOFFICE preview is enabled.",
);
assert(
JSON.stringify(frameSources) ===
JSON.stringify(["'self'", "blob:", "https:", "http://localhost:*", "http://127.0.0.1:*"]),
"Tauri frame-src may only add HTTPS and local development servers for the isolated ONLYOFFICE host.",
);
assert(mainWindow?.minWidth === 1180, "Main desktop window must keep the minimum width at 1180.");
assert(mainWindow?.minHeight === 760, "Main desktop window must keep the minimum height at 760.");
assert(mainWindow?.decorations === true, "Main desktop window must keep native window decorations enabled.");
assert(mainWindow?.titleBarStyle === "Overlay", "Main desktop window must use the macOS overlay title bar.");
assert(mainWindow?.hiddenTitle === true, "Main desktop window must hide the native title text.");
assert(mainWindow?.backgroundColor === "#f9fafb", "Main desktop window must use a non-black WebView background.");
assert(
mainWindow?.backgroundThrottling === "disabled",
"Main desktop window must disable background suspend to avoid macOS resume black flashes.",
);
assert(
mainWindow?.trafficLightPosition?.x === 16 && mainWindow?.trafficLightPosition?.y === 18,
"Main desktop window must keep traffic light controls at x=16 y=18.",
);
};
const verifyCapabilities = async () => {
@@ -86,10 +116,35 @@ const verifyCapabilities = async () => {
"fs:allow-read-dir",
"fs:allow-read-text-file",
"fs:allow-write-text-file",
"notification:default",
"opener:default",
"opener:allow-open-url",
"opener:allow-reveal-item-in-dir",
"updater:default",
"updater:allow-check",
"updater:allow-download",
"updater:allow-install",
"updater:allow-download-and-install",
]);
const requiredNotificationPermissions = [
"notification:allow-is-permission-granted",
"notification:allow-request-permission",
"notification:allow-notify",
];
const requiredTemporaryFilePermissions = [
"fs:allow-read-file",
"fs:allow-write-file",
"fs:allow-mkdir",
"fs:allow-remove",
];
for (const file of files) {
const capability = await readJson(resolve(capabilitiesDir, file));
assert(!capability.remote, `${file}: remote origins must not receive Tauri capabilities.`);
assert(
Array.isArray(capability.windows) && capability.windows.length === 1 && capability.windows[0] === "main",
`${file}: capabilities must remain scoped to the main local window.`,
);
const permissions = Array.isArray(capability.permissions) ? capability.permissions : [];
const identifiers = permissions.map(permissionIdentifier).filter(Boolean);
@@ -97,6 +152,25 @@ const verifyCapabilities = async () => {
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.`);
assert(!identifier.startsWith("updater:"), `${file}: updater permissions must not be exposed directly to WebView.`);
if (identifier.startsWith("notification:")) {
assert(
requiredNotificationPermissions.includes(identifier),
`${file}: notification permission ${identifier} is broader than the CTMS Desktop notification boundary.`,
);
}
if (identifier.startsWith("core:window:")) {
fail(`${file}: window permissions are not allowed; use Tauri overlay drag regions instead.`);
}
if (identifier.startsWith("opener:")) {
assert(identifier === "opener:allow-open-path", `${file}: opener permission ${identifier} is not allowed.`);
}
}
for (const identifier of requiredNotificationPermissions) {
assert(identifiers.includes(identifier), `${file}: missing ${identifier}.`);
}
for (const identifier of requiredTemporaryFilePermissions) {
assert(identifiers.includes(identifier), `${file}: missing ${identifier}.`);
}
const fsScope = permissions.find((permission) => permissionIdentifier(permission) === "fs:scope");
@@ -113,6 +187,35 @@ const verifyCapabilities = async () => {
}
};
const verifyOnlyOfficeBoundary = async () => {
const runtimeSource = await readFile(resolve(sourceDir, "runtime/onlyoffice.ts"), "utf8");
const pageSource = await readFile(resolve(sourceDir, "views/OfficePreviewWorkspace.vue"), "utf8");
const viewerSource = await readFile(resolve(sourceDir, "components/OnlyOfficeViewer.vue"), "utf8");
const apiSource = await readFile(resolve(sourceDir, "api/onlyoffice.ts"), "utf8");
const hostSource = await readFile(resolve(frontendDir, "public/onlyoffice-host.js"), "utf8");
assert(runtimeSource.includes('ONLYOFFICE_HOST_PATH = "/onlyoffice-host.html"'), "ONLYOFFICE host path must remain fixed.");
assert(runtimeSource.includes("resolveApiBaseUrl()"), "ONLYOFFICE host must derive from the validated runtime server base URL.");
assert(!runtimeSource.includes("url:"), "ONLYOFFICE runtime URL resolver must not accept a business-provided URL.");
assert(pageSource.includes("response.data.host_path !== ONLYOFFICE_HOST_PATH"), "ONLYOFFICE config host path must be checked against the fixed host path.");
assert(pageSource.includes("onDeactivated") && pageSource.includes("destroyViewer()"), "ONLYOFFICE viewer must be destroyed when a desktop task is deactivated.");
assert(viewerSource.includes("event.source !== frameWindow"), "ONLYOFFICE bridge must validate the message source window.");
assert(viewerSource.includes("event.origin !== hostUrl.origin"), "ONLYOFFICE bridge must validate message origin.");
assert(viewerSource.includes("message.nonce !== nonce"), "ONLYOFFICE bridge must validate its in-memory nonce.");
assert(hostSource.includes("event.source !== window.parent"), "ONLYOFFICE host must only accept parent-window messages.");
assert(hostSource.includes("!isAllowedParentOrigin(event.origin)"), "ONLYOFFICE host must validate the parent origin.");
assert(hostSource.includes("initialized ||"), "ONLYOFFICE host must only accept one initialization.");
assert(hostSource.includes("message?.nonce"), "ONLYOFFICE host must require the in-memory nonce.");
assert(hostSource.includes("url.origin !== window.location.origin"), "ONLYOFFICE save-as URL must remain same-origin.");
assert(hostSource.includes('url.pathname.startsWith("/onlyoffice/")'), "ONLYOFFICE save-as URL must remain under the fixed proxy path.");
assert(hostSource.includes("postToParent(MESSAGE.SAVE_AS, { fileType, title, mimeType, data }, [data])"), "ONLYOFFICE save-as bridge must transfer validated file bytes.");
assert(!hostSource.includes("postToParent(MESSAGE.SAVE_AS, { fileType, title, url"), "ONLYOFFICE save-as bridge must not expose the signed result URL to business pages.");
assert(viewerSource.includes("allowSaveAs: props.allowSaveAs"), "ONLYOFFICE save-as must stay behind an explicit server capability.");
assert(!/\b(?:localStorage|sessionStorage|console\.)\b/.test(hostSource), "ONLYOFFICE host must not persist or log signed configuration.");
assert(apiSource.includes("cache: false"), "ONLYOFFICE signed config must not enter the desktop data cache.");
assert(apiSource.includes("disableRequestDedupe: true"), "ONLYOFFICE signed config requests must not be deduplicated.");
};
const verifyRustBoundary = async () => {
const libSource = await readFile(resolve(tauriDir, "src/lib.rs"), "utf8");
const forbiddenRust = ["tauri_plugin_shell", "std::process::Command", "std::process"];
@@ -127,20 +230,99 @@ const verifyRustBoundary = async () => {
dialogIndex < 0 || singleInstanceIndex < dialogIndex,
"Single-instance plugin must be registered before other desktop plugins.",
);
const singleInstanceSource = libSource.slice(
singleInstanceIndex,
dialogIndex > singleInstanceIndex ? dialogIndex : singleInstanceIndex + 600,
);
const restoreWindowStart = libSource.indexOf("fn restore_main_window");
const restoreWindowEnd = libSource.indexOf("fn is_allowed_shortcut_key", restoreWindowStart);
const restoreWindowSource = libSource.slice(restoreWindowStart, restoreWindowEnd);
const restoreWindowTokens = ['get_webview_window("main")', "window.unminimize()", "window.show()", "window.set_focus()"];
assert(restoreWindowStart >= 0, "Desktop runtime must define a shared main-window restore helper.");
assert(
singleInstanceSource.includes("restore_main_window(app)"),
"Single-instance duplicate launch handler must use the shared main-window restore helper.",
);
for (const token of restoreWindowTokens) {
assert(restoreWindowSource.includes(token), `Main-window restore helper must call ${token}.`);
}
assert(
restoreWindowSource.indexOf("window.unminimize()") < restoreWindowSource.indexOf("window.show()") &&
restoreWindowSource.indexOf("window.show()") < restoreWindowSource.indexOf("window.set_focus()"),
"Main-window restore helper must restore, show, then focus the main window.",
);
assert(libSource.includes("RunEvent::Reopen"), "macOS Dock reopen events must restore the main window.");
assert(libSource.includes("WebviewWindowBuilder::from_config"), "Dock reopen must rebuild a main window that was actually closed.");
assert(libSource.includes('find(|config| config.label == "main")'), "Main-window rebuild must use only the configured main window.");
assert(!libSource.includes("WindowEvent::CloseRequested"), "The macOS red close button must keep its native close-window semantics.");
assert(!libSource.includes("api.prevent_close()"), "The macOS red close button must not be converted into a hide action.");
assert(!libSource.includes("window.hide()"), "The macOS red close button must not hide the main window.");
const appMenuIndex = libSource.indexOf('package.name.clone()');
const fileMenuIndex = libSource.indexOf('"文件"');
assert(appMenuIndex >= 0 && appMenuIndex < fileMenuIndex, "macOS application menu must precede the File menu.");
for (const token of [
'Some("关于 CTMS")',
"short_version: Some(String::new())",
"icon: handle.default_window_icon().cloned()",
'"ctms.desktop.preferences"',
"PredefinedMenuItem::services",
"PredefinedMenuItem::hide",
"PredefinedMenuItem::hide_others",
"PredefinedMenuItem::show_all",
"PredefinedMenuItem::quit",
"WINDOW_SUBMENU_ID",
"HELP_SUBMENU_ID",
"TOGGLE_SIDEBAR_COMMAND_ID",
'"显示/隐藏导航栏"',
]) {
assert(libSource.includes(token), `Desktop menu must include ${token}.`);
}
const handlerSource = libSource.match(/generate_handler!\s*\\?\[([\s\S]*?)\]/)?.[1] || "";
const commands = handlerSource.match(/[a-z_]+::[a-z_]+/g) || [];
const commands = handlerSource
.split(",")
.map((command) => command.trim())
.filter(Boolean);
const allowedCommands = [
"credentials::credential_get",
"credentials::credential_set",
"credentials::credential_delete",
"credentials::login_credential_get",
"credentials::login_credential_set",
"credentials::login_credential_delete",
"updates::desktop_update_check",
"updates::desktop_update_install",
"desktop_menu_set_shortcuts",
"desktop_window_set_theme",
"desktop_window_get_fullscreen",
"desktop_window_set_fullscreen",
];
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>"}.`);
assert(libSource.includes("window.is_fullscreen()"), "Desktop fullscreen state must be read from the native window.");
assert(libSource.includes(".set_fullscreen(fullscreen)"), "Desktop fullscreen changes must target the native window.");
assert(libSource.includes("DESKTOP_FULLSCREEN_CHANGED_EVENT"), "Native fullscreen state must be emitted back to the WebView.");
};
const verifyDesktopUiNativeSync = async () => {
const menuSource = await readFile(resolve(sourceDir, "runtime/desktopMenu.ts"), "utf8");
const preferencesSource = await readFile(resolve(sourceDir, "runtime/desktopUiPreferences.ts"), "utf8");
const fullscreenSource = await readFile(resolve(sourceDir, "runtime/webFullscreen.ts"), "utf8");
const appSource = await readFile(resolve(sourceDir, "App.vue"), "utf8");
assert(menuSource.includes('invoke("desktop_menu_set_shortcuts"'), "Desktop shortcuts must sync through the controlled Tauri command.");
assert(menuSource.includes("DESKTOP_SHORTCUTS_CHANGED_EVENT"), "Native menu shortcuts must track preference changes.");
assert(preferencesSource.includes('"system" | "light" | "dark"'), "Desktop theme must support following the system appearance.");
assert(preferencesSource.includes('invoke("desktop_window_set_theme"'), "Desktop window theme must sync through the controlled Tauri command.");
assert(fullscreenSource.includes('invoke<boolean>("desktop_window_get_fullscreen"'), "Desktop fullscreen state must use the controlled Tauri query command.");
assert(fullscreenSource.includes('invoke<boolean>("desktop_window_set_fullscreen"'), "Desktop fullscreen changes must use the controlled Tauri mutation command.");
assert(fullscreenSource.includes("ctms:desktop-fullscreen-changed"), "Desktop fullscreen changes must synchronize native window state back to the WebView.");
assert(preferencesSource.includes('matchMedia("(prefers-color-scheme: dark)")'), "System theme changes must update the WebView appearance.");
assert(appSource.includes("initializeDesktopThemePreference()"), "Desktop theme synchronization must initialize at app startup.");
assert(appSource.includes("initializeDesktopMenuShortcutSync()"), "Native menu shortcut synchronization must initialize at app startup.");
};
const verifySourceSafety = async () => {
@@ -152,28 +334,52 @@ const verifySourceSafety = async () => {
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.`);
const file = toPosixPath(relative(rootDir, path));
const isRuntimeFile = file.startsWith("frontend/src/runtime/");
assert(!/[?&](?:token|access_token)=/i.test(source), `${file}: token must not be passed through query parameters.`);
assert(
!/console\.(log|debug|info|warn|error)\s*\([^)]*token/i.test(source),
!/console\.(log|debug|info|warn|error)\s*\([^)]*(?:token|access_token|authorization|bearer)/i.test(source),
`${file}: token-related values must not be written to console logs.`,
);
if (source.includes("ctms_token") && file !== "frontend/src/runtime/secureSessionStorage.ts") {
fail(`${file}: ctms_token may only be handled by secureSessionStorage.`);
}
assert(
!/(?:localStorage|sessionStorage)\.setItem\([^)]*password/i.test(source),
`${file}: passwords must not be written to browser storage.`,
);
if (source.includes("sendNotification") && file !== "frontend/src/runtime/notifications.ts") {
fail(`${file}: system notifications must be routed through frontend/src/runtime/notifications.ts.`);
}
if (!isRuntimeFile) {
assert(
!/\bindexedDB\b|\bcaches\.open\s*\(|\bCacheStorage\b|\bsqlite\b/i.test(source),
`${file}: local data cache storage must be routed through frontend/src/runtime.`,
);
}
}
};
const verifyNotificationBoundary = async () => {
const source = await readFile(resolve(sourceDir, "runtime/notifications.ts"), "utf8");
assert(source.includes('title: "CTMS 文件更新"'), "Desktop notification title must stay generic.");
assert(source.includes('body: "有新的文件版本待查看"'), "Desktop notification body must stay generic.");
assert(source.includes('title: "CTMS 待办提醒"'), "Desktop notification title must stay generic.");
assert(source.includes('body: "有新的业务提醒待查看"'), "Desktop notification body must stay generic.");
assert(!/showSystemNotification\s*=\s*async\s*\([^)]*[a-zA-Z]/.test(source), "Desktop notification body must not accept dynamic business content.");
};
const verifySessionBoundary = async () => {
const source = await readFile(resolve(sourceDir, "session/sessionManager.ts"), "utf8");
const tokenBroadcastIndex = source.indexOf('message.type === "TOKEN_UPDATED"');
const storageBroadcastIndex = source.indexOf('localStorage.setItem("ctms_auth_broadcast"');
assert(tokenBroadcastIndex >= 0, "Session manager must branch TOKEN_UPDATED broadcasts before storage fallback.");
assert(storageBroadcastIndex >= 0, "Session manager must keep storage fallback for non-token auth broadcasts.");
assert(
tokenBroadcastIndex >= 0 && storageBroadcastIndex >= 0 && tokenBroadcastIndex < storageBroadcastIndex,
"Session manager must not persist TOKEN_UPDATED payloads through localStorage broadcast fallback.",
);
};
const verifyUpdaterBoundary = async () => {
const source = await readFile(resolve(tauriDir, "src/updates.rs"), "utf8");
assert(source.includes('join("desktop-updates/stable/latest.json")'), "Desktop updater must derive the fixed stable latest.json path.");
@@ -182,6 +388,20 @@ const verifyUpdaterBoundary = async () => {
};
const verifyWorkflowGates = async () => {
const packageInfo = await readJson(resolve(frontendDir, "package.json"));
assert(packageInfo.engines?.node === ">=22.13.0", "package.json must require Node.js >=22.13.0.");
const requiredScripts = [
"desktop:build:macos-release",
"desktop:update-feed:create",
"desktop:update-feed:check",
"desktop:release-readiness:check",
"release:env:check",
];
for (const script of requiredScripts) {
assert(Boolean(packageInfo.scripts?.[script]), `package.json must define ${script}.`);
}
const workflow = await readFile(resolve(rootDir, ".github/workflows/client-quality-gates.yml"), "utf8");
const requiredCommands = [
"npm run version:check",
@@ -200,14 +420,92 @@ const verifyWorkflowGates = async () => {
}
assert(workflow.includes("VITE_BUILD_CHANNEL"), "Client quality gates workflow must inject VITE_BUILD_CHANNEL.");
assert(workflow.includes("VITE_BUILD_COMMIT"), "Client quality gates workflow must inject VITE_BUILD_COMMIT.");
assert(workflow.match(/node-version: "22\.13"/g)?.length === 2, "Client quality gates must use Node.js 22.13 for Web and Desktop jobs.");
const releaseWorkflow = await readFile(resolve(rootDir, ".github/workflows/desktop-release-candidate.yml"), "utf8");
const requiredReleaseWorkflowTokens = [
"REQUIRE_DESKTOP_SIGNING",
"TAURI_SIGNING_PRIVATE_KEY",
"APPLE_ID",
"APPLE_PASSWORD",
"APPLE_TEAM_ID",
"npm run desktop:build:macos-release",
"npm run desktop:update-feed:create",
"npm run desktop:update-feed:check",
"npm run desktop:release-readiness:check",
"actions/upload-artifact",
];
for (const token of requiredReleaseWorkflowTokens) {
assert(releaseWorkflow.includes(token), `Desktop release candidate workflow must include ${token}.`);
}
assert(releaseWorkflow.includes('node-version: "22.13"'), "Desktop release candidates must use Node.js 22.13.");
const windowsInternalWorkflow = await readFile(resolve(rootDir, ".github/workflows/desktop-windows-internal.yml"), "utf8");
const requiredWindowsInternalWorkflowTokens = [
"workflow_dispatch",
"runs-on: windows-latest",
"VITE_BUILD_CHANNEL",
"VITE_BUILD_COMMIT",
"npm run release:env:check",
"npm run version:check",
"npm run runtime:check",
"npm run desktop:release:check",
"npm run ui:contract",
"npm run type-check",
"npm run test:unit",
"npm run build",
"npm run desktop:build",
"createUpdaterArtifacts",
"false",
"--bundles nsis",
"SHA256SUMS.txt",
"actions/upload-artifact",
];
for (const token of requiredWindowsInternalWorkflowTokens) {
assert(windowsInternalWorkflow.includes(token), `Desktop Windows internal workflow must include ${token}.`);
}
const forbiddenWindowsInternalWorkflowTokens = [
"desktop:update-feed:create",
"desktop:update-feed:check",
"desktop:release-readiness:check",
"TAURI_SIGNING_PRIVATE_KEY",
"REQUIRE_DESKTOP_SIGNING",
"latest.json",
];
for (const token of forbiddenWindowsInternalWorkflowTokens) {
assert(!windowsInternalWorkflow.includes(token), `Desktop Windows internal workflow must not include ${token}.`);
}
assert(windowsInternalWorkflow.includes('node-version: "22.13"'), "Desktop Windows internal builds must use Node.js 22.13.");
for (const dockerfile of [resolve(frontendDir, "Dockerfile"), resolve(rootDir, "nginx/Dockerfile")]) {
const dockerSource = await readFile(dockerfile, "utf8");
assert(dockerSource.startsWith("FROM node:22.13-alpine"), `${relative(rootDir, dockerfile)} must build with Node.js 22.13.`);
}
const developmentCompose = await readFile(resolve(rootDir, "docker-compose.dev.yaml"), "utf8");
assert(developmentCompose.includes("image: node:22.13-alpine"), "Development frontend container must use Node.js 22.13.");
assert(developmentCompose.includes('dependency_hash="$$lock_hash:$$(node --version)"'), "Development dependency cache must include the Node.js version.");
assert(developmentCompose.includes("pdfjs-dist"), "Development dependency checks must include PDF.js.");
assert(
developmentCompose.includes("frontend_node_modules_node22:/app/node_modules") &&
developmentCompose.includes("frontend_node_modules_node22:"),
"Development dependencies must use the Node.js 22-specific volume.",
);
};
await verifyTauriConfig();
await verifyCapabilities();
await verifyRustBoundary();
await verifyDesktopUiNativeSync();
await verifySourceSafety();
await verifyNotificationBoundary();
await verifySessionBoundary();
await verifyUpdaterBoundary();
await verifyOnlyOfficeBoundary();
await verifyWorkflowGates();
if (failures.length > 0) {
@@ -1,4 +1,5 @@
import { access, readFile } from "node:fs/promises";
import { createHash } from "node:crypto";
import { basename, resolve } from "node:path";
import { fileURLToPath } from "node:url";
@@ -18,6 +19,10 @@ const feedPath = resolve(
);
const artifactDir = optionValue("--artifacts-dir") || process.env.DESKTOP_UPDATE_ARTIFACTS_DIR;
const expectedBaseUrl = optionValue("--base-url") || process.env.DESKTOP_UPDATE_BASE_URL;
const checksumManifestPath =
optionValue("--checksum-manifest") ||
process.env.DESKTOP_UPDATE_CHECKSUM_MANIFEST ||
(artifactDir ? resolve(artifactDir, "SHA256SUMS.txt") : undefined);
const fail = (message) => failures.push(message);
const assert = (condition, message) => {
@@ -32,6 +37,55 @@ const assertFileExists = async (path, description) => {
}
};
const sha256 = async (path) => createHash("sha256").update(await readFile(path)).digest("hex");
const readChecksumManifest = async () => {
if (!checksumManifestPath) return undefined;
try {
const source = await readFile(checksumManifestPath, "utf8");
const checksums = new Map();
for (const line of source.split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed) continue;
const match = trimmed.match(/^([a-f0-9]{64})\s+\*?(.+)$/i);
if (!match) {
fail(`Checksum manifest contains an invalid line: ${line}`);
continue;
}
checksums.set(basename(match[2]), match[1].toLowerCase());
}
return checksums;
} catch (error) {
fail(`Cannot read checksum manifest ${checksumManifestPath}: ${error.message}`);
return undefined;
}
};
const assertChecksum = async (checksums, path, description) => {
if (!checksums) return;
const name = basename(path);
const expected = checksums.get(name);
assert(Boolean(expected), `Checksum manifest must include ${description}: ${name}`);
if (expected) {
const actual = await sha256(path);
assert(actual === expected, `${description} checksum mismatch for ${name}.`);
}
};
const assertManifestEntries = async (checksums) => {
if (!checksums || !artifactDir) return;
for (const [name, expected] of checksums.entries()) {
const path = resolve(artifactDir, name);
await assertFileExists(path, `checksum manifest entry ${name}`);
try {
const actual = await sha256(path);
assert(actual === expected, `Checksum manifest entry mismatch for ${name}.`);
} catch (error) {
fail(`Cannot verify checksum manifest entry ${name}: ${error.message}`);
}
}
};
let feed;
try {
feed = JSON.parse(await readFile(feedPath, "utf8"));
@@ -40,6 +94,8 @@ try {
}
if (feed) {
const checksums = await readChecksumManifest();
await assertManifestEntries(checksums);
const normalizedFeedVersion = String(feed.version || "").replace(/^v/, "");
const platforms = feed.platforms || {};
const darwinArm = platforms["darwin-aarch64"];
@@ -80,14 +136,21 @@ if (feed) {
if (artifactDir) {
const artifactPath = resolve(artifactDir, basename(url.pathname));
const signaturePath = `${artifactPath}.sig`;
await assertFileExists(artifactPath, `${platform} updater artifact`);
await assertFileExists(`${artifactPath}.sig`, `${platform} updater artifact signature`);
await assertFileExists(signaturePath, `${platform} updater artifact signature`);
await assertChecksum(checksums, artifactPath, `${platform} updater artifact`);
await assertChecksum(checksums, signaturePath, `${platform} updater artifact signature`);
}
}
if (darwinArm?.url && darwinIntel?.url) {
assert(darwinArm.url === darwinIntel.url, "Universal macOS latest.json must point both darwin architectures at the same artifact.");
}
if (artifactDir) {
await assertChecksum(checksums, feedPath, "latest.json");
}
}
if (failures.length > 0) {
@@ -47,6 +47,9 @@ if (isTagBuild) {
if (env.REQUIRE_DESKTOP_SIGNING === "true") {
assert(process.platform === "darwin", "Signed macOS desktop release builds must run on macOS.");
if (isCi) {
assert(isTagBuild, "Signed desktop release candidate builds in CI must run from a release tag.");
}
requireEnv("TAURI_SIGNING_PRIVATE_KEY");
requireEnv("TAURI_SIGNING_PRIVATE_KEY_PASSWORD");
requireEnv("APPLE_ID");
@@ -56,6 +59,9 @@ if (env.REQUIRE_DESKTOP_SIGNING === "true") {
Boolean(env.APPLE_CERTIFICATE || env.APPLE_SIGNING_IDENTITY),
"APPLE_CERTIFICATE or APPLE_SIGNING_IDENTITY must be configured for macOS signing.",
);
if (env.APPLE_CERTIFICATE) {
requireEnv("APPLE_CERTIFICATE_PASSWORD");
}
}
if (failures.length > 0) {
+18 -4
View File
@@ -1,5 +1,5 @@
import { readdir, readFile } from "node:fs/promises";
import { extname, relative, resolve } from "node:path";
import { extname, isAbsolute, relative, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const frontendDir = fileURLToPath(new URL("../", import.meta.url));
@@ -7,6 +7,15 @@ const sourceDir = resolve(frontendDir, "src");
const runtimeDir = resolve(sourceDir, "runtime");
const sourceExtensions = new Set([".ts", ".tsx", ".vue", ".js", ".jsx"]);
const violations = [];
const toPosixPath = (path) => path.split("\\").join("/");
const platformSource = await readFile(resolve(runtimeDir, "platform.ts"), "utf8");
if (!platformSource.includes("runtimeGlobal.isTauri")) {
violations.push("src/runtime/platform.ts: Tauri v2 runtime detection must use the official isTauri marker");
}
if (platformSource.includes("__TAURI")) {
violations.push("src/runtime/platform.ts: Tauri runtime detection must not depend on legacy or internal markers");
}
const walk = async (directory) => {
const entries = await readdir(directory, { withFileTypes: true });
@@ -21,16 +30,21 @@ const walk = async (directory) => {
};
for (const path of await walk(sourceDir)) {
if (!sourceExtensions.has(extname(path)) || path.startsWith(`${runtimeDir}/`)) continue;
const runtimeRelativePath = relative(runtimeDir, path);
const isRuntimeFile = Boolean(runtimeRelativePath) && !runtimeRelativePath.startsWith("..") && !isAbsolute(runtimeRelativePath);
if (!sourceExtensions.has(extname(path)) || isRuntimeFile) continue;
const source = await readFile(path, "utf8");
const file = relative(frontendDir, path);
if (source.includes("@tauri-apps/") || source.includes("__TAURI")) {
const file = toPosixPath(relative(frontendDir, path));
if (source.includes("@tauri-apps/") || source.includes("__TAURI") || /(?:globalThis|window)\.isTauri/.test(source)) {
violations.push(`${file}: direct Tauri access is only allowed inside src/runtime`);
}
if (/from\s+["'][^"']*\/runtime\/[^"']+["']/.test(source)) {
violations.push(`${file}: import platform behavior through src/runtime/index.ts`);
}
if (/\bindexedDB\b|\bcaches\.open\s*\(|\bCacheStorage\b|\bsqlite\b/i.test(source)) {
violations.push(`${file}: local data cache storage must be routed through src/runtime`);
}
}
if (violations.length > 0) {
+49 -5
View File
@@ -2,6 +2,7 @@ import { readFileSync } from "node:fs";
const main = readFileSync("src/styles/main.css", "utf8");
const unified = readFileSync("src/styles/unified-page.css", "utf8");
const webLayout = readFileSync("src/components/WebLayout.vue", "utf8");
const requiredMainTokens = [
"--ctms-primary",
@@ -31,6 +32,29 @@ const missing = [
...requiredUnifiedTokens.filter((t) => !unified.includes(t))
];
const requiredWebEdgeTokens = [
".web-layout-container .ctms-route-shell > *",
".web-layout-container .ctms-route-shell > .page > .table-card"
];
for (const token of requiredWebEdgeTokens) {
if (!main.includes(token)) {
missing.push(`src/styles/main.css:${token}`);
}
}
if (!/\.web-layout-container \.content-wrapper\s*\{[^}]*padding:\s*0;/s.test(webLayout)) {
missing.push("src/components/WebLayout.vue:web content wrapper edge alignment");
}
if (!/\.web-layout-container \.content-wrapper\s*\{[^}]*height:\s*100%;[^}]*min-height:\s*0;/s.test(webLayout)) {
missing.push("src/components/WebLayout.vue:web content height chain");
}
if (webLayout.includes("padding: 18px 24px 24px;") || webLayout.includes("padding: 22px 32px 32px;")) {
missing.push("src/components/WebLayout.vue:large-screen content padding override");
}
const pageContractChecks = [
{
file: "src/views/ia/ProjectMilestones.vue",
@@ -100,26 +124,46 @@ for (const group of requiredOverviewGroups) {
}
}
const financeAndFilePages = [
const unifiedShellPages = [
"src/views/fees/ContractFees.vue",
"src/views/ia/FileVersionManagement.vue"
"src/views/documents/DocumentList.vue"
];
const requiredFinanceAndFileClasses = [
const requiredUnifiedShellClasses = [
"ctms-page-shell",
"unified-action-bar",
"unified-shell"
];
for (const file of financeAndFilePages) {
for (const file of unifiedShellPages) {
const content = readFileSync(file, "utf8");
for (const className of requiredFinanceAndFileClasses) {
for (const className of requiredUnifiedShellClasses) {
if (!content.includes(className)) {
missing.push(`${file}:${className}`);
}
}
}
const router = readFileSync("src/router/index.ts", "utf8");
const legacyFileVersionRouteIndex = router.indexOf('name: "FileVersionManagement"');
const canonicalDocumentRouteIndex = router.indexOf('path: "trial/:trialId/documents"', legacyFileVersionRouteIndex);
const legacyFileVersionRoute =
legacyFileVersionRouteIndex >= 0 && canonicalDocumentRouteIndex > legacyFileVersionRouteIndex
? router.slice(legacyFileVersionRouteIndex, canonicalDocumentRouteIndex)
: "";
if (router.includes("FileVersionManagement.vue")) {
missing.push("src/router/index.ts:legacy FileVersionManagement component import");
}
if (!legacyFileVersionRoute.includes("component: DocumentList")) {
missing.push("src/router/index.ts:FileVersionManagement->DocumentList");
}
if (!router.includes('next({ name: "DocumentList", params: { trialId: studyStore.currentStudy.id }, replace: true });')) {
missing.push("src/router/index.ts:legacy file version canonical redirect");
}
const adminProjectDetail = readFileSync("src/views/admin/ProjectDetail.vue", "utf8");
const requiredAdminProjectDetailClasses = [
"ctms-page-shell",