Sync latest TrueGrowth updates
Some checks failed
CI / main (push) Has been cancelled
CI / release-e2e (push) Has been cancelled

This commit is contained in:
jiam
2026-07-08 02:03:18 +08:00
parent 52636c91ae
commit 5119ac0ef8
102 changed files with 20985 additions and 1760 deletions

417
scripts/desktop-signing.mjs Normal file
View File

@@ -0,0 +1,417 @@
import fs from 'node:fs';
import path from 'node:path';
import { spawnSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const workspaceRoot = path.resolve(__dirname, '..');
const defaultEnvPath = path.join(workspaceRoot, '.env.desktop-signing.local');
const secretKeys = new Set([
'APPLE_APP_SPECIFIC_PASSWORD',
'CSC_KEY_PASSWORD',
'WIN_CSC_KEY_PASSWORD',
]);
function expandHome(filePath) {
if (!filePath) return filePath;
const cleanPath = filePath.startsWith('file://')
? filePath.slice('file://'.length)
: filePath;
if (cleanPath === '~') {
return process.env.HOME || cleanPath;
}
if (cleanPath.startsWith('~/')) {
return path.join(process.env.HOME || '', cleanPath.slice(2));
}
return cleanPath;
}
function parseEnvValue(rawValue) {
let value = rawValue.trim();
const quote = value[0];
if (
value.length >= 2 &&
(quote === '"' || quote === "'") &&
value[value.length - 1] === quote
) {
value = value.slice(1, -1);
if (quote === '"') {
value = value.replace(/\\n/g, '\n').replace(/\\"/g, '"').replace(/\\\\/g, '\\');
}
}
return value;
}
function loadEnvFile(filePath) {
if (!fs.existsSync(filePath)) {
return { loaded: false, values: {} };
}
const values = {};
const content = fs.readFileSync(filePath, 'utf8');
for (const [index, line] of content.split(/\r?\n/).entries()) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const match = trimmed.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
if (!match) {
throw new Error(
`Invalid env line in ${path.relative(workspaceRoot, filePath)}:${index + 1}`
);
}
values[match[1]] = parseEnvValue(match[2]);
}
return { loaded: true, values };
}
function mergeEnv(envFilePath) {
const { loaded, values } = loadEnvFile(envFilePath);
for (const [key, value] of Object.entries(values)) {
if (process.env[key] == null || process.env[key] === '') {
process.env[key] = value;
}
}
return loaded;
}
function readArgValue(args, name, fallback) {
const prefix = `${name}=`;
const match = args.find((arg) => arg.startsWith(prefix));
return match ? match.slice(prefix.length) : fallback;
}
function flag(args, name) {
return args.includes(name);
}
function run(command, args, options = {}) {
const result = spawnSync(command, args, {
cwd: workspaceRoot,
encoding: 'utf8',
env: process.env,
stdio: options.stdio || 'pipe',
timeout: options.timeout || 0,
});
if (result.error) {
throw result.error;
}
return result;
}
function runRequired(command, args) {
const result = spawnSync(command, args, {
cwd: workspaceRoot,
env: process.env,
stdio: 'inherit',
});
if (result.error) {
throw result.error;
}
if (result.status !== 0) {
throw new Error(`Command failed: ${command} ${args.join(' ')}`);
}
}
function maskEnvValue(key, value) {
if (!value) return '<empty>';
return secretKeys.has(key) ? '<set>' : value;
}
function certificateSubjectsFromP12(p12Path) {
const result = run('openssl', [
'pkcs12',
'-in',
p12Path,
'-nokeys',
'-clcerts',
'-passin',
'env:CSC_KEY_PASSWORD',
'-info',
]);
return {
ok: result.status === 0,
output: [result.stdout, result.stderr].filter(Boolean).join('\n'),
};
}
function codesigningIdentities() {
const result = run('security', ['find-identity', '-v', '-p', 'codesigning']);
return {
ok: result.status === 0,
output: [result.stdout, result.stderr].filter(Boolean).join('\n'),
};
}
function checkTool(command, args) {
try {
const result = run(command, args, { timeout: 5000 });
return result.status === 0;
} catch {
return false;
}
}
function findXcodeDeveloperDir() {
try {
const result = run('xcode-select', ['-p'], { timeout: 5000 });
if (result.status === 0) {
const developerDir = result.stdout.trim();
return developerDir || '';
}
} catch {
return '';
}
return '';
}
function hasXcodeTool(toolName) {
const developerDir = findXcodeDeveloperDir();
if (developerDir) {
const directTool = path.join(developerDir, 'usr/bin', toolName);
if (fs.existsSync(directTool)) {
return true;
}
}
return checkTool('xcrun', ['--find', toolName]);
}
function hasCompleteApiKeyLogin() {
return Boolean(
process.env.APPLE_API_KEY &&
process.env.APPLE_API_KEY_ID &&
process.env.APPLE_API_ISSUER
);
}
function hasPartialApiKeyLogin() {
return Boolean(
process.env.APPLE_API_KEY ||
process.env.APPLE_API_KEY_ID ||
process.env.APPLE_API_ISSUER
);
}
function hasCompleteAppleIdLogin() {
return Boolean(
process.env.APPLE_ID &&
process.env.APPLE_APP_SPECIFIC_PASSWORD &&
process.env.APPLE_TEAM_ID
);
}
function hasPartialAppleIdLogin() {
return Boolean(
process.env.APPLE_ID ||
process.env.APPLE_APP_SPECIFIC_PASSWORD ||
process.env.APPLE_TEAM_ID
);
}
function checkSigning({ requireNotarization = true, quiet = false } = {}) {
const errors = [];
const warnings = [];
const details = [];
if (process.platform !== 'darwin') {
errors.push('macOS signed builds must run on macOS.');
}
const cscLink = process.env.CSC_LINK ? expandHome(process.env.CSC_LINK) : '';
const cscName = process.env.CSC_NAME || '';
if (cscLink) {
process.env.CSC_LINK = cscLink;
if (!fs.existsSync(cscLink)) {
errors.push(`CSC_LINK does not exist: ${cscLink}`);
} else {
details.push(`CSC_LINK=${cscLink}`);
if (!process.env.CSC_KEY_PASSWORD) {
errors.push('CSC_KEY_PASSWORD is required when CSC_LINK points to a .p12 file.');
} else {
const certInfo = certificateSubjectsFromP12(cscLink);
if (!certInfo.ok) {
errors.push('Unable to read CSC_LINK .p12. Check the certificate password.');
} else if (!/Developer ID Application:/i.test(certInfo.output)) {
errors.push(
'CSC_LINK is not a "Developer ID Application" certificate. It cannot be used for direct customer distribution outside the Mac App Store.'
);
} else {
const subject =
certInfo.output.match(/subject=.*Developer ID Application:[^\n]+/i)?.[0] ||
'Developer ID Application certificate found in CSC_LINK';
details.push(subject);
}
}
}
} else {
const identities = codesigningIdentities();
if (!identities.ok) {
errors.push('Unable to inspect macOS code signing identities with security.');
} else if (!/Developer ID Application:/i.test(identities.output)) {
errors.push(
'No valid "Developer ID Application" identity is available. Set CSC_LINK to a Developer ID .p12 or import one into Keychain.'
);
} else {
details.push('Developer ID Application identity found in Keychain.');
}
if (cscName) {
details.push(`CSC_NAME=${cscName}`);
}
}
const appleApiKey = process.env.APPLE_API_KEY
? expandHome(process.env.APPLE_API_KEY)
: '';
if (appleApiKey) {
process.env.APPLE_API_KEY = appleApiKey;
}
if (appleApiKey && !fs.existsSync(appleApiKey)) {
errors.push(`APPLE_API_KEY does not exist: ${appleApiKey}`);
}
if (requireNotarization) {
if (hasCompleteApiKeyLogin()) {
details.push(`APPLE_API_KEY=${appleApiKey}`);
details.push(`APPLE_API_KEY_ID=${process.env.APPLE_API_KEY_ID}`);
if (process.env.APPLE_TEAM_ID) {
details.push(`APPLE_TEAM_ID=${process.env.APPLE_TEAM_ID}`);
}
} else if (hasCompleteAppleIdLogin()) {
warnings.push(
'Apple ID notarization is configured. The App Store Connect API key method is recommended for repeatable production builds.'
);
details.push(`APPLE_ID=${process.env.APPLE_ID}`);
details.push(`APPLE_TEAM_ID=${process.env.APPLE_TEAM_ID}`);
} else {
if (hasPartialApiKeyLogin()) {
errors.push(
'Notarization API key config is incomplete. Set APPLE_API_KEY, APPLE_API_KEY_ID, and APPLE_API_ISSUER.'
);
} else if (hasPartialAppleIdLogin()) {
errors.push(
'Apple ID notarization config is incomplete. Set APPLE_ID, APPLE_APP_SPECIFIC_PASSWORD, and APPLE_TEAM_ID.'
);
} else {
errors.push(
'Notarization credentials are missing. Use APPLE_API_KEY + APPLE_API_KEY_ID + APPLE_API_ISSUER for production macOS builds.'
);
}
}
if (!hasXcodeTool('notarytool')) {
errors.push('xcrun notarytool is unavailable. Install or select Xcode command line tools.');
}
if (!hasXcodeTool('stapler')) {
warnings.push('xcrun stapler is unavailable. Notarization may succeed, but ticket validation/stapling checks will be limited.');
}
} else {
warnings.push('TRUEGROWTH_NOTARIZE=false: this build can be signed, but it is not a full commercial macOS release.');
}
if (!quiet) {
console.log('[desktop:signing] macOS signing inputs');
for (const key of [
'CSC_LINK',
'CSC_NAME',
'APPLE_API_KEY',
'APPLE_API_KEY_ID',
'APPLE_API_ISSUER',
'APPLE_ID',
'APPLE_TEAM_ID',
'TRUEGROWTH_MAC_TARGETS',
'TRUEGROWTH_NOTARIZE',
]) {
if (process.env[key]) {
console.log(` ${key}=${maskEnvValue(key, process.env[key])}`);
}
}
for (const detail of details) {
console.log(` ok: ${detail}`);
}
for (const warning of warnings) {
console.warn(` warn: ${warning}`);
}
for (const error of errors) {
console.error(` error: ${error}`);
}
}
return { ok: errors.length === 0, errors, warnings, details };
}
function resolveMacTargets(args) {
const fromArg = readArgValue(args, '--targets', '');
const fromEnv = process.env.TRUEGROWTH_MAC_TARGETS || '';
const raw = fromArg || fromEnv || 'zip';
return raw
.split(/[,\s]+/)
.map((target) => target.trim())
.filter(Boolean);
}
function buildMac(args) {
const envFile = readArgValue(args, '--env', defaultEnvPath);
const envLoaded = mergeEnv(envFile);
const requireNotarization = process.env.TRUEGROWTH_NOTARIZE !== 'false';
const check = checkSigning({ requireNotarization });
if (!check.ok) {
throw new Error('macOS signing preflight failed; fix the errors above before building a customer release.');
}
const targets = resolveMacTargets(args);
const builderArgs = [
'exec',
'electron-builder',
'--config',
'electron-builder.yml',
'--mac',
...targets,
'--arm64',
'-c.mac.forceCodeSigning=true',
];
if (process.env.APPLE_TEAM_ID) {
builderArgs.push(`-c.mac.notarize.teamId=${process.env.APPLE_TEAM_ID}`);
}
console.log(
`[desktop:signing] Loaded ${envLoaded ? envFile : 'process environment'}`
);
console.log(`[desktop:signing] Building macOS targets: ${targets.join(', ')}`);
runRequired('pnpm', ['build:web']);
runRequired('pnpm', ['desktop:prepare']);
runRequired('pnpm', builderArgs);
runRequired('pnpm', ['desktop:release-manifest', '--', '--platform=mac']);
}
function printHelp() {
console.log(`Usage:
node scripts/desktop-signing.mjs check [--env=.env.desktop-signing.local] [--no-notarize]
node scripts/desktop-signing.mjs build-mac [--env=.env.desktop-signing.local] [--targets=zip|dmg,zip]
`);
}
function main() {
const [command = 'help', ...args] = process.argv.slice(2);
const envFile = readArgValue(args, '--env', defaultEnvPath);
if (command === 'help' || flag(args, '--help')) {
printHelp();
return;
}
if (command === 'check') {
const loaded = mergeEnv(envFile);
const requireNotarization =
!flag(args, '--no-notarize') && process.env.TRUEGROWTH_NOTARIZE !== 'false';
if (!loaded && !process.env.CSC_LINK && !process.env.CSC_NAME) {
console.warn(
`[desktop:signing] ${path.relative(workspaceRoot, envFile)} was not found; checking process environment only.`
);
}
const result = checkSigning({ requireNotarization });
if (!result.ok) {
process.exitCode = 1;
}
return;
}
if (command === 'build-mac') {
buildMac(args);
return;
}
throw new Error(`Unknown command: ${command}`);
}
main();

View File

@@ -17,7 +17,7 @@ const sourceIcon = path.join(
const requiredWebFiles = [
path.join(workspaceRoot, 'dist/apps/web/index.html'),
];
const runtimeDependencies = ['croner', 'p-queue', 'xstate'];
const runtimeDependencies = ['croner', 'electron-updater', 'p-queue', 'xstate'];
function copyIfExists(from, to, options = {}) {
if (!fs.existsSync(from)) {

View File

@@ -0,0 +1,203 @@
import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import yaml from 'js-yaml';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const workspaceRoot = path.resolve(__dirname, '..');
const packageJson = JSON.parse(
fs.readFileSync(path.join(workspaceRoot, 'package.json'), 'utf8')
);
const releaseDirArg = process.argv
.find((arg) => arg.startsWith('--release-dir='))
?.slice('--release-dir='.length);
const releaseDir = releaseDirArg
? path.resolve(workspaceRoot, releaseDirArg)
: path.join(workspaceRoot, 'dist/desktop/release');
const channelUrl =
process.env.TRUEGROWTH_UPDATE_FEED_URL ||
'https://truegrowth.benchu.cloud/desktop/stable/';
const channelHost = new URL(channelUrl).host;
const platformArg =
process.argv
.find((arg) => arg.startsWith('--platform='))
?.slice('--platform='.length) || 'all';
function readFileIfExists(filePath) {
return fs.existsSync(filePath) ? fs.readFileSync(filePath) : null;
}
function sha256(filePath) {
const hash = crypto.createHash('sha256');
hash.update(fs.readFileSync(filePath));
return hash.digest('hex');
}
function statArtifact(filePath) {
const stats = fs.statSync(filePath);
return {
name: path.basename(filePath),
size: stats.size,
sha256: sha256(filePath),
url: new URL(path.basename(filePath), channelUrl).toString(),
};
}
function readYamlMetadata(fileName) {
const filePath = path.join(releaseDir, fileName);
const content = readFileIfExists(filePath);
if (!content) {
return null;
}
return yaml.load(content.toString('utf8'));
}
function assertMetadataUrl(metadata, fileName) {
if (!metadata) {
throw new Error(`Missing required update metadata: ${fileName}`);
}
const files = Array.isArray(metadata.files) ? metadata.files : [];
const urls = [metadata.path, ...files.map((file) => file?.url)].filter(
Boolean
);
if (!urls.length) {
throw new Error(`${fileName} does not contain any update file URLs.`);
}
for (const url of urls) {
const parsed = new URL(String(url), channelUrl);
if (parsed.host !== channelHost) {
throw new Error(
`${fileName} points to ${parsed.host}; expected ${channelHost}.`
);
}
}
}
function listReleaseArtifacts() {
if (!fs.existsSync(releaseDir)) {
throw new Error(
`Desktop release directory is missing: ${releaseDir}\nRun pnpm desktop:build first.`
);
}
const escapedVersion = packageJson.version.replace(
/[.*+?^${}()|[\]\\]/g,
'\\$&'
);
const macArtifactPattern = new RegExp(
`^TrueGrowth-${escapedVersion}-(arm64|x64)(-mac\\.zip|\\.dmg)(\\.blockmap)?$`,
'i'
);
const winArtifactPattern = new RegExp(
`^TrueGrowth-${escapedVersion}-x64-(Setup|Portable)\\.exe(\\.blockmap)?$`,
'i'
);
return fs
.readdirSync(releaseDir)
.filter((name) => {
const isMacMetadata = name === 'latest-mac.yml';
const isWinMetadata = name === 'latest.yml';
const isMacArtifact = macArtifactPattern.test(name);
const isWinArtifact = winArtifactPattern.test(name);
if (platformArg === 'mac') {
return isMacMetadata || isMacArtifact;
}
if (platformArg === 'win') {
return isWinMetadata || isWinArtifact;
}
return isMacMetadata || isWinMetadata || isMacArtifact || isWinArtifact;
})
.sort();
}
function classifyArtifact(name) {
if (name === 'latest.yml') {
return 'windows-update-metadata';
}
if (name === 'latest-mac.yml') {
return 'mac-update-metadata';
}
if (/\.dmg$/i.test(name)) {
return 'mac-installer';
}
if (/-mac\.zip$/i.test(name)) {
return 'mac-auto-update';
}
if (/-Setup\.exe$/i.test(name)) {
return 'windows-installer';
}
if (/-Portable\.exe$/i.test(name)) {
return 'windows-portable-manual';
}
if (/\.blockmap$/i.test(name)) {
return 'differential-update';
}
return 'release-file';
}
function writeReleaseManifest(artifacts) {
const generatedAt = new Date().toISOString();
const releaseFiles = artifacts.map((name) => {
const filePath = path.join(releaseDir, name);
const base = statArtifact(filePath);
return {
...base,
role: classifyArtifact(name),
};
});
const manifest = {
app: 'TrueGrowth',
version: packageJson.version,
channel: 'stable',
channelUrl,
generatedAt,
files: releaseFiles,
cachePolicy: {
metadata: ['latest.yml', 'latest-mac.yml', 'releases.json'],
metadataHeader: 'Cache-Control: no-store, no-cache, must-revalidate',
artifactsHeader:
'Cache-Control: public, max-age=31536000, immutable',
},
onePanelUploadRoot: '/desktop/stable/',
signingGate: {
macos: 'Developer ID signed and notarized before customer release',
windows: 'Authenticode signed before customer release',
unsignedBuilds: 'internal testing only',
},
};
const outputPath = path.join(releaseDir, 'releases.json');
fs.writeFileSync(outputPath, `${JSON.stringify(manifest, null, 2)}\n`);
return manifest;
}
function printUploadGuide(manifest) {
console.log('\n[desktop:update] Upload these files to 1Panel static root:');
for (const file of manifest.files) {
console.log(`- ${file.name} -> ${manifest.onePanelUploadRoot}${file.name}`);
}
if (!manifest.files.some((file) => file.name === 'releases.json')) {
console.log('- releases.json -> /desktop/stable/releases.json');
}
console.log('\n[desktop:update] Nginx cache guidance:');
console.log(
'- latest.yml, latest-mac.yml, releases.json: Cache-Control no-store'
);
console.log('- installers, zip, blockmap: long cache with immutable');
console.log(`\n[desktop:update] Channel: ${manifest.channelUrl}`);
}
function main() {
const artifacts = listReleaseArtifacts();
const latest = readYamlMetadata('latest.yml');
const latestMac = readYamlMetadata('latest-mac.yml');
if (platformArg === 'all' || platformArg === 'win') {
assertMetadataUrl(latest, 'latest.yml');
}
if (platformArg === 'all' || platformArg === 'mac') {
assertMetadataUrl(latestMac, 'latest-mac.yml');
}
const manifest = writeReleaseManifest(artifacts);
printUploadGuide(manifest);
}
main();

File diff suppressed because it is too large Load Diff

View File

@@ -144,7 +144,7 @@ function publishingWorkflowFixture(id) {
inputs: [{ id: 'video', label: '视频', type: 'video' }],
outputs: [{ id: 'video', label: '成片', type: 'video' }],
config: {
providerId: 'funclip-local',
providerId: 'videoagent-local',
operations: ['asr', 'highlight-cut', 'subtitle', 'ffmpeg-export'],
},
},
@@ -278,7 +278,7 @@ async function main() {
capability: 'clip.compose',
inputs: [],
outputs: [{ id: 'video', label: '成片', type: 'video' }],
config: { providerId: 'funclip-local', prompt: '生成字幕成片' },
config: { providerId: 'videoagent-local', prompt: '生成字幕成片' },
},
],
edges: [],