Support generating info.plist for Watch Apps & Extensions (#756)

Part of: https://github.com/tuist/tuist/issues/628

- Added watchOS App / Extension defaults to the info plist content provider
- Watch apps reference their host applications bundle identifier in the info plist `WKCompanionAppBundleIdentifier` key
- Watch app extensions reference their host watch apps bundle identifier in the info plist `NSExtension.NSExtensionAttributes.WKAppBundleIdentifier`
- As such the parent project is now used to perform lookups for those hosts to extract their bundle identifiers
- Updated fixture to leverage generated info.plist files

Test Plan:

- run `tuist generate` within `fixtures/ios_app_with_watchapp2`
- Verify the info.plist files generated in `Derrived/InfoPlists` matche the ones created by Xcode
  (They were previously checked in under `Support`)
This commit is contained in:
Kas 2019-12-15 20:40:12 +00:00 committed by GitHub
parent b61d9a4ed5
commit de14ceb7dc
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 265 additions and 28 deletions

View File

@ -36,6 +36,7 @@ Please, check out guidelines: https://keepachangelog.com/en/1.0.0/
- Reactive interface to the System utility https://github.com/tuist/tuist/pull/770 by @pepibumur
- Workflow to make sure that documentation and website build https://github.com/tuist/tuist/pull/783 by @pepibumur.
- Support for `xcframework` https://github.com/tuist/tuist/pull/769 by @lakpa
- Support generating info.plist for Watch Apps & Extensions https://github.com/tuist/tuist/pull/756 by @kwridan
### Fixed

View File

@ -134,6 +134,16 @@ public protocol Graphing: AnyObject, Encodable {
/// Returns all the transitive dependencies of the given target that are static libraries.
/// - Parameter targetNode: Target node whose transitive static libraries will be returned.
func transitiveStaticTargetNodes(for targetNode: TargetNode) -> Set<TargetNode>
/// Retuns the first host target node for a given target node
///
/// (e.g. finding host application for an extension)
///
/// - Parameter path: Path of the hosted target
/// - Parameter name: Name of the hosted target
///
/// - Note: Search is limited to nodes with a matching path (i.e. targets within the same project)
func hostTargetNodeFor(path: AbsolutePath, name: String) -> TargetNode?
}
public class Graph: Graphing {
@ -486,6 +496,15 @@ public class Graph: Graphing {
skip: canLinkStaticProducts)
}
public func hostTargetNodeFor(path: AbsolutePath, name: String) -> TargetNode? {
guard let cachedTargetNodesForPath = cache.targetNodes[path] else {
return nil
}
return cachedTargetNodesForPath.values.first {
$0.dependencies.contains(where: { $0.path == path && $0.name == name })
}
}
// MARK: - Fileprivate
fileprivate func productDependencyReference(for targetNode: TargetNode) -> GraphDependencyReference {

View File

@ -8,11 +8,12 @@ protocol DerivedFileGenerating {
/// Generates the derived files that are associated to the given project.
///
/// - Parameters:
/// - graph: The dependencies graph.
/// - project: Project whose derived files will be generated.
/// - sourceRootPath: Path to the directory in which the Xcode project will be generated.
/// - Throws: An error if the generation of the derived files errors.
/// - Returns: A function to be called after the project generation to delete the derived files that are not necessary anymore.
func generate(project: Project, sourceRootPath: AbsolutePath) throws -> () throws -> Void
func generate(graph: Graphing, project: Project, sourceRootPath: AbsolutePath) throws -> () throws -> Void
}
final class DerivedFileGenerator: DerivedFileGenerating {
@ -33,16 +34,17 @@ final class DerivedFileGenerator: DerivedFileGenerating {
/// Generates the derived files that are associated to the given project.
///
/// - Parameters:
/// - graph: The dependencies graph.
/// - project: Project whose derived files will be generated.
/// - sourceRootPath: Path to the directory in which the Xcode project will be generated.
/// - Throws: An error if the generation of the derived files errors.
/// - Returns: A function to be called after the project generation to delete the derived files that are not necessary anymore.
func generate(project: Project, sourceRootPath: AbsolutePath) throws -> () throws -> Void {
func generate(graph: Graphing, project: Project, sourceRootPath: AbsolutePath) throws -> () throws -> Void {
/// The files that are not necessary anymore should be deleted after we generate the project.
/// Otherwise, Xcode will try to reload their references before the project generation.
var toDelete: Set<AbsolutePath> = []
toDelete.formUnion(try generateInfoPlists(project: project, sourceRootPath: sourceRootPath))
toDelete.formUnion(try generateInfoPlists(graph: graph, project: project, sourceRootPath: sourceRootPath))
return {
try toDelete.forEach { try FileHandler.shared.delete($0) }
@ -52,11 +54,12 @@ final class DerivedFileGenerator: DerivedFileGenerating {
/// Genreates the Info.plist files.
///
/// - Parameters:
/// - graph: The dependencies graph.
/// - project: Project that contains the targets whose Info.plist files will be generated.
/// - sourceRootPath: Path to the directory in which the project is getting generated.
/// - Returns: A set with paths to the Info.plist files that are no longer necessary and therefore need to be removed.
/// - Throws: An error if the encoding of the Info.plist content fails.
func generateInfoPlists(project: Project, sourceRootPath: AbsolutePath) throws -> Set<AbsolutePath> {
func generateInfoPlists(graph: Graphing, project: Project, sourceRootPath: AbsolutePath) throws -> Set<AbsolutePath> {
let infoPlistsPath = DerivedFileGenerator.infoPlistsPath(sourceRootPath: sourceRootPath)
let targetsWithGeneratableInfoPlists = project.targets.filter {
if let infoPlist = $0.infoPlist, case InfoPlist.file = infoPlist {
@ -86,7 +89,10 @@ final class DerivedFileGenerator: DerivedFileGenerating {
if case let InfoPlist.dictionary(content) = infoPlist {
dictionary = content.mapValues { $0.value }
} else if case let InfoPlist.extendingDefault(extended) = infoPlist,
let content = self.infoPlistContentProvider.content(target: target, extendedWith: extended) {
let content = self.infoPlistContentProvider.content(graph: graph,
project: project,
target: target,
extendedWith: extended) {
dictionary = content
} else {
return

View File

@ -8,10 +8,12 @@ protocol InfoPlistContentProviding {
/// and product, and extends them with the values provided by the user.
///
/// - Parameters:
/// - graph: The dependencies graph.
/// - project: The project that hosts the target for which the Info.plist content will be returned
/// - target: Target whose Info.plist content will be returned.
/// - extendedWith: Values provided by the user to extend the default ones.
/// - Returns: Content to generate the Info.plist file.
func content(target: Target, extendedWith: [String: InfoPlist.Value]) -> [String: Any]?
func content(graph: Graphing, project: Project, target: Target, extendedWith: [String: InfoPlist.Value]) -> [String: Any]?
}
final class InfoPlistContentProvider: InfoPlistContentProviding {
@ -20,10 +22,12 @@ final class InfoPlistContentProvider: InfoPlistContentProviding {
/// and product, and extends them with the values provided by the user.
///
/// - Parameters:
/// - graph: The dependencies graph.
/// - project: The project that hosts the target for which the Info.plist content will be returned
/// - target: Target whose Info.plist content will be returned.
/// - extendedWith: Values provided by the user to extend the default ones.
/// - Returns: Content to generate the Info.plist file.
func content(target: Target, extendedWith: [String: InfoPlist.Value]) -> [String: Any]? {
func content(graph: Graphing, project: Project, target: Target, extendedWith: [String: InfoPlist.Value]) -> [String: Any]? {
if target.product == .staticLibrary || target.product == .dynamicLibrary {
return nil
}
@ -48,6 +52,20 @@ final class InfoPlistContentProvider: InfoPlistContentProviding {
extend(&content, with: macos())
}
// watchOS app
if target.product == .watch2App, target.platform == .watchOS {
let host = graph.hostTargetNodeFor(path: project.path, name: target.name)
extend(&content, with: watchosApp(name: target.name,
hostAppBundleId: host?.target.bundleId))
}
// watchOS app extension
if target.product == .watch2Extension, target.platform == .watchOS {
let host = graph.hostTargetNodeFor(path: project.path, name: target.name)
extend(&content, with: watchosAppExtension(name: target.name,
hostAppBundleId: host?.target.bundleId))
}
extend(&content, with: extendedWith.unwrappingValues())
return content
@ -145,6 +163,39 @@ final class InfoPlistContentProvider: InfoPlistContentProviding {
]
}
/// Returns the default Info.plist content for a watchOS App
///
/// - Parameter hostAppBundleId: The host application's bundle identifier
private func watchosApp(name: String, hostAppBundleId: String?) -> [String: Any] {
var infoPlist: [String: Any] = [
"CFBundleDisplayName": name,
"WKWatchKitApp": true,
"UISupportedInterfaceOrientations": [
"UIInterfaceOrientationPortrait",
"UIInterfaceOrientationPortraitUpsideDown",
],
]
if let hostAppBundleId = hostAppBundleId {
infoPlist["WKCompanionAppBundleIdentifier"] = hostAppBundleId
}
return infoPlist
}
/// Returns the default Info.plist content for a watchOS App Extension
///
/// - Parameter hostAppBundleId: The host application's bundle identifier
private func watchosAppExtension(name: String, hostAppBundleId: String?) -> [String: Any] {
let extensionAttributes: [String: Any] = hostAppBundleId.map { ["WKAppBundleIdentifier": $0] } ?? [:]
return [
"CFBundleDisplayName": name,
"NSExtension": [
"NSExtensionAttributes": extensionAttributes,
"NSExtensionPointIdentifier": "com.apple.watchkit",
],
"WKExtensionDelegateClassName": "$(PRODUCT_MODULE_NAME).ExtensionDelegate",
]
}
/// Given a dictionary, it extends it with another dictionary.
///
/// - Parameters:

View File

@ -88,7 +88,7 @@ final class ProjectGenerator: ProjectGenerating {
sourceRootPath: AbsolutePath,
xcodeprojPath: AbsolutePath) throws -> GeneratedProject {
// Derived files
let deleteOldDerivedFiles = try derivedFileGenerator.generate(project: project, sourceRootPath: sourceRootPath)
let deleteOldDerivedFiles = try derivedFileGenerator.generate(graph: graph, project: project, sourceRootPath: sourceRootPath)
let workspaceData = XCWorkspaceData(children: [])
let workspace = XCWorkspace(data: workspaceData)

View File

@ -859,6 +859,44 @@ final class GraphTests: TuistUnitTestCase {
XCTAssertEqual(got.first?.name, "StickerPackExtension")
}
func test_hostTargetNode_watchApp() {
// Given
let app = Target.test(name: "App", platform: .iOS, product: .app)
let watchApp = Target.test(name: "WatchApp", platform: .watchOS, product: .watch2App)
let project = Project.test(path: "/path/a")
let graph = Graph.create(project: project,
dependencies: [
(target: app, dependencies: [watchApp]),
(target: watchApp, dependencies: []),
])
// When
let result = graph.hostTargetNodeFor(path: project.path, name: "WatchApp")
// Then
XCTAssertEqual(result?.target, app)
}
func test_hostTargetNode_watchAppExtension() {
// Given
let watchApp = Target.test(name: "WatchApp", platform: .watchOS, product: .watch2App)
let watchAppExtension = Target.test(name: "WatchAppExtension", platform: .watchOS, product: .watch2Extension)
let project = Project.test(path: "/path/a")
let graph = Graph.create(project: project,
dependencies: [
(target: watchApp, dependencies: [watchAppExtension]),
(target: watchAppExtension, dependencies: []),
])
// When
let result = graph.hostTargetNodeFor(path: project.path, name: "WatchAppExtension")
// Then
XCTAssertEqual(result?.target, watchApp)
}
func test_encode() {
// Given
System.shared = System()

View File

@ -33,7 +33,7 @@ final class DerivedFileGeneratorTests: TuistUnitTestCase {
let path = infoPlistsPath.appending(component: "Target.plist")
// When
_ = try subject.generate(project: project, sourceRootPath: temporaryPath)
_ = try subject.generate(graph: Graph.test(), project: project, sourceRootPath: temporaryPath)
// Then
XCTAssertTrue(FileHandler.shared.exists(path))
@ -53,7 +53,7 @@ final class DerivedFileGeneratorTests: TuistUnitTestCase {
infoPlistContentProvider.contentStub = ["test": "value"]
// When
_ = try subject.generate(project: project, sourceRootPath: temporaryPath)
_ = try subject.generate(graph: Graph.test(), project: project, sourceRootPath: temporaryPath)
// Then
XCTAssertTrue(FileHandler.shared.exists(path))
@ -78,7 +78,8 @@ final class DerivedFileGeneratorTests: TuistUnitTestCase {
try FileHandler.shared.touch(oldPlistPath)
// When
let deleteOldDerivedFiles = try subject.generate(project: project,
let deleteOldDerivedFiles = try subject.generate(graph: Graph.test(),
project: project,
sourceRootPath: temporaryPath)
try deleteOldDerivedFiles()
@ -95,7 +96,7 @@ final class DerivedFileGeneratorTests: TuistUnitTestCase {
let path = infoPlistsPath.appending(component: "Target.plist")
// When
_ = try subject.generate(project: project, sourceRootPath: temporaryPath)
_ = try subject.generate(graph: Graph.test(), project: project, sourceRootPath: temporaryPath)
// Then
XCTAssertFalse(FileHandler.shared.exists(path))

View File

@ -17,7 +17,10 @@ final class InfoPlistContentProviderTests: XCTestCase {
let target = Target.test(platform: .iOS, product: .app)
// When
let got = subject.content(target: target, extendedWith: ["ExtraAttribute": "Value"])
let got = subject.content(graph: Graph.test(),
project: .empty(),
target: target,
extendedWith: ["ExtraAttribute": "Value"])
// Then
assertEqual(got, [
@ -53,7 +56,10 @@ final class InfoPlistContentProviderTests: XCTestCase {
let target = Target.test(platform: .macOS, product: .app)
// When
let got = subject.content(target: target, extendedWith: ["ExtraAttribute": "Value"])
let got = subject.content(graph: Graph.test(),
project: .empty(),
target: target,
extendedWith: ["ExtraAttribute": "Value"])
// Then
assertEqual(got, [
@ -79,7 +85,10 @@ final class InfoPlistContentProviderTests: XCTestCase {
let target = Target.test(platform: .macOS, product: .framework)
// When
let got = subject.content(target: target, extendedWith: ["ExtraAttribute": "Value"])
let got = subject.content(graph: Graph.test(),
project: .empty(),
target: target,
extendedWith: ["ExtraAttribute": "Value"])
// Then
assertEqual(got, [
@ -101,7 +110,10 @@ final class InfoPlistContentProviderTests: XCTestCase {
let target = Target.test(platform: .macOS, product: .staticLibrary)
// When
let got = subject.content(target: target, extendedWith: ["ExtraAttribute": "Value"])
let got = subject.content(graph: Graph.test(),
project: .empty(),
target: target,
extendedWith: ["ExtraAttribute": "Value"])
// Then
XCTAssertNil(got)
@ -112,21 +124,128 @@ final class InfoPlistContentProviderTests: XCTestCase {
let target = Target.test(platform: .macOS, product: .dynamicLibrary)
// When
let got = subject.content(target: target, extendedWith: ["ExtraAttribute": "Value"])
let got = subject.content(graph: Graph.test(),
project: .empty(),
target: target,
extendedWith: ["ExtraAttribute": "Value"])
// Then
XCTAssertNil(got)
}
func test_contentPackageType() {
assertPackageType(subject.content(target: .test(product: .app), extendedWith: [:]), "APPL")
assertPackageType(subject.content(target: .test(product: .unitTests), extendedWith: [:]), "BNDL")
assertPackageType(subject.content(target: .test(product: .uiTests), extendedWith: [:]), "BNDL")
assertPackageType(subject.content(target: .test(product: .bundle), extendedWith: [:]), "BNDL")
assertPackageType(subject.content(target: .test(product: .framework), extendedWith: [:]), "FMWK")
assertPackageType(subject.content(target: .test(product: .staticFramework), extendedWith: [:]), "FMWK")
func content(for target: Target) -> [String: Any]? {
subject.content(graph: Graph.test(),
project: .empty(),
target: target,
extendedWith: [:])
}
assertPackageType(content(for: .test(product: .app)), "APPL")
assertPackageType(content(for: .test(product: .unitTests)), "BNDL")
assertPackageType(content(for: .test(product: .uiTests)), "BNDL")
assertPackageType(content(for: .test(product: .bundle)), "BNDL")
assertPackageType(content(for: .test(product: .framework)), "FMWK")
assertPackageType(content(for: .test(product: .staticFramework)), "FMWK")
assertPackageType(content(for: .test(product: .watch2App)), "$(PRODUCT_BUNDLE_PACKAGE_TYPE)")
}
func test_content_whenWatchOSApp() {
// Given
let watchApp = Target.test(name: "MyWatchApp",
platform: .watchOS,
product: .watch2App)
let app = Target.test(platform: .iOS,
product: .app,
bundleId: "io.tuist.my.app.id")
let project = Project.test(targets: [
app,
watchApp,
])
let graph = Graph.create(project: project, dependencies: [
(target: app, dependencies: [watchApp]),
(target: watchApp, dependencies: []),
])
// When
let got = subject.content(graph: graph,
project: project,
target: watchApp,
extendedWith: [
"ExtraAttribute": "Value",
])
// Then
assertEqual(got, [
"CFBundleName": "$(PRODUCT_NAME)",
"CFBundleShortVersionString": "1.0",
"CFBundlePackageType": "$(PRODUCT_BUNDLE_PACKAGE_TYPE)",
"UISupportedInterfaceOrientations": [
"UIInterfaceOrientationPortrait",
"UIInterfaceOrientationPortraitUpsideDown",
],
"CFBundleIdentifier": "$(PRODUCT_BUNDLE_IDENTIFIER)",
"CFBundleInfoDictionaryVersion": "6.0",
"CFBundleVersion": "1",
"CFBundleDevelopmentRegion": "$(DEVELOPMENT_LANGUAGE)",
"CFBundleExecutable": "$(EXECUTABLE_NAME)",
"CFBundleDisplayName": "MyWatchApp",
"WKWatchKitApp": true,
"WKCompanionAppBundleIdentifier": "io.tuist.my.app.id",
"ExtraAttribute": "Value",
])
}
func test_content_whenWatchOSAppExtension() {
// Given
let watchAppExtension = Target.test(name: "MyWatchAppExtension",
platform: .watchOS,
product: .watch2Extension)
let watchApp = Target.test(platform: .watchOS,
product: .watch2App,
bundleId: "io.tuist.my.app.id.mywatchapp")
let project = Project.test(targets: [
watchApp,
watchAppExtension,
])
let graph = Graph.create(project: project, dependencies: [
(target: watchApp, dependencies: [watchAppExtension]),
(target: watchAppExtension, dependencies: []),
])
// When
let got = subject.content(graph: graph,
project: project,
target: watchAppExtension,
extendedWith: [
"ExtraAttribute": "Value",
])
// Then
assertEqual(got, [
"CFBundleName": "$(PRODUCT_NAME)",
"CFBundleShortVersionString": "1.0",
"CFBundlePackageType": "$(PRODUCT_BUNDLE_PACKAGE_TYPE)",
"CFBundleIdentifier": "$(PRODUCT_BUNDLE_IDENTIFIER)",
"CFBundleInfoDictionaryVersion": "6.0",
"CFBundleVersion": "1",
"CFBundleDevelopmentRegion": "$(DEVELOPMENT_LANGUAGE)",
"CFBundleExecutable": "$(EXECUTABLE_NAME)",
"CFBundleDisplayName": "MyWatchAppExtension",
"NSExtension": [
"NSExtensionAttributes": [
"WKAppBundleIdentifier": "io.tuist.my.app.id.mywatchapp",
],
"NSExtensionPointIdentifier": "com.apple.watchkit",
],
"WKExtensionDelegateClassName": "$(PRODUCT_MODULE_NAME).ExtensionDelegate",
"ExtraAttribute": "Value",
])
}
// MARK: - Helpers
fileprivate func assertPackageType(_ lhs: [String: Any]?,
_ packageType: String?,
file: StaticString = #file,

View File

@ -4,11 +4,11 @@ import TuistCoreTesting
@testable import TuistGenerator
final class MockInfoPlistContentProvider: InfoPlistContentProviding {
var contentArgs: [(target: Target, extendedWith: [String: InfoPlist.Value])] = []
var contentArgs: [(graph: Graphing, project: Project, target: Target, extendedWith: [String: InfoPlist.Value])] = []
var contentStub: [String: Any]?
func content(target: Target, extendedWith: [String: InfoPlist.Value]) -> [String: Any]? {
contentArgs.append((target: target, extendedWith: extendedWith))
func content(graph: Graphing, project: Project, target: Target, extendedWith: [String: InfoPlist.Value]) -> [String: Any]? {
contentArgs.append((graph: graph, project: project, target: target, extendedWith: extendedWith))
return contentStub ?? [:]
}
}

View File

@ -21,7 +21,7 @@ let project = Project(name: "App",
platform: .watchOS,
product: .watch2App,
bundleId: "io.tuist.App.watchkitapp",
infoPlist: "Support/WatchApp-Info.plist",
infoPlist: .default,
resources: "WatchApp/**",
dependencies: [
.target(name: "WatchAppExtension")
@ -30,7 +30,9 @@ let project = Project(name: "App",
platform: .watchOS,
product: .watch2Extension,
bundleId: "io.tuist.App.watchkitapp.watchkitextension",
infoPlist: "Support/WatchAppExtension-Info.plist",
infoPlist: .extendingDefault(with: [
"CFBundleDisplayName": "WatchApp Extension"
]),
sources: ["WatchAppExtension/**"],
resources: ["WatchAppExtension/**/*.xcassets"],
dependencies: [