Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add On Demand Resources Support #6178

Merged
merged 7 commits into from
May 6, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
15 changes: 15 additions & 0 deletions Sources/ProjectDescription/OnDemandResourcesTags.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
/// On-demand resources tags associated with Initial Install and Prefetched Order categories
public struct OnDemandResourcesTags: Codable, Equatable {
/// Initial install tags associated with on demand resources
public let initialInstall: [String]?
/// Prefetched tag order associated with on demand resources
public let prefetchOrder: [String]?

/// Returns OnDemandResourcesTags.
/// - Parameter initialInstall: An array of strings that lists the tags assosiated with the Initial install tags category.
/// - Parameter prefetchOrder: An array of strings that lists the tags associated with the Prefetch tag order category.
/// - Returns: OnDemandResourcesTags.
public static func tags(initialInstall: [String]?, prefetchOrder: [String]?) -> Self {
OnDemandResourcesTags(initialInstall: initialInstall, prefetchOrder: prefetchOrder)
}
}
9 changes: 7 additions & 2 deletions Sources/ProjectDescription/Target.swift
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,9 @@ public struct Target: Codable, Equatable {
/// Specifies whether if the target can be merged as part of another binary or not
public var mergeable: Bool

/// The target's tags associated with on demand resources
public var onDemandResourcesTags: OnDemandResourcesTags?

public static func target(
name: String,
destinations: Destinations,
Expand All @@ -92,7 +95,8 @@ public struct Target: Codable, Equatable {
additionalFiles: [FileElement] = [],
buildRules: [BuildRule] = [],
mergedBinaryType: MergedBinaryType = .disabled,
mergeable: Bool = false
mergeable: Bool = false,
onDemandResourcesTags: OnDemandResourcesTags? = nil
) -> Self {
self.init(
name: name,
Expand All @@ -116,7 +120,8 @@ public struct Target: Codable, Equatable {
additionalFiles: additionalFiles,
buildRules: buildRules,
mergedBinaryType: mergedBinaryType,
mergeable: mergeable
mergeable: mergeable,
onDemandResourcesTags: onDemandResourcesTags
)
}
}
3 changes: 3 additions & 0 deletions Sources/TuistAcceptanceTesting/TuistAcceptanceFixtures.swift
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ public enum TuistAcceptanceFixtures {
case iosAppWithLocalBinarySwiftPackage
case iosAppWithLocalSwiftPackage
case iosAppWithMultiConfigs
case iosAppWithOnDemandResources
case iosAppWithPluginsAndTemplates
case iosAppWithPrivacyManifest
case iosAppWithRemoteBinarySwiftPackage
Expand Down Expand Up @@ -139,6 +140,8 @@ public enum TuistAcceptanceFixtures {
return "ios_app_with_local_swift_package"
case .iosAppWithMultiConfigs:
return "ios_app_with_multi_configs"
case .iosAppWithOnDemandResources:
return "ios_app_with_on_demand_resources"
case .iosAppWithPluginsAndTemplates:
return "ios_app_with_plugins_and_templates"
case .iosAppWithPrivacyManifest:
Expand Down
16 changes: 16 additions & 0 deletions Sources/TuistGenerator/Generator/ConfigGenerator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -361,6 +361,22 @@ final class ConfigGenerator: ConfigGenerating {
}
}

if let initialInstallTags = target.onDemandResourcesTags?.initialInstall, !initialInstallTags.isEmpty {
settings["ON_DEMAND_RESOURCES_INITIAL_INSTALL_TAGS"] = .string(
initialInstallTags.sorted().map {
$0.replacingOccurrences(of: " ", with: "\\ ")
}.joined(separator: " ")
)
}

if let prefetchOrder = target.onDemandResourcesTags?.prefetchOrder, !prefetchOrder.isEmpty {
settings["ON_DEMAND_RESOURCES_PREFETCH_ORDER"] = .string(
prefetchOrder.map {
$0.replacingOccurrences(of: " ", with: "\\ ")
}.joined(separator: " ")
)
}

return settings
}

Expand Down
62 changes: 62 additions & 0 deletions Sources/TuistGenerator/Generator/KnownAssetTagsFetcher.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import Foundation
import PathKit
import TuistGraph

private struct ContentJson: Decodable {
struct ContentProperties: Decodable {
enum CodingKeys: String, CodingKey {
case onDemandResourceTags = "on-demand-resource-tags"
}

let onDemandResourceTags: [String]
}

let properties: ContentProperties
}

protocol KnownAssetTagsFetching: AnyObject {
func fetch(project: Project) throws -> [String]
}

final class KnownAssetTagsFetcher: KnownAssetTagsFetching {
func fetch(project: Project) throws -> [String] {
var tags = project.targets.map { $0.resources.resources.map(\.tags).flatMap { $0 } }.flatMap { $0 }

let initialInstallTags = project.targets.compactMap {
$0.onDemandResourcesTags?.initialInstall?.compactMap { $0 }
}.flatMap { $0 }

let prefetchOrderTags = project.targets.compactMap {
$0.onDemandResourcesTags?.prefetchOrder?.compactMap { $0 }
}.flatMap { $0 }

tags.append(contentsOf: initialInstallTags)
tags.append(contentsOf: prefetchOrderTags)

var assetContentsPaths: Set<Path> = []
let decoder = JSONDecoder()
for target in project.targets {
let assetCatalogs = target.resources.resources.filter { $0.path.extension == "xcassets" }
for assetCatalog in assetCatalogs {
guard let children = try? assetCatalog.path.path.recursiveChildren() else { continue }
let contents = children.filter { $0.lastComponent == "Contents.json" }
for content in contents {
assetContentsPaths.insert(content)
}
}
}

var assetsTags: [String] = []
for path in assetContentsPaths {
guard let data = try? Data(contentsOf: path.url) else { continue }
guard let attributes = try? decoder.decode(ContentJson.self, from: data) else { continue }
assetsTags.append(contentsOf: attributes.properties.onDemandResourceTags)
}

tags.append(contentsOf: assetsTags)

let uniqueTags = Set(tags).sorted()

return uniqueTags
}
}
17 changes: 10 additions & 7 deletions Sources/TuistGenerator/Generator/ProjectDescriptorGenerator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,9 @@ final class ProjectDescriptorGenerator: ProjectDescriptorGenerating {
/// Generator for the project schemes.
let schemeDescriptorsGenerator: SchemeDescriptorsGenerating

/// Fetcher for the project known asset tags associated with on-demand resources.
let knownAssetTagsFetcher: KnownAssetTagsFetching

// MARK: - Init

/// Initializes the project generator with its attributes.
Expand All @@ -62,14 +65,17 @@ final class ProjectDescriptorGenerator: ProjectDescriptorGenerating {
/// - targetGenerator: Generator for the project targets.
/// - configGenerator: Generator for the project configuration.
/// - schemeDescriptorsGenerator: Generator for the project schemes.
/// - knownAssetTagsFetcher: Fetcher for the project known asset tags associated with on-demand resources.
init(
targetGenerator: TargetGenerating = TargetGenerator(),
configGenerator: ConfigGenerating = ConfigGenerator(),
schemeDescriptorsGenerator: SchemeDescriptorsGenerating = SchemeDescriptorsGenerator()
schemeDescriptorsGenerator: SchemeDescriptorsGenerating = SchemeDescriptorsGenerator(),
knownAssetTagsFetcher: KnownAssetTagsFetching = KnownAssetTagsFetcher()
) {
self.targetGenerator = targetGenerator
self.configGenerator = configGenerator
self.schemeDescriptorsGenerator = schemeDescriptorsGenerator
self.knownAssetTagsFetcher = knownAssetTagsFetcher
}

// MARK: - ProjectGenerating
Expand Down Expand Up @@ -295,12 +301,9 @@ final class ProjectDescriptorGenerator: ProjectDescriptorGenerating {
private func generateAttributes(project: Project) -> [String: Any] {
var attributes: [String: Any] = [:]

/// ODR tags
let tags = project.targets.map { $0.resources.resources.map(\.tags).flatMap { $0 } }.flatMap { $0 }
let uniqueTags = Set(tags).sorted()

if !uniqueTags.isEmpty {
attributes["KnownAssetTags"] = uniqueTags
// On Demand Resources tags
if let knownAssetTags = try? knownAssetTagsFetcher.fetch(project: project), !knownAssetTags.isEmpty {
attributes["KnownAssetTags"] = knownAssetTags
}

// BuildIndependentTargetsInParallel
Expand Down
14 changes: 14 additions & 0 deletions Sources/TuistGenerator/Linter/TargetLinter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ class TargetLinter: TargetLinting {
issues.append(contentsOf: validateCoreDataModelsExist(target: target))
issues.append(contentsOf: validateCoreDataModelVersionsExist(target: target))
issues.append(contentsOf: lintMergeableLibrariesOnlyAppliesToDynamicTargets(target: target))
issues.append(contentsOf: lintOnDemandResourcesTags(target: target))
for script in target.scripts {
issues.append(contentsOf: targetScriptLinter.lint(script))
}
Expand Down Expand Up @@ -292,6 +293,19 @@ class TargetLinter: TargetLinting {
}
return []
}

private func lintOnDemandResourcesTags(target: Target) -> [LintingIssue] {
guard let onDemandResourcesTags = target.onDemandResourcesTags else { return [] }
guard let initialInstall = onDemandResourcesTags.initialInstall else { return [] }
guard let prefetchOrder = onDemandResourcesTags.prefetchOrder else { return [] }
let intersection = Set(initialInstall).intersection(Set(prefetchOrder))
return intersection.map { tag in
kapitoshka438 marked this conversation as resolved.
Show resolved Hide resolved
LintingIssue(
reason: "Prefetched Order Tag \"\(tag)\" is already assigned to Initial Install Tags category for the target \(target.name) and will be ignored by Xcode",
severity: .warning
)
}
}
}

extension TargetDependency {
Expand Down
9 changes: 9 additions & 0 deletions Sources/TuistGraph/Models/OnDemandResourcesTags.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
public struct OnDemandResourcesTags: Codable, Equatable {
public let initialInstall: [String]?
public let prefetchOrder: [String]?

public init(initialInstall: [String]?, prefetchOrder: [String]?) {
self.initialInstall = initialInstall
self.prefetchOrder = prefetchOrder
}
}
5 changes: 4 additions & 1 deletion Sources/TuistGraph/Models/Target.swift
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ public struct Target: Equatable, Hashable, Comparable, Codable {
public var prune: Bool
public let mergedBinaryType: MergedBinaryType
public let mergeable: Bool
public let onDemandResourcesTags: OnDemandResourcesTags?

// MARK: - Init

Expand Down Expand Up @@ -75,7 +76,8 @@ public struct Target: Equatable, Hashable, Comparable, Codable {
buildRules: [BuildRule] = [],
prune: Bool = false,
mergedBinaryType: MergedBinaryType = .disabled,
mergeable: Bool = false
mergeable: Bool = false,
onDemandResourcesTags: OnDemandResourcesTags? = nil
) {
self.name = name
self.product = product
Expand Down Expand Up @@ -103,6 +105,7 @@ public struct Target: Equatable, Hashable, Comparable, Codable {
self.prune = prune
self.mergedBinaryType = mergedBinaryType
self.mergeable = mergeable
self.onDemandResourcesTags = onDemandResourcesTags
}

/// Target can be included in the link phase of other targets
Expand Down
6 changes: 4 additions & 2 deletions Sources/TuistGraphTesting/Models/Target+TestData.swift
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,8 @@ extension Target {
environmentVariables: [String: EnvironmentVariable] = [:],
filesGroup: ProjectGroup = .group(name: "Project"),
dependencies: [TargetDependency] = [],
rawScriptBuildPhases: [RawScriptBuildPhase] = []
rawScriptBuildPhases: [RawScriptBuildPhase] = [],
onDemandResourcesTags: OnDemandResourcesTags? = nil
) -> Target {
Target(
name: name,
Expand All @@ -160,7 +161,8 @@ extension Target {
environmentVariables: environmentVariables,
filesGroup: filesGroup,
dependencies: dependencies,
rawScriptBuildPhases: rawScriptBuildPhases
rawScriptBuildPhases: rawScriptBuildPhases,
onDemandResourcesTags: onDemandResourcesTags
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,10 @@ extension TuistGraph.Target {
TuistGraph.BuildRule.from(manifest: $0)
}

let onDemandResourcesTags = manifest.onDemandResourcesTags.map {
TuistGraph.OnDemandResourcesTags(initialInstall: $0.initialInstall, prefetchOrder: $0.prefetchOrder)
}

return TuistGraph.Target(
name: name,
destinations: destinations,
Expand All @@ -125,7 +129,8 @@ extension TuistGraph.Target {
additionalFiles: additionalFiles,
buildRules: buildRules,
mergedBinaryType: mergedBinaryType,
mergeable: manifest.mergeable
mergeable: manifest.mergeable,
onDemandResourcesTags: onDemandResourcesTags
)
}

Expand Down
30 changes: 30 additions & 0 deletions Tests/TuistGeneratorAcceptanceTests/GenerateAcceptanceTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,36 @@ final class GenerateAcceptanceTestiOSAppWithFrameworkAndResources: TuistAcceptan
}
}

final class GenerateAcceptanceTestiOSAppWithOnDemandResources: TuistAcceptanceTestCase {
func test_ios_app_with_on_demand_resources() async throws {
try setUpFixture(.iosAppWithOnDemandResources)
try await run(GenerateCommand.self)
try await run(BuildCommand.self)
let pbxprojPath = xcodeprojPath.appending(component: "project.pbxproj")
let data = try Data(contentsOf: pbxprojPath.url)
let pbxProj = try PBXProj(data: data)
let attributes = try XCTUnwrap(pbxProj.projects.first?.attributes)
let knownAssetTags = try XCTUnwrap(attributes["KnownAssetTags"] as? [String])
let givenTags = [
"ar-resource-group",
"cube-texture",
"data",
"data file",
"datafile",
"datafolder",
"image",
"image-stack",
"json",
"nestedimage",
"newfolder",
"sprite",
"tag with space",
"texture",
]
XCTAssertEqual(knownAssetTags, givenTags)
}
}

final class GenerateAcceptanceTestiOSAppWithPrivacyManifest: TuistAcceptanceTestCase {
func test_ios_app_with_privacy_manifest() async throws {
try setUpFixture(.iosAppWithPrivacyManifest)
Expand Down
19 changes: 19 additions & 0 deletions Tests/TuistGeneratorTests/Linter/TargetLinterTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -454,4 +454,23 @@ final class TargetLinterTests: TuistUnitTestCase {
severity: .warning
))
}

func test_lint_when_target_has_invalid_on_demand_resources_tags() throws {
// Given
let target = Target.empty(
onDemandResourcesTags: .init(
initialInstall: ["tag1", "tag2"],
prefetchOrder: ["tag2", "tag3"]
)
)

// When
let got = subject.lint(target: target)

// Then
XCTContainsLintingIssue(got, .init(
reason: "Prefetched Order Tag \"tag2\" is already assigned to Initial Install Tags category for the target \(target.name) and will be ignored by Xcode",
severity: .warning
))
}
}
7 changes: 7 additions & 0 deletions fixtures/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,13 @@ An iOS application with local Swift package.

An workspace that contains an application and frameworks that leverage multiple configurations (Debug, Beta and Release) each of which also has an associated xcconfig file within `ConfigurationFiles`.

## ios_app_with_on_demand_resources

An iOS applicaiton with on-demand resources. It contains file resources and asset catalogs associated with tags which in turn are distributed between three categories:
- Initial install tags
- Prefetch tag order
- Dowloaded only on demand

## ios_app_with_remote_swift_package

An iOS application with remote Swift package.
Expand Down