| // Copyright 2014 The Bazel Authors. All rights reserved. |
| // |
| // Licensed under the Apache License, Version 2.0 (the "License"); |
| // you may not use this file except in compliance with the License. |
| // You may obtain a copy of the License at |
| // |
| // http://www.apache.org/licenses/LICENSE-2.0 |
| // |
| // Unless required by applicable law or agreed to in writing, software |
| // distributed under the License is distributed on an "AS IS" BASIS, |
| // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| // See the License for the specific language governing permissions and |
| // limitations under the License. |
| |
| package com.google.devtools.build.xcode.plmerge; |
| |
| import com.dd.plist.BinaryPropertyListWriter; |
| import com.dd.plist.NSDictionary; |
| import com.dd.plist.NSObject; |
| import com.dd.plist.NSString; |
| import com.dd.plist.PropertyListFormatException; |
| import com.dd.plist.PropertyListParser; |
| import com.google.common.annotations.VisibleForTesting; |
| import com.google.common.base.Optional; |
| import com.google.common.base.Preconditions; |
| import com.google.common.base.Strings; |
| import com.google.common.collect.ImmutableList; |
| import com.google.common.collect.Sets; |
| import com.google.common.io.ByteSource; |
| import com.google.devtools.build.xcode.plmerge.proto.PlMergeProtos.Control; |
| import com.google.devtools.build.xcode.util.Equaling; |
| import com.google.devtools.build.xcode.util.Mapping; |
| import com.google.devtools.build.xcode.util.Value; |
| import java.io.BufferedInputStream; |
| import java.io.IOException; |
| import java.io.InputStream; |
| import java.io.OutputStream; |
| import java.nio.charset.StandardCharsets; |
| import java.nio.file.FileSystem; |
| import java.nio.file.FileSystems; |
| import java.nio.file.Files; |
| import java.nio.file.Path; |
| import java.text.ParseException; |
| import java.util.Arrays; |
| import java.util.Collection; |
| import java.util.List; |
| import java.util.Map; |
| import java.util.Set; |
| import java.util.regex.Pattern; |
| import javax.xml.parsers.ParserConfigurationException; |
| import org.xml.sax.SAXException; |
| |
| /** |
| * Utility code for merging project files. |
| */ |
| public class PlistMerging extends Value<PlistMerging> { |
| private static final String BUNDLE_IDENTIFIER_PLIST_KEY = "CFBundleIdentifier"; |
| private static final String BUNDLE_IDENTIFIER_DEFAULT = "com.generic.bundleidentifier"; |
| private static final String BUNDLE_VERSION_PLIST_KEY = "CFBundleVersion"; |
| private static final String BUNDLE_VERSION_DEFAULT = "1.0.0"; |
| private static final String BUNDLE_SHORT_VERSION_STRING_PLIST_KEY = "CFBundleShortVersionString"; |
| private static final String BUNDLE_SHORT_VERSION_STRING_DEFAULT = "1.0"; |
| |
| /** |
| * Exception type thrown when validation of the plist file fails. |
| */ |
| public static class ValidationException extends RuntimeException { |
| ValidationException(String message) { |
| super(message); |
| } |
| } |
| |
| private final NSDictionary merged; |
| |
| /** |
| * Wraps a {@code NSDictionary} as a PlistMerging. |
| */ |
| public PlistMerging(NSDictionary merged) { |
| super(merged); |
| this.merged = merged; |
| } |
| |
| /** |
| * Merges several plist files into a single {@code NSDictionary}. Each file should be a plist (of |
| * one of these formats: ASCII, Binary, or XML) that contains an NSDictionary. |
| */ |
| @VisibleForTesting |
| static NSDictionary merge(Collection<? extends Path> sourceFilePaths) throws IOException { |
| NSDictionary result = new NSDictionary(); |
| for (Path sourceFilePath : sourceFilePaths) { |
| result.putAll(readPlistFile(sourceFilePath)); |
| } |
| return result; |
| } |
| |
| public static NSDictionary readPlistFile(final Path sourceFilePath) throws IOException { |
| ByteSource rawBytes = new Utf8BomSkippingByteSource(sourceFilePath); |
| |
| try { |
| try (InputStream in = rawBytes.openStream()) { |
| return (NSDictionary) PropertyListParser.parse(in); |
| } catch (PropertyListFormatException | ParseException e) { |
| // If we failed to parse, the plist may implicitly be a map. To handle this, wrap the plist |
| // with {}. |
| // TODO(bazel-team): Do this in a cleaner way. |
| ByteSource concatenated = ByteSource.concat( |
| ByteSource.wrap(new byte[] {'{'}), |
| rawBytes, |
| ByteSource.wrap(new byte[] {'}'})); |
| try (InputStream in = concatenated.openStream()) { |
| return (NSDictionary) PropertyListParser.parse(in); |
| } |
| } |
| } catch (PropertyListFormatException | ParseException | ParserConfigurationException |
| | SAXException e) { |
| throw new IOException(e); |
| } |
| } |
| |
| /** |
| * Writes the results of a merge operation to a binary plist file. |
| * @param plistPath the path of the plist to write in binary format |
| */ |
| public PlistMerging writePlist(Path plistPath) throws IOException { |
| try (OutputStream out = Files.newOutputStream(plistPath)) { |
| BinaryPropertyListWriter.write(out, merged); |
| } |
| return this; |
| } |
| |
| /** |
| * Writes the results of a merge operation to an XML plist file. |
| * @param plistPath the path of the plist to write in XML format |
| */ |
| public PlistMerging writeXmlPlist(Path plistPath) throws IOException { |
| try (OutputStream out = Files.newOutputStream(plistPath)) { |
| PropertyListParser.saveAsXML(merged, out); |
| } |
| return this; |
| } |
| |
| /** |
| * Writes a PkgInfo file based on certain keys in the merged plist. |
| * @param pkgInfoPath the path of the PkgInfo file to write. In many iOS apps, this file just |
| * contains the raw string {@code APPL????}. |
| */ |
| public PlistMerging writePkgInfo(Path pkgInfoPath) throws IOException { |
| String pkgInfo = |
| Mapping.of(merged, "CFBundlePackageType").or(NSObject.wrap("APPL")).toString() |
| + Mapping.of(merged, "CFBundleSignature").or(NSObject.wrap("????")).toString(); |
| Files.write(pkgInfoPath, pkgInfo.getBytes(StandardCharsets.UTF_8)); |
| return this; |
| } |
| |
| /** |
| * Generates a Plistmerging combining values from sourceFiles and immutableSourceFiles, and |
| * modifying them based on substitutions and keysToRemoveIfEmptyString. |
| */ |
| public static PlistMerging from( |
| Control control, |
| KeysToRemoveIfEmptyString keysToRemoveIfEmptyString) |
| throws IOException { |
| |
| FileSystem fileSystem = FileSystems.getDefault(); |
| |
| ImmutableList.Builder<Path> sourceFilePathsBuilder = new ImmutableList.Builder<>(); |
| for (String pathString : control.getSourceFileList()) { |
| sourceFilePathsBuilder.add(fileSystem.getPath(pathString)); |
| } |
| ImmutableList.Builder<Path> immutableSourceFilePathsBuilder = new ImmutableList.Builder<>(); |
| for (String pathString : control.getImmutableSourceFileList()) { |
| immutableSourceFilePathsBuilder.add(fileSystem.getPath(pathString)); |
| } |
| |
| return from( |
| sourceFilePathsBuilder.build(), |
| immutableSourceFilePathsBuilder.build(), |
| control.getVariableSubstitutionMap(), |
| keysToRemoveIfEmptyString, |
| Strings.emptyToNull(control.getExecutableName())); |
| } |
| |
| /** |
| * Generates a Plistmerging combining values from sourceFiles and immutableSourceFiles, and |
| * modifying them based on subsitutions and keysToRemoveIfEmptyString. |
| */ |
| public static PlistMerging from( |
| List<Path> sourceFiles, |
| List<Path> immutableSourceFiles, |
| Map<String, String> substitutions, |
| KeysToRemoveIfEmptyString keysToRemoveIfEmptyString, |
| String executableName) |
| throws IOException { |
| NSDictionary merged = PlistMerging.merge(sourceFiles); |
| NSDictionary immutableEntries = PlistMerging.merge(immutableSourceFiles); |
| Set<String> conflictingEntries = Sets.intersection(immutableEntries.keySet(), merged.keySet()); |
| |
| Preconditions.checkArgument( |
| conflictingEntries.isEmpty(), |
| "The following plist entries may not be overridden, but are present in more than one " |
| + "of the input lists: %s", |
| conflictingEntries); |
| merged.putAll(immutableEntries); |
| |
| for (Map.Entry<String, NSObject> entry : merged.entrySet()) { |
| if (entry.getValue().toJavaObject() instanceof String) { |
| String newValue = substituteEnvironmentVariable( |
| substitutions, (String) entry.getValue().toJavaObject()); |
| merged.put(entry.getKey(), newValue); |
| } |
| } |
| |
| for (String key : keysToRemoveIfEmptyString) { |
| if (Equaling.of(Mapping.of(merged, key), Optional.<NSObject>of(new NSString("")))) { |
| merged.remove(key); |
| } |
| } |
| |
| // Info.plist files must contain a valid CFBundleVersion and a valid CFBundleShortVersionString, |
| // or it will be rejected by Apple. |
| // A valid Bundle Version is 18 characters or less, and only contains [0-9.] |
| // We know we have an info.plist file as opposed to a strings file if the immutableEntries |
| // have any values set. |
| // TODO(bazel-team): warn user if we replace their values. |
| if (!immutableEntries.isEmpty()) { |
| Pattern versionPattern = Pattern.compile("[^0-9.]"); |
| if (!merged.containsKey(BUNDLE_VERSION_PLIST_KEY)) { |
| merged.put(BUNDLE_VERSION_PLIST_KEY, BUNDLE_VERSION_DEFAULT); |
| } else { |
| NSObject nsVersion = merged.get(BUNDLE_VERSION_PLIST_KEY); |
| String version = (String) nsVersion.toJavaObject(); |
| if (version.length() > 18 || versionPattern.matcher(version).find()) { |
| merged.put(BUNDLE_VERSION_PLIST_KEY, BUNDLE_VERSION_DEFAULT); |
| } |
| } |
| if (!merged.containsKey(BUNDLE_SHORT_VERSION_STRING_PLIST_KEY)) { |
| merged.put(BUNDLE_SHORT_VERSION_STRING_PLIST_KEY, BUNDLE_SHORT_VERSION_STRING_DEFAULT); |
| } else { |
| NSObject nsVersion = merged.get(BUNDLE_SHORT_VERSION_STRING_PLIST_KEY); |
| String version = (String) nsVersion.toJavaObject(); |
| if (version.length() > 18 || versionPattern.matcher(version).find()) { |
| merged.put(BUNDLE_SHORT_VERSION_STRING_PLIST_KEY, BUNDLE_SHORT_VERSION_STRING_DEFAULT); |
| } |
| } |
| } |
| |
| PlistMerging result = new PlistMerging(merged); |
| |
| if (executableName != null) { |
| result.setExecutableName(executableName); |
| } |
| |
| return result; |
| } |
| |
| private static String substituteEnvironmentVariable( |
| Map<String, String> substitutions, String string) { |
| // The substitution is *not* performed recursively. |
| for (Map.Entry<String, String> variable : substitutions.entrySet()) { |
| String key = variable.getKey(); |
| String value = variable.getValue(); |
| string = string |
| .replace("${" + key + "}", value) |
| .replace("$(" + key + ")", value); |
| key = key + ":rfc1034identifier"; |
| value = convertToRFC1034(value); |
| string = string |
| .replace("${" + key + "}", value) |
| .replace("$(" + key + ")", value); |
| } |
| |
| return string; |
| } |
| |
| // Force RFC1034 compliance by changing any "bad" character to a '-' |
| // This is essentially equivalent to what Xcode does. |
| private static String convertToRFC1034(String value) { |
| return value.replaceAll("[^-0-9A-Za-z.]", "-"); |
| } |
| |
| @VisibleForTesting |
| NSDictionary asDictionary() { |
| return merged; |
| } |
| |
| /** |
| * Sets the given executable name on this merged plist in the {@code CFBundleExecutable} |
| * attribute. |
| * |
| * @param executableName name of the bundle executable |
| * @return this plist merging |
| * @throws ValidationException if the plist already contains an incompatible |
| * {@code CFBundleExecutable} entry |
| */ |
| public PlistMerging setExecutableName(String executableName) { |
| NSString bundleExecutable = (NSString) merged.get("CFBundleExecutable"); |
| |
| if (bundleExecutable == null) { |
| merged.put("CFBundleExecutable", executableName); |
| } else if (!executableName.equals(bundleExecutable.getContent())) { |
| throw new ValidationException( |
| String.format( |
| "Bazel generated the executable %s but the Plist CFBundleExecutable is %s", |
| executableName, bundleExecutable)); |
| } |
| |
| return this; |
| } |
| |
| /** |
| * Sets the given identifier on this merged plist in the {@code CFBundleIdentifier} |
| * attribute. |
| * |
| * @param primaryIdentifier used to set the bundle identifier or override the existing one from |
| * plist file, can be null |
| * @param fallbackIdentifier used to set the bundle identifier if it is not set by plist file or |
| * primary identifier, can be null |
| * @return this plist merging |
| */ |
| public PlistMerging setBundleIdentifier(String primaryIdentifier, String fallbackIdentifier) { |
| NSString bundleIdentifier = (NSString) merged.get(BUNDLE_IDENTIFIER_PLIST_KEY); |
| |
| if (primaryIdentifier != null) { |
| merged.put(BUNDLE_IDENTIFIER_PLIST_KEY, convertToRFC1034(primaryIdentifier)); |
| } else if (bundleIdentifier == null) { |
| if (fallbackIdentifier != null) { |
| merged.put(BUNDLE_IDENTIFIER_PLIST_KEY, convertToRFC1034(fallbackIdentifier)); |
| } else { |
| // TODO(bazel-team): We shouldn't be generating an info.plist in this case. |
| merged.put(BUNDLE_IDENTIFIER_PLIST_KEY, BUNDLE_IDENTIFIER_DEFAULT); |
| } |
| } |
| |
| return this; |
| } |
| |
| private static class Utf8BomSkippingByteSource extends ByteSource { |
| |
| private static final byte[] UTF8_BOM = |
| new byte[] { (byte) 0xEF, (byte) 0xBB, (byte) 0xBF }; |
| |
| private final Path path; |
| |
| public Utf8BomSkippingByteSource(Path path) { |
| this.path = path; |
| } |
| |
| @Override |
| public InputStream openStream() throws IOException { |
| InputStream stream = new BufferedInputStream(Files.newInputStream(path)); |
| stream.mark(UTF8_BOM.length); |
| byte[] buffer = new byte[UTF8_BOM.length]; |
| int read = stream.read(buffer); |
| stream.reset(); |
| |
| if (UTF8_BOM.length == read && Arrays.equals(buffer, UTF8_BOM)) { |
| stream.skip(UTF8_BOM.length); |
| } |
| |
| return stream; |
| } |
| } |
| } |