#!/usr/bin/env objj require("narwhal").ensureEngine("rhino"); @import @import "../lib/cappuccino/objj-analysis-tools.j" @import "../lib/cappuccino/cib-analysis-tools.j" var FILE = require("file"); var OS = require("os"); var CACHEMANIFEST = require("objective-j/cache-manifest"); var stream = require("narwhal/term").stream; var parser = new (require("narwhal/args").Parser)(); parser.usage("INPUT_PROJECT OUTPUT_PROJECT"); parser.help("Analyze and strip unused files from a Cappuccino project's .sj bundles."); parser.option("-m", "--main", "main") .def("main.j") .set() .help("The relative path (from INPUT_PROJECT) to the main file (default: 'main.j')"); parser.option("-F", "--framework", "frameworks") .def(["Frameworks"]) .push() .help("Add a frameworks directory, relative to INPUT_PROJECT (default: ['Frameworks'])"); parser.option("-f", "--force", "force") .def(false) .set(true) .help("Force overwriting OUTPUT_PROJECT if it exists"); parser.option("--index", "index") .def("index.html") .set() .help("The root HTML file to modify (default: index.html) (NOTE: currently only used by '--manifest' option)"); parser.option("-p", "--pngcrush", "png") .def(false) .set(true) .help("Run pngcrush on all PNGs (pngcrush must be installed!)"); parser.option("--manifest", "manifest") .set(true) .help("Generate HTML5 cache manifest."); parser.option("-v", "--verbose", "verbose") .def(false) .set(true) .help("Verbose logging"); parser.helpful(); function main(args) { var options = parser.parse(args); if (options.args.length < 2) { parser.printUsage(options); return; } CPLogRegister(CPLogPrint); // HACK: ensure trailing slashes for "relative" to work correctly var rootPath = FILE.path(options.args[0]).absolute().join(""); var outputPath = FILE.path(options.args[1]).absolute().join(""); if (outputPath.exists()) { if (options.force) { // FIXME: why doesn't this work?! //outputPath.rmtree(); OS.system(["rm", "-rf", outputPath]); } else { stream.print("\0red(OUTPUT_PROJECT " + outputPath + " exists. Use -f to overwrite.\0)"); OS.exit(1); } } press(rootPath, outputPath, options); } function press(rootPath, outputPath, options) { stream.print("\0yellow("+Array(81).join("=")+"\0)"); stream.print("Application root: \0green(" + rootPath + "\0)"); stream.print("Output directory: \0green(" + outputPath + "\0)"); var outputFiles = {}, totalBytes = {executable:0, data:0, mhtml:0}; var environment = "Browser"; // analyze and gather files for each environment: var returnedBytes = pressEnvironment(rootPath, outputFiles, environment, options); for (var i in returnedBytes) totalBytes[i] += returnedBytes[i]; // phase 4: copy everything and write out the new files stream.print("\0red(PHASE 4:\0) copy to output \0green(" + rootPath + "\0) => \0green(" + outputPath + "\0)"); FILE.copyTree(rootPath, outputPath); for (var path in outputFiles) { var file = outputPath.join(rootPath.relative(path)); var parent = file.dirname(); if (!parent.exists()) { CPLog.warn(parent + " doesn't exist, creating directories."); parent.mkdirs(); } if (typeof outputFiles[path] !== "string") outputFiles[path] = outputFiles[path].join(""); stream.print((file.exists() ? "\0red(Overwriting:\0) " : "\0green(Writing:\0) ") + file); FILE.write(file, outputFiles[path], { charset : "UTF-8" }); } // phase 4a: update the info.plist to contain meta information about the app stream.print("\0red(PHASE 4a:\0) Add application size data to Info.plist"); var outputInfoPlistPath = FILE.join(outputPath, "Info.plist"), outputInfoPlistContents = FILE.read(outputInfoPlistPath, { charset:"UTF-8" }), format = CFPropertyList.sniffedFormatOfString(outputInfoPlistContents), outputInfoPlist = CFPropertyList.propertyListFromString(outputInfoPlistContents); // read in the default theme name, and attempt to get its size var themeName = outputInfoPlist.valueForKey("CPDefaultTheme") || "Aristo2", themePath = nil; if (themeName === "Aristo" || themeName === "Aristo2") themePath = FILE.join(outputPath, options.frameworks, "AppKit", "Resources", themeName+".blend"); else themePath = FILE.join(outputPath, "Resources", themeName + ".blend"); if (FILE.exists(themePath)) { var themeEnvPath = FILE.join(themePath, environment + ".environment"), themeExecutablePath = FILE.join(themeEnvPath, themeName+".blend.sj"), themeDataPath = FILE.join(themeEnvPath, "dataURLs.txt"), themeMHTMLPath = FILE.join(themeEnvPath, "MHTMLPaths.txt"); totalBytes.executable += FILE.read(themeExecutablePath, {charset : "UTF-8"}).length; totalBytes.data += FILE.read(themeDataPath, {charset : "UTF-8"}).length; totalBytes.mhtml += FILE.read(themeMHTMLPath, {charset : "UTF-8"}).length; } outputInfoPlist.setValueForKey("CPApplicationSize", [CPDictionary dictionaryWithJSObject:totalBytes]); FILE.write(outputInfoPlistPath, CFPropertyList.stringFromPropertyList(outputInfoPlist, format), { charset:"UTF-8" }); // strip known unnecessary files // outputPath.glob("**/Frameworks/Debug").forEach(function(debugFramework) { // outputPath.join(debugFramework).rmtree(); // }); // outputPath.join("index-debug.html").remove(); if (options.png) { pngcrushDirectory(outputPath); } if (options.manifest) { CACHEMANIFEST.generateManifest(outputPath, { index : outputPath.join(options.index) }); } } function pressEnvironment(rootPath, outputFiles, environment, options) { var mainPath = String(rootPath.join(options.main)); var frameworks = options.frameworks.map(function(framework) { return rootPath.join(framework); }); stream.print("\0yellow("+Array(81).join("=")+"\0)"); stream.print("Main file: \0green(" + mainPath + "\0)"); stream.print("Frameworks: \0green(" + frameworks + "\0)"); stream.print("Environment: \0green(" + environment + "\0)"); var analyzer = new ObjectiveJRuntimeAnalyzer(rootPath); var _OBJJ = analyzer.require("objective-j"); analyzer.setIncludePaths(frameworks); analyzer.setEnvironments([environment, "ObjJ"]); // build list of cibs to inspect for dependent classes // FIXME: what's the best way to determine which cibs to look in? var cibs = FILE.glob(rootPath.join("**", "*.cib")).filter(function(path) { return !(/Frameworks/).test(path); }); // phase 1: get global defines stream.print("\0red(PHASE 1:\0) Loading application..."); analyzer.initializeGlobalRecorder(); analyzer.load(mainPath); analyzer.finishLoading(); // coalesce the results var dependencies = analyzer.mapGlobalsToFiles(); // log identifer => files defining stream.print("Global defines:"); Object.keys(dependencies).sort().forEach(function(identifier) { stream.print("\0blue(" + identifier + "\0) => \0cyan(" + dependencies[identifier].map(rootPath.relative.bind(rootPath)) + "\0)"); }); // phase 2: walk the dependency tree (both imports and references) to determine exactly which files need to be included stream.print("\0red(PHASE 2:\0) Traverse dependency graph..."); var requiredFiles = {}; requiredFiles[mainPath] = true; var context = { ignoreFrameworkImports : true, // ignores "XXX/XXX.j" imports importCallback: function(importing, imported) { requiredFiles[imported] = true; }, referenceCallback: function(referencing, referenced) { requiredFiles[referenced] = true; }, progressCallback: function(relPath) { stream.print("Processing \0cyan("+relPath+"\0)"); }, ignoreFrameworkImportsCallback : function(relPath) { stream.print("\0yellow(Ignoring imports in "+relPath+"\0)"); } } mainExecutable = analyzer.executableForImport(mainPath); // check the code analyzer.traverseDependencies(mainExecutable, context); // check the cibs var globalsToFiles = analyzer.mapGlobalsToFiles(); cibs.forEach(function(cibPath) { var cibClasses = findCibClassDependencies(cibPath); stream.print("Cib: \0green("+rootPath.relative(cibPath)+"\0) => \0cyan("+cibClasses+"\0)"); var referencedFiles = {}; markFilesReferencedByTokens(cibClasses, globalsToFiles, referencedFiles); analyzer.checkReferenced(context, null, referencedFiles); }); var included = 0, total = 0; var includedBytes = 0, totalBytes = 0, dataBytes = 0, mhtmlBytes = 0; _OBJJ.FileExecutable.allFileExecutables().forEach(function(aFileExecutable) { var path = aFileExecutable.path(); // mark all ".keytheme"s as required if (/\.keyedtheme$/.test(path)) requiredFiles[path] = true; if (requiredFiles[path]) { stream.print("Included: \0green(" + rootPath.relative(path) + "\0)"); included++; includedBytes += aFileExecutable.code().length } else { stream.print("Excluded: \0red(" + rootPath.relative(path) + "\0)"); } total++; totalBytes += aFileExecutable.code().length }, this); stream.print(_OBJJ.sprintf( "Saved \0green(%f%%\0) (\0blue(%s\0)); Total required files: \0magenta(%d\0) (\0blue(%s\0)) of \0magenta(%d\0) (\0blue(%s\0));", Math.round(((includedBytes - totalBytes) / totalBytes) * -100), bytesToString(totalBytes - includedBytes), included, bytesToString(includedBytes), total, bytesToString(totalBytes) )); // phase 3b: rebuild .sj files with correct imports, copy .j files stream.print("\0red(PHASE 3b:\0) Rebuild .sj files"); var processedBundles = {}; for (var path in requiredFiles) { var executable = analyzer.executableForImport(path), bundle = analyzer.context.global.CFBundle.bundleContainingURL(executable.path()), relativePath = FILE.relative(FILE.join(bundle.path(), ""), executable.path()); if (executable.path() !== path) CPLog.warn("Sanity check failed (file path): " + executable.path() + " vs. " + path); if (bundle && bundle.infoDictionary()) { var executablePath = bundle.executablePath(); if (executablePath) { if (context.ignoredImports[path]) { stream.print("Stripping extra imports from \0blue(" + path + "\0)"); var code = executable.code(); var dependencies = executable.fileDependencies(), fileExecutableSearcher = executable.fileExecutableSearcher(); for (var i = 0; i < dependencies.length; i++) { var dependency = dependencies[i]; var dependencyExecutable = nil; fileExecutableSearcher(dependency.URL(), dependency.isLocal(), function(aFileExecutable) { dependencyExecutable = aFileExecutable; }); var dependencyPath = dependencyExecutable.path(); if (!requiredFiles[dependencyPath]) { stream.print(" -> \0red(" + dependencyPath + "\0)"); // build up a regex to match objj_executeFile with optional whitespace var regex = new RegExp([ RegExp.escape("objj_executeFile"), RegExp.escape("("), "[\"']"+RegExp.escape(dependency.path())+"[\"']", RegExp.escape(","), dependency.isLocal() ? "(true|YES)" : "(false|NO)", RegExp.escape(")") ].join("\\s*"), "g"); // replace instances of "objj_executeFile()" with "(undefined)" newCode = code.replace(regex, "/* $& */ (undefined)"); if (newCode === code) stream.print("\0yellow(Warning:\0) Unable to strip import for \0cyan(" + dependency.path() + "\0) (" + dependency.isLocal() + ")"); code = newCode; // remove from dependencies list (decrement i since we're removing an item) dependencies.splice(i--, 1); } } if (code !== executable.code()) executable.setCode(code); } if (!outputFiles[executablePath]) { outputFiles[executablePath] = []; outputFiles[executablePath].push("@STATIC;1.0;"); } var fileContents = executable.toMarkedString(); outputFiles[executablePath].push("p;" + relativePath.length + ";" + relativePath); outputFiles[executablePath].push("t;" + fileContents.length + ";" + fileContents); stream.print("Adding \0green(" + rootPath.relative(path) + "\0) to \0cyan(" + rootPath.relative(executablePath) + "\0)"); } else { stream.print("Passing .j through: \0green(" + rootPath.relative(path) + "\0)"); } if (!processedBundles[bundle.path()] && bundle.hasSpritedImages()) { processedBundles[bundle.path()] = bundle; stream.print("Sizing bundle's sprited resources \0green(" + bundle.path() + "\0)"); var bundlePath = FILE.join(bundle.path(), environment+".environment"), bundleDataPath = FILE.join(bundlePath, "dataURLs.txt"), bundleMHTMLPath = FILE.join(bundlePath, "MHTMLPaths.txt"); dataBytes += FILE.read(bundleDataPath, {charset : "UTF-8"}).length; mhtmlBytes += FILE.read(bundleMHTMLPath, {charset : "UTF-8"}).length; } } else CPLog.warn("No bundle (or info dictionary for) " + rootPath.relative(path)); } return {executable:includedBytes, data:dataBytes, mhtml:mhtmlBytes}; } function pngcrushDirectory(directory) { var directoryPath = FILE.path(directory); var pngs = directoryPath.glob("**/*.png"); system.stderr.print("Running pngcrush on " + pngs.length + " pngs:"); pngs.forEach(function(dstPath) { var tmpPath = FILE.path(dstPath+".tmp"); var p = OS.popen(["pngcrush", "-rem", "alla", "-reduce", /*"-brute",*/ dstPath, tmpPath]); if (p.wait()) { CPLog.warn("pngcrush failed. Ensure it's installed and on your PATH."); } else { FILE.move(tmpPath, dstPath); system.stderr.write(".").flush(); } }); system.stderr.print(""); } function bytesToString(bytes) { var n = 0; while (bytes > 1024) { bytes /= 1024; n++; } return Math.round(bytes*100)/100 + " " + ["", "K", "M"][n] + "B"; }