fix(POSTV1-06): 恢复贴纸与字体运行时资源链路
Dada P0-A isolated Windows CI / validate-and-package (push) Waiting to run
Dada P0-A isolated Windows CI / validate-and-package (push) Waiting to run
This commit is contained in:
@@ -0,0 +1,37 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { isAbsolute, join, resolve } from "node:path";
|
||||
|
||||
import {
|
||||
buildP0aRuntimeAssetPlan,
|
||||
defaultReplicationRoot,
|
||||
deployRuntimeAssetPlan,
|
||||
readRuntimeAssetManifest,
|
||||
serializeRuntimeAssetManifest,
|
||||
} from "./lib/runtime-assets.mjs";
|
||||
|
||||
function option(name) {
|
||||
const index = process.argv.indexOf(name);
|
||||
return index >= 0 ? process.argv[index + 1] : undefined;
|
||||
}
|
||||
|
||||
function defaultConfigPath() {
|
||||
if (!process.env.LOCALAPPDATA || !isAbsolute(process.env.LOCALAPPDATA)) throw new Error("local_app_data_unavailable");
|
||||
return join(process.env.LOCALAPPDATA, "Dada", "P0A", "config", "instance.json");
|
||||
}
|
||||
|
||||
const configFile = resolve(option("--config") ?? process.env.DADA_INSTANCE_CONFIG_PATH ?? defaultConfigPath());
|
||||
const configuration = JSON.parse(readFileSync(configFile, "utf8"));
|
||||
const assetRootCandidate = option("--asset-root") ?? configuration.asset_root;
|
||||
if (typeof assetRootCandidate !== "string" || !isAbsolute(assetRootCandidate)) {
|
||||
throw new Error("asset_root_configuration_invalid");
|
||||
}
|
||||
const assetRoot = resolve(assetRootCandidate);
|
||||
const trustedManifest = readRuntimeAssetManifest(resolve(option("--trusted-manifest") ?? "config/runtime-assets-manifest.json"));
|
||||
const plan = await buildP0aRuntimeAssetPlan({
|
||||
replicationRoot: resolve(option("--replication-root") ?? defaultReplicationRoot()),
|
||||
});
|
||||
if (serializeRuntimeAssetManifest(plan.manifest) !== serializeRuntimeAssetManifest(trustedManifest)) {
|
||||
throw new Error("runtime_asset_source_does_not_match_trusted_manifest");
|
||||
}
|
||||
const result = deployRuntimeAssetPlan({ assetRoot, manifest: trustedManifest, resources: plan.resources });
|
||||
process.stdout.write(`${JSON.stringify({ linked_files: result.linked_files, status: result.status })}\n`);
|
||||
@@ -0,0 +1,23 @@
|
||||
import { resolve } from "node:path";
|
||||
|
||||
import {
|
||||
buildP0aRuntimeAssetPlan,
|
||||
defaultReplicationRoot,
|
||||
serializeRuntimeAssetManifest,
|
||||
writeRuntimeAssetManifest,
|
||||
} from "./lib/runtime-assets.mjs";
|
||||
|
||||
function option(name) {
|
||||
const index = process.argv.indexOf(name);
|
||||
return index >= 0 ? process.argv[index + 1] : undefined;
|
||||
}
|
||||
|
||||
const replicationRoot = resolve(option("--replication-root") ?? defaultReplicationRoot());
|
||||
const outputPath = resolve(option("--output") ?? "config/runtime-assets-manifest.json");
|
||||
const plan = await buildP0aRuntimeAssetPlan({ replicationRoot });
|
||||
writeRuntimeAssetManifest(outputPath, plan.manifest);
|
||||
process.stdout.write(`${JSON.stringify({
|
||||
counts: plan.manifest.counts,
|
||||
manifest_bytes: Buffer.byteLength(serializeRuntimeAssetManifest(plan.manifest)),
|
||||
status: "generated",
|
||||
})}\n`);
|
||||
@@ -18,6 +18,7 @@ import { tmpdir } from "node:os";
|
||||
import { basename, dirname, join, relative, resolve, sep } from "node:path";
|
||||
|
||||
import { frozenRuntime } from "../frozen-versions.mjs";
|
||||
import { readRuntimeAssetManifest } from "./runtime-assets.mjs";
|
||||
|
||||
const repositoryRoot = resolve(import.meta.dirname, "..", "..");
|
||||
const fixedPort = 43121;
|
||||
@@ -363,7 +364,10 @@ export async function buildAndValidatePortablePackage({ evidenceDirectory, outpu
|
||||
copyTree(join(repositoryRoot, "apps", "web", "dist"), join(packageDirectory, "web"));
|
||||
copyTree(join(repositoryRoot, "apps", "web", "support-gate"), join(packageDirectory, "web", "support-gate"));
|
||||
writeJson(join(packageDirectory, "migrations", "manifest.json"), { migrations: [], schema_version: "0" });
|
||||
writeJson(join(packageDirectory, "asset-metadata", "manifest.json"), { resources: [], schema_version: "1.0", source: "external_read_only" });
|
||||
writeJson(
|
||||
join(packageDirectory, "asset-metadata", "manifest.json"),
|
||||
readRuntimeAssetManifest(join(repositoryRoot, "config", "runtime-assets-manifest.json")),
|
||||
);
|
||||
writeJson(join(packageDirectory, "LICENSES", "third-party.json"), { api: apiDependencies, runtime: { node: frozenRuntime.node }, schema_version: "1.0", worker: workerDependencies });
|
||||
|
||||
const commit = run("git", ["rev-parse", "HEAD"]);
|
||||
|
||||
@@ -0,0 +1,317 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import {
|
||||
existsSync,
|
||||
linkSync,
|
||||
lstatSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
readdirSync,
|
||||
realpathSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { basename, dirname, extname, isAbsolute, join, resolve, sep } from "node:path";
|
||||
|
||||
export const P0A_RUNTIME_ASSET_ROOT_REF = "p0a_runtime_assets";
|
||||
export const RUNTIME_ASSET_MANIFEST_SCHEMA = "DadaRuntimeAssets/v1";
|
||||
|
||||
const assetIdPattern = /^[a-z0-9][a-z0-9_-]{2,119}$/i;
|
||||
const mimePattern = /^[a-z0-9][a-z0-9.+-]*\/[a-z0-9][a-z0-9.+-]*$/i;
|
||||
const releasePattern = /^[a-z0-9][a-z0-9._-]{0,79}$/i;
|
||||
const shaPattern = /^[a-f0-9]{64}$/i;
|
||||
const fontMimeTypes = new Map([
|
||||
[".otf", "font/otf"],
|
||||
[".ttf", "font/ttf"],
|
||||
[".woff", "font/woff"],
|
||||
[".woff2", "font/woff2"],
|
||||
]);
|
||||
|
||||
function sha256(bytes) {
|
||||
return createHash("sha256").update(bytes).digest("hex");
|
||||
}
|
||||
|
||||
function fileSha256(path) {
|
||||
return sha256(readFileSync(path));
|
||||
}
|
||||
|
||||
function stableEntries(entries) {
|
||||
return entries.map((entry) => {
|
||||
if (!entry || typeof entry !== "object") throw new Error("runtime_asset_entry_invalid");
|
||||
if (!assetIdPattern.test(entry.assetId)) throw new Error("runtime_asset_id_invalid");
|
||||
if (!mimePattern.test(entry.mimeType)) throw new Error("runtime_asset_mime_invalid");
|
||||
if (!releasePattern.test(entry.resourceVersion)) throw new Error("runtime_asset_version_invalid");
|
||||
if (entry.rootRef !== P0A_RUNTIME_ASSET_ROOT_REF) throw new Error("runtime_asset_root_ref_invalid");
|
||||
if (!shaPattern.test(entry.sha256)) throw new Error("runtime_asset_sha256_invalid");
|
||||
if (
|
||||
typeof entry.relativePath !== "string"
|
||||
|| isAbsolute(entry.relativePath)
|
||||
|| entry.relativePath.includes("\\")
|
||||
|| entry.relativePath.split("/").some((part) => part === "" || part === "..")
|
||||
) throw new Error("runtime_asset_relative_path_invalid");
|
||||
return { ...entry, sha256: entry.sha256.toLowerCase() };
|
||||
}).sort((left, right) => {
|
||||
const byVersion = left.resourceVersion.localeCompare(right.resourceVersion);
|
||||
return byVersion || left.assetId.localeCompare(right.assetId);
|
||||
});
|
||||
}
|
||||
|
||||
function derivedCounts(entries) {
|
||||
return {
|
||||
dynamic_fonts: entries.filter((entry) => entry.resourceVersion === "p0a-complex-v1" && /^[a-f0-9]{32}$/.test(entry.assetId)).length,
|
||||
dynamic_images: entries.filter((entry) => entry.resourceVersion === "p0a-complex-v1" && /^DYN\d{3}-/.test(entry.assetId)).length,
|
||||
font_panel_items: entries.filter((entry) => entry.resourceVersion === "p0a-complex-v1" && /^FONT\d{3}$/.test(entry.assetId)).length,
|
||||
static_stickers: entries.filter((entry) => entry.resourceVersion === "p0a-static-v1" && /^STK\d{3,4}$/.test(entry.assetId)).length,
|
||||
};
|
||||
}
|
||||
|
||||
export function createRuntimeAssetManifest({ counts, entries, sourceManifestSha256 }) {
|
||||
const normalizedEntries = stableEntries(entries);
|
||||
const keys = new Set();
|
||||
const paths = new Set();
|
||||
for (const entry of normalizedEntries) {
|
||||
const key = `${entry.resourceVersion}\u0000${entry.assetId}`;
|
||||
if (keys.has(key)) throw new Error("runtime_asset_id_duplicate");
|
||||
if (paths.has(entry.relativePath)) throw new Error("runtime_asset_path_duplicate");
|
||||
keys.add(key);
|
||||
paths.add(entry.relativePath);
|
||||
}
|
||||
const actualCounts = derivedCounts(normalizedEntries);
|
||||
if (JSON.stringify(counts) !== JSON.stringify(actualCounts)) throw new Error("runtime_asset_counts_invalid");
|
||||
if (sourceManifestSha256 !== undefined && !shaPattern.test(sourceManifestSha256)) {
|
||||
throw new Error("runtime_asset_source_manifest_sha256_invalid");
|
||||
}
|
||||
return {
|
||||
counts: actualCounts,
|
||||
entries: normalizedEntries,
|
||||
root_ref: P0A_RUNTIME_ASSET_ROOT_REF,
|
||||
schema_version: RUNTIME_ASSET_MANIFEST_SCHEMA,
|
||||
source: "external_read_only",
|
||||
...(sourceManifestSha256 ? { source_manifest_sha256: sourceManifestSha256.toLowerCase() } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function readRuntimeAssetManifest(path) {
|
||||
const value = JSON.parse(readFileSync(path, "utf8"));
|
||||
if (
|
||||
value?.schema_version !== RUNTIME_ASSET_MANIFEST_SCHEMA
|
||||
|| value?.source !== "external_read_only"
|
||||
|| value?.root_ref !== P0A_RUNTIME_ASSET_ROOT_REF
|
||||
|| !Array.isArray(value.entries)
|
||||
) throw new Error("runtime_asset_manifest_invalid");
|
||||
return createRuntimeAssetManifest({
|
||||
counts: value.counts,
|
||||
entries: value.entries,
|
||||
...(value.source_manifest_sha256 ? { sourceManifestSha256: value.source_manifest_sha256 } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
export function serializeRuntimeAssetManifest(manifest) {
|
||||
return `${JSON.stringify(manifest, null, 2)}\n`;
|
||||
}
|
||||
|
||||
export function writeRuntimeAssetManifest(path, manifest) {
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
writeFileSync(path, serializeRuntimeAssetManifest(manifest));
|
||||
}
|
||||
|
||||
function targetWithinRoot(root, relativePath) {
|
||||
const absoluteRoot = resolve(root);
|
||||
const target = resolve(absoluteRoot, ...relativePath.split("/"));
|
||||
if (target === absoluteRoot || !target.startsWith(`${absoluteRoot}${sep}`)) throw new Error("asset_target_path_invalid");
|
||||
return target;
|
||||
}
|
||||
|
||||
function sameFile(left, right) {
|
||||
const leftStat = statSync(left);
|
||||
const rightStat = statSync(right);
|
||||
return leftStat.dev === rightStat.dev && leftStat.ino === rightStat.ino;
|
||||
}
|
||||
|
||||
export function deployRuntimeAssetPlan({ assetRoot, manifest, resources }) {
|
||||
if (!isAbsolute(assetRoot)) throw new Error("asset_root_must_be_absolute");
|
||||
const normalizedManifest = createRuntimeAssetManifest({
|
||||
counts: manifest.counts,
|
||||
entries: manifest.entries,
|
||||
...(manifest.source_manifest_sha256 ? { sourceManifestSha256: manifest.source_manifest_sha256 } : {}),
|
||||
});
|
||||
const entries = new Map(normalizedManifest.entries.map((entry) => [`${entry.resourceVersion}\u0000${entry.assetId}`, entry]));
|
||||
if (resources.length !== entries.size) throw new Error("asset_resource_plan_incomplete");
|
||||
mkdirSync(assetRoot, { recursive: true });
|
||||
for (const resource of resources) {
|
||||
const key = `${resource.entry.resourceVersion}\u0000${resource.entry.assetId}`;
|
||||
const entry = entries.get(key);
|
||||
if (!entry || JSON.stringify(entry) !== JSON.stringify({ ...resource.entry, sha256: resource.entry.sha256.toLowerCase() })) {
|
||||
throw new Error("asset_resource_plan_mismatch");
|
||||
}
|
||||
if (!existsSync(resource.sourcePath) || !statSync(resource.sourcePath).isFile() || lstatSync(resource.sourcePath).isSymbolicLink()) {
|
||||
throw new Error("asset_source_invalid");
|
||||
}
|
||||
if (fileSha256(resource.sourcePath) !== entry.sha256) throw new Error("asset_source_hash_invalid");
|
||||
const targetPath = targetWithinRoot(assetRoot, entry.relativePath);
|
||||
mkdirSync(dirname(targetPath), { recursive: true });
|
||||
if (existsSync(targetPath)) {
|
||||
if (fileSha256(targetPath) !== entry.sha256) throw new Error("asset_target_conflict");
|
||||
if (!sameFile(resource.sourcePath, targetPath)) throw new Error("asset_target_not_hardlink");
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
linkSync(resource.sourcePath, targetPath);
|
||||
} catch (error) {
|
||||
if (error && typeof error === "object" && "code" in error && error.code === "EXDEV") {
|
||||
throw new Error("asset_hardlink_volume_mismatch");
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
if (!sameFile(resource.sourcePath, targetPath)) throw new Error("asset_hardlink_verification_failed");
|
||||
}
|
||||
writeRuntimeAssetManifest(join(assetRoot, "manifest.json"), normalizedManifest);
|
||||
return { linked_files: resources.length, manifest: normalizedManifest, status: "ready" };
|
||||
}
|
||||
|
||||
function oneDirectoryWithPrefix(root, prefix) {
|
||||
const matches = readdirSync(root, { withFileTypes: true })
|
||||
.filter((entry) => entry.isDirectory() && entry.name.startsWith(`${prefix}_`));
|
||||
if (matches.length !== 1) throw new Error(`runtime_asset_source_directory_invalid:${prefix}`);
|
||||
return join(root, matches[0].name);
|
||||
}
|
||||
|
||||
function oneSupportedFont(root) {
|
||||
const matches = readdirSync(root, { withFileTypes: true })
|
||||
.filter((entry) => entry.isFile() && fontMimeTypes.has(extname(entry.name).toLowerCase()));
|
||||
if (matches.length !== 1) throw new Error(`runtime_font_source_invalid:${basename(root)}`);
|
||||
return join(root, matches[0].name);
|
||||
}
|
||||
|
||||
function entryFor(sourcePath, assetId, resourceVersion, relativePath, mimeType) {
|
||||
return {
|
||||
assetId,
|
||||
mimeType,
|
||||
relativePath,
|
||||
resourceVersion,
|
||||
rootRef: P0A_RUNTIME_ASSET_ROOT_REF,
|
||||
sha256: fileSha256(sourcePath),
|
||||
};
|
||||
}
|
||||
|
||||
function dynamicMetadata(templateRoot, descriptor, field) {
|
||||
const templateDirectory = join(templateRoot, descriptor.templateId);
|
||||
const metadata = JSON.parse(readFileSync(join(templateDirectory, "metadata.json"), "utf8"));
|
||||
if (!Array.isArray(metadata?.files?.[field]) || !metadata.files[field].includes(descriptor.sourceReference)) {
|
||||
throw new Error(`runtime_dynamic_reference_invalid:${descriptor.assetId}`);
|
||||
}
|
||||
return templateDirectory;
|
||||
}
|
||||
|
||||
export async function buildP0aRuntimeAssetPlan({ replicationRoot }) {
|
||||
const [{ compileStaticStickerCatalog }, registry] = await Promise.all([
|
||||
import("../../packages/asset-compiler/dist/index.js"),
|
||||
import("../../packages/template-registry/dist/index.js"),
|
||||
]);
|
||||
const compilerOutput = mkdtempSync(join(tmpdir(), "dada-runtime-asset-plan-"));
|
||||
try {
|
||||
const staticSourceRoot = join(replicationRoot, "sticker_normal");
|
||||
const staticResult = compileStaticStickerCatalog({
|
||||
outputDirectory: compilerOutput,
|
||||
releaseVersion: registry.P0A_STATIC_STICKER_RELEASE_VERSION,
|
||||
sourceRoot: staticSourceRoot,
|
||||
});
|
||||
const resources = staticResult.catalog.items.map((item) => {
|
||||
const sourcePath = join(staticSourceRoot, ...item.relative_path.split("/"));
|
||||
const entry = entryFor(
|
||||
sourcePath,
|
||||
item.stable_id,
|
||||
registry.P0A_STATIC_STICKER_RELEASE_VERSION,
|
||||
`${registry.P0A_STATIC_STICKER_RELEASE_VERSION}/${item.stable_id}.png`,
|
||||
"image/png",
|
||||
);
|
||||
if (entry.sha256 !== item.sha256.toLowerCase()) throw new Error(`static_sticker_hash_invalid:${item.stable_id}`);
|
||||
return { entry, sourcePath };
|
||||
});
|
||||
|
||||
const fontPackagesRoot = join(
|
||||
replicationRoot,
|
||||
"sticker_text",
|
||||
"字体",
|
||||
"面板全量采集",
|
||||
"font_panel_full_20260722",
|
||||
"resources",
|
||||
"font_packages",
|
||||
);
|
||||
for (const assetId of registry.P0A_REQUIRED_FONT_PANEL_IDS) {
|
||||
const packageDirectory = oneDirectoryWithPrefix(fontPackagesRoot, assetId);
|
||||
const sourcePath = oneSupportedFont(join(packageDirectory, "font_files"));
|
||||
const extension = extname(sourcePath).toLowerCase();
|
||||
resources.push({
|
||||
entry: entryFor(
|
||||
sourcePath,
|
||||
assetId,
|
||||
registry.P0A_COMPLEX_RELEASE_VERSION,
|
||||
`${registry.P0A_COMPLEX_RELEASE_VERSION}/${assetId}${extension}`,
|
||||
fontMimeTypes.get(extension),
|
||||
),
|
||||
sourcePath,
|
||||
});
|
||||
}
|
||||
|
||||
const templateRoot = join(replicationRoot, "sticker_interactive", "单模板归档", "templates");
|
||||
for (const descriptor of registry.P0A_DYNAMIC_RUNTIME_FONT_SOURCES) {
|
||||
const templateDirectory = dynamicMetadata(templateRoot, descriptor, "fonts");
|
||||
const sourcePath = oneSupportedFont(join(templateDirectory, ...descriptor.sourceReference.split("/")));
|
||||
const extension = extname(sourcePath).toLowerCase();
|
||||
resources.push({
|
||||
entry: entryFor(
|
||||
sourcePath,
|
||||
descriptor.assetId,
|
||||
registry.P0A_COMPLEX_RELEASE_VERSION,
|
||||
`${registry.P0A_COMPLEX_RELEASE_VERSION}/${descriptor.assetId}${extension}`,
|
||||
fontMimeTypes.get(extension),
|
||||
),
|
||||
sourcePath,
|
||||
});
|
||||
}
|
||||
for (const descriptor of registry.P0A_DYNAMIC_RUNTIME_IMAGE_SOURCES) {
|
||||
const templateDirectory = dynamicMetadata(templateRoot, descriptor, "images");
|
||||
const sourcePath = join(templateDirectory, ...descriptor.sourceReference.split("/"));
|
||||
if (!existsSync(sourcePath) || extname(sourcePath).toLowerCase() !== ".png") {
|
||||
throw new Error(`runtime_dynamic_image_invalid:${descriptor.assetId}`);
|
||||
}
|
||||
resources.push({
|
||||
entry: entryFor(
|
||||
sourcePath,
|
||||
descriptor.assetId,
|
||||
registry.P0A_COMPLEX_RELEASE_VERSION,
|
||||
`${registry.P0A_COMPLEX_RELEASE_VERSION}/${descriptor.assetId}.png`,
|
||||
"image/png",
|
||||
),
|
||||
sourcePath,
|
||||
});
|
||||
}
|
||||
|
||||
const manifestPath = join(replicationRoot, "sticker_web_handoff", "sticker_web_catalog_manifest.json");
|
||||
const manifest = createRuntimeAssetManifest({
|
||||
counts: {
|
||||
dynamic_fonts: registry.P0A_DYNAMIC_RUNTIME_FONT_SOURCES.length,
|
||||
dynamic_images: registry.P0A_DYNAMIC_RUNTIME_IMAGE_SOURCES.length,
|
||||
font_panel_items: registry.P0A_REQUIRED_FONT_PANEL_IDS.length,
|
||||
static_stickers: staticResult.catalog.count,
|
||||
},
|
||||
entries: resources.map((resource) => resource.entry),
|
||||
sourceManifestSha256: fileSha256(manifestPath),
|
||||
});
|
||||
const resourcesByKey = new Map(resources.map((resource) => [`${resource.entry.resourceVersion}\u0000${resource.entry.assetId}`, resource]));
|
||||
return {
|
||||
manifest,
|
||||
resources: manifest.entries.map((entry) => resourcesByKey.get(`${entry.resourceVersion}\u0000${entry.assetId}`)),
|
||||
};
|
||||
} finally {
|
||||
rmSync(compilerOutput, { force: true, recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
export function defaultReplicationRoot(environment = process.env) {
|
||||
if (!environment.USERPROFILE || !isAbsolute(environment.USERPROFILE)) throw new Error("user_profile_unavailable");
|
||||
return join(environment.USERPROFILE, "Desktop", "sticker_web_replication_assets");
|
||||
}
|
||||
Reference in New Issue
Block a user