de3dc87920
Client Quality Gates / Shared client and Web (push) Has been cancelled
Client Quality Gates / macOS Desktop (push) Has been cancelled
Storage Persistence Guard / storage-persistence-audit (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
* docs: add desktop project plan * feat(desktop): implement phase 1 tauri client * refactor(client): unify web and desktop release workflow * feat(desktop): implement phase 2 native capabilities * 完善桌面端交互体验与发布检查 * 完善桌面端界面、发布检查与邮箱域名同步 * fix(deploy): 修复数据库初始化复用旧镜像 * fix(deploy): 增加部署更新实时进度 * fix(auth): 支持无邮箱后缀时手动输入 * feat(desktop): 稳定桌面端界面与文件操作反馈 - 重构 DesktopPreferences 为分栏式设置面板,整合连接、外观、通知、更新与诊断信息分区,并补充过渡动效与暗色主题样式 - DesktopLayout 侧边栏导航分组支持展开折叠,调整管理/项目区块顺序并统一图标与标题 - 新增 fileTaskFeedback 工具,统一 pickFiles/saveFile/openFile 的成功/取消提示,替换审计导出、权限日志、附件、文档、线程、项目配置等处的直接调用 - desktopUpdateManager 暴露更新状态快照与状态变更监听,区分检查中、安装中、已推迟、失败等状态 - DesktopServerSettings 增加连接诊断信息(检查时间、健康地址、耗时、HTTP 状态) - unified-page.css 与 ProjectMilestones 引入 CSS 变量以适配暗色主题 - WebLayout 将服务器设置入口改为打开系统偏好面板,管理菜单中邮件服务归入系统设置分组 - ProfileSettings 移除已迁入偏好面板的桌面端专属区块 - 补充 Layout.desktop 布局与偏好面板契约测试 * feat(desktop): 支持桌面端三十天免登录 * 完善桌面端发布稳定化门禁 * 完善桌面端端到端回归收口 * 补齐桌面端附件文件流回归 * 完善桌面端回归与安全边界复审 * 完善桌面体验与系统通知收口 * feat(desktop): 收口桌面工作台视觉与活动反馈 * 优化桌面端界面布局 * style: 优化个人中心和偏好设置弹窗样式,重构工作入口为精致分屏布局并移除首字徽标 * 功能(桌面端):增加在线辅助本地缓存 * 优化桌面端标签导航与后台交互 * ci: 新增 Windows 桌面端内测构建 * fix: 修复桌面检查脚本的 Windows 路径判断 * test: 兼容 Windows 换行的桌面布局断言 * test: 兼容 Windows 换行的路由断言 * ci: 修复 Windows 内测构建配置传参 * ci: 避免 Windows 安装器构建交互等待 * 修复桌面端界面显示与稳定性问题 * feat(网页端): 完善登录后工作台与项目管理体验 * docs(desktop): 精简桌面端约束入口 * feat(admin): 完善审计访问上下文与后台布局 * fix(git): 跟踪原生图标资源 * fix(web): 修正工作台端侧标识 * feat(监控): 完善系统监控、登录状态与访问审计能力 * 修复(权限管理):统一 PM 系统导航与权限校验 * feat(工作台): 优化入口布局与连接安全状态 * feat(桌面与监控): 完善工作台导航和登录活动定位 - 优化桌面标签、上下文标题、前进后退、导航栏隐藏和原生菜单体验 - 补充登录会话 IP 采集、地理位置回退、管理端展示及数据库迁移 - 更新桌面发布检查、运维文档和前后端测试覆盖 * 功能(文档与桌面):完善文件预览下载与客户端构建基线 - 保存文档版本原始文件名,规范下载响应并持久化上传目录\n- 增加 PDF.js 预览、桌面保存打开流程及统一错误反馈\n- 统一 Node.js 22.13 构建基线并收紧临时文件权限门禁\n- 补充迁移、单元测试、发布检查与运维文档 * 功能(文档预览):集成 ONLYOFFICE 安全只读预览与工作台体验 新增 ONLYOFFICE 配置签名、内部内容接口、容器编排与反向代理。 打通网页端和桌面端独立预览工作区,完善文档入口、布局及帮助体验。 补充桌面安全发布门禁、开发脚本、使用文档和前后端测试。 * feat(collaboration): 完善在线文档协作与通知闭环 - 新增协作文件夹、文件、不可变修订、成员、会话、回调回执、编辑申请与分享链接数据模型。 - 补齐新建、导入、复制、下载、回收站、恢复、成员授权、所有权转让及文件级权限接口。 - 接入 ONLYOFFICE 共同编辑、历史版本预览与恢复、修订另存副本、导出下载审计和幂等回调保存。 - 增加编辑权限申请、审批通知、项目提醒聚合、通知 Feed、已读处理及历史待办数据回填。 - 支持公开分享的查看或编辑模式、有效期、密码哈希、失败锁定、短时访问凭证与固定分享地址。 - 增加协作者导出、申请编辑、工作表结构保护和所有权管理策略,并纳入项目接口权限矩阵。 - 新增协作文件库、编辑工作区、公开分享页、下载与另存为对话框,以及导航、路由和权限入口。 - 统一网页端与桌面端通知布局,增加沉浸式工作区和浏览器、Tauri 双端全屏能力。 - 扩展运行时文件下载适配、Tauri 环境识别和原生全屏命令,继续保持业务代码运行时边界。 - 加固 ONLYOFFICE 消息桥的同源下载、签名地址隔离和保存为能力校验,并更新桌面发布检查。 - 增加连续数据库迁移、50MB 上传限制、OnlyOffice 中文文案与开发启动路由校验。 - 补充协作、通知、权限、路由、运行时、布局和 OnlyOffice 相关测试及模块说明文档。 * refactor(frontend): 按需加载页面并清理未使用代码 - 将业务页面路由统一改为动态导入,拆分首屏入口与各功能模块构建产物。 - 将网页端和桌面端布局改为异步组件,避免两套平台布局同时进入初始包。 - 新增 Element Plus 按需安装入口,并通过全局配置组件统一注入中文语言包。 - 提取 API 运行时钩子,在应用启动时注入项目清理、令牌续期和认证失效退出能力。 - 将权限监控面板及地图资源改为延迟加载,补充地图加载状态、失败提示和切换竞态保护。 - 删除已被现有工作流替代的项目成员、接口权限、中心绑定、培训表单及旧项目首页等页面。 - 清理废弃的快捷操作、项目选择、用户选择、FAQ 表单、风险占位组件和旧地图辅助模块。 - 移除未使用的 API 方法、类型、字典、状态机、展示工具、样式和项目详情编辑逻辑。 - 开启 TypeScript 未使用变量与参数检查,并同步收紧相关测试和组件暴露类型。 - 移除未使用的 updater、date-fns 和 Sass 前端依赖,更新锁文件并删除旧 CSS 清洗插件。 - 更新路由、Axios、ETMF、通知、权限监控和桌面布局测试以覆盖重构后的边界。 * fix(审计): 移除共享库审计与预览噪声 * 功能(提醒):统一项目提醒中心与桌面通知链路 增加通用提醒状态、数据库迁移和定时同步,覆盖风险时效、文件回执、项目里程碑、访视窗口与协作申请。 新增网页端和桌面端提醒中心、真实投递诊断与固定隐私通知正文,并补齐登录来源聚合、测试和说明文档。 * feat(deploy): 默认安装 ONLYOFFICE 标准组件 * build(release): 加固 v0.1.0 桌面发布链路 (#3)
533 lines
27 KiB
JavaScript
533 lines
27 KiB
JavaScript
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 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 : [];
|
|
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);
|
|
const mainWindow = tauriConfig.app?.windows?.find((window) => window.label === "main") || tauriConfig.app?.windows?.[0];
|
|
|
|
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 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 () => {
|
|
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",
|
|
"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);
|
|
|
|
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.`);
|
|
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");
|
|
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 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"];
|
|
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 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
|
|
.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 () => {
|
|
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 = 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|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(!/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.");
|
|
assert(source.includes("desktop updates require HTTPS outside localhost"), "Desktop updater must reject non-local HTTP update feeds.");
|
|
assert(source.includes("server origin must not include credentials"), "Desktop updater must reject server origins that include credentials.");
|
|
};
|
|
|
|
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:build:windows-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",
|
|
"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:app",
|
|
"npm run release:env:check",
|
|
];
|
|
|
|
for (const command of requiredCommands) {
|
|
assert(workflow.includes(command), `Client quality gates workflow must run ${command}.`);
|
|
}
|
|
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 = [
|
|
"runs-on: macos-latest",
|
|
"runs-on: windows-latest",
|
|
"REQUIRE_DESKTOP_SIGNING",
|
|
"REQUIRE_WINDOWS_SIGNING",
|
|
"TAURI_SIGNING_PRIVATE_KEY",
|
|
"APPLE_ID",
|
|
"APPLE_PASSWORD",
|
|
"APPLE_TEAM_ID",
|
|
"WINDOWS_CERTIFICATE",
|
|
"WINDOWS_CERTIFICATE_PASSWORD",
|
|
"WINDOWS_TIMESTAMP_URL",
|
|
"npm audit",
|
|
"npm run desktop:build:macos-release",
|
|
"npm run desktop:build:windows-release",
|
|
"Get-AuthenticodeSignature",
|
|
"EnhancedKeyUsageList",
|
|
"TimeStamperCertificate",
|
|
".nsis.zip",
|
|
"npm run desktop:update-feed:create",
|
|
"--platform-artifact",
|
|
"npm run desktop:update-feed:check",
|
|
"--require-platform windows-x86_64",
|
|
"npm run desktop:release-readiness:check",
|
|
"actions/download-artifact",
|
|
"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) {
|
|
console.error(`Desktop release gate failed:\n${failures.map((item) => ` - ${item}`).join("\n")}`);
|
|
process.exitCode = 1;
|
|
} else {
|
|
console.log("Desktop release gate passed.");
|
|
}
|