mirror of
https://github.com/MrUnknownDE/VRCX.git
synced 2026-05-07 06:56:04 +02:00
chore: improve third-party license compliance
This commit is contained in:
@@ -0,0 +1,456 @@
|
||||
/* 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();
|
||||
Reference in New Issue
Block a user