refactor(client): unify web and desktop release workflow
Client Quality Gates / Shared client and Web (push) Has been cancelled
Client Quality Gates / macOS Desktop (push) Has been cancelled

This commit is contained in:
Cheng Zhou
2026-06-30 20:25:07 +08:00
parent d1a6c957f7
commit 7c721d4e5c
21 changed files with 1016 additions and 14 deletions
+4 -1
View File
@@ -10,7 +10,10 @@
"tauri": "tauri",
"desktop:dev": "tauri dev",
"desktop:build": "tauri build",
"desktop:bundle:dmg": "tauri build -- --bundles dmg",
"desktop:bundle:dmg": "tauri build --bundles dmg",
"version:check": "node scripts/client-version.mjs --check",
"version:set": "node scripts/client-version.mjs --set",
"runtime:check": "node scripts/verify-runtime-boundary.mjs",
"test:unit": "vitest run --environment jsdom",
"type-check": "vue-tsc --noEmit",
"ui:contract": "node scripts/verify-ui-contract.mjs"
+122
View File
@@ -0,0 +1,122 @@
import { readFile, writeFile } from "node:fs/promises";
import { fileURLToPath } from "node:url";
const frontendDir = fileURLToPath(new URL("../", import.meta.url));
const paths = {
packageJson: new URL("../package.json", import.meta.url),
packageLock: new URL("../package-lock.json", import.meta.url),
tauriConfig: new URL("../src-tauri/tauri.conf.json", import.meta.url),
cargoToml: new URL("../src-tauri/Cargo.toml", import.meta.url),
cargoLock: new URL("../src-tauri/Cargo.lock", import.meta.url),
};
const SEMVER_PATTERN = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
const readJson = async (path) => JSON.parse(await readFile(path, "utf8"));
const writeJson = async (path, value) => writeFile(path, `${JSON.stringify(value, null, 2)}\n`);
const readCargoPackageVersion = async () => {
const cargo = await readFile(paths.cargoToml, "utf8");
const packageSection = cargo.match(/\[package\]([\s\S]*?)(?=\n\[|$)/)?.[1];
const version = packageSection?.match(/^version\s*=\s*"([^"]+)"/m)?.[1];
if (!version) throw new Error("Cannot find [package].version in src-tauri/Cargo.toml");
return version;
};
const readCargoLockPackageVersion = async () => {
const cargoLock = await readFile(paths.cargoLock, "utf8");
const packageSection = cargoLock
.match(/\[\[package\]\]([\s\S]*?)(?=\n\[\[package\]\]|$)/g)
?.find((section) => /^name\s*=\s*"ctms-desktop"$/m.test(section));
const version = packageSection?.match(/^version\s*=\s*"([^"]+)"/m)?.[1];
if (!version) throw new Error("Cannot find ctms-desktop version in src-tauri/Cargo.lock");
return version;
};
const readVersions = async () => {
const [packageJson, packageLock, tauriConfig, cargoVersion, cargoLockVersion] = await Promise.all([
readJson(paths.packageJson),
readJson(paths.packageLock),
readJson(paths.tauriConfig),
readCargoPackageVersion(),
readCargoLockPackageVersion(),
]);
return {
"package.json": packageJson.version,
"package-lock.json": packageLock.version,
"package-lock.json root": packageLock.packages?.[""]?.version,
"tauri.conf.json": tauriConfig.version,
"Cargo.toml": cargoVersion,
"Cargo.lock": cargoLockVersion,
};
};
const assertVersionsMatch = async () => {
const versions = await readVersions();
const uniqueVersions = new Set(Object.values(versions));
if (uniqueVersions.size !== 1 || uniqueVersions.has(undefined)) {
const details = Object.entries(versions)
.map(([file, version]) => ` ${file}: ${version ?? "<missing>"}`)
.join("\n");
throw new Error(`Client versions are not synchronized:\n${details}`);
}
const [version] = uniqueVersions;
console.log(`Client version ${version} is synchronized.`);
};
const setVersion = async (version) => {
if (!SEMVER_PATTERN.test(version)) {
throw new Error(`Invalid semantic version: ${version}`);
}
const [packageJson, packageLock, tauriConfig, tauriConfigSource, cargo, cargoLock] = await Promise.all([
readJson(paths.packageJson),
readJson(paths.packageLock),
readJson(paths.tauriConfig),
readFile(paths.tauriConfig, "utf8"),
readFile(paths.cargoToml, "utf8"),
readFile(paths.cargoLock, "utf8"),
]);
packageJson.version = version;
packageLock.version = version;
packageLock.packages[""].version = version;
const tauriVersionPattern = /("version"\s*:\s*")[^"]+(")/;
const packageSectionPattern = /(\[package\][\s\S]*?^version\s*=\s*")[^"]+(")/m;
const lockPackagePattern =
/(\[\[package\]\]\nname\s*=\s*"ctms-desktop"\nversion\s*=\s*")[^"]+(")/m;
if (typeof tauriConfig.version !== "string" || !tauriVersionPattern.test(tauriConfigSource)) {
throw new Error("Cannot update version in src-tauri/tauri.conf.json");
}
if (!packageSectionPattern.test(cargo)) {
throw new Error("Cannot update [package].version in src-tauri/Cargo.toml");
}
if (!lockPackagePattern.test(cargoLock)) {
throw new Error("Cannot update ctms-desktop version in src-tauri/Cargo.lock");
}
await Promise.all([
writeJson(paths.packageJson, packageJson),
writeJson(paths.packageLock, packageLock),
writeFile(paths.tauriConfig, tauriConfigSource.replace(tauriVersionPattern, `$1${version}$2`)),
writeFile(paths.cargoToml, cargo.replace(packageSectionPattern, `$1${version}$2`)),
writeFile(paths.cargoLock, cargoLock.replace(lockPackagePattern, `$1${version}$2`)),
]);
console.log(`Updated CTMS Web and Desktop client version to ${version} in ${frontendDir}`);
await assertVersionsMatch();
};
const [, , command = "--check", value] = process.argv;
try {
if (command === "--check") {
await assertVersionsMatch();
} else if (command === "--set" && value) {
await setVersion(value);
} else {
throw new Error("Usage: node scripts/client-version.mjs [--check | --set <semver>]");
}
} catch (error) {
console.error(error instanceof Error ? error.message : error);
process.exitCode = 1;
}
@@ -0,0 +1,41 @@
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 sourceDir = resolve(frontendDir, "src");
const runtimeDir = resolve(sourceDir, "runtime");
const sourceExtensions = new Set([".ts", ".tsx", ".vue", ".js", ".jsx"]);
const violations = [];
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();
};
for (const path of await walk(sourceDir)) {
if (!sourceExtensions.has(extname(path)) || path.startsWith(`${runtimeDir}/`)) continue;
const source = await readFile(path, "utf8");
const file = relative(frontendDir, path);
if (source.includes("@tauri-apps/") || source.includes("__TAURI")) {
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 (violations.length > 0) {
console.error(`Runtime boundary violations:\n${violations.map((item) => ` ${item}`).join("\n")}`);
process.exitCode = 1;
} else {
console.log("Runtime boundary is respected.");
}
+3 -4
View File
@@ -1,15 +1,14 @@
import axios from "axios";
import type { AxiosResponse } from "axios";
import { resolveApiBaseUrl } from "../runtime/apiBaseUrl";
import { DESKTOP_SERVER_URL_CHANGED_EVENT } from "../runtime/desktopServerConfig";
import { clientRuntime, DESKTOP_SERVER_URL_CHANGED_EVENT } from "../runtime";
const authClient = axios.create({
baseURL: resolveApiBaseUrl(),
baseURL: clientRuntime.apiBaseUrl(),
timeout: 15000,
});
export const refreshAuthClientBaseUrl = (): void => {
authClient.defaults.baseURL = resolveApiBaseUrl();
authClient.defaults.baseURL = clientRuntime.apiBaseUrl();
};
if (typeof window !== "undefined") {
+3 -4
View File
@@ -3,16 +3,15 @@ import { ElMessage } from "element-plus";
import { getToken } from "../utils/auth";
import type { ApiError } from "../types/api";
import { TEXT } from "../locales";
import { resolveApiBaseUrl } from "../runtime/apiBaseUrl";
import { DESKTOP_SERVER_URL_CHANGED_EVENT } from "../runtime/desktopServerConfig";
import { clientRuntime, DESKTOP_SERVER_URL_CHANGED_EVENT } from "../runtime";
const instance: AxiosInstance = axios.create({
baseURL: resolveApiBaseUrl(),
baseURL: clientRuntime.apiBaseUrl(),
timeout: 15000,
});
export const refreshApiBaseUrl = (): void => {
instance.defaults.baseURL = resolveApiBaseUrl();
instance.defaults.baseURL = clientRuntime.apiBaseUrl();
};
if (typeof window !== "undefined") {
+9
View File
@@ -1,5 +1,14 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_BUILD_CHANNEL?: "dev" | "main" | "release" | "local";
readonly VITE_BUILD_COMMIT?: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}
declare module "*.vue" {
import type { DefineComponent } from "vue";
const component: DefineComponent<{}, {}, any>;
+1 -1
View File
@@ -12,7 +12,7 @@ import App from "./App.vue";
import router from "./router";
import { getToken } from "./utils/auth";
import { useStudyStore } from "./store/study";
import { shouldRequireDesktopServerUrl } from "./runtime/desktopServerConfig";
import { shouldRequireDesktopServerUrl } from "./runtime";
const bootstrap = async () => {
const app = createApp(App);
+1 -2
View File
@@ -1,6 +1,5 @@
import type { RouteLocationNormalized } from "vue-router";
import { hasDesktopServerUrl, shouldRequireDesktopServerUrl } from "../runtime/desktopServerConfig";
import { isTauriRuntime } from "../runtime/platform";
import { hasDesktopServerUrl, isTauriRuntime, shouldRequireDesktopServerUrl } from "../runtime";
const DESKTOP_SETTINGS_PATH = "/desktop/server-settings";
+35
View File
@@ -0,0 +1,35 @@
import { afterEach, describe, expect, it } from "vitest";
import packageInfo from "../../package.json";
import { clientRuntime } from "./clientRuntime";
import { getAppMetadata } from "./appMetadata";
afterEach(() => {
Reflect.deleteProperty(window, "__TAURI_INTERNALS__");
});
describe("client runtime", () => {
it("reports a web runtime with desktop capabilities disabled", () => {
expect(getAppMetadata()).toMatchObject({
version: packageInfo.version,
commit: "local",
channel: "local",
clientType: "web",
platform: "web",
});
expect(clientRuntime.capabilities()).toEqual({
serverConfiguration: false,
nativeFiles: false,
systemNotifications: false,
secureSessionStorage: false,
automaticUpdates: false,
});
});
it("exposes only the implemented desktop capability", () => {
Object.defineProperty(window, "__TAURI_INTERNALS__", { value: {}, configurable: true });
expect(getAppMetadata().clientType).toBe("desktop");
expect(clientRuntime.capabilities().serverConfiguration).toBe(true);
expect(clientRuntime.capabilities().secureSessionStorage).toBe(false);
});
});
+26
View File
@@ -0,0 +1,26 @@
import packageInfo from "../../package.json";
import { getRuntimePlatform, isTauriRuntime, type RuntimePlatform } from "./platform";
export type ClientType = "web" | "desktop";
export type BuildChannel = "dev" | "main" | "release" | "local";
export interface AppMetadata {
version: string;
commit: string;
channel: BuildChannel;
clientType: ClientType;
platform: RuntimePlatform;
}
const BUILD_CHANNELS = new Set<BuildChannel>(["dev", "main", "release", "local"]);
const resolveBuildChannel = (value: string | undefined): BuildChannel =>
value && BUILD_CHANNELS.has(value as BuildChannel) ? (value as BuildChannel) : "local";
export const getAppMetadata = (): AppMetadata => ({
version: packageInfo.version,
commit: import.meta.env.VITE_BUILD_COMMIT || "local",
channel: resolveBuildChannel(import.meta.env.VITE_BUILD_CHANNEL),
clientType: isTauriRuntime() ? "desktop" : "web",
platform: getRuntimePlatform(),
});
+29
View File
@@ -0,0 +1,29 @@
import { resolveApiBaseUrl } from "./apiBaseUrl";
import { getAppMetadata } from "./appMetadata";
import { isTauriRuntime } from "./platform";
export interface RuntimeCapabilities {
serverConfiguration: boolean;
nativeFiles: boolean;
systemNotifications: boolean;
secureSessionStorage: boolean;
automaticUpdates: boolean;
}
export interface ClientRuntime {
apiBaseUrl(): string;
metadata: typeof getAppMetadata;
capabilities(): RuntimeCapabilities;
}
export const clientRuntime: ClientRuntime = {
apiBaseUrl: resolveApiBaseUrl,
metadata: getAppMetadata,
capabilities: () => ({
serverConfiguration: isTauriRuntime(),
nativeFiles: false,
systemNotifications: false,
secureSessionStorage: false,
automaticUpdates: false,
}),
};
+12
View File
@@ -0,0 +1,12 @@
export { clientRuntime, type ClientRuntime, type RuntimeCapabilities } from "./clientRuntime";
export {
clearDesktopServerUrl,
DESKTOP_SERVER_URL_CHANGED_EVENT,
getDesktopServerUrl,
hasDesktopServerUrl,
normalizeDesktopServerUrl,
setDesktopServerUrl,
shouldRequireDesktopServerUrl,
} from "./desktopServerConfig";
export { getAppMetadata, type AppMetadata, type BuildChannel, type ClientType } from "./appMetadata";
export { getRuntimePlatform, isTauriRuntime, type RuntimePlatform } from "./platform";
+1 -1
View File
@@ -37,7 +37,7 @@ import { useRouter } from "vue-router";
import { ElMessage } from "element-plus";
import { useAuthStore } from "../store/auth";
import { useStudyStore } from "../store/study";
import { getDesktopServerUrl, normalizeDesktopServerUrl, setDesktopServerUrl } from "../runtime/desktopServerConfig";
import { getDesktopServerUrl, normalizeDesktopServerUrl, setDesktopServerUrl } from "../runtime";
const router = useRouter();
const auth = useAuthStore();
+1 -1
View File
@@ -260,7 +260,7 @@ import { useAuthStore } from "../store/auth";
import { useStudyStore } from "../store/study";
import { fetchEmailDomains } from "../api/auth";
import { TEXT, requiredMessage } from "../locales";
import { isTauriRuntime } from "../runtime/platform";
import { isTauriRuntime } from "../runtime";
import {
consumeLogoutReason,
LOGOUT_REASON_AUTH_EXPIRED,