diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d9c5ed..ffea79a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,16 @@ # DevFoundation Changelog +## 1.8.0: January 13, 2026 + +This release adds helpers for using remote content for localization. + + - Create a remote content bundle using `Bundle.makeRemoteContentBundle(at:localizedStrings:)` + - Set the default remote content bundle using `Bundle.defaultRemoteContentBundle` + - Access your remote localized strings (with a local fallback) using + `#remoteLocalizedString(_:bundle:)` and `#remoteLocalizedString(format:bundle:_:)` + + ## 1.7.0: October 27, 2025 This is a small release that updates `ExpiringValue` to work better with `DateProvider`. diff --git a/Package.resolved b/Package.resolved index f964ad1..b9d1ad3 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "92ff398a7ce63387dbc1def4471ad9511bc7ac22e49894ad104172d481ee0012", + "originHash" : "b5e1ad211e1b6f6be3d143c9860e34ccae4e248dbaa423c63ac2bb413dd47d34", "pins" : [ { "identity" : "devtesting", @@ -46,6 +46,15 @@ "version" : "1.1.0" } }, + { + "identity" : "swift-syntax", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swiftlang/swift-syntax.git", + "state" : { + "revision" : "4799286537280063c85a32f09884cfbca301b1a1", + "version" : "602.0.0" + } + }, { "identity" : "urlmock", "kind" : "remoteSourceControl", diff --git a/Package.swift b/Package.swift index 83e5c90..ca766fd 100644 --- a/Package.swift +++ b/Package.swift @@ -1,5 +1,6 @@ // swift-tools-version: 6.2 +import CompilerPluginSupport import PackageDescription let swiftSettings: [SwiftSetting] = [ @@ -34,13 +35,15 @@ let package = Package( .package(url: "https://github.com/apple/swift-async-algorithms.git", from: "1.0.4"), .package(url: "https://github.com/apple/swift-numerics.git", from: "1.1.0"), .package(url: "https://github.com/DevKitOrganization/DevTesting", from: "1.5.0"), + .package(url: "https://github.com/swiftlang/swift-syntax.git", from: "602.0.0"), .package(url: "https://github.com/prachigauriar/URLMock.git", from: "1.3.6"), ], targets: [ .target( name: "DevFoundation", dependencies: [ - .product(name: "AsyncAlgorithms", package: "swift-async-algorithms") + .product(name: "AsyncAlgorithms", package: "swift-async-algorithms"), + "RemoteLocalizationMacros", ], swiftSettings: swiftSettings ), @@ -54,6 +57,16 @@ let package = Package( ], swiftSettings: swiftSettings ), + .macro( + name: "RemoteLocalizationMacros", + dependencies: [ + .product(name: "SwiftCompilerPlugin", package: "swift-syntax"), + .product(name: "SwiftSyntax", package: "swift-syntax"), + .product(name: "SwiftSyntaxBuilder", package: "swift-syntax"), + .product(name: "SwiftSyntaxMacros", package: "swift-syntax"), + ], + swiftSettings: swiftSettings + ), .executableTarget( name: "dfob", diff --git a/Sources/DevFoundation/Documentation.docc/Documentation.md b/Sources/DevFoundation/Documentation.docc/Documentation.md index 3adcf19..028fbfb 100644 --- a/Sources/DevFoundation/Documentation.docc/Documentation.md +++ b/Sources/DevFoundation/Documentation.docc/Documentation.md @@ -30,6 +30,13 @@ for paging through data, and essential utility types for building robust applica - ``LiveQueryResultsProducer`` - ``LiveQuerySchedulingStrategy`` +### Localizing with Remote Content + +- ``remoteLocalizedString(_:bundle:)`` +- ``remoteLocalizedString(format:bundle:_:)`` +- ``remoteLocalizedString(_:key:bundle:remoteContentBundle:)`` +- ``Foundation/Bundle`` + ### Caching - ``ExpiringValue`` diff --git a/Sources/DevFoundation/Remote Localization/Bundle+RemoteContent.swift b/Sources/DevFoundation/Remote Localization/Bundle+RemoteContent.swift new file mode 100644 index 0000000..64ed37d --- /dev/null +++ b/Sources/DevFoundation/Remote Localization/Bundle+RemoteContent.swift @@ -0,0 +1,55 @@ +// +// Bundle+RemoteContent.swift +// DevFoundation +// +// Created by Prachi Gauriar on 1/13/26. +// + +import Foundation +import Synchronization + +extension Bundle { + /// A mutex used to synchronize access to the default remote content bundle. + private static let defaultRemoteContentBundleMutex: Mutex = .init(nil) + + + /// The default bundle used to load remote content, such as localized strings fetched from a server. + /// + /// This property is thread-safe and can be accessed from multiple threads concurrently. + /// + /// Set this property after creating a remote content bundle using + /// ``makeRemoteContentBundle(at:localizedStrings:)``. Once set, you can use this bundle to look up localized + /// strings that were downloaded from a remote source. + /// + /// - Note: This property is `nil` by default and must be explicitly set before use. + public static var defaultRemoteContentBundle: Bundle? { + get { + defaultRemoteContentBundleMutex.withLock(\.self) + } + + set { + defaultRemoteContentBundleMutex.withLock { $0 = newValue } + } + } + + + /// Creates and returns a remote content bundle at the specified URL. + /// + /// - Parameters: + /// - bundleURL: The URL at which to create the remote content bundle. + /// - localizedStrings: The localized strings to store in the bundle. + public static func makeRemoteContentBundle( + at bundleURL: URL, + localizedStrings: [String: String] + ) throws -> Bundle? { + // We write directly into the resources directory rather than putting it in an lproj, as we don’t actually + // know language the strings are in. + let resourcesDirectoryURL = bundleURL.appending(path: "Contents/Resources") + try FileManager.default.createDirectory(at: resourcesDirectoryURL, withIntermediateDirectories: true) + + let localizedStringsData = try PropertyListEncoder().encode(localizedStrings) + try localizedStringsData.write(to: resourcesDirectoryURL.appendingPathComponent("Localizable.strings")) + + return Bundle(url: bundleURL) + } +} diff --git a/Sources/DevFoundation/Remote Localization/RemoteLocalizedString.swift b/Sources/DevFoundation/Remote Localization/RemoteLocalizedString.swift new file mode 100644 index 0000000..cc7f3c3 --- /dev/null +++ b/Sources/DevFoundation/Remote Localization/RemoteLocalizedString.swift @@ -0,0 +1,86 @@ +// +// RemoteLocalizedString.swift +// DevFoundation +// +// Created by Prachi Gauriar on 1/13/26. +// + +import Foundation + +/// Returns a localized version of the key using a combination of remote- and local localization data. +/// +/// The function works by first checking the remote content bundle, and if no key is found, falling back to the local +/// bundle. +/// +/// You should generally use the ``#remoteLocalizedString(_:bundle:)`` macro instead of using this function directly. +/// +/// - Parameters: +/// - keyAndValue: A `String.LocalizationValue` that provides the localization key to look up. This parameter also +/// serves as the default value if the system can’t find a localized string. +/// - key: A string representation of the localization key. +/// - bundle: The bundle to use for looking up strings if a string cannot be found in the remote content bundle. +/// - remoteContentBundle: The bundle to use to look up remote localization data. If `nil`, no remote content is used. +/// Defaults to ``Foundation/Bundle/defaultRemoteContentBundle``. +public func remoteLocalizedString( + _ keyAndValue: String.LocalizationValue, + key: String, + bundle: Bundle, + remoteContentBundle: Bundle? = .defaultRemoteContentBundle +) -> String { + if let remoteContentBundle { + let value = String(localized: keyAndValue, bundle: remoteContentBundle) + + // If you got back a value that was different than the key, that suggests that it was localized, so return it + if value != key { + return value + } + } + + return String(localized: keyAndValue, bundle: bundle) +} + + +/// A macro that returns a localized version of the key using a combination of remote- and local localization data. +/// +/// This macro transforms: +/// +/// #remoteLocalizedString("feline.adoptionMessage") +/// +/// Into: +/// +/// remoteLocalizedString( +/// "feline.adoptionMessage", +/// key: "feline.adoptionMessage", +/// bundle: #bundle +/// ) +/// +/// - Parameters: +/// - key: A string literal containing the localization key. +/// - bundle: The bundle to use for looking up strings if a string cannot be found in the remote content bundle. +/// `#bundle` by default. +@freestanding(expression) +public macro remoteLocalizedString(_ key: String, bundle: Bundle = #bundle) -> String = + #externalMacro(module: "RemoteLocalizationMacros", type: "RemoteLocalizedStringMacro") + + +/// A macro that returns a formatted localized string using a combination of remote- and local localization data. +/// +/// This macro transforms: +/// +/// #remoteLocalizedString(format: "feline.count.format", bundle: .main, catCount, kittenCount) +/// +/// Into: +/// +/// String.localizedStringWithFormat( +/// #remoteLocalizedString("feline.count.format", bundle: .main), +/// catCount, kittenCount +/// ) +/// +/// - Parameters: +/// - format: A string literal containing the localization key for the format string. +/// - bundle: The bundle to use for looking up strings if a string cannot be found in the remote content bundle. +/// `#bundle` by default. +/// - arguments: The arguments to substitute into the format string. +@freestanding(expression) +public macro remoteLocalizedString(format: String, bundle: Bundle = #bundle, _ arguments: any CVarArg...) -> String = + #externalMacro(module: "RemoteLocalizationMacros", type: "RemoteLocalizedStringWithFormatMacro") diff --git a/Sources/RemoteLocalizationMacros/RemoteLocalizationMacrosPlugin.swift b/Sources/RemoteLocalizationMacros/RemoteLocalizationMacrosPlugin.swift new file mode 100644 index 0000000..6722561 --- /dev/null +++ b/Sources/RemoteLocalizationMacros/RemoteLocalizationMacrosPlugin.swift @@ -0,0 +1,17 @@ +// +// RemoteLocalizationMacrosPlugin.swift +// RemoteLocalizationMacros +// +// Created by Prachi Gauriar on 1/13/26. +// + +import SwiftCompilerPlugin +import SwiftSyntaxMacros + +@main +struct RemoteLocalizationMacrosPlugin: CompilerPlugin { + let providingMacros: [any Macro.Type] = [ + RemoteLocalizedStringMacro.self, + RemoteLocalizedStringWithFormatMacro.self, + ] +} diff --git a/Sources/RemoteLocalizationMacros/RemoteLocalizedStringMacro.swift b/Sources/RemoteLocalizationMacros/RemoteLocalizedStringMacro.swift new file mode 100644 index 0000000..6356e93 --- /dev/null +++ b/Sources/RemoteLocalizationMacros/RemoteLocalizedStringMacro.swift @@ -0,0 +1,87 @@ +// +// RemoteLocalizedStringMacro.swift +// RemoteLocalizationMacros +// +// Created by Prachi Gauriar on 1/13/26. +// + +import SwiftSyntax +import SwiftSyntaxBuilder +import SwiftSyntaxMacros + +public struct RemoteLocalizedStringMacro: ExpressionMacro { + public static func expansion( + of node: some FreestandingMacroExpansionSyntax, + in context: some MacroExpansionContext + ) throws -> ExprSyntax { + guard + let firstArgument = node.arguments.first, + let stringLiteral = firstArgument.expression.as(StringLiteralExprSyntax.self), + stringLiteral.segments.count == 1, + case .stringSegment(let stringSegment) = stringLiteral.segments.first + else { + throw RemoteLocalizedStringMacroError.requiresStringLiteral + } + + let keyString = stringSegment.content.text + + // Build the arguments for localizedString call + var argumentsArray: [LabeledExprSyntax] = [] + + // First argument: String.LocalizationValue from the original string literal + argumentsArray.append( + LabeledExprSyntax( + expression: ExprSyntax(StringLiteralExprSyntax(content: keyString)), + trailingComma: .commaToken() + ) + ) + + // Second argument: key parameter + argumentsArray.append( + LabeledExprSyntax( + label: .identifier("key"), + colon: .colonToken(), + expression: ExprSyntax(StringLiteralExprSyntax(content: keyString)), + trailingComma: .commaToken() + ) + ) + + // Third argument: bundle parameter - use #bundle if not provided, otherwise use provided value + let bundleArgument = node.arguments.first { $0.label?.text == "bundle" } + let bundleExpression: ExprSyntax + + if let bundleArgument = bundleArgument { + // Use the explicitly provided bundle argument + bundleExpression = bundleArgument.expression + } else { + // Default to #bundle + bundleExpression = ExprSyntax( + MacroExpansionExprSyntax( + macroName: .identifier("bundle"), + leftParen: .leftParenToken(), + arguments: LabeledExprListSyntax([]), + rightParen: .rightParenToken() + ) + ) + } + + argumentsArray.append( + LabeledExprSyntax( + label: .identifier("bundle"), + colon: .colonToken(), + expression: bundleExpression + ) + ) + + let arguments = LabeledExprListSyntax(argumentsArray) + + return ExprSyntax( + FunctionCallExprSyntax( + calledExpression: DeclReferenceExprSyntax(baseName: .identifier("remoteLocalizedString")), + leftParen: .leftParenToken(), + arguments: arguments, + rightParen: .rightParenToken() + ) + ) + } +} diff --git a/Sources/RemoteLocalizationMacros/RemoteLocalizedStringMacroError.swift b/Sources/RemoteLocalizationMacros/RemoteLocalizedStringMacroError.swift new file mode 100644 index 0000000..f6389f4 --- /dev/null +++ b/Sources/RemoteLocalizationMacros/RemoteLocalizedStringMacroError.swift @@ -0,0 +1,22 @@ +// +// RemoteLocalizedStringMacroError.swift +// RemoteLocalizationMacros +// +// Created by Prachi Gauriar on 1/13/26. +// + +import Foundation + +enum RemoteLocalizedStringMacroError: Error, CustomStringConvertible { + case requiresStringLiteral + case requiresFormatStringLiteral + + var description: String { + switch self { + case .requiresStringLiteral: + return "remoteLocalizedString macro requires a string literal as the first argument" + case .requiresFormatStringLiteral: + return "remoteLocalizedString(format:) macro requires a string literal as the format argument" + } + } +} diff --git a/Sources/RemoteLocalizationMacros/RemoteLocalizedStringWithFormatMacro.swift b/Sources/RemoteLocalizationMacros/RemoteLocalizedStringWithFormatMacro.swift new file mode 100644 index 0000000..4ebf4ec --- /dev/null +++ b/Sources/RemoteLocalizationMacros/RemoteLocalizedStringWithFormatMacro.swift @@ -0,0 +1,102 @@ +// +// RemoteLocalizedStringWithFormatMacro.swift +// RemoteLocalizationMacros +// +// Created by Prachi Gauriar on 1/13/26. +// + +import Foundation +import SwiftSyntax +import SwiftSyntaxBuilder +import SwiftSyntaxMacros + +public struct RemoteLocalizedStringWithFormatMacro: ExpressionMacro { + public static func expansion( + of node: some FreestandingMacroExpansionSyntax, + in context: some MacroExpansionContext + ) throws -> ExprSyntax { + guard + let formatArgument = node.arguments.first(where: { $0.label?.text == "format" }), + let stringLiteral = formatArgument.expression.as(StringLiteralExprSyntax.self), + stringLiteral.segments.count == 1, + case .stringSegment(let stringSegment) = stringLiteral.segments.first + else { + throw RemoteLocalizedStringMacroError.requiresFormatStringLiteral + } + + let keyString = stringSegment.content.text + + // Build the arguments for String.localizedStringWithFormat call + var argumentsArray: [LabeledExprSyntax] = [] + + // First argument: the localized string using #localizedString macro + let bundleArgument = node.arguments.first { $0.label?.text == "bundle" } + let localizedStringArguments: [LabeledExprSyntax] + + if let bundleArgument = bundleArgument { + // Use the explicitly provided bundle argument + localizedStringArguments = [ + LabeledExprSyntax( + expression: ExprSyntax(StringLiteralExprSyntax(content: keyString)), + trailingComma: .commaToken() + ), + LabeledExprSyntax( + label: .identifier("bundle"), + colon: .colonToken(), + expression: bundleArgument.expression + ), + ] + } else { + // Default to just the key (which will use #bundle by default) + localizedStringArguments = [ + LabeledExprSyntax( + expression: ExprSyntax(StringLiteralExprSyntax(content: keyString)) + ) + ] + } + + let localizedStringCall = MacroExpansionExprSyntax( + macroName: .identifier("remoteLocalizedString"), + leftParen: .leftParenToken(), + arguments: LabeledExprListSyntax(localizedStringArguments), + rightParen: .rightParenToken() + ) + + argumentsArray.append( + LabeledExprSyntax( + expression: ExprSyntax(localizedStringCall), + trailingComma: .commaToken() + ) + ) + + // Add remaining arguments (the format parameters) - skip format and bundle arguments + let formatParameters = node.arguments.filter { argument in + let label = argument.label?.text + return label != "format" && label != "bundle" + } + + for (index, argument) in formatParameters.enumerated() { + let trailingComma: TokenSyntax? = index == formatParameters.count - 1 ? nil : .commaToken() + argumentsArray.append( + LabeledExprSyntax( + expression: argument.expression, + trailingComma: trailingComma + ) + ) + } + + let arguments = LabeledExprListSyntax(argumentsArray) + + return ExprSyntax( + FunctionCallExprSyntax( + calledExpression: MemberAccessExprSyntax( + base: DeclReferenceExprSyntax(baseName: .identifier("String")), + name: .identifier("localizedStringWithFormat") + ), + leftParen: .leftParenToken(), + arguments: arguments, + rightParen: .rightParenToken() + ) + ) + } +} diff --git a/Tests/DevFoundationTests/Remote Localization/Bundle+RemoteContentTests.swift b/Tests/DevFoundationTests/Remote Localization/Bundle+RemoteContentTests.swift new file mode 100644 index 0000000..e6fb9a7 --- /dev/null +++ b/Tests/DevFoundationTests/Remote Localization/Bundle+RemoteContentTests.swift @@ -0,0 +1,80 @@ +// +// Bundle+RemoteContentTests.swift +// DevFoundation +// +// Created by Prachi Gauriar on 1/13/26. +// + +import DevFoundation +import DevTesting +import Foundation +import Testing + +struct Bundle_RemoteContentTests: RandomValueGenerating { + var randomNumberGenerator = makeRandomNumberGenerator() + + + @Test + mutating func createsValidBundleWithLocalizedStrings() throws { + // set up the test by creating a bundle URL and localized strings + let bundleURL = makeTestBundleURL() + let localizedStrings = Dictionary(count: randomInt(in: 3 ..< 10)) { + (randomAlphanumericString(), randomAlphanumericString()) + } + + // exercise the test by creating the remote content bundle + let bundle = try #require(try Bundle.makeRemoteContentBundle(at: bundleURL, localizedStrings: localizedStrings)) + + // expect that the bundle was created at the correct URL with the correct structure + #expect(bundle.bundleURL.standardizedFileURL == bundleURL.standardizedFileURL) + let stringsFileURL = bundleURL.appending(path: "Contents/Resources/Localizable.strings") + #expect(FileManager.default.fileExists(atPath: stringsFileURL.path)) + #expect(Bundle(url: bundleURL) != nil) + + // expect that the bundle can look up the localized strings + for (key, value) in localizedStrings { + #expect(bundle.localizedString(forKey: key, value: nil, table: nil) == value) + } + } + + + @Test + mutating func createsValidBundleWithEmptyLocalizedStrings() throws { + // set up the test by creating a bundle URL + let bundleURL = makeTestBundleURL() + + // exercise the test by creating the remote content bundle with an empty dictionary + let bundle = try #require(try Bundle.makeRemoteContentBundle(at: bundleURL, localizedStrings: [:])) + + // expect that the bundle was created at the correct URL with the correct structure + #expect(bundle.bundleURL.standardizedFileURL == bundleURL.standardizedFileURL) + let stringsFileURL = bundleURL.appending(path: "Contents/Resources/Localizable.strings") + #expect(FileManager.default.fileExists(atPath: stringsFileURL.path)) + #expect(Bundle(url: bundleURL) != nil) + } + + + @Test + @DefaultRemoteContentBundleActor + mutating func defaultRemoteContentBundleGetAndSet() throws { + // set up the test by ensuring the bundle is nil and creating a test bundle + #expect(Bundle.defaultRemoteContentBundle == nil) + defer { Bundle.defaultRemoteContentBundle = nil } + + let bundleURL = makeTestBundleURL() + let bundle = try #require(try Bundle.makeRemoteContentBundle(at: bundleURL, localizedStrings: [:])) + + // exercise the test by setting the default remote content bundle + Bundle.defaultRemoteContentBundle = bundle + + // expect that the bundle can be retrieved + #expect(Bundle.defaultRemoteContentBundle === bundle) + } + + + private mutating func makeTestBundleURL() -> URL { + FileManager.default.temporaryDirectory + .appendingPathComponent(randomAlphanumericString(count: 32)) + .appendingPathComponent("RemoteContent.bundle") + } +} diff --git a/Tests/DevFoundationTests/Remote Localization/RemoteLocalizedStringTests.swift b/Tests/DevFoundationTests/Remote Localization/RemoteLocalizedStringTests.swift new file mode 100644 index 0000000..31a5bcc --- /dev/null +++ b/Tests/DevFoundationTests/Remote Localization/RemoteLocalizedStringTests.swift @@ -0,0 +1,83 @@ +// +// LocalizationTests.swift +// AppPlatform +// +// Created by Prachi Gauriar on 9/19/25. +// + +import DevFoundation +import DevTesting +import Foundation +import Testing + +@DefaultRemoteContentBundleActor +struct LocalizationTests: RandomValueGenerating { + var randomNumberGenerator = makeRandomNumberGenerator() + + + @Test + mutating func localizedStringReturnsValueFromRemoteBundleWhenKeyExists() throws { + defer { Bundle.defaultRemoteContentBundle = nil } + + let key = randomAlphanumericString() + let remoteValue = randomAlphanumericString() + let localValue = randomAlphanumericString() + + let remoteBundle = try createTestBundle(with: [key: remoteValue]) + Bundle.defaultRemoteContentBundle = remoteBundle + + let localBundle = try createTestBundle(with: [key: localValue]) + let result = remoteLocalizedString(String.LocalizationValue(key), key: key, bundle: localBundle) + #expect(result == remoteValue) + } + + + @Test + mutating func localizedStringFallsBackToLocalBundleWhenKeyNotInRemote() throws { + let remoteKey = randomAlphanumericString() + let localKey = randomAlphanumericString() + let remoteValue = randomAlphanumericString() + let localValue = randomAlphanumericString() + + let remoteBundle = try createTestBundle(with: [remoteKey: remoteValue]) + let localBundle = try createTestBundle(with: [localKey: localValue]) + + let result = remoteLocalizedString( + String.LocalizationValue(localKey), + key: localKey, + bundle: localBundle, + remoteContentBundle: remoteBundle + ) + + #expect(result == localValue) + } + + + @Test + mutating func localizedStringUsesLocalBundleWhenNoRemoteBundle() throws { + Bundle.defaultRemoteContentBundle = nil + + let key = randomAlphanumericString() + let localValue = randomAlphanumericString() + + let localBundle = try createTestBundle(with: [key: localValue]) + + let result = remoteLocalizedString(String.LocalizationValue(key), key: key, bundle: localBundle) + + #expect(result == localValue) + } + + + private mutating func createTestBundle(with localizedStrings: [String: String]) throws -> Bundle { + let tempDirectory = FileManager.default.temporaryDirectory + let bundleURL = tempDirectory.appendingPathComponent("\(randomAlphanumericString(count: 32)).bundle") + let resourcesURL = bundleURL.appendingPathComponent("Contents/Resources") + + try FileManager.default.createDirectory(at: resourcesURL, withIntermediateDirectories: true) + + let localizedStringsData = try PropertyListEncoder().encode(localizedStrings) + try localizedStringsData.write(to: resourcesURL.appendingPathComponent("Localizable.strings")) + + return try #require(Bundle(url: bundleURL)) + } +} diff --git a/Tests/DevFoundationTests/Testing Helpers/DefaultRemoteContentBundleActor.swift b/Tests/DevFoundationTests/Testing Helpers/DefaultRemoteContentBundleActor.swift new file mode 100644 index 0000000..e57182a --- /dev/null +++ b/Tests/DevFoundationTests/Testing Helpers/DefaultRemoteContentBundleActor.swift @@ -0,0 +1,13 @@ +// +// DefaultRemoteContentBundleActor.swift +// DevFoundation +// +// Created by Prachi Gauriar on 1/13/26. +// + +import Foundation + +@globalActor +actor DefaultRemoteContentBundleActor { + static let shared = DefaultRemoteContentBundleActor() +}