Appclip support (#1854)

* Introduce product type appClips

* Add embed AppClips build phase

* Add appclips to be executable

* Apply swift formatting

* Add linter rules for appClips target bundle ID

* Add default settings for AppClips

* Apply swift formatting

* Add fixture for appclips

* Add acceptance test for appclips

* Add appClips product type to be supported for iOS platform

* Add appClips product type tests

* Add lint for required parent application identifiers entitlement in app clip target

* Add missing appclips product in unit test and ui tests lintable target

* Update documentation with appClips addition

* Update changelog

* Rename appClips product type to appClip

* Add appClip lint tests for required entitlements

* Update docs with appClip product type renaming

* Update appClip acceptance test with appClip renaming

* Add target of appClip product type can host tests

* Skip adding embed app clip build phase for targets of non-app product type

* Add unit test and ui test targets in app clip test fixture

* Include missing appClip to be returned as apps

* Avoid traversing graph to extract app and appClip pair

* Add steps to build app clip unit and ui tests

* Apply formatting

* Add a lint rule to detect an app dependency on more one app clip

* Improve missing parent application identifiers entitlement linting message

* Fix ambiguous rake task name

* Run generate-6 acceptance tests containing appclip test with Xcode 12

* Restore changelog

* Replace references of info.plist app clip fixture with default one

* Remove redundant references to assets in app clip fixture

* Add tests for appClip product type

* Update doc to include app clip reference

* Add test to confirm app clip bundle package type

* Add a step to appClip acceptance test to verify valid architecture

* Add a test for appClip build phase attributes

* Add App Clips example docs

* Infer AppClip framework dependency for target of AppClip product type

* Rename appclip target to avoid name collision with system AppClip import
This commit is contained in:
ldindu 2020-10-27 19:07:45 +00:00 committed by GitHub
parent c3de526717
commit 4a8bfd826e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
46 changed files with 753 additions and 46 deletions

View File

@ -78,7 +78,6 @@ jobs:
'generate-3',
'generate-4',
'generate-5',
'generate-6',
'init',
'lint-project',
'lint-code',
@ -121,7 +120,8 @@ jobs:
strategy:
matrix:
xcode: ['12.1']
feature: ['cache-xcframeworks', 'cache-frameworks', 'precompiled']
feature:
['generate-6', 'cache-xcframeworks', 'cache-frameworks', 'precompiled']
steps:
- uses: actions/checkout@v1
- name: Select Xcode
@ -150,7 +150,6 @@ jobs:
bundle install --jobs 4 --retry 3
- name: Run tests
run: FEATURE=features/${{ matrix.feature }}.feature bundle exec rake features
upload:
if: github.ref == 'refs/heads/master'
name: Upload

View File

@ -12,6 +12,7 @@ Please, check out guidelines: https://keepachangelog.com/en/1.0.0/
- Extended `BuildPhaseGenerator` to generate script build phases [#1932](https://github.com/tuist/tuist/pull/1932) by [@pepibumur](https://github.com/pepibumur).
- Extend the `TargetContentHasher` to account for the `Target.scripts` attribute [#1933](https://github.com/tuist/tuist/pull/1933) by [@pepibumur](https://github.com/pepibumur).
- Extend the `CacheController` to generate projects with the build phase to locate the targets' built products directory [#1933](https://github.com/tuist/tuist/pull/1933) by [@pepibumur](https://github.com/pepibumur).
- Add support for appClip [#1854](https://github.com/tuist/tuist/pull/1854) by [@lakpa](https://github.com/lakpa).
### Fixed

View File

@ -11,6 +11,7 @@ public enum Product: String, Codable, Equatable {
case unitTests = "unit_tests"
case uiTests = "ui_tests"
case bundle
case appClip
// Not supported yet
case appExtension = "app_extension"

View File

@ -206,7 +206,7 @@ public class Graph: Encodable, Equatable {
/// - Parameters:
/// - path: Path to the directory where the project that defines the target is located.
/// - name: Name of the target.
public func linkableDependencies(path: AbsolutePath, name: String) -> [GraphDependencyReference] {
public func linkableDependencies(path: AbsolutePath, name: String) throws -> [GraphDependencyReference] {
guard let targetNode = findTargetNode(path: path, name: name) else {
return []
}
@ -225,6 +225,13 @@ public class Graph: Encodable, Equatable {
references = references.union(transitiveSystemLibraries)
}
if targetNode.target.isAppClip {
let path = try SDKNode.appClip(status: .required).path
references.insert(GraphDependencyReference.sdk(path: path,
status: .required,
source: .system))
}
let directSystemLibrariesAndFrameworks = targetNode.sdkDependencies.map {
GraphDependencyReference.sdk(path: $0.path, status: $0.status, source: $0.source)
}
@ -406,8 +413,8 @@ public class Graph: Encodable, Equatable {
/// This method is useful to know which references should be added to the products directory in the generated project.
/// - Parameter project: Project whose dependency references will be returned.
public func allDependencyReferences(for project: Project) throws -> [GraphDependencyReference] {
let linkableDependencies = project.targets.flatMap {
self.linkableDependencies(path: project.path, name: $0.name)
let linkableDependencies = try project.targets.flatMap {
try self.linkableDependencies(path: project.path, name: $0.name)
}
let embeddableDependencies = try project.targets.flatMap {
@ -439,6 +446,14 @@ public class Graph: Encodable, Equatable {
.filter { validProducts.contains($0.target.product) }
}
public func appClipsDependency(path: AbsolutePath, name: String) -> TargetNode? {
guard let targetNode = findTargetNode(path: path, name: name) else {
return nil
}
return targetNode.targetDependencies.first { $0.target.product == .appClip }
}
/// Depth-first search (DFS) is an algorithm for traversing graph data structures. It starts at a source node
/// and explores as far as possible along each branch before backtracking.
///

View File

@ -34,4 +34,8 @@ public final class GraphTraverser: GraphTraversing {
public func directStaticDependencies(path: AbsolutePath, name: String) -> [GraphDependencyReference] {
graph.staticDependencies(path: path, name: name)
}
public func appClipsDependency(path: AbsolutePath, name: String) -> Target? {
graph.appClipsDependency(path: path, name: name).map { $0.target }
}
}

View File

@ -51,6 +51,14 @@ public class SDKNode: GraphNode {
try! SDKNode(name: "XCTest.framework", platform: platform, status: status, source: .system)
}
/// Creates an instace of SDKNode that represents the AppClip framework.
/// - Parameters:
/// - status: SDK status
/// - Returns: Initialized SDK node.
public static func appClip(status: SDKStatus) throws -> SDKNode {
try SDKNode(name: "AppClip.framework", platform: .iOS, status: status, source: .system)
}
static func path(name: String, platform: Platform, source _: SDKSource, type: Type) throws -> AbsolutePath {
let sdkRootPath: AbsolutePath
if name == SDKNode.xctestFrameworkName {

View File

@ -41,4 +41,10 @@ public protocol GraphTraversing {
/// - path: Path to the directory where the project that defines the target is located.
/// - name: Name of the target.
func directStaticDependencies(path: AbsolutePath, name: String) -> [GraphDependencyReference]
/// Given a project directory and a target name, it returns an appClips dependency.
/// - Parameters:
/// - path: Path to the directory that contains the project.
/// - name: Target name.
func appClipsDependency(path: AbsolutePath, name: String) -> Target?
}

View File

@ -19,6 +19,7 @@ public enum Product: String, CustomStringConvertible, CaseIterable, Encodable {
// case messagesApplication = "messages_application"
case messagesExtension = "messages_extension"
case stickerPackExtension = "sticker_pack_extension"
case appClip
public var caseValue: String {
switch self {
@ -56,6 +57,8 @@ public enum Product: String, CustomStringConvertible, CaseIterable, Encodable {
return "messagesExtension"
case .stickerPackExtension:
return "stickerPackExtension"
case .appClip:
return "appClip"
}
}
@ -95,13 +98,15 @@ public enum Product: String, CustomStringConvertible, CaseIterable, Encodable {
return "iMessage extension"
case .stickerPackExtension:
return "sticker pack extension"
case .appClip:
return "appClip"
}
}
/// Returns true if the target can be ran.
public var runnable: Bool {
switch self {
case .app:
case .app, .appClip:
return true
default:
return false
@ -126,6 +131,7 @@ public enum Product: String, CustomStringConvertible, CaseIterable, Encodable {
base.append(.stickerPackExtension)
// base.append(.messagesApplication)
base.append(.messagesExtension)
base.append(.appClip)
}
if platform == .tvOS {
@ -195,6 +201,12 @@ public enum Product: String, CustomStringConvertible, CaseIterable, Encodable {
return .messagesExtension
case .stickerPackExtension:
return .stickerPack
case .appClip:
return .onDemandInstallCapableApplication
}
}
public func canHostTests() -> Bool {
[.app, .appClip].contains(self)
}
}

View File

@ -170,6 +170,14 @@ public struct Target: Equatable, Hashable, Comparable {
}
}
/// Returns true if the target is an AppClip
public var isAppClip: Bool {
if case .appClip = product {
return true
}
return false
}
/// Returns true if the file at the given path is a resource.
/// - Parameter path: Path to the file to be checked.
public static func isResource(path: AbsolutePath) -> Bool {
@ -287,8 +295,8 @@ extension Sequence where Element == Target {
filter { $0.product.testsBundle }
}
/// Filters and returns only the targets that are apps.
/// Filters and returns only the targets that are apps and app clips.
var apps: [Target] {
filter { $0.product == .app }
filter { $0.product == .app || $0.product == .appClip }
}
}

View File

@ -71,6 +71,11 @@ public class ValueGraphTraverser: GraphTraversing {
.sorted()
}
public func appClipsDependency(path: AbsolutePath, name: String) -> Target? {
directTargetDependencies(path: path, name: name)
.first { $0.product == .appClip }
}
public func directStaticDependencies(path: AbsolutePath, name: String) -> [GraphDependencyReference] {
graph.dependencies[.target(name: name, path: path)]?
.compactMap { (dependency: ValueGraphDependency) -> (path: AbsolutePath, name: String)? in

View File

@ -100,4 +100,8 @@ final class MockGraphTraverser: GraphTraversing {
invokedDirectStaticDependenciesParametersList.append((path, name))
return stubbedDirectStaticDependenciesResult
}
func appClipsDependency(path _: AbsolutePath, name _: String) -> Target? {
nil
}
}

View File

@ -99,6 +99,13 @@ final class BuildPhaseGenerator: BuildPhaseGenerating {
generateScripts(target.scripts,
pbxTarget: pbxTarget,
pbxproj: pbxproj)
try generateEmbedAppClipsBuildPhase(path: path,
target: target,
graphTraverser: graphTraverser,
pbxTarget: pbxTarget,
fileElements: fileElements,
pbxproj: pbxproj)
}
func generateActions(actions: [TargetAction],
@ -376,4 +383,32 @@ final class BuildPhaseGenerator: BuildPhaseGenerating {
embedWatchAppBuildPhase.files?.append(pbxBuildFile)
}
}
func generateEmbedAppClipsBuildPhase(path: AbsolutePath,
target: Target,
graphTraverser: GraphTraversing,
pbxTarget: PBXTarget,
fileElements: ProjectFileElements,
pbxproj: PBXProj) throws
{
guard target.product == .app else {
return
}
guard let appClips = graphTraverser.appClipsDependency(path: path, name: target.name) else {
return
}
let embedAppClipsBuildPhase = PBXCopyFilesBuildPhase(dstPath: "$(CONTENTS_FOLDER_PATH)/AppClips",
dstSubfolderSpec: .productsDirectory,
name: "Embed App Clips")
pbxproj.add(object: embedAppClipsBuildPhase)
pbxTarget.buildPhases.append(embedAppClipsBuildPhase)
let refs = fileElements.product(target: appClips.name)
let pbxBuildFile = PBXBuildFile(file: refs, settings: ["ATTRIBUTES": ["RemoveHeadersOnCopy"]])
pbxproj.add(object: pbxBuildFile)
embedAppClipsBuildPhase.files?.append(pbxBuildFile)
}
}

View File

@ -225,7 +225,7 @@ final class ConfigGenerator: ConfigGenerating {
}
let targetDependencies = graphTraverser.directTargetDependencies(path: projectPath, name: target.name)
let appDependency = targetDependencies.first { $0.product == .app }
let appDependency = targetDependencies.first { $0.product.canHostTests() }
guard let app = appDependency else {
return [:]

View File

@ -96,7 +96,7 @@ final class InfoPlistContentProvider: InfoPlistContentProviding {
var packageType: String?
switch target.product {
case .app:
case .app, .appClip:
packageType = "APPL"
case .staticLibrary, .dynamicLibrary:
packageType = nil

View File

@ -91,7 +91,7 @@ final class LinkGenerator: LinkGenerating {
graph: Graph) throws
{
let embeddableFrameworks = try graph.embeddableFrameworks(path: path, name: target.name)
let linkableModules = graph.linkableDependencies(path: path, name: target.name)
let linkableModules = try graph.linkableDependencies(path: path, name: target.name)
try setupSearchAndIncludePaths(target: target,
pbxTarget: pbxTarget,

View File

@ -60,7 +60,7 @@ extension GraphNode {
if let targetNode = self as? TargetNode {
switch targetNode.target.product {
case .app, .watch2App:
case .app, .watch2App, .appClip:
return .init(fillColorName: .deepskyblue, strokeWidth: 1.5, shape: .box3d)
case .appExtension, .watch2Extension:
return .init(fillColorName: .deepskyblue2, shape: .component)

View File

@ -40,6 +40,7 @@ public class GraphLinter: GraphLinting {
issues.append(contentsOf: lintDependencies(graph: graph))
issues.append(contentsOf: lintMismatchingConfigurations(graph: graph))
issues.append(contentsOf: lintWatchBundleIndentifiers(graph: graph))
return issues
}
@ -57,6 +58,7 @@ public class GraphLinter: GraphLinting {
issues.append(contentsOf: lintCarthageDependencies(graph: graph))
issues.append(contentsOf: lintCocoaPodsDependencies(graph: graph))
issues.append(contentsOf: lintPackageDependencies(graph: graph))
issues.append(contentsOf: lintAppClip(graph: graph))
return issues
}
@ -170,6 +172,34 @@ public class GraphLinter: GraphLinting {
}
}
private func lintAppClip(graph: Graph) -> [LintingIssue] {
let apps = graph
.targets.values
.flatMap { targets -> [TargetNode] in
targets.compactMap { target in
if target.target.product == .app { return target }
return nil
}
}
let issues = apps.flatMap { app -> [LintingIssue] in
let appClips = products(ofType: .appClip, for: app, graph: graph)
if appClips.count > 1 {
return [
LintingIssue(reason: "App '\(app)' cannot depend on more than one app clip -> \(appClips.map(\.name).joined(separator: ", "))",
severity: .error),
]
}
return appClips.flatMap { appClip -> [LintingIssue] in
lint(appClip: appClip, parentApp: app)
}
}
return issues
}
private func lintCarthageDependencies(graph: Graph) -> [LintingIssue] {
let frameworks = graph.frameworks
let carthageFrameworks = frameworks.filter { $0.isCarthage }
@ -200,10 +230,10 @@ public class GraphLinter: GraphLinting {
}
let issues = apps.flatMap { app -> [LintingIssue] in
let watchApps = watchAppsFor(targetNode: app, graph: graph)
let watchApps = products(ofType: .watch2App, for: app, graph: graph)
return watchApps.flatMap { watchApp -> [LintingIssue] in
let watchAppIssues = lint(watchApp: watchApp, parentApp: app)
let watchExtensions = watchExtensionsFor(targetNode: watchApp, graph: graph)
let watchExtensions = products(ofType: .watch2Extension, for: watchApp, graph: graph)
let watchExtensionIssues = watchExtensions.flatMap { watchExtension in
lint(watchExtension: watchExtension, parentWatchApp: watchApp)
}
@ -236,16 +266,31 @@ public class GraphLinter: GraphLinting {
return []
}
private func watchAppsFor(targetNode: TargetNode, graph: Graph) -> [TargetNode] {
private func products(ofType type: Product, for targetNode: TargetNode, graph: Graph) -> [TargetNode] {
graph.targetDependencies(path: targetNode.path,
name: targetNode.name)
.filter { $0.target.product == .watch2App }
.filter { $0.target.product == type }
}
private func watchExtensionsFor(targetNode: TargetNode, graph: Graph) -> [TargetNode] {
graph.targetDependencies(path: targetNode.path,
name: targetNode.name)
.filter { $0.target.product == .watch2Extension }
private func lint(appClip: TargetNode, parentApp: TargetNode) -> [LintingIssue] {
var foundIssues = [LintingIssue]()
if !appClip.target.bundleId.hasPrefix(parentApp.target.bundleId) {
foundIssues.append(
LintingIssue(reason: """
AppClip '\(appClip.name)' bundleId: \(appClip.target.bundleId) isn't prefixed with its parent's app '\(parentApp.name)' bundleId '\(parentApp.target.bundleId)'
""", severity: .error))
}
if let entitlements = appClip.target.entitlements {
if !FileHandler.shared.exists(entitlements) {
foundIssues.append(LintingIssue(reason: "The entitlements at path '\(entitlements)' referenced by target does not exist", severity: .error))
}
} else {
foundIssues.append(LintingIssue(reason: "An AppClip '\(appClip.target.name)' requires its Parent Application Identifiers Entitlement to be set", severity: .error))
}
return foundIssues
}
struct LintableTarget: Equatable, Hashable {
@ -265,6 +310,7 @@ public class GraphLinter: GraphLinting {
LintableTarget(platform: .iOS, product: .messagesExtension),
LintableTarget(platform: .iOS, product: .stickerPackExtension),
LintableTarget(platform: .watchOS, product: .watch2App),
LintableTarget(platform: .iOS, product: .appClip),
// LintableTarget(platform: .watchOS, product: .watchApp),
],
LintableTarget(platform: .iOS, product: .staticLibrary): [
@ -296,6 +342,7 @@ public class GraphLinter: GraphLinting {
LintableTarget(platform: .iOS, product: .framework),
LintableTarget(platform: .iOS, product: .staticFramework),
LintableTarget(platform: .iOS, product: .bundle),
LintableTarget(platform: .iOS, product: .appClip),
],
LintableTarget(platform: .iOS, product: .uiTests): [
LintableTarget(platform: .iOS, product: .app),
@ -304,6 +351,7 @@ public class GraphLinter: GraphLinting {
LintableTarget(platform: .iOS, product: .framework),
LintableTarget(platform: .iOS, product: .staticFramework),
LintableTarget(platform: .iOS, product: .bundle),
LintableTarget(platform: .iOS, product: .appClip),
],
LintableTarget(platform: .iOS, product: .appExtension): [
LintableTarget(platform: .iOS, product: .staticLibrary),

View File

@ -98,7 +98,7 @@ public final class AutogeneratedSchemesProjectMapper: ProjectMapping {
project: Project) -> [TargetReference]
{
project.targets
.filter { $0.product == .app && $0.dependencies.contains(.target(name: target.name)) }
.filter { $0.product.canHostTests() && $0.dependencies.contains(.target(name: target.name)) }
.sorted(by: { $0.name < $1.name })
.map { TargetReference(projectPath: project.path, name: $0.name) }
}

View File

@ -30,7 +30,7 @@ final class SettingsHelper {
func settingsProviderProduct(_ target: Target) -> BuildSettingsProvider.Product? {
switch target.product {
case .app, .watch2App:
case .app, .watch2App, .appClip:
return .application
case .dynamicLibrary:
return .dynamicLibrary

View File

@ -35,6 +35,8 @@ extension TuistCore.Product {
return .watch2Extension
case .messagesExtension:
return .messagesExtension
case .appClip:
return .appClip
}
}
}

View File

@ -12,5 +12,6 @@ final class ProductTests: XCTestCase {
XCTAssertCodableEqualToJson([Product.framework], "[\"framework\"]")
XCTAssertCodableEqualToJson([Product.unitTests], "[\"unit_tests\"]")
XCTAssertCodableEqualToJson([Product.uiTests], "[\"ui_tests\"]")
XCTAssertCodableEqualToJson([Product.appClip], "[\"appClip\"]")
}
}

View File

@ -81,7 +81,7 @@ final class GraphTests: TuistUnitTestCase {
dependencies: [precompiledNode])
let graph = Graph.test(targets: [targetNode.path: [targetNode]])
let got = graph.linkableDependencies(path: project.path, name: target.name)
let got = try graph.linkableDependencies(path: project.path, name: target.name)
XCTAssertEqual(got.first, GraphDependencyReference(precompiledNode: precompiledNode))
}
@ -99,7 +99,7 @@ final class GraphTests: TuistUnitTestCase {
project.path: [dependencyNode, targetNode],
])
let got = graph.linkableDependencies(path: project.path, name: target.name)
let got = try graph.linkableDependencies(path: project.path, name: target.name)
XCTAssertEqual(got.first, .product(target: "Dependency", productName: "libDependency.a"))
}
@ -120,12 +120,12 @@ final class GraphTests: TuistUnitTestCase {
dependencies: [dependencyNode])
let graph = Graph.test(projects: [project], targets: [project.path: [targetNode, dependencyNode, staticDependencyNode]])
let got = graph.linkableDependencies(path: project.path,
name: target.name)
let got = try graph.linkableDependencies(path: project.path,
name: target.name)
XCTAssertEqual(got.count, 1)
XCTAssertEqual(got.first, .product(target: "Dependency", productName: "Dependency.framework"))
let frameworkGot = graph.linkableDependencies(path: project.path, name: dependency.name)
let frameworkGot = try graph.linkableDependencies(path: project.path, name: dependency.name)
XCTAssertEqual(frameworkGot.count, 1)
XCTAssertTrue(frameworkGot.contains(.product(target: "StaticDependency", productName: "libStaticDependency.a")))
@ -152,7 +152,7 @@ final class GraphTests: TuistUnitTestCase {
])
// When
let result = graph.linkableDependencies(path: projectA.path, name: app.name)
let result = try graph.linkableDependencies(path: projectA.path, name: app.name)
// Then
XCTAssertEqual(result, [GraphDependencyReference.product(target: "DynamicFramework", productName: "DynamicFramework.framework"),
@ -188,8 +188,8 @@ final class GraphTests: TuistUnitTestCase {
])
// When
let appResult = graph.linkableDependencies(path: projectA.path, name: app.name)
let dynamicFramework1Result = graph.linkableDependencies(path: projectA.path, name: dynamicFramework1.name)
let appResult = try graph.linkableDependencies(path: projectA.path, name: app.name)
let dynamicFramework1Result = try graph.linkableDependencies(path: projectA.path, name: dynamicFramework1.name)
// Then
XCTAssertEqual(appResult, [
@ -235,7 +235,7 @@ final class GraphTests: TuistUnitTestCase {
])
// When
let dynamicFramework1Result = graph.linkableDependencies(path: projectA.path, name: dynamicFramework1.name)
let dynamicFramework1Result = try graph.linkableDependencies(path: projectA.path, name: dynamicFramework1.name)
// Then
XCTAssertEqual(dynamicFramework1Result, [GraphDependencyReference.product(target: "DynamicFramework2", productName: "DynamicFramework2.framework")])
@ -262,7 +262,7 @@ final class GraphTests: TuistUnitTestCase {
])
// When
let result = graph.linkableDependencies(path: projectA.path, name: app.name)
let result = try graph.linkableDependencies(path: projectA.path, name: app.name)
// Then
XCTAssertEqual(result.compactMap(sdkDependency), [
@ -291,8 +291,8 @@ final class GraphTests: TuistUnitTestCase {
])
// When
let appResult = graph.linkableDependencies(path: projectA.path, name: app.name)
let dynamicResult = graph.linkableDependencies(path: projectA.path, name: dynamicFramework.name)
let appResult = try graph.linkableDependencies(path: projectA.path, name: app.name)
let dynamicResult = try graph.linkableDependencies(path: projectA.path, name: dynamicFramework.name)
// Then
XCTAssertEqual(appResult.compactMap(sdkDependency), [])
@ -318,7 +318,7 @@ final class GraphTests: TuistUnitTestCase {
])
// When
let result = graph.linkableDependencies(path: projectA.path, name: app.name)
let result = try graph.linkableDependencies(path: projectA.path, name: app.name)
// Then
XCTAssertEqual(result.compactMap(sdkDependency), [SDKPathAndStatus(name: "some.framework", status: .optional)])
@ -339,7 +339,7 @@ final class GraphTests: TuistUnitTestCase {
])
// When
let result = graph.linkableDependencies(path: projectA.path, name: staticFramework.name)
let result = try graph.linkableDependencies(path: projectA.path, name: staticFramework.name)
// Then
XCTAssertEqual(result.compactMap(sdkDependency),
@ -365,7 +365,7 @@ final class GraphTests: TuistUnitTestCase {
])
// When
let result = graph.linkableDependencies(path: projectA.path, name: staticFrameworkA.name)
let result = try graph.linkableDependencies(path: projectA.path, name: staticFrameworkA.name)
// Then
XCTAssertEqual(result.compactMap(sdkDependency),
@ -387,7 +387,7 @@ final class GraphTests: TuistUnitTestCase {
])
// When
let result = graph.linkableDependencies(path: project.path, name: watchExtension.name)
let result = try graph.linkableDependencies(path: project.path, name: watchExtension.name)
// Then
XCTAssertEqual(result, [
@ -410,7 +410,7 @@ final class GraphTests: TuistUnitTestCase {
])
// When
let result = graph.linkableDependencies(path: project.path, name: watchExtension.name)
let result = try graph.linkableDependencies(path: project.path, name: watchExtension.name)
// Then
XCTAssertEqual(result, [
@ -436,7 +436,7 @@ final class GraphTests: TuistUnitTestCase {
])
// When
let result = graph.linkableDependencies(path: projectA.path, name: tests.name)
let result = try graph.linkableDependencies(path: projectA.path, name: tests.name)
// Then
XCTAssertTrue(result.isEmpty)
@ -459,7 +459,7 @@ final class GraphTests: TuistUnitTestCase {
])
// When
let result = graph.linkableDependencies(path: projectA.path, name: tests.name)
let result = try graph.linkableDependencies(path: projectA.path, name: tests.name)
// Then
XCTAssertEqual(result, [
@ -484,12 +484,27 @@ final class GraphTests: TuistUnitTestCase {
])
// When
let result = graph.linkableDependencies(path: projectA.path, name: tests.name)
let result = try graph.linkableDependencies(path: projectA.path, name: tests.name)
// Then
XCTAssertTrue(result.isEmpty)
}
func test_linkableDependencies_when_appClipSDKNode() throws {
// Given
let target = Target.test(name: "AppClip", product: .appClip)
let projectA = Project.test(path: "/path/a")
let graph = Graph.create(project: projectA,
dependencies: [(target: target, dependencies: [])])
// When
let linkableModules = try graph.linkableDependencies(path: projectA.path, name: target.name)
// Then
XCTAssertEqual(linkableModules, [.sdk(path: try SDKNode.appClip(status: .required).path, status: .required, source: .system)])
}
func test_librariesPublicHeaders() throws {
let target = Target.test(name: "Main")
let publicHeadersPath = AbsolutePath("/test/public/")

View File

@ -106,4 +106,15 @@ final class SDKNodeTests: XCTestCase {
// Then
XCTAssertEqual(got, "CoreData")
}
func test_appClip_sdk_framework_path() throws {
// Given
let subject = try SDKNode.appClip(status: .required)
// When
let got = subject.path
// Then
XCTAssertEqual(got, "/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS.sdk/System/Library/Frameworks/AppClip.framework")
}
}

View File

@ -14,6 +14,7 @@ final class ProductTests: XCTestCase {
XCTAssertEqual(Product.uiTests.xcodeValue, PBXProductType.uiTestBundle)
XCTAssertEqual(Product.appExtension.xcodeValue, PBXProductType.appExtension)
XCTAssertEqual(Product.stickerPackExtension.xcodeValue, PBXProductType.stickerPack)
XCTAssertEqual(Product.appClip.xcodeValue, PBXProductType.onDemandInstallCapableApplication)
}
func test_description() {
@ -25,6 +26,7 @@ final class ProductTests: XCTestCase {
XCTAssertEqual(Product.uiTests.description, "ui tests")
XCTAssertEqual(Product.appExtension.description, "app extension")
XCTAssertEqual(Product.stickerPackExtension.description, "sticker pack extension")
XCTAssertEqual(Product.appClip.description, "appClip")
}
func test_forPlatform_when_ios() {
@ -40,6 +42,7 @@ final class ProductTests: XCTestCase {
.messagesExtension,
.unitTests,
.uiTests,
.appClip,
]
XCTAssertEqual(Set(got), Set(expected))
}
@ -73,7 +76,7 @@ final class ProductTests: XCTestCase {
func test_runnable() {
Product.allCases.forEach { product in
if product == .app {
if [.app, .appClip].contains(product) {
XCTAssertTrue(product.runnable)
} else {
XCTAssertFalse(product.runnable)

View File

@ -43,6 +43,11 @@ final class TargetTests: TuistUnitTestCase {
XCTAssertEqual(target.productNameWithExtension, "Test.app")
}
func test_productName_when_appClip() {
let target = Target.test(name: "Test", product: .appClip)
XCTAssertEqual(target.productNameWithExtension, "Test.app")
}
func test_sequence_testBundles() {
let app = Target.test(product: .app)
let tests = Target.test(product: .unitTests)
@ -59,6 +64,14 @@ final class TargetTests: TuistUnitTestCase {
XCTAssertEqual(targets.apps, [app])
}
func test_sequence_appClips() {
let appClip = Target.test(product: .appClip)
let tests = Target.test(product: .unitTests)
let targets = [appClip, tests]
XCTAssertEqual(targets.apps, [appClip])
}
func test_targetLocatorBuildPhaseVariable() {
XCTAssertEqual(Target.test(productName: "My Framework").targetLocatorBuildPhaseVariable, "MY_FRAMEWORK_LOCATE_HASH")
XCTAssertEqual(Target.test(productName: "Feature").targetLocatorBuildPhaseVariable, "FEATURE_LOCATE_HASH")
@ -198,6 +211,7 @@ final class TargetTests: TuistUnitTestCase {
XCTAssertTrue(Target.test(product: .watch2Extension).supportsResources)
XCTAssertTrue(Target.test(product: .messagesExtension).supportsResources)
XCTAssertTrue(Target.test(product: .stickerPackExtension).supportsResources)
XCTAssertTrue(Target.test(product: .appClip).supportsResources)
}
func test_resources() throws {

View File

@ -643,6 +643,44 @@ final class BuildPhaseGeneratorTests: TuistUnitTestCase {
XCTAssertTrue(postBuildPhase.alwaysOutOfDate)
}
func test_generateEmbedAppClipsBuildPhase() throws {
// Given
let app = Target.test(name: "App", product: .app)
let appClip = Target.test(name: "AppClip", product: .appClip)
let project = Project.test()
let pbxproj = PBXProj()
let nativeTarget = PBXNativeTarget(name: "Test")
let fileElements = createProductFileElements(for: [app, appClip])
let targets: [AbsolutePath: [String: Target]] = [
project.path: [app.name: app, appClip.name: appClip],
]
let dependencies: [ValueGraphDependency: Set<ValueGraphDependency>] = [
.target(name: appClip.name, path: project.path): Set(),
.target(name: app.name, path: project.path): Set([.target(name: appClip.name, path: project.path)]),
]
let graph = ValueGraph.test(path: project.path,
projects: [project.path: project],
targets: targets,
dependencies: dependencies)
let graphTraverser = ValueGraphTraverser(graph: graph)
// When
try subject.generateEmbedAppClipsBuildPhase(path: project.path,
target: app,
graphTraverser: graphTraverser,
pbxTarget: nativeTarget,
fileElements: fileElements,
pbxproj: pbxproj)
// Then
let pbxBuildPhase: PBXBuildPhase? = nativeTarget.buildPhases.first
XCTAssertNotNil(pbxBuildPhase)
XCTAssertTrue(pbxBuildPhase is PBXCopyFilesBuildPhase)
XCTAssertEqual(pbxBuildPhase?.files?.compactMap { $0.file?.nameOrPath }, ["AppClip"])
XCTAssertEqual(pbxBuildPhase?.files?.compactMap { $0.settings as? [String: [String]] },
[["ATTRIBUTES": ["RemoveHeadersOnCopy"]]])
}
// MARK: - Helpers
private func createProductFileElements(for targets: [Target]) -> ProjectFileElements {

View File

@ -168,6 +168,7 @@ final class InfoPlistContentProviderTests: XCTestCase {
assertPackageType(content(for: .test(product: .framework)), "FMWK")
assertPackageType(content(for: .test(product: .staticFramework)), "FMWK")
assertPackageType(content(for: .test(product: .watch2App)), "$(PRODUCT_BUNDLE_PACKAGE_TYPE)")
assertPackageType(content(for: .test(product: .appClip)), "APPL")
}
func test_content_whenWatchOSApp() {

View File

@ -475,4 +475,167 @@ final class GraphLinterTests: TuistUnitTestCase {
severity: .error),
])
}
func test_lint_valid_appClipTargetBundleIdentifiers() throws {
// Given
let temporaryPath = try self.temporaryPath()
try createFiles([
"entitlements/AppClip.entitlements",
])
let entitlementsPath = temporaryPath.appending(RelativePath("entitlements/AppClip.entitlements"))
let app = Target.test(name: "App",
product: .app,
bundleId: "com.example.app")
let appClip = Target.test(name: "AppClip",
platform: .iOS,
product: .appClip,
bundleId: "com.example.app.clip",
entitlements: entitlementsPath)
let project = Project.test(targets: [app, appClip])
let graph = Graph.create(project: project,
dependencies: [
(target: app, dependencies: [appClip]),
(target: appClip, dependencies: []),
])
// When
let got = subject.lint(graph: graph)
// Then
XCTAssertTrue(got.isEmpty)
}
func test_lint_invalid_appClipTargetBundleIdentifiers() throws {
// Given
let temporaryPath = try self.temporaryPath()
try createFiles([
"entitlements/AppClip.entitlements",
])
let entitlementsPath = temporaryPath.appending(RelativePath("entitlements/AppClip.entitlements"))
let app = Target.test(name: "TestApp",
product: .app,
bundleId: "com.example.app")
let appClip = Target.test(name: "TestAppClip",
platform: .iOS,
product: .appClip,
bundleId: "com.example1.app.clip",
entitlements: entitlementsPath)
let project = Project.test(targets: [app, appClip])
let graph = Graph.create(project: project,
dependencies: [
(target: app, dependencies: [appClip]),
(target: appClip, dependencies: []),
])
// When
let got = subject.lint(graph: graph)
// Then
XCTAssertEqual(got, [
LintingIssue(reason: "AppClip 'TestAppClip' bundleId: com.example1.app.clip isn't prefixed with its parent's app 'TestApp' bundleId 'com.example.app'",
severity: .error),
])
}
func test_lint_when_appclip_is_missing_required_entitlements() throws {
// Given
let app = Target.test(name: "App",
product: .app,
bundleId: "com.example.app")
let appClip = Target.test(name: "AppClip",
platform: .iOS,
product: .appClip,
bundleId: "com.example.app.clip")
let project = Project.test(targets: [app, appClip])
let graph = Graph.create(project: project,
dependencies: [
(target: app, dependencies: [appClip]),
(target: appClip, dependencies: []),
])
// When
let got = subject.lint(graph: graph)
// Then
XCTAssertEqual(got, [
LintingIssue(reason: "An AppClip 'AppClip' requires its Parent Application Identifiers Entitlement to be set",
severity: .error),
])
}
func test_lint_when_appclip_entitlements_does_not_exist() throws {
// Given
let app = Target.test(name: "App",
product: .app,
bundleId: "com.example.app")
let appClip = Target.test(name: "AppClip",
platform: .iOS,
product: .appClip,
bundleId: "com.example.app.clip",
entitlements: "/entitlements/AppClip.entitlements")
let project = Project.test(targets: [app, appClip])
let graph = Graph.create(project: project,
dependencies: [
(target: app, dependencies: [appClip]),
(target: appClip, dependencies: []),
])
// When
let got = subject.lint(graph: graph)
// Then
XCTAssertEqual(got, [
LintingIssue(reason: "The entitlements at path '/entitlements/AppClip.entitlements' referenced by target does not exist",
severity: .error),
])
}
func test_lint_when_app_contains_more_than_one_appClip() throws {
// Given
let temporaryPath = try self.temporaryPath()
try createFiles([
"entitlements/AppClip.entitlements",
])
let entitlementsPath = temporaryPath.appending(RelativePath("entitlements/AppClip.entitlements"))
let app = Target.test(name: "App",
product: .app,
bundleId: "com.example.app")
let appClip1 = Target.test(name: "AppClip1",
platform: .iOS,
product: .appClip,
bundleId: "com.example.app.clip1",
entitlements: entitlementsPath)
let appClip2 = Target.test(name: "AppClip2",
platform: .iOS,
product: .appClip,
bundleId: "com.example.app.clip2",
entitlements: entitlementsPath)
let project = Project.test(targets: [app, appClip1, appClip2])
let graph = Graph.create(project: project,
dependencies: [
(target: app, dependencies: [appClip1, appClip2]),
(target: appClip1, dependencies: []),
(target: appClip2, dependencies: []),
])
// When
let got = subject.lint(graph: graph)
// Then
XCTAssertEqual(got, [
LintingIssue(reason: "App 'App' cannot depend on more than one app clip -> AppClip1, AppClip2",
severity: .error),
])
}
}

View File

@ -179,6 +179,7 @@ final class SettingsHelpersTests: XCTestCase {
XCTAssertEqual(subject.settingsProviderProduct(.test(product: .framework)), .framework)
XCTAssertEqual(subject.settingsProviderProduct(.test(product: .unitTests)), .unitTests)
XCTAssertEqual(subject.settingsProviderProduct(.test(product: .uiTests)), .uiTests)
XCTAssertEqual(subject.settingsProviderProduct(.test(product: .appClip)), .application)
XCTAssertNil(subject.settingsProviderProduct(.test(product: .bundle)))
}

View File

@ -16,4 +16,16 @@ Scenario: The project is an iOS application with core data models (ios_app_with_
Then tuist generates the project
Then I should be able to build for iOS the scheme App
Then the product 'App.app' with destination 'Debug-iphonesimulator' contains resource 'Users.momd'
Then the product 'App.app' with destination 'Debug-iphonesimulator' contains resource '1_2.cdm'
Then the product 'App.app' with destination 'Debug-iphonesimulator' contains resource '1_2.cdm'
Scenario: The project is an iOS application with appclip (ios_app_with_appclip)
Given that tuist is available
And I have a working directory
Then I copy the fixture ios_app_with_appclip into the working directory
Then tuist generates the project
Then I should be able to build for iOS the scheme App
Then the product 'App.app' with destination 'Debug-iphonesimulator' contains the appClip 'AppClip1' with architecture 'x86_64'
Then the product 'App.app' with destination 'Debug-iphonesimulator' contains the appClip 'AppClip1' without architecture 'armv7'
Then I should be able to build for iOS the scheme AppClip1
Then I should be able to test for iOS the scheme AppClip1Tests
Then I should be able to test for iOS the scheme AppClip1UITests

View File

@ -121,3 +121,33 @@ Then(/^a file (.+) exists$/) do |file|
file_path = File.join(@dir, file)
assert(File.exist?(file_path), "#{file_path} does not exist")
end
Then("the product {string} with destination {string} contains the appClip {string} with architecture {string}") do |product, destination, app_clip, architecture|
app_clip_path = Xcode.find_app_clip(
product: product,
destination: destination,
app_clip: app_clip,
derived_data_path: @derived_data_path
)
flunk("AppClip #{app_clip} not found") if app_clip_path.nil?
binary_path = File.join(app_clip_path, app_clip)
out, err, status = Open3.capture3("file", binary_path)
assert(status.success?, err)
assert(out.include?(architecture))
end
Then("the product {string} with destination {string} contains the appClip {string} without architecture {string}") do |product, destination, app_clip, architecture|
app_clip_path = Xcode.find_app_clip(
product: product,
destination: destination,
app_clip: app_clip,
derived_data_path: @derived_data_path
)
flunk("AppClip #{app_clip} not found") if app_clip_path.nil?
binary_path = File.join(app_clip_path, app_clip)
out, err, status = Open3.capture3("file", binary_path)
assert(status.success?, err)
refute(out.include?(architecture))
end

View File

@ -106,4 +106,3 @@ Then(/^in project (.+) the target (.+) should have the build phase (.+) in the l
flunk("The target #{target_name} doesn't have build phases") if build_phase.nil?
assert_equal phase_name, build_phase.name
end

View File

@ -39,6 +39,22 @@ module Xcode
framework_path
end
def self.find_app_clip(product:, destination:, app_clip:, derived_data_path:)
product_path = product_with_name(
product,
destination: destination,
derived_data_path: derived_data_path
)
return if product_path.nil?
app_clip_glob = File.join(product_path, "/AppClips/#{app_clip}.app")
# /path/to/product/AppClips/AppClip.app
app_clip_path = Dir.glob(app_clip_glob).first
app_clip_path
end
def self.find_resource(product:, destination:, resource:, derived_data_path:)
product_path = product_with_name(
product,

View File

@ -0,0 +1,67 @@
### macOS ###
# General
.DS_Store
.AppleDouble
.LSOverride
# Icon must end with two
Icon
# Thumbnails
._*
# Files that might appear in the root of a volume
.DocumentRevisions-V100
.fseventsd
.Spotlight-V100
.TemporaryItems
.Trashes
.VolumeIcon.icns
.com.apple.timemachine.donotpresent
# Directories potentially created on remote AFP share
.AppleDB
.AppleDesktop
Network Trash Folder
Temporary Items
.apdisk
### Xcode ###
# Xcode
#
# gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore
## User settings
xcuserdata/
## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9)
*.xcscmblueprint
*.xccheckout
## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4)
build/
DerivedData/
*.moved-aside
*.pbxuser
!default.pbxuser
*.mode1v3
!default.mode1v3
*.mode2v3
!default.mode2v3
*.perspectivev3
!default.perspectivev3
### Xcode Patch ###
*.xcodeproj/*
!*.xcodeproj/project.pbxproj
!*.xcodeproj/xcshareddata/
!*.xcworkspace/contents.xcworkspacedata
/*.gcno
### Projects ###
*.xcodeproj
*.xcworkspace
### Tuist derived files ###
graph.dot
Derived/

View File

@ -0,0 +1,14 @@
import SwiftUI
struct ContentView: View {
var body: some View {
Text("Hello, world!")
.padding()
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}

View File

@ -0,0 +1,10 @@
import SwiftUI
@main
struct MainApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
}

View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.developer.parent-application-identifiers</key>
<array>
<string>$(AppIdentifierPrefix)io.tuist.App</string>
</array>
</dict>
</plist>

View File

@ -0,0 +1,14 @@
import SwiftUI
struct ContentView: View {
var body: some View {
Text("Hello Appclips!")
.padding()
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}

View File

@ -0,0 +1,11 @@
import SwiftUI
import AppClip
@main
struct MainAppClip: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
}

View File

@ -0,0 +1,10 @@
import XCTest
@testable import AppClip
final class AppClipTests: XCTestCase {
func testExample() {
XCTAssertTrue(2==2)
}
}

View File

@ -0,0 +1,26 @@
import XCTest
final class AppClipUITests: XCTestCase {
override func setUpWithError() throws {
// Put setup code here. This method is called before the invocation of each test method in the class.
// In UI tests it is usually best to stop immediately when a failure occurs.
continueAfterFailure = false
// In UI tests its important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this.
}
override func tearDownWithError() throws {
// Put teardown code here. This method is called after the invocation of each test method in the class.
}
func testExample() throws {
// UI tests must launch the application that they test.
let app = XCUIApplication()
app.launch()
// Use recording to get started writing UI tests.
// Use XCTAssert and related functions to verify your tests produce the correct results.
}
}

View File

@ -0,0 +1,41 @@
import ProjectDescription
let project = Project(name: "App",
organizationName: "Tuist",
targets: [
Target(name: "App",
platform: .iOS,
product: .app,
bundleId: "io.tuist.App",
infoPlist: .default,
sources: ["App/Sources/**",],
dependencies: [
.target(name: "AppClip1"),
]),
Target(name: "AppClip1",
platform: .iOS,
product: .appClip,
bundleId: "io.tuist.App.Clip",
infoPlist: .default,
sources: ["AppClip1/Sources/**",],
entitlements: "AppClip1/Entitlements/AppClip.entitlements",
dependencies: []),
Target(name: "AppClip1Tests",
platform: .iOS,
product: .unitTests,
bundleId: "io.tuist.AppClip1Tests",
infoPlist: .default,
sources: ["AppClip1Tests/Tests/**"],
dependencies: [
.target(name: "AppClip1")
]),
Target(name: "AppClip1UITests",
platform: .iOS,
product: .uiTests,
bundleId: "io.tuist.AppClip1UITests",
infoPlist: .default,
sources: ["AppClip1UITests/Tests/**"],
dependencies: [
.target(name: "AppClip1")
])
])

View File

@ -0,0 +1,47 @@
---
name: App Clips
excerpt: 'This page documents how to declare and use App Clip targets.'
---
import Message from '../components/message'
# App Clips
## Product Types
An App Clip is a small part of your app thats discoverable at the moment its needed. App Clips are fast and lightweight so a user can open them quickly.
For example, this is how an AppClip can be declared:
```swift
let project = Project(name: "App",
targets: [
Target(name: "App",
platform: .iOS,
product: .app,
bundleId: "io.tuist.App",
infoPlist: "App/Configs/Info.plist",
sources: ["App/Sources/**"],
dependencies: [
.target(name: "AppClip"),
]),
Target(name: "AppClip",
platform: .iOS,
product: .appClip,
bundleId: "io.tuist.App.Clip",
infoPlist: "AppClip/Configs/Info.plist",
sources: ["AppClip/Sources/**",],
entitlements: "AppClip/Entitlements/AppClip.entitlements",
dependencies: [
.sdk(name: "AppClip.framework", status: .required),
]),
])
```
## Parent Application Identifiers Entitlement
The Parent Application Identifiers entitlement establishes a secure association between an App Clip and its corresponding app. Add it only to an App Clip target.
An App Clip is always associated with exactly one app, ensure the parent application entitlement has exactly one entry, the corresponding apps application identifier as shown below.
![Diagram that shows appclip's parent application entitlement configuration](assets/appclip-entitlement-example.png)

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

View File

@ -28,6 +28,7 @@ import {
#### Examples
- [App Extensions](/docs/examples/app-extensions/)
- [App Clips](/docs/examples/app-clips/)
#### Building at scale

View File

@ -524,6 +524,10 @@ The type of build product this target will output. It can be any of the followin
case: '.messagesExtension',
description: 'An iMessage extension. (iOS platform only)',
},
{
case: '.appClip',
description: 'An appClip. (iOS platform only)',
},
]}
/>