purgetss
Version:
A package that simplifies mobile app creation for Titanium developers.
432 lines (391 loc) • 17.9 kB
JavaScript
/**
* PurgeTSS - Branding pipeline orchestrator
*
* Composes the branding pipeline for Titanium projects.
*
* Every invocation (kitchen-sink) generates:
*
* iOS & marketplace:
* - DefaultIcon.png (alpha preserved)
* - DefaultIcon-ios.png (flattened on bg-color)
* - DefaultIcon-Dark.png (iOS 18+, transparent by default)
* - DefaultIcon-Tinted.png (iOS 18+, grayscale on black)
* - iTunesConnect.png (1024²)
* - MarketplaceArtwork.png (512²)
*
* Android:
* - ic_launcher_foreground.png × 5 densities
* - ic_launcher_background.png × 5 densities
* - ic_launcher_monochrome.png × 5 densities (themed icons / dark mode)
* - ic_launcher.png × 5 densities (legacy pre-adaptive)
* - mipmap-anydpi-v26/ic_launcher.xml
*
* Optional (opt-in):
* - ic_stat_notify.png × 5 (--notification)
* - splash_icon.png × 5 (--splash)
*
* Opt-out:
* - DefaultIcon-Dark.png (--no-dark)
* - DefaultIcon-Tinted.png (--no-tinted)
*
* @fileoverview Titanium branding pipeline orchestrator
* @author César Estrada
*/
import fs from 'fs'
import os from 'os'
import path from 'path'
import sharp from 'sharp'
import { logger } from './branding-logger.js'
import { logger as mainLogger } from '../../shared/logger.js'
import { confirmWithAlways } from '../../shared/prompt.js'
import { setConfigProperty } from '../../shared/config-writer.js'
import { prepareMaster } from './prepare-master.js'
import { genIos } from './gen-ios.js'
import { genIosDark } from './gen-ios-dark.js'
import { genIosTinted } from './gen-ios-tinted.js'
import { genAndroidAdaptive } from './gen-android-adaptive.js'
import { genAndroidLegacy } from './gen-android-legacy.js'
import { genAndroidDefault } from './gen-android-default.js'
import { genMarketplace } from './gen-marketplace.js'
import { genFeatureGraphic } from './gen-feature-graphic.js'
import { genNotification } from './gen-notification.js'
import { genSplash } from './gen-splash.js'
import { genIcLauncherXml } from './gen-ic-launcher-xml.js'
import { detectProjectType } from './tiapp-reader.js'
import { cleanupLegacy } from './cleanup-legacy.js'
import { printPostGenNotes } from './post-gen-notes.js'
export async function runBranding(opts) {
const {
logo,
iconLogo = null,
splashLogo = null,
featureLogo = null,
monochromeLogo = null,
darkLogo = null,
darkBgColor = null,
withDark = true,
withTinted = true,
tintedLogo = null,
bgColor = '#FFFFFF',
bgColorExplicit = false,
androidAdaptivePadding = 19,
androidLegacyPadding = 10,
iosPadding = 4,
featureGraphicPadding = 12,
notification = false,
splash = false,
cleanupLegacy: runCleanup = false,
aggressive = false,
projectRoot = process.cwd(),
output,
dryRun = false,
inPlace = false,
notes = false,
yes = false,
confirmOverwrites = true
} = opts
validateOptions({ logo, bgColor, darkBgColor, androidAdaptivePadding, androidLegacyPadding, iosPadding, featureGraphicPadding, cleanupLegacy: runCleanup })
const projectType = detectProjectType(projectRoot)
const isInPlace = inPlace && !output
const stagingRoot = output || (isInPlace ? projectRoot : path.join(projectRoot, '.ti-branding'))
console.log()
mainLogger.info('Generating branding assets...')
console.log()
logger.property('Project: ', `${projectRoot} (${projectType})`)
if (logo) {
logger.property('Logo: ', logo)
logger.property('Background: ', bgColor)
logger.property('Padding: ', `Android adaptive ${androidAdaptivePadding}% / Android legacy ${androidLegacyPadding}% / iOS ${iosPadding}% per side / Feature Graphic ${featureGraphicPadding}% vertical`)
console.log()
logger.property(isInPlace ? 'Writing IN PLACE to: ' : 'Staging: ', isInPlace ? projectRoot : stagingRoot)
}
if (isInPlace && !dryRun && confirmOverwrites) {
logger.warning(`⚠ In-place mode will OVERWRITE files in ${projectRoot}.`)
logger.warning(` Commit first if you want a rollback.`)
const choice = await confirmWithAlways('Continue? [y/N/a]', { yes })
if (choice === 'no') {
logger.info('Aborted.')
// eslint-disable-next-line n/no-process-exit
process.exit(0)
}
if (choice === 'always') {
const saved = setConfigProperty('brand', 'confirmOverwrites', false)
if (saved) {
logger.success('Saved brand.confirmOverwrites = false to purgetss/config.cjs — you won\'t be asked again.')
} else {
logger.warning('Could not persist preference (config.cjs missing or unreadable). Proceeding anyway.')
}
}
}
if (dryRun) logger.warning('DRY RUN — no files will be written')
const generated = []
// Cleanup-only mode
if (!logo && runCleanup) {
logger.info('Cleanup-only mode')
await cleanupLegacy({ projectRoot, projectType, aggressive, dryRun })
return { stagingRoot, generated }
}
if (!logo) {
throw new Error('Logo image is required (unless running --cleanup-legacy alone).')
}
if (!fs.existsSync(logo)) {
throw new Error(`Logo image not found: ${logo}`)
}
if (projectType === 'unknown') {
logger.warning(`Could not detect project layout. Expected 'app/' (Alloy) or 'Resources/' (Classic).`)
logger.warning(`Assets will be staged under ${stagingRoot}/standalone/ — copy manually.`)
}
const androidResStaging = getStagingAndroidResRoot(stagingRoot, projectType)
if (dryRun) {
const lines = [
`${stagingRoot}/DefaultIcon.png + DefaultIcon-ios.png`
]
if (withDark) {
const darkSrc = darkLogo
? `from ${darkLogo}`
: (darkBgColor ? `opaque bg ${darkBgColor}` : 'transparent per Apple HIG')
lines.push(`${stagingRoot}/DefaultIcon-Dark.png (${darkSrc})`)
}
if (withTinted) {
const tintedSrc = tintedLogo ? `from ${tintedLogo}` : 'grayscale of logo, flattened on black'
lines.push(`${stagingRoot}/DefaultIcon-Tinted.png (${tintedSrc})`)
}
lines.push(`${stagingRoot}/iTunesConnect.png + MarketplaceArtwork.png`)
const featureSrc = featureLogo ? `from ${featureLogo}` : 'from main logo'
lines.push(`${stagingRoot}/MarketplaceArtworkFeature.png (${featureSrc}, ${featureGraphicPadding}% vertical padding)`)
lines.push(`${androidResStaging}/mipmap-{mdpi,hdpi,xhdpi,xxhdpi,xxxhdpi}/ic_launcher_{foreground,background,monochrome}.png`)
lines.push(`${androidResStaging}/mipmap-{...}/ic_launcher.png (legacy)`)
lines.push(`${androidResStaging}/mipmap-anydpi-v26/ic_launcher.xml`)
const androidAssetsStaging = getStagingAndroidAssetsRoot(stagingRoot, projectType)
if (androidAssetsStaging) lines.push(`${androidAssetsStaging}/default.png (Android <12 legacy splash fallback)`)
if (notification) lines.push(`${androidResStaging}/drawable-*/ic_stat_notify.png × 5`)
if (splash) lines.push(`${androidResStaging}/drawable-*/splash_icon.png × 5`)
mainLogger.block('[dry-run] Would generate:', ...lines)
if (runCleanup) {
await cleanupLegacy({ projectRoot, projectType, aggressive, dryRun })
}
return { stagingRoot, generated }
}
// Route temp logos through the OS temp dir in --in-place mode so the
// project tree (and VSCode's file explorer) stays clean. Using a unique
// subdir per run avoids clashes between parallel invocations.
const tempDir = isInPlace
? path.join(os.tmpdir(), `pt-branding-${process.pid}-${Date.now()}`)
: stagingRoot
const weCreatedTempDir = isInPlace && !fs.existsSync(tempDir)
if (weCreatedTempDir) fs.mkdirSync(tempDir, { recursive: true })
// ---- Section: Logos ---------------------------------------------------
logger.section('Logos')
logger.bullet('Dual logos (square + tight)')
const logoBase = path.join(tempDir, '_logo')
const { square, tight } = await prepareMaster(logo, logoBase)
let iconMaster = square
if (iconLogo) {
if (!fs.existsSync(iconLogo)) {
throw new Error(`Android icon logo not found: ${iconLogo}`)
}
logger.bullet(`Android icon logo: ${iconLogo}`)
const iconBase = path.join(tempDir, '_logo_icon')
const iconResult = await prepareMaster(iconLogo, iconBase)
iconMaster = iconResult.square
}
await warnIfLogoAspectIsUnsafeForLauncher(tight)
let splashMaster = iconMaster
if (splashLogo) {
if (!fs.existsSync(splashLogo)) {
throw new Error(`Android splash logo not found: ${splashLogo}`)
}
logger.bullet(`Android splash logo: ${splashLogo}`)
const splashBase = path.join(tempDir, '_logo_splash')
const splashResult = await prepareMaster(splashLogo, splashBase)
splashMaster = splashResult.square
}
let monoMaster = null
if (monochromeLogo) {
if (!fs.existsSync(monochromeLogo)) {
throw new Error(`Monochrome logo not found: ${monochromeLogo}`)
}
logger.bullet(`Monochrome logo: ${monochromeLogo}`)
const monoBase = path.join(tempDir, '_logo_mono')
const monoResult = await prepareMaster(monochromeLogo, monoBase)
monoMaster = monoResult.square
}
// ---- Section: iOS & marketplace ----------------------------------------
logger.section('iOS & marketplace')
logger.bullet(`DefaultIcon.png (Android-safe padding ${androidAdaptivePadding}%) + DefaultIcon-ios.png (iOS padding ${iosPadding}%)`)
const ios = await genIos(tight, bgColor, androidAdaptivePadding, iosPadding, stagingRoot)
generated.push(ios.defaultIcon, ios.defaultIconIos)
if (withDark) {
let darkSource = tight
if (darkLogo) {
if (!fs.existsSync(darkLogo)) throw new Error(`Dark logo not found: ${darkLogo}`)
const darkBase = path.join(tempDir, '_logo_dark')
const darkResult = await prepareMaster(darkLogo, darkBase)
darkSource = darkResult.tight
}
const darkSrcLabel = darkLogo ? 'from --dark-logo, ' : ''
const darkBgLabel = darkBgColor ? `opaque bg ${darkBgColor}` : 'transparent per Apple HIG'
logger.bullet(`DefaultIcon-Dark.png (${darkSrcLabel}${darkBgLabel})`)
const darkPath = await genIosDark(darkSource, darkBgColor, iosPadding, stagingRoot)
generated.push(darkPath)
}
if (withTinted) {
let tintedSource = tight
if (tintedLogo) {
if (!fs.existsSync(tintedLogo)) throw new Error(`Tinted logo not found: ${tintedLogo}`)
const tintedBase = path.join(tempDir, '_logo_tinted')
const tintedResult = await prepareMaster(tintedLogo, tintedBase)
tintedSource = tintedResult.tight
}
const tintedSrcLabel = tintedLogo ? 'from --tinted-logo' : 'grayscale of logo'
logger.bullet(`DefaultIcon-Tinted.png (${tintedSrcLabel}, flattened on black)`)
const tintedPath = await genIosTinted(tintedSource, iosPadding, stagingRoot)
generated.push(tintedPath)
}
const alphaMode = bgColorExplicit ? `flattened on ${bgColor}` : 'alpha preserved'
logger.bullet(`iTunesConnect.png + MarketplaceArtwork.png (${alphaMode})`)
const mkt = await genMarketplace(tight, iosPadding, stagingRoot, {
flatten: bgColorExplicit,
bgColor
})
generated.push(mkt.itunesConnect, mkt.marketplaceArtwork)
let featureMaster = tight
if (featureLogo) {
if (!fs.existsSync(featureLogo)) {
throw new Error(`Feature Graphic logo not found: ${featureLogo}`)
}
const featureBase = path.join(tempDir, '_logo_feature')
const featureResult = await prepareMaster(featureLogo, featureBase)
featureMaster = featureResult.tight
}
const featureSrcLabel = featureLogo ? 'from --feature-logo' : 'from main logo'
logger.bullet(`MarketplaceArtworkFeature.png (1024×500, ${featureSrcLabel}, ${featureGraphicPadding}% vertical padding, flattened on ${bgColor})`)
const featurePath = await genFeatureGraphic(featureMaster, featureGraphicPadding, stagingRoot, { bgColor })
generated.push(featurePath)
// ---- Section: Android --------------------------------------------------
logger.section('Android')
const monoLabel = monoMaster ? ', monochrome from --monochrome-logo' : ''
logger.bullet(`Adaptive icons (foreground + background + monochrome${monoLabel}, padding ${androidAdaptivePadding}%) × 5`)
const adaptiveFiles = await genAndroidAdaptive(iconMaster, bgColor, androidAdaptivePadding, androidResStaging, { monoMaster })
generated.push(...adaptiveFiles)
logger.bullet(`Legacy ic_launcher.png × 5 (padding ${androidLegacyPadding}%)`)
const legacyFiles = await genAndroidLegacy(iconMaster, bgColor, androidLegacyPadding, androidResStaging)
generated.push(...legacyFiles)
const xmlPath = genIcLauncherXml(androidResStaging)
generated.push(xmlPath)
logger.bullet(`Adaptive icon XML: ${xmlPath}`)
const androidDefaultDir = getStagingAndroidAssetsRoot(stagingRoot, projectType)
if (androidDefaultDir) {
logger.bullet('Legacy Android default.png splash fallback')
const defaultSplashPath = await genAndroidDefault(splashMaster, bgColor, androidDefaultDir)
generated.push(defaultSplashPath)
}
if (notification) {
const monoLabelNotif = monoMaster ? ' from --monochrome-logo' : ' whitened from logo'
logger.bullet(`Notification icons (white+alpha, edge-to-edge${monoLabelNotif}) × 5`)
const notifFiles = await genNotification(monoMaster || iconMaster, androidResStaging)
generated.push(...notifFiles)
}
if (splash) {
logger.bullet('Splash icons × 5')
const splashFiles = await genSplash(splashMaster, androidResStaging)
generated.push(...splashFiles)
}
if (runCleanup) {
logger.info('Cleanup legacy artifacts')
await cleanupLegacy({ projectRoot, projectType, aggressive, dryRun })
}
// Clean up temp _logo_* files in --in-place mode
if (isInPlace) {
if (weCreatedTempDir) {
fs.rmSync(tempDir, { recursive: true, force: true })
} else {
const tmpFiles = [
path.join(tempDir, '_logo_square.png'),
path.join(tempDir, '_logo_tight.png'),
path.join(tempDir, '_logo_icon_square.png'),
path.join(tempDir, '_logo_icon_tight.png'),
path.join(tempDir, '_logo_mono_square.png'),
path.join(tempDir, '_logo_mono_tight.png'),
path.join(tempDir, '_logo_dark_square.png'),
path.join(tempDir, '_logo_dark_tight.png'),
path.join(tempDir, '_logo_tinted_square.png'),
path.join(tempDir, '_logo_tinted_tight.png'),
path.join(tempDir, '_logo_splash_square.png'),
path.join(tempDir, '_logo_splash_tight.png'),
path.join(tempDir, '_logo_feature_square.png'),
path.join(tempDir, '_logo_feature_tight.png')
]
for (const tmp of tmpFiles) {
if (fs.existsSync(tmp)) fs.unlinkSync(tmp)
}
}
logger.info('')
logger.success(`All assets written IN PLACE at: ${projectRoot}`)
} else {
logger.info('')
logger.success(`All assets staged at: ${stagingRoot}`)
}
printPostGenNotes({
projectType,
projectRoot,
stagingRoot,
bgColor,
androidAdaptivePadding,
androidLegacyPadding,
iosPadding,
withSplash: splash,
withNotification: notification,
inPlace: isInPlace,
fullNotes: notes
})
return { stagingRoot, generated }
}
async function warnIfLogoAspectIsUnsafeForLauncher(tightLogoPath) {
const meta = await sharp(tightLogoPath).metadata()
const width = meta.width || 0
const height = meta.height || 0
if (!width || !height) return
const aspect = width / height
const isWideWordmark = aspect > 1.25
const isTallWordmark = aspect < 0.8
if (!isWideWordmark && !isTallWordmark) return
logger.warning('The source logo is not close to square.')
logger.warning(`Aspect ratio detected: ${width}×${height} (${aspect.toFixed(2)}:1).`)
logger.warning('Launcher icons and Android 12+ system splash screens work best with a square mark.')
logger.warning('A wide/tall wordmark can look cramped or cropped once centered inside icon masks.')
logger.warning('Recommendation: use a dedicated square app-icon source for `purgetss brand`.')
}
function getStagingAndroidResRoot(stagingRoot, projectType) {
if (projectType === 'alloy') return path.join(stagingRoot, 'app', 'platform', 'android', 'res')
if (projectType === 'classic') return path.join(stagingRoot, 'platform', 'android', 'res')
return path.join(stagingRoot, 'standalone', 'platform', 'android', 'res')
}
function getStagingAndroidAssetsRoot(stagingRoot, projectType) {
if (projectType === 'alloy') return path.join(stagingRoot, 'app', 'assets', 'android')
if (projectType === 'classic') return path.join(stagingRoot, 'Resources', 'android')
return null
}
function validateOptions({ logo, bgColor, darkBgColor, androidAdaptivePadding, androidLegacyPadding, iosPadding, featureGraphicPadding, cleanupLegacy }) {
if (!logo && !cleanupLegacy) {
throw new Error('Logo image path is required (unless using --cleanup-legacy alone).')
}
if (!/^#[0-9A-Fa-f]{6}$/.test(bgColor)) {
throw new Error(`--bg-color must be a 6-digit hex like #0B1326 (got: ${bgColor}).`)
}
if (darkBgColor && !/^#[0-9A-Fa-f]{6}$/.test(darkBgColor)) {
throw new Error(`--dark-bg-color must be a 6-digit hex like #1C1C1E (got: ${darkBgColor}).`)
}
if (androidAdaptivePadding < 0 || androidAdaptivePadding > 40) {
throw new Error(`--android-adaptive-padding must be between 0 and 40 (got: ${androidAdaptivePadding}).`)
}
if (androidLegacyPadding < 0 || androidLegacyPadding > 40) {
throw new Error(`--android-legacy-padding must be between 0 and 40 (got: ${androidLegacyPadding}).`)
}
if (iosPadding < 0 || iosPadding > 40) {
throw new Error(`--ios-padding must be between 0 and 40 (got: ${iosPadding}).`)
}
if (featureGraphicPadding < 0 || featureGraphicPadding > 40) {
throw new Error(`--feature-graphic-padding must be between 0 and 40 (got: ${featureGraphicPadding}).`)
}
}