Files
hy 1eedabb914 feat: add support for language and service route preferences
- Updated ModelSource type to include 'huggingface'.
- Enhanced MODEL_SOURCE_LABELS for better clarity.
- Introduced defaultModelSource function to determine default source based on service route.
- Modified AppSettings to include language and serviceRoute.
- Created end-to-end tests for language and service route settings.
- Added unit tests for application language and service preferences.
- Updated free models test to reflect new catalog naming conventions.
- Enhanced official API tests to ensure correct handling of service regions.
- Adjusted release artifact tests to accommodate internationalization.
- Updated SQLite migration and storage tests to include new preferences.
2026-10-21 10:05:42 +08:00

254 lines
10 KiB
JavaScript

import { parse as parseYaml } from 'yaml'
import { hashSharedEntries, distributionName } from '../../release/shared/distribution.mjs'
import fs from 'node:fs'
import path from 'node:path'
import process from 'node:process'
import { listPackage, extractFile, getRawHeader } from '@electron/asar'
import {
assertMinimalPackageJson,
assertSafePackagePaths,
assertWebCrawlerSidecarPackage,
auditPackageDirectory,
normalizeRelativePath,
scanTextForSecrets
} from './content-policy.mjs'
import { assertProtectedMainPlaintextAbsent } from './obfuscation.mjs'
import { assertVcRuntimeDllsPackaged } from './vc-runtime.mjs'
import { evaluatePackageSizeBudget, loadPackageSizeBudgetConfig } from './size-budget.mjs'
const inputDirectory = path.resolve(process.argv[2] || 'dist')
const reportPath = path.join(inputDirectory, 'package-audit.json')
const sizeBudgetPath = path.resolve('build/package-size-budgets.json')
const runtimeTraceReportPath = process.argv[3] ? path.resolve(process.argv[3]) : ''
const runtimeTraceReport = runtimeTraceReportPath
? JSON.parse(fs.readFileSync(runtimeTraceReportPath, 'utf8'))
: null
const assertRuntimeTracePackaged = (archiveEntries, archivePath) => {
if (!runtimeTraceReport) return
const entries = new Set(archiveEntries)
for (const dependency of runtimeTraceReport.packages || []) {
const manifestPath = `node_modules/${dependency.name}/package.json`
if (!entries.has(manifestPath)) {
throw new Error(`${archivePath} is missing traced runtime package ${dependency.name}`)
}
}
for (const required of runtimeTraceReport.requiredFiles || []) {
const requiredPath = `node_modules/${required.package}/${required.path}`
if (!entries.has(requiredPath)) {
throw new Error(`${archivePath} is missing required runtime file ${required.id}`)
}
}
}
const findFiles = (directory, predicate) => {
const result = []
const walk = (current) => {
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
const entryPath = path.join(current, entry.name)
if (entry.isDirectory()) walk(entryPath)
else if (entry.isFile() && predicate(entryPath)) result.push(entryPath)
}
}
walk(directory)
return result
}
const findApplicationRoot = (archivePath) => {
let current = path.dirname(archivePath)
while (current !== path.dirname(current)) {
if (current.endsWith('.app')) return current
current = path.dirname(current)
}
return path.dirname(path.dirname(archivePath))
}
const listArchiveFiles = (archivePath) => {
const files = []
const walk = (node, parent = '') => {
for (const [name, entry] of Object.entries(node.files || {})) {
const relativePath = parent ? `${parent}/${name}` : name
if (entry.files) walk(entry, relativePath)
else
files.push({
relativePath,
size: Number(entry.size || 0),
unpacked: Boolean(entry.unpacked)
})
}
}
walk(getRawHeader(archivePath).header)
return files
}
const formatBytes = (bytes) => `${(bytes / 1024 / 1024).toFixed(1)} MiB`
const extractArchiveFile = (archivePath, relativePath) =>
extractFile(archivePath, relativePath.replaceAll('/', path.sep))
const assertPackagedNodePtyHelpersExecutable = (appRoot) => {
if (process.platform === 'win32') return
const helpers = findFiles(
appRoot,
(filePath) =>
path.basename(filePath) === 'spawn-helper' &&
filePath.includes(`${path.sep}node-pty${path.sep}`)
)
if (helpers.length === 0) {
throw new Error(`packaged node-pty spawn-helper not found: ${appRoot}`)
}
for (const helperPath of helpers) {
try {
fs.accessSync(helperPath, fs.constants.X_OK)
} catch {
throw new Error(`packaged node-pty spawn-helper is not executable: ${helperPath}`)
}
}
}
if (!fs.existsSync(inputDirectory)) throw new Error(`package output not found: ${inputDirectory}`)
const archives = findFiles(inputDirectory, (filePath) => path.basename(filePath) === 'app.asar')
if (archives.length === 0) throw new Error(`no packaged app.asar found under ${inputDirectory}`)
const applications = []
for (const archivePath of archives) {
const archiveEntries = listPackage(archivePath).map(normalizeRelativePath)
assertSafePackagePaths(archiveEntries)
assertRuntimeTracePackaged(archiveEntries, archivePath)
const archiveRoots = [...new Set(archiveEntries.map((entry) => entry.split('/')[0]))].sort()
const unexpectedRoots = archiveRoots.filter(
(name) => !['node_modules', 'out', 'package.json'].includes(name)
)
if (unexpectedRoots.length > 0) {
throw new Error(`unexpected app.asar root entries: ${unexpectedRoots.join(', ')}`)
}
const packagedPackageJson = JSON.parse(
extractArchiveFile(archivePath, 'package.json').toString('utf8')
)
assertMinimalPackageJson(packagedPackageJson, `${archivePath}:package.json`)
const archiveFiles = listArchiveFiles(archivePath)
const sharedAppHash = hashSharedEntries(
archiveFiles
.filter((item) => item.relativePath.startsWith('out/'))
.map((item) => [item.relativePath, extractArchiveFile(archivePath, item.relativePath)])
)
const hashPath = path.join(inputDirectory, 'shared-app.sha256')
if (fs.existsSync(hashPath) && fs.readFileSync(hashPath, 'utf8').trim() !== sharedAppHash) {
throw new Error('Packaged shared application resources do not match the build')
}
for (const item of archiveFiles) {
if (!item.relativePath.startsWith('out/') || item.size > 25 * 1024 * 1024) continue
if (!/\.(?:js|cjs|mjs|json|html|css)$/u.test(item.relativePath)) continue
const source = extractArchiveFile(archivePath, item.relativePath).toString('utf8')
scanTextForSecrets(source, `${archivePath}:${item.relativePath}`)
if (item.relativePath.startsWith('out/main/')) {
assertProtectedMainPlaintextAbsent(source, `${archivePath}:${item.relativePath}`)
}
}
const appRoot = findApplicationRoot(archivePath)
const expectedMarkerPath = path.join(inputDirectory, 'app-distribution.json')
if (fs.existsSync(expectedMarkerPath)) {
const distribution = JSON.parse(fs.readFileSync(expectedMarkerPath, 'utf8')).distribution
const resourcesDirectory = path.dirname(archivePath)
const marker = JSON.parse(
fs.readFileSync(path.join(resourcesDirectory, 'app-distribution.json'), 'utf8')
)
const update = parseYaml(
fs.readFileSync(path.join(resourcesDirectory, 'app-update.yml'), 'utf8')
)
const origin =
distribution === 'international'
? 'https://download.ncurator.com'
: 'https://download.guanzhangai.cn'
if (
marker.distribution !== distribution ||
update.url !== `${origin}/updates/latest` ||
update.updaterCacheDirName !== 'ncurator-updater'
)
throw new Error('Packaged distribution or fixed update configuration is incorrect')
const name = distributionName(distribution)
if (process.platform === 'darwin' && path.basename(appRoot) !== `${name}.app`)
throw new Error('Packaged application name is incorrect')
if (process.platform === 'win32' && !fs.existsSync(path.join(appRoot, `${name}.exe`)))
throw new Error('Packaged executable name is incorrect')
}
assertPackagedNodePtyHelpersExecutable(appRoot)
assertVcRuntimeDllsPackaged(appRoot)
const directoryAudit = auditPackageDirectory(appRoot)
const totalBytes = directoryAudit.files.reduce((sum, item) => sum + item.size, 0)
const webCrawlerSidecarBytes = assertWebCrawlerSidecarPackage(directoryAudit.files, appRoot)
const topFiles = [...directoryAudit.files]
.sort((left, right) => right.size - left.size)
.slice(0, 30)
.map((item) => ({ path: item.relativePath, bytes: item.size }))
applications.push({
root: path.relative(inputDirectory, appRoot) || '.',
sharedAppHash,
archive: path.relative(inputDirectory, archivePath),
totalBytes,
webCrawlerSidecarBytes,
archiveBytes: fs.statSync(archivePath).size,
archiveFileCount: archiveFiles.length,
unpackedPayloadBytes: archiveFiles
.filter((item) => item.unpacked)
.reduce((sum, item) => sum + item.size, 0),
topFiles
})
process.stdout.write(
`[package] audit passed: ${path.basename(appRoot)} ${formatBytes(totalBytes)}, ` +
`web crawler sidecar ${formatBytes(webCrawlerSidecarBytes)}, ` +
`app.asar ${formatBytes(fs.statSync(archivePath).size)}\n`
)
}
const installerExtensions = new Set(['.dmg', '.zip', '.exe', '.appimage', '.deb', '.snap'])
const installers = fs
.readdirSync(inputDirectory, { withFileTypes: true })
.filter(
(entry) => entry.isFile() && installerExtensions.has(path.extname(entry.name).toLowerCase())
)
.map((entry) => ({
name: entry.name,
bytes: fs.statSync(path.join(inputDirectory, entry.name)).size
}))
.sort((left, right) => right.bytes - left.bytes)
const report = {
schemaVersion: 1,
generatedAt: new Date().toISOString(),
platform: process.platform,
arch: process.arch,
applications,
distribution: fs.existsSync(path.join(inputDirectory, 'app-distribution.json'))
? JSON.parse(fs.readFileSync(path.join(inputDirectory, 'app-distribution.json'), 'utf8'))
.distribution
: 'domestic',
sharedAppHash: applications[0]?.sharedAppHash,
installers,
runtimeTrace: runtimeTraceReport
? {
schemaVersion: runtimeTraceReport.schemaVersion,
packages: runtimeTraceReport.packages.length,
requiredFiles: runtimeTraceReport.requiredFiles.length,
report: path.relative(inputDirectory, runtimeTraceReportPath)
}
: null
}
report.sizeBudget = evaluatePackageSizeBudget(report, loadPackageSizeBudgetConfig(sizeBudgetPath))
fs.writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8')
process.stdout.write(`[package] audit report: ${reportPath}\n`)
for (const metric of report.sizeBudget.metrics) {
if (metric.status === 'passed') continue
process.stdout.write(
`[package] size budget ${metric.status}: ${metric.label} ${formatBytes(metric.actualBytes)}, ` +
`baseline ${formatBytes(metric.baselineBytes)}, growth ${metric.growthPercent.toFixed(1)}%, ` +
`hard limit ${formatBytes(metric.hardLimitBytes)}\n`
)
}
if (report.sizeBudget.status === 'failed') {
throw new Error(`package size budget failed for ${report.sizeBudget.platform}`)
}