diff --git a/package.json b/package.json index 24a07d6..702d8ff 100644 --- a/package.json +++ b/package.json @@ -79,7 +79,7 @@ {"from": "runtime/models/whisper-base.json", "to": "manifests/model-manifest.json"} ], "win": {"target": "nsis"}, - "mac": {"target": "dmg", "icon": "build/icon.icns"}, + "mac": {"target": "dmg", "icon": "build/icon.icns", "electronLanguages": ["en", "en_GB", "es", "es_419"]}, "linux": {"target": "AppImage"} } } diff --git a/scripts/check-packaged-electron-locales.js b/scripts/check-packaged-electron-locales.js new file mode 100644 index 0000000..082663f --- /dev/null +++ b/scripts/check-packaged-electron-locales.js @@ -0,0 +1,95 @@ +#!/usr/bin/env node + +const fs = require('fs'); +const path = require('path'); +const packageJson = require('../package.json'); +const { newestApp } = require('./check-packaged-runtime'); + +const root = path.join(__dirname, '..'); + +function optionValue(name) { + const index = process.argv.indexOf(name); + return index >= 0 ? process.argv[index + 1] : undefined; +} + +function fail(message) { + throw new Error(`Packaged Electron locale check failed: ${message}`); +} + +function configuredLanguages() { + const languages = packageJson.build?.mac?.electronLanguages; + if (!Array.isArray(languages) || languages.length === 0) { + fail('package.json build.mac.electronLanguages must be a non-empty array'); + } + const normalized = languages.map((language) => String(language).trim()).filter(Boolean); + if (normalized.length !== languages.length || new Set(normalized).size !== normalized.length) { + fail('package.json build.mac.electronLanguages contains an empty or duplicate locale'); + } + return normalized; +} + +function localeDirectories(directory) { + if (!fs.existsSync(directory) || !fs.statSync(directory).isDirectory()) { + fail(`locale resource directory is missing: ${path.relative(root, directory)}`); + } + return fs.readdirSync(directory, { withFileTypes: true }) + .filter((entry) => entry.isDirectory() && entry.name.endsWith('.lproj')) + .map((entry) => entry.name.slice(0, -'.lproj'.length)) + .sort(); +} + +function inspectLocaleRoot(directory, allowedLanguages, { requireAll = false } = {}) { + const locales = localeDirectories(directory); + const allowed = new Set(allowedLanguages.map((language) => language.toLowerCase())); + const unexpected = locales.filter((locale) => !allowed.has(locale.toLowerCase())); + if (unexpected.length > 0) { + fail(`${path.relative(root, directory)} contains unexpected Electron locales: ${unexpected.join(', ')}`); + } + if (requireAll) { + const present = new Set(locales.map((locale) => locale.toLowerCase())); + const missing = allowedLanguages.filter((language) => !present.has(language.toLowerCase())); + if (missing.length > 0) { + fail(`${path.relative(root, directory)} is missing configured Electron locales: ${missing.join(', ')}`); + } + } + return locales; +} + +function inspectPackage(appPath) { + const allowedLanguages = configuredLanguages(); + const resourcesPath = path.join(appPath, 'Contents', 'Resources'); + const frameworkResourcesPath = path.join( + appPath, + 'Contents', + 'Frameworks', + 'Electron Framework.framework', + 'Versions', + 'A', + 'Resources', + ); + const appLocales = inspectLocaleRoot(resourcesPath, allowedLanguages); + const frameworkLocales = inspectLocaleRoot(frameworkResourcesPath, allowedLanguages, { requireAll: true }); + console.log(`Configured Electron locales: ${allowedLanguages.join(', ')}`); + console.log(`App resource locales: ${appLocales.join(', ') || 'none'}`); + console.log(`Electron Framework locales: ${frameworkLocales.join(', ')}`); + console.log('Packaged Electron locale check passed.'); + return { appPath, allowedLanguages, appLocales, frameworkLocales }; +} + +function main() { + if (process.platform !== 'darwin') fail(`requires macOS, received ${process.platform}`); + const appPath = optionValue('--app') ? path.resolve(optionValue('--app')) : newestApp('arm64'); + if (!appPath.endsWith('.app') || !fs.existsSync(appPath)) fail(`app does not exist: ${appPath}`); + return inspectPackage(appPath); +} + +if (require.main === module) { + try { + main(); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); + } +} + +module.exports = { configuredLanguages, inspectPackage }; diff --git a/scripts/check-release-workflow.js b/scripts/check-release-workflow.js index 9f2c527..8cc21d0 100644 --- a/scripts/check-release-workflow.js +++ b/scripts/check-release-workflow.js @@ -229,11 +229,19 @@ function main() { assert(/entitlementsInherit:\s*['"]build\/entitlements\.mac\.inherit\.plist['"]/.test(signedReleaseConfig), 'signed release config must set inherited entitlements'); assert(!/identity:\s*['"]-['"]/.test(signedReleaseConfig), 'signed release config must not use ad-hoc identity'); const packageText = fs.readFileSync(path.join(root, 'package.json'), 'utf8'); + const packageJson = JSON.parse(packageText); + assert( + JSON.stringify(packageJson.build?.mac?.electronLanguages) === JSON.stringify(['en', 'en_GB', 'es', 'es_419']), + 'macOS Electron locale allowlist must remain en/en_GB/es/es_419', + ); + const releaseAlphaScript = fs.readFileSync(releaseAlphaPath, 'utf8'); + assert(releaseAlphaScript.includes('check-packaged-electron-locales.js'), 'candidate release path is missing packaged Electron locale gate'); assert(/release:signed:arm64/.test(packageText) && /scripts\/release-signed\.js/.test(packageText), 'signed release command is missing'); assert(/check:signed-release/.test(packageText) && /check-macos-signed-release\.js/.test(packageText), 'signed release verification command is missing'); const signedReleaseScript = fs.readFileSync(path.join(root, 'scripts', 'release-signed.js'), 'utf8'); for (const requiredGate of [ 'check-packaged-runtime.js', + 'check-packaged-electron-locales.js', 'smoke-packaged-release-identity.js', 'check-packaged-ffmpeg.js', 'smoke-packaged-backend.js', diff --git a/scripts/release-alpha.js b/scripts/release-alpha.js index c667887..4093ea1 100644 --- a/scripts/release-alpha.js +++ b/scripts/release-alpha.js @@ -306,6 +306,7 @@ async function main() { const outputs = candidateOutputs(pkg); const packageInfo = inspectPackage(outputs.appPath); runPackagedGate('Packaged runtime gate', 'scripts/check-packaged-runtime.js', ['--arch', 'arm64', '--app', outputs.appPath], env); + runPackagedGate('Packaged Electron locale gate', 'scripts/check-packaged-electron-locales.js', ['--app', outputs.appPath], env); runPackagedGate('Packaged release identity gate', 'scripts/smoke-packaged-release-identity.js', ['--app', outputs.appPath], env); runPackagedGate('Packaged FFmpeg gate', 'scripts/check-packaged-ffmpeg.js', ['--arch', 'arm64', '--app', outputs.appPath], env); runPackagedGate('Packaged backend gate', 'scripts/smoke-packaged-backend.js', ['--arch', 'arm64', '--app', outputs.appPath], env); diff --git a/scripts/release-signed.js b/scripts/release-signed.js index e0150c0..abe2422 100644 --- a/scripts/release-signed.js +++ b/scripts/release-signed.js @@ -45,6 +45,7 @@ function main() { const appPath = path.join(outputRoot, 'mac-arm64', 'ScriptCut.app'); const dmgPath = path.join(outputRoot, `ScriptCut-${version}-arm64.dmg`); run('Packaged runtime gate', 'node', ['scripts/check-packaged-runtime.js', '--arch', 'arm64', '--app', appPath]); + run('Packaged Electron locale gate', 'node', ['scripts/check-packaged-electron-locales.js', '--app', appPath]); run('Packaged release identity gate', 'node', ['scripts/smoke-packaged-release-identity.js', '--app', appPath]); run('Packaged FFmpeg gate', 'node', ['scripts/check-packaged-ffmpeg.js', '--arch', 'arm64', '--app', appPath]); run('Packaged backend gate', 'node', ['scripts/smoke-packaged-backend.js', '--arch', 'arm64', '--app', appPath]);