From dd340fafcf3c8180888f7dea251ead27aaeb1272 Mon Sep 17 00:00:00 2001 From: David Hunt Date: Wed, 24 Dec 2025 15:41:23 -0600 Subject: [PATCH 1/2] Fixed the (re)localization of Date & Int and Added MVVMEnvironment support for WebApps --- .../Localization/LocalizableDate.swift | 17 ++- .../FOSMVVM/Localization/LocalizableInt.swift | 11 +- .../SwiftUI Support/MVVMEnvironment.swift | 130 +++++++++++++++--- .../Extensions/Application+FOS.swift | 46 +++++++ 4 files changed, 177 insertions(+), 27 deletions(-) diff --git a/Sources/FOSMVVM/Localization/LocalizableDate.swift b/Sources/FOSMVVM/Localization/LocalizableDate.swift index 010e7e0..71ca117 100644 --- a/Sources/FOSMVVM/Localization/LocalizableDate.swift +++ b/Sources/FOSMVVM/Localization/LocalizableDate.swift @@ -36,9 +36,13 @@ public struct LocalizableDate: Codable, Hashable, Comparable, LocalizableValue, timeStyle: DateFormatter.Style? = nil, dateFormat: String? = nil ) { + let defaultStyle: DateFormatter.Style? = (dateStyle == nil && timeStyle == nil && dateFormat == nil) + ? DateFormatter.Style.medium + : nil + self.init( value: value, - dateStyle: dateStyle, + dateStyle: dateStyle ?? defaultStyle, timeStyle: timeStyle, dateFormat: dateFormat, localizedString: nil @@ -91,9 +95,14 @@ public extension LocalizableDate { try container.encodeIfPresent(timeStyle?.rawValue, forKey: .timeStyle) try container.encodeIfPresent(dateFormat, forKey: .dateFormat) - // REVIEWED: DGH - The RHS of this ternary will never be executed, so a block - // will never be covered - try container.encode(encoder.localizeString(self) ?? "", forKey: .localizedString) + // If we've already been localized, just send that + if let _localizedString { + try container.encode(_localizedString, forKey: .localizedString) + } else { + // REVIEWED: DGH - The RHS of this ternary will never be executed, so a block + // will never be covered + try container.encode(encoder.localizeString(self) ?? "", forKey: .localizedString) + } } // MARK: Identifiable Protocol diff --git a/Sources/FOSMVVM/Localization/LocalizableInt.swift b/Sources/FOSMVVM/Localization/LocalizableInt.swift index 7d3d187..19cee3d 100644 --- a/Sources/FOSMVVM/Localization/LocalizableInt.swift +++ b/Sources/FOSMVVM/Localization/LocalizableInt.swift @@ -75,9 +75,14 @@ public extension LocalizableInt { try container.encode(showGroupingSeparator, forKey: .showGroupingSeparator) try container.encode(groupingSize, forKey: .groupingSize) - // REVIEWED: DGH - The RHS of this ternary will never be executed, so a block - // will never be covered - try container.encode(encoder.localizeString(self) ?? "", forKey: .localizedString) + // If we've already been localized, just send that + if let _localizedString { + try container.encode(_localizedString, forKey: .localizedString) + } else { + // REVIEWED: DGH - The RHS of this ternary will never be executed, so a block + // will never be covered + try container.encode(encoder.localizeString(self) ?? "", forKey: .localizedString) + } } // MARK: Identifiable Protocol diff --git a/Sources/FOSMVVM/SwiftUI Support/MVVMEnvironment.swift b/Sources/FOSMVVM/SwiftUI Support/MVVMEnvironment.swift index 6ce4a0e..2ede4b0 100644 --- a/Sources/FOSMVVM/SwiftUI Support/MVVMEnvironment.swift +++ b/Sources/FOSMVVM/SwiftUI Support/MVVMEnvironment.swift @@ -1,6 +1,6 @@ // MVVMEnvironment.swift // -// Copyright 2025 FOS Computer Services, LLC +// Copyright 2024 FOS Computer Services, LLC // // Licensed under the Apache License, Version 2.0 (the License); // you may not use this file except in compliance with the License. @@ -15,9 +15,10 @@ // limitations under the License. import FOSFoundation -#if canImport(SwiftUI) import Foundation +#if canImport(SwiftUI) import SwiftUI +#endif /// ``MVVMEnvironment`` provides configuration information to to the /// SwiftUI MVVM implementation @@ -79,6 +80,16 @@ public final class MVVMEnvironment: @unchecked Sendable { public let resourceDirectoryName: String? private let localizationStore: LocalizationStore? + /// A configuration of server URLs for each given ``Deployment`` + public let deploymentURLs: [Deployment: URLPackage] + + /// A dictionary of values to populate the *URLRequest*'s HTTPHeaderFields + public let requestHeaders: [String: String] + + /// A function that is called when there is an error processing a ``ServerRequest`` + public let requestErrorHandler: (@Sendable (any ServerRequest, any ServerRequestError) -> Void)? + + #if canImport(SwiftUI) typealias ViewFactory = (Data) throws -> AnyView #if DEBUG @@ -93,20 +104,12 @@ public final class MVVMEnvironment: @unchecked Sendable { #endif } - /// A configuration of server URLs for each given ``Deployment`` - public let deploymentURLs: [Deployment: URLPackage] - - /// A dictionary of values to populate the *URLRequest*'s HTTPHeaderFields - public let requestHeaders: [String: String] - - /// A function that is called when there is an error processing a ``ServerRequest`` - public let requestErrorHandler: (@Sendable (any ServerRequest, any ServerRequestError) -> Void)? - /// A view to be presented when the ``ViewModel`` is being requested /// from the web service /// /// > Note: A non-localized "Loading..." is presented if no view is provided public let loadingView: @Sendable () -> AnyView + #endif /// A ``LocalizationStore`` instance that provides access to the localization data /// @@ -171,7 +174,8 @@ public final class MVVMEnvironment: @unchecked Sendable { } } - /// Initializes the ``MVVMEnvironment`` + #if canImport(SwiftUI) + /// Initializes the ``MVVMEnvironment`` for SwiftUI /// /// > If *currentVersion* is not specified, *SystemVersion.currentVersion* is set to *appBundle.appleOSVersion*, which is loaded from the xcodeproj. /// > See also: @@ -211,7 +215,7 @@ public final class MVVMEnvironment: @unchecked Sendable { SystemVersion.setCurrentVersion(currentVersion) } - /// Initializes the ``MVVMEnvironment`` + /// Initializes the ``MVVMEnvironment`` for SwiftUI /// /// This convenience initializer uses each deployment URL for both the *serverBaseURL* and the *resourcesBaseURL*. /// @@ -258,7 +262,7 @@ public final class MVVMEnvironment: @unchecked Sendable { ) } - /// Initializes ``MVVMEnvironment`` for previews + /// Initializes ``MVVMEnvironment`` for SwiftUI previews /// /// > This overload does **NOT** check the application's version as it is not necessary for previews @MainActor init( @@ -278,6 +282,89 @@ public final class MVVMEnvironment: @unchecked Sendable { SystemVersion.setCurrentVersion(SystemVersion.current) } + #endif + + /// Initializes the ``MVVMEnvironment`` for non-SwiftUI Applications + /// + /// > If *currentVersion* is not specified, *SystemVersion.currentVersion* is set to *appBundle.appleOSVersion*, which is loaded from the xcodeproj. + /// > See also: + /// + /// - Parameters: + /// - currentVersion: The current SystemVersion of the application (default: see note) + /// - appBundle: The application's *Bundle* (e.g. *Bundle.main*) + /// - resourceBundles: All *Bundle*s that contain YAML resources (default: appBundle) + /// - resourceDirectoryName: The directory name that contains the resources (default: nil). Only needed + /// if the client application is hosting the YAML files. + /// - requestHeaders: A set of HTTP header fields for the URLRequest + /// - deploymentURLs: The base URLs of the web service for the given ``Deployment``s + /// - requestErrorHandler: A function that can take action when an error occurs when resolving + /// ``ViewModel`` via a ``ViewModelRequest`` (default: nil) + @MainActor public init( + currentVersion: SystemVersion? = nil, + appBundle: Bundle, + resourceBundles: [Bundle]? = nil, + resourceDirectoryName: String? = nil, + requestHeaders: [String: String] = [:], + deploymentURLs: [Deployment: URLPackage], + requestErrorHandler: (@Sendable (any ServerRequest, any ServerRequestError) -> Void)? = nil + ) { + self.localizationStore = nil + self.resourceBundles = resourceBundles ?? [appBundle] + self.resourceDirectoryName = resourceDirectoryName + self.requestHeaders = requestHeaders + self.deploymentURLs = deploymentURLs + self.requestErrorHandler = requestErrorHandler + + #if canImport(SwiftUI) + self.loadingView = { AnyView(DefaultLoadingView()) } + #endif + + let currentVersion = currentVersion ?? (try? appBundle.appleOSVersion) ?? SystemVersion.current + SystemVersion.setCurrentVersion(currentVersion) + } + + /// Initializes the ``MVVMEnvironment`` for non-SwiftUI Applications + /// + /// This convenience initializer uses each deployment URL for both the *serverBaseURL* and the *resourcesBaseURL*. + /// + /// > If *currentVersion* is not specified, *SystemVersion.currentVersion* is set to *appBundle.appleOSVersion*, which is loaded from the xcodeproj. + /// > See also: + /// + /// - Parameters: + /// - currentVersion: The current SystemVersion of the application (default: see note) + /// - appBundle: The applications *Bundle* (e.g. *Bundle.main*) + /// - resourceBundles: All *Bundle*s that contain YAML resources (default: appBundle) + /// - resourceDirectoryName: The directory name that contains the resources (default: nil). Only needed + /// if the client application is hosting the YAML files. + /// - requestHeaders: A set of HTTP header fields for the URLRequest + /// - deploymentURLs: The base URLs of the web service for the given ``Deployment``s + /// - requestErrorHandler: A function that can take action when an error occurs when resolving + /// ``ViewModel`` via a ``ViewModelRequest`` (default: nil) + @MainActor public convenience init( + currentVersion: SystemVersion? = nil, + appBundle: Bundle, + resourceBundles: [Bundle]? = nil, + resourceDirectoryName: String? = nil, + requestHeaders: [String: String] = [:], + deploymentURLs: [Deployment: URL], + requestErrorHandler: (@Sendable (any ServerRequest, any ServerRequestError) -> Void)? = nil + ) { + self.init( + currentVersion: currentVersion, + appBundle: appBundle, + resourceBundles: resourceBundles, + resourceDirectoryName: resourceDirectoryName, + requestHeaders: requestHeaders, + deploymentURLs: deploymentURLs.reduce([Deployment: URLPackage]()) { result, pair in + var result = result + let (deployment, url) = pair + + result[deployment] = .init(serverBaseURL: url, resourcesBaseURL: url) + return result + }, + requestErrorHandler: requestErrorHandler + ) + } } public enum MVVMEnvironmentError: Error, CustomDebugStringConvertible { @@ -291,12 +378,6 @@ public enum MVVMEnvironmentError: Error, CustomDebugStringConvertible { } } -private struct DefaultLoadingView: View { - var body: some View { - ProgressView() - } -} - private extension MVVMEnvironment { static func ensureVersionsCompatible(currentVersion: SystemVersion, appBundle: Bundle) { do { @@ -312,4 +393,13 @@ private extension MVVMEnvironment { } } } + +#if canImport(SwiftUI) + +private struct DefaultLoadingView: View { + var body: some View { + ProgressView() + } +} + #endif diff --git a/Sources/FOSMVVMVapor/Extensions/Application+FOS.swift b/Sources/FOSMVVMVapor/Extensions/Application+FOS.swift index 2f1b110..eb4b8e1 100644 --- a/Sources/FOSMVVMVapor/Extensions/Application+FOS.swift +++ b/Sources/FOSMVVMVapor/Extensions/Application+FOS.swift @@ -63,6 +63,33 @@ public extension Application { ) lifecycle.use(YamlLocalizationInitializer(config: config)) } + + // MARK: MVVMEnvironment + + var mvvmEnvironment: MVVMEnvironment? { + get { storage[MVVMEnvironmentStore.self] } + set { storage[MVVMEnvironmentStore.self] = newValue } + } + + fileprivate var _serverBaseURL: URL? { + get { storage[ServerBaseURLStore.self] } + set { storage[ServerBaseURLStore.self] = newValue } + } + + var serverBaseURL: URL { + guard let _serverBaseURL else { + fatalError("Attempted to access MVVMEnvironment/serverBaseURL before it was initialized") + } + + return _serverBaseURL + } + + func initMVVMEnvironment(_ mvvmEnvironment: MVVMEnvironment) async throws { + lifecycle.use(MVVMEnvironmentInitializer( + mvvmEnvironment: mvvmEnvironment, + serverBaseURL: try await mvvmEnvironment.serverBaseURL + )) + } } private struct YamlLocalizationInitializer: LifecycleHandler { @@ -80,3 +107,22 @@ private struct YamlLocalizationInitializer: LifecycleHandler { private struct YamlLocalizationStore: StorageKey { typealias Value = LocalizationStore } + +private struct MVVMEnvironmentInitializer: LifecycleHandler { + let mvvmEnvironment: MVVMEnvironment + let serverBaseURL: URL + + fileprivate func willBootAsync(_ app: Application) async throws { + app.logger.info("MVVM Environment WebService: \(serverBaseURL.absoluteString)") + app.mvvmEnvironment = mvvmEnvironment + app._serverBaseURL = try await mvvmEnvironment.serverBaseURL + } +} + +private struct MVVMEnvironmentStore: StorageKey { + typealias Value = MVVMEnvironment +} + +private struct ServerBaseURLStore: StorageKey { + typealias Value = URL +} From a5af8ff1418adec3f082e09b4f7544926a82bf05 Mon Sep 17 00:00:00 2001 From: David Hunt Date: Wed, 24 Dec 2025 16:05:30 -0600 Subject: [PATCH 2/2] Cleaned up Linux support --- Package.resolved | 60 +++++++++---------- .../SwiftUI Support/MVVMEnvironment.swift | 12 +++- 2 files changed, 40 insertions(+), 32 deletions(-) diff --git a/Package.resolved b/Package.resolved index d821c2d..b100f96 100644 --- a/Package.resolved +++ b/Package.resolved @@ -6,8 +6,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/swift-server/async-http-client.git", "state" : { - "revision" : "efb14fec9f79f3f8d4f2a6c0530303efb6fe6533", - "version" : "1.29.1" + "revision" : "5dd84c7bb48b348751d7bbe7ba94a17bafdcef37", + "version" : "1.30.2" } }, { @@ -33,8 +33,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/vapor/fluent-kit.git", "state" : { - "revision" : "8baacd7e8f7ebf68886c496b43bbe6cdcc5b57e0", - "version" : "1.52.2" + "revision" : "0272fdaf7cf6f482c2799026c0695f5fe40e3e8c", + "version" : "1.53.0" } }, { @@ -51,8 +51,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/vapor/routing-kit.git", "state" : { - "revision" : "93f7222c8e195cbad39fafb5a0e4cc85a8def7ea", - "version" : "4.9.2" + "revision" : "1a10ccea61e4248effd23b6e814999ce7bdf0ee0", + "version" : "4.9.3" } }, { @@ -60,8 +60,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/vapor/sql-kit.git", "state" : { - "revision" : "1a9ab0523fb742d9629558cede64290165c4285b", - "version" : "3.33.2" + "revision" : "c0ea243ffeb8b5ff9e20a281e44003c6abb8896f", + "version" : "3.34.0" } }, { @@ -78,8 +78,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-asn1.git", "state" : { - "revision" : "40d25bbb2fc5b557a9aa8512210bded327c0f60d", - "version" : "1.5.0" + "revision" : "810496cf121e525d660cd0ea89a758740476b85f", + "version" : "1.5.1" } }, { @@ -87,8 +87,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-async-algorithms.git", "state" : { - "revision" : "042e1c4d9d19748c9c228f8d4ebc97bb1e339b0b", - "version" : "1.0.4" + "revision" : "6c050d5ef8e1aa6342528460db614e9770d7f804", + "version" : "1.1.1" } }, { @@ -105,8 +105,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-certificates.git", "state" : { - "revision" : "c399f90e7bbe8874f6cbfda1d5f9023d1f5ce122", - "version" : "1.15.1" + "revision" : "133a347911b6ad0fc8fe3bf46ca90c66cff97130", + "version" : "1.17.0" } }, { @@ -123,8 +123,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-crypto.git", "state" : { - "revision" : "e8ed8867ec23bccf5f3bb9342148fa8deaff9b49", - "version" : "4.1.0" + "revision" : "6f70fa9eab24c1fd982af18c281c4525d05e3095", + "version" : "4.2.0" } }, { @@ -159,8 +159,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-http-structured-headers.git", "state" : { - "revision" : "a9f3c352f4d46afd155e00b3c6e85decae6bcbeb", - "version" : "1.5.0" + "revision" : "76d7627bd88b47bf5a0f8497dd244885960dde0b", + "version" : "1.6.0" } }, { @@ -177,8 +177,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-log.git", "state" : { - "revision" : "ce592ae52f982c847a4efc0dd881cc9eb32d29f2", - "version" : "1.6.4" + "revision" : "bc386b95f2a16ccd0150a8235e7c69eab2b866ca", + "version" : "1.8.0" } }, { @@ -195,8 +195,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-nio.git", "state" : { - "revision" : "a24771a4c228ff116df343c85fcf3dcfae31a06c", - "version" : "2.88.0" + "revision" : "a1605a3303a28e14d822dec8aaa53da8a9490461", + "version" : "2.92.0" } }, { @@ -204,8 +204,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-nio-extras.git", "state" : { - "revision" : "7ee281d816fa8e5f3967a2c294035a318ea551c7", - "version" : "1.31.0" + "revision" : "1c90641b02b6ab47c6d0db2063a12198b04e83e2", + "version" : "1.31.2" } }, { @@ -213,8 +213,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-nio-http2.git", "state" : { - "revision" : "5e9e99ec96c53bc2c18ddd10c1e25a3cd97c55e5", - "version" : "1.38.0" + "revision" : "c2ba4cfbb83f307c66f5a6df6bb43e3c88dfbf80", + "version" : "1.39.0" } }, { @@ -231,8 +231,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-nio-transport-services.git", "state" : { - "revision" : "df6c28355051c72c884574a6c858bc54f7311ff9", - "version" : "1.25.2" + "revision" : "60c3e187154421171721c1a38e800b390680fb5d", + "version" : "1.26.0" } }, { @@ -285,8 +285,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/vapor/vapor.git", "state" : { - "revision" : "c84ffd383814bf510240f269596a271221fc8964", - "version" : "4.119.0" + "revision" : "f7090db27390ebc4cadbff06d76fe8ce79d6ece6", + "version" : "4.120.0" } }, { diff --git a/Sources/FOSMVVM/SwiftUI Support/MVVMEnvironment.swift b/Sources/FOSMVVM/SwiftUI Support/MVVMEnvironment.swift index 2ede4b0..360a0ff 100644 --- a/Sources/FOSMVVM/SwiftUI Support/MVVMEnvironment.swift +++ b/Sources/FOSMVVM/SwiftUI Support/MVVMEnvironment.swift @@ -18,6 +18,8 @@ import FOSFoundation import Foundation #if canImport(SwiftUI) import SwiftUI +#else +import Observation #endif /// ``MVVMEnvironment`` provides configuration information to to the @@ -315,12 +317,16 @@ public final class MVVMEnvironment: @unchecked Sendable { self.deploymentURLs = deploymentURLs self.requestErrorHandler = requestErrorHandler + #if canImport(SwiftUI) self.loadingView = { AnyView(DefaultLoadingView()) } - #endif - let currentVersion = currentVersion ?? (try? appBundle.appleOSVersion) ?? SystemVersion.current SystemVersion.setCurrentVersion(currentVersion) + #else + let currentVersion = currentVersion ?? SystemVersion.current + SystemVersion.setCurrentVersion(currentVersion) + #endif + } /// Initializes the ``MVVMEnvironment`` for non-SwiftUI Applications @@ -380,6 +386,7 @@ public enum MVVMEnvironmentError: Error, CustomDebugStringConvertible { private extension MVVMEnvironment { static func ensureVersionsCompatible(currentVersion: SystemVersion, appBundle: Bundle) { + #if canImport(SwiftUI) do { let bundleVersion = try appBundle.appleOSVersion @@ -391,6 +398,7 @@ private extension MVVMEnvironment { } catch let e { fatalError("Error retrieving SystemVersion: \(e.localizedDescription)") } + #endif } }