refactor(client): unify web and desktop release workflow
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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.");
|
||||
}
|
||||
@@ -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,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") {
|
||||
|
||||
Vendored
+9
@@ -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>;
|
||||
|
||||
@@ -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,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";
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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(),
|
||||
});
|
||||
@@ -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,
|
||||
}),
|
||||
};
|
||||
@@ -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";
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user