mirror of
https://github.com/MrUnknownDE/VRCX.git
synced 2026-04-17 22:03:50 +02:00
457 lines
14 KiB
JavaScript
457 lines
14 KiB
JavaScript
/* global __dirname, require */
|
|
// generate-third-party-licenses.js
|
|
// use by frontend open source software notice dialog
|
|
|
|
const fs = require('fs');
|
|
const os = require('os');
|
|
const path = require('path');
|
|
|
|
const rootDir = path.join(__dirname, '..');
|
|
const frontendLicensePath = path.join(
|
|
rootDir,
|
|
'build',
|
|
'html',
|
|
'.vite',
|
|
'license.md'
|
|
);
|
|
const outputDir = path.join(rootDir, 'build', 'html', 'licenses');
|
|
const outputManifestPath = path.join(outputDir, 'third-party-licenses.json');
|
|
const outputNoticePath = path.join(outputDir, 'THIRD_PARTY_NOTICES.txt');
|
|
const dotnetDir = path.join(rootDir, 'Dotnet');
|
|
const nugetCacheDir =
|
|
process.env.NUGET_PACKAGES || path.join(os.homedir(), '.nuget', 'packages');
|
|
const overridesPath = path.join(__dirname, 'licenses', 'nuget-overrides.json');
|
|
|
|
const nugetOverrides = JSON.parse(fs.readFileSync(overridesPath, 'utf8'));
|
|
|
|
function ensureDirectory(directoryPath) {
|
|
fs.mkdirSync(directoryPath, { recursive: true });
|
|
}
|
|
|
|
function readFileIfExists(filePath) {
|
|
if (!fs.existsSync(filePath)) {
|
|
return null;
|
|
}
|
|
|
|
return fs.readFileSync(filePath, 'utf8');
|
|
}
|
|
|
|
function normalizeWhitespace(value) {
|
|
return value?.replace(/\r\n/g, '\n').trim() || '';
|
|
}
|
|
|
|
function sanitizeId(value) {
|
|
return value
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9]+/g, '-')
|
|
.replace(/^-|-$/g, '');
|
|
}
|
|
|
|
function extractXmlTagValue(xml, tagName) {
|
|
const match = xml.match(
|
|
new RegExp(`<${tagName}>([\\s\\S]*?)<\\/${tagName}>`, 'i')
|
|
);
|
|
|
|
return match?.[1]?.trim() || '';
|
|
}
|
|
|
|
function extractXmlSelfClosingTagAttribute(xml, tagName, attributeName) {
|
|
const match = xml.match(
|
|
new RegExp(
|
|
`<${tagName}[^>]*${attributeName}="([^"]+)"[^>]*>(?:[\\s\\S]*?)<\\/${tagName}>`,
|
|
'i'
|
|
)
|
|
);
|
|
|
|
return match?.[1]?.trim() || '';
|
|
}
|
|
|
|
function extractRepositoryUrl(xml) {
|
|
const match = xml.match(/<repository[^>]*url="([^"]+)"/i);
|
|
|
|
return match?.[1]?.trim() || '';
|
|
}
|
|
|
|
function findFirstExistingFile(filePaths) {
|
|
return filePaths.find((filePath) => fs.existsSync(filePath)) || null;
|
|
}
|
|
|
|
function findPackageLicenseFile(packageDir) {
|
|
if (!fs.existsSync(packageDir)) {
|
|
return null;
|
|
}
|
|
|
|
const stack = [packageDir];
|
|
|
|
while (stack.length > 0) {
|
|
const currentDir = stack.pop();
|
|
const dirEntries = fs.readdirSync(currentDir, { withFileTypes: true });
|
|
|
|
for (const dirEntry of dirEntries) {
|
|
const fullPath = path.join(currentDir, dirEntry.name);
|
|
|
|
if (dirEntry.isDirectory()) {
|
|
if (!fullPath.includes(`${path.sep}tools${path.sep}`)) {
|
|
stack.push(fullPath);
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (
|
|
/^(license|licence|notice|copying)(\.[^.]+)?$/i.test(
|
|
dirEntry.name
|
|
)
|
|
) {
|
|
return fullPath;
|
|
}
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function parseFrontendLicenses(markdown) {
|
|
const normalized = normalizeWhitespace(markdown);
|
|
if (!normalized) {
|
|
return [];
|
|
}
|
|
|
|
const sections = normalized.split(/\n(?=## )/g).slice(1);
|
|
|
|
return sections
|
|
.map((section) => {
|
|
const [headerLine, ...bodyLines] = section.split('\n');
|
|
const headerMatch = headerLine.match(
|
|
/^##\s+(.+?)\s+-\s+(.+?)\s+\((.+?)\)$/
|
|
);
|
|
|
|
if (!headerMatch) {
|
|
return null;
|
|
}
|
|
|
|
const [, name, version, license] = headerMatch;
|
|
const noticeText = normalizeWhitespace(bodyLines.join('\n'));
|
|
|
|
return {
|
|
id: `frontend-${sanitizeId(`${name}-${version}`)}`,
|
|
name,
|
|
version,
|
|
license,
|
|
sourceType: 'frontend',
|
|
sourceLabel: 'Frontend bundle',
|
|
noticeText,
|
|
needsReview: !license && !noticeText
|
|
};
|
|
})
|
|
.filter(Boolean)
|
|
.sort((left, right) => left.name.localeCompare(right.name));
|
|
}
|
|
|
|
function parseCsprojPackageReferences(csprojText) {
|
|
return [
|
|
...csprojText.matchAll(
|
|
/<PackageReference\s+Include="([^"]+)"\s+Version="([^"]+)"/g
|
|
)
|
|
].map(([, name, version]) => ({
|
|
name,
|
|
version,
|
|
sourceType: 'dotnet'
|
|
}));
|
|
}
|
|
|
|
function parseCsprojBinaryReferences(csprojText) {
|
|
const binaryEntries = [];
|
|
|
|
for (const [, name, hintPath] of csprojText.matchAll(
|
|
/<Reference\s+Include="([^"]+)">[\s\S]*?<HintPath>([^<]+)<\/HintPath>[\s\S]*?<\/Reference>/g
|
|
)) {
|
|
binaryEntries.push({
|
|
name,
|
|
version: '',
|
|
sourceType: 'native',
|
|
filePath: hintPath.replaceAll('\\', '/')
|
|
});
|
|
}
|
|
|
|
for (const [, includePath] of csprojText.matchAll(
|
|
/<None\s+Include="([^"]*libs[^"]+\.(?:dll|so|dylib))">/g
|
|
)) {
|
|
const normalizedPath = includePath.replaceAll('\\', '/');
|
|
const fileName = path.basename(normalizedPath);
|
|
const overrideName =
|
|
fileName === 'openvr_api.dll' ? 'OpenVR SDK' : fileName;
|
|
|
|
binaryEntries.push({
|
|
name: overrideName,
|
|
version: '',
|
|
sourceType: 'native',
|
|
filePath: normalizedPath
|
|
});
|
|
}
|
|
|
|
return binaryEntries;
|
|
}
|
|
|
|
function parseAssetsLibraries(projectAssetsPath) {
|
|
const assetsRaw = readFileIfExists(projectAssetsPath);
|
|
if (!assetsRaw) {
|
|
return [];
|
|
}
|
|
|
|
const assets = JSON.parse(assetsRaw);
|
|
const libraries = assets.libraries || {};
|
|
|
|
return Object.keys(libraries)
|
|
.filter(
|
|
(libraryKey) =>
|
|
!libraries[libraryKey]?.type ||
|
|
libraries[libraryKey].type === 'package'
|
|
)
|
|
.map((libraryKey) => {
|
|
const lastSlashIndex = libraryKey.lastIndexOf('/');
|
|
|
|
return {
|
|
name: libraryKey.slice(0, lastSlashIndex),
|
|
version: libraryKey.slice(lastSlashIndex + 1),
|
|
sourceType: 'dotnet'
|
|
};
|
|
});
|
|
}
|
|
|
|
function mergeDotnetEntries(csprojFiles) {
|
|
const collectedEntries = new Map();
|
|
|
|
for (const csprojFile of csprojFiles) {
|
|
const projectName = path.basename(csprojFile, '.csproj');
|
|
const csprojText = fs.readFileSync(csprojFile, 'utf8');
|
|
const assetEntries = parseAssetsLibraries(
|
|
path.join(path.dirname(csprojFile), 'obj', 'project.assets.json')
|
|
);
|
|
const packageEntries =
|
|
assetEntries.length > 0
|
|
? assetEntries
|
|
: parseCsprojPackageReferences(csprojText);
|
|
const binaryEntries = parseCsprojBinaryReferences(csprojText);
|
|
|
|
for (const entry of [...packageEntries, ...binaryEntries]) {
|
|
const key = `${entry.sourceType}:${entry.name}:${entry.version}`;
|
|
const existingEntry = collectedEntries.get(key) || {
|
|
...entry,
|
|
projects: []
|
|
};
|
|
|
|
existingEntry.projects = [
|
|
...new Set([...existingEntry.projects, projectName])
|
|
].sort();
|
|
collectedEntries.set(key, existingEntry);
|
|
}
|
|
}
|
|
|
|
return [...collectedEntries.values()].sort((left, right) =>
|
|
left.name.localeCompare(right.name)
|
|
);
|
|
}
|
|
|
|
function resolveNugetMetadata(name, version) {
|
|
const packageDir = path.join(nugetCacheDir, name.toLowerCase(), version);
|
|
const nuspecPath =
|
|
findFirstExistingFile([
|
|
path.join(packageDir, `${name.toLowerCase()}.nuspec`),
|
|
...(fs.existsSync(packageDir)
|
|
? fs
|
|
.readdirSync(packageDir)
|
|
.filter((fileName) => fileName.endsWith('.nuspec'))
|
|
.map((fileName) => path.join(packageDir, fileName))
|
|
: [])
|
|
]) || null;
|
|
|
|
const override = nugetOverrides[name] || {};
|
|
const metadata = {
|
|
license: override.license || '',
|
|
licenseUrl: override.licenseUrl || '',
|
|
projectUrl: override.projectUrl || '',
|
|
noticeText: normalizeWhitespace(override.noticeText),
|
|
needsReview: false
|
|
};
|
|
|
|
if (!nuspecPath) {
|
|
metadata.needsReview = !metadata.license && !metadata.noticeText;
|
|
return metadata;
|
|
}
|
|
|
|
const nuspecText = fs.readFileSync(nuspecPath, 'utf8');
|
|
const licenseExpression =
|
|
extractXmlSelfClosingTagAttribute(nuspecText, 'license', 'type') ===
|
|
'expression'
|
|
? extractXmlTagValue(nuspecText, 'license')
|
|
: '';
|
|
const licenseFilePath =
|
|
extractXmlSelfClosingTagAttribute(nuspecText, 'license', 'type') ===
|
|
'file'
|
|
? extractXmlTagValue(nuspecText, 'license')
|
|
: '';
|
|
|
|
metadata.license ||= licenseExpression;
|
|
metadata.licenseUrl ||= extractXmlTagValue(nuspecText, 'licenseUrl');
|
|
metadata.projectUrl ||=
|
|
extractXmlTagValue(nuspecText, 'projectUrl') ||
|
|
extractRepositoryUrl(nuspecText);
|
|
|
|
if (!metadata.noticeText) {
|
|
const embeddedLicensePath = licenseFilePath
|
|
? path.join(packageDir, licenseFilePath.replaceAll('\\', path.sep))
|
|
: null;
|
|
const discoveredLicensePath = findPackageLicenseFile(packageDir);
|
|
const resolvedLicensePath = findFirstExistingFile(
|
|
[embeddedLicensePath, discoveredLicensePath].filter(Boolean)
|
|
);
|
|
|
|
metadata.noticeText = normalizeWhitespace(
|
|
readFileIfExists(resolvedLicensePath)
|
|
);
|
|
}
|
|
|
|
metadata.needsReview = !metadata.license && !metadata.noticeText;
|
|
return metadata;
|
|
}
|
|
|
|
function enrichDotnetEntries(entries) {
|
|
return entries.map((entry) => {
|
|
const override = nugetOverrides[entry.name] || {};
|
|
|
|
if (entry.sourceType === 'native') {
|
|
return {
|
|
id: `native-${sanitizeId(entry.name)}`,
|
|
...entry,
|
|
license: override.license || 'Unknown',
|
|
licenseUrl: override.licenseUrl || '',
|
|
projectUrl: override.projectUrl || '',
|
|
noticeText: normalizeWhitespace(override.noticeText),
|
|
sourceLabel: 'Bundled native/.NET component',
|
|
needsReview: !override.license && !override.noticeText
|
|
};
|
|
}
|
|
|
|
const metadata = entry.version
|
|
? resolveNugetMetadata(entry.name, entry.version)
|
|
: {
|
|
license: override.license || '',
|
|
licenseUrl: override.licenseUrl || '',
|
|
projectUrl: override.projectUrl || '',
|
|
noticeText: normalizeWhitespace(override.noticeText),
|
|
needsReview: !override.license && !override.noticeText
|
|
};
|
|
|
|
return {
|
|
id: `${entry.sourceType}-${sanitizeId(`${entry.name}-${entry.version}`)}`,
|
|
...entry,
|
|
license: metadata.license || 'Unknown',
|
|
licenseUrl: metadata.licenseUrl || '',
|
|
projectUrl: metadata.projectUrl || '',
|
|
noticeText: metadata.noticeText,
|
|
sourceLabel: 'Bundled .NET/native backend component',
|
|
needsReview: metadata.needsReview
|
|
};
|
|
});
|
|
}
|
|
|
|
function createThirdPartyNoticeText(frontendLicenseMarkdown, entries) {
|
|
const lines = [
|
|
'VRCX Third-Party Notices',
|
|
'',
|
|
`Generated: ${new Date().toISOString()}`,
|
|
'',
|
|
'========================================',
|
|
'Frontend bundled dependencies',
|
|
'========================================',
|
|
'',
|
|
normalizeWhitespace(frontendLicenseMarkdown) ||
|
|
'No frontend license manifest was available.',
|
|
'',
|
|
'',
|
|
'========================================',
|
|
'.NET and native bundled components',
|
|
'========================================',
|
|
''
|
|
];
|
|
|
|
for (const entry of entries.filter(
|
|
(item) => item.sourceType !== 'frontend'
|
|
)) {
|
|
lines.push(
|
|
`${entry.name}${entry.version ? ` - ${entry.version}` : ''} (${entry.license})`
|
|
);
|
|
lines.push(`Source: ${entry.sourceLabel}`);
|
|
|
|
if (entry.projects?.length) {
|
|
lines.push(`Used by: ${entry.projects.join(', ')}`);
|
|
}
|
|
|
|
if (entry.projectUrl) {
|
|
lines.push(`Project URL: ${entry.projectUrl}`);
|
|
}
|
|
|
|
if (entry.licenseUrl) {
|
|
lines.push(`License URL: ${entry.licenseUrl}`);
|
|
}
|
|
|
|
if (entry.filePath) {
|
|
lines.push(`Bundled file: ${entry.filePath}`);
|
|
}
|
|
|
|
lines.push('');
|
|
|
|
if (entry.noticeText) {
|
|
lines.push(entry.noticeText);
|
|
} else {
|
|
lines.push(
|
|
'No local license text was available during generation. Review this component before release.'
|
|
);
|
|
}
|
|
|
|
lines.push('');
|
|
lines.push('----------------------------------------');
|
|
lines.push('');
|
|
}
|
|
|
|
return `${lines.join('\n').trimEnd()}\n`;
|
|
}
|
|
|
|
function main() {
|
|
ensureDirectory(outputDir);
|
|
|
|
const frontendLicenseMarkdown = readFileIfExists(frontendLicensePath) || '';
|
|
const frontendEntries = parseFrontendLicenses(frontendLicenseMarkdown);
|
|
const csprojFiles = fs
|
|
.readdirSync(dotnetDir)
|
|
.filter((fileName) => fileName.endsWith('.csproj'))
|
|
.map((fileName) => path.join(dotnetDir, fileName))
|
|
.concat(path.join(dotnetDir, 'DBMerger', 'DBMerger.csproj'))
|
|
.filter(
|
|
(filePath, index, filePaths) =>
|
|
filePaths.indexOf(filePath) === index && fs.existsSync(filePath)
|
|
);
|
|
|
|
const dotnetEntries = enrichDotnetEntries(mergeDotnetEntries(csprojFiles));
|
|
const manifest = {
|
|
generatedAt: new Date().toISOString(),
|
|
noticePath: 'licenses/THIRD_PARTY_NOTICES.txt',
|
|
entries: [...frontendEntries, ...dotnetEntries]
|
|
};
|
|
|
|
fs.writeFileSync(outputManifestPath, JSON.stringify(manifest, null, 4));
|
|
fs.writeFileSync(
|
|
outputNoticePath,
|
|
createThirdPartyNoticeText(frontendLicenseMarkdown, manifest.entries)
|
|
);
|
|
|
|
const reviewCount = manifest.entries.filter(
|
|
(entry) => entry.needsReview
|
|
).length;
|
|
console.log(
|
|
`Generated third-party license manifest with ${manifest.entries.length} entries (${reviewCount} requiring review).`
|
|
);
|
|
}
|
|
|
|
main();
|