diff --git a/FirebaseCoreLinux/Sources/Component.swift b/FirebaseCoreLinux/Sources/Component.swift new file mode 100644 index 00000000000..7241087e45b --- /dev/null +++ b/FirebaseCoreLinux/Sources/Component.swift @@ -0,0 +1,66 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import Foundation + +/// Describes the timing of instantiation. +public enum InstantiationTiming { + case lazy + case alwaysEager + case eagerInDefaultApp +} + +/// A protocol describing functionality provided from the Component. +public protocol ComponentLifecycleMaintainer { + /// The associated app will be deleted, clean up any resources as they are about to be deallocated. + func appWillBeDeleted(_ app: FirebaseApp) +} + +/// A component that can be used from other Firebase SDKs. +public struct Component { + /// The protocol describing functionality provided by the component. + public let serviceType: T.Type + + /// The timing of instantiation. + public let instantiationTiming: InstantiationTiming + + /// A block to instantiate an instance of the component with the appropriate dependencies. + public let creationBlock: (ComponentContainer) -> T? + + public init(_ serviceType: T.Type, + instantiationTiming: InstantiationTiming = .lazy, + creationBlock: @escaping (ComponentContainer) -> T?) { + self.serviceType = serviceType + self.instantiationTiming = instantiationTiming + self.creationBlock = creationBlock + } +} + +// MARK: - Internal + +protocol AnyComponent { + var instantiationTiming: InstantiationTiming { get } + func instantiate(container: ComponentContainer) -> Any? + var serviceTypeID: ObjectIdentifier { get } +} + +extension Component: AnyComponent { + func instantiate(container: ComponentContainer) -> Any? { + return creationBlock(container) + } + + var serviceTypeID: ObjectIdentifier { + return ObjectIdentifier(serviceType) + } +} diff --git a/FirebaseCoreLinux/Sources/ComponentContainer.swift b/FirebaseCoreLinux/Sources/ComponentContainer.swift new file mode 100644 index 00000000000..0b5a5dc048b --- /dev/null +++ b/FirebaseCoreLinux/Sources/ComponentContainer.swift @@ -0,0 +1,24 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import Foundation + +/// A container that holds different components. +public protocol ComponentContainer { + /// A reference to the app that an instance of the container belongs to. + var app: FirebaseApp { get } + + /// Fetch an instance for the given service type. + func instance(for serviceType: T.Type) -> T? +} diff --git a/FirebaseCoreLinux/Sources/FirebaseApp.swift b/FirebaseCoreLinux/Sources/FirebaseApp.swift new file mode 100644 index 00000000000..9912cb8f154 --- /dev/null +++ b/FirebaseCoreLinux/Sources/FirebaseApp.swift @@ -0,0 +1,118 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import Foundation + +/// The entry point of Firebase SDKs. +public final class FirebaseApp: @unchecked Sendable { + private static let defaultAppName = "__FIRAPP_DEFAULT" + nonisolated(unsafe) private static var _allApps: [String: FirebaseApp] = [:] + private static let lock = NSLock() + + // Components registered by other SDKs + private static var registeredComponents: [AnyComponent] = [] + + /// Gets the name of this app. + public let name: String + + /// Gets a copy of the options for this app. + public let options: FirebaseOptions + + /// Gets or sets whether automatic data collection is enabled for all products. + public var isDataCollectionDefaultEnabled: Bool = true + + // Internal container + internal var _container: FirebaseComponentContainer! + + /// The component container for this app. + public var container: ComponentContainer { + return _container + } + + /// Returns true if this is the default app. + public var isDefaultApp: Bool { + return name == FirebaseApp.defaultAppName + } + + private init(name: String, options: FirebaseOptions) { + self.name = name + self.options = options + self._container = FirebaseComponentContainer(app: self, registeredComponents: FirebaseApp.registeredComponents) + } + + private func initializeComponents() { + _container.instantiateEagerComponents() + } + + /// Configures a default Firebase app. + public static func configure() { + guard let options = FirebaseOptions.defaultOptions() else { + print("[FirebaseCore] Error: Could not find default options.") + return + } + configure(options: options) + } + + /// Configures a Firebase app with the given name and options. + /// If name is omitted, the default app name is used. + public static func configure(name: String = defaultAppName, options: FirebaseOptions) { + lock.lock() + defer { lock.unlock() } + + if _allApps[name] != nil { + print("[FirebaseCore] App \(name) is already configured.") + return + } + + let app = FirebaseApp(name: name, options: options) + _allApps[name] = app + app.initializeComponents() + } + + /// Returns the default app, or `nil` if the default app does not exist. + public static func app() -> FirebaseApp? { + return app(name: defaultAppName) + } + + /// Returns a previously created `FirebaseApp` instance with the given name, or `nil` if no such app exists. + public static func app(name: String) -> FirebaseApp? { + lock.lock() + defer { lock.unlock() } + return _allApps[name] + } + + /// Returns the set of all extant `FirebaseApp` instances. + public static var allApps: [String: FirebaseApp] { + lock.lock() + defer { lock.unlock() } + return _allApps + } + + /// Cleans up the current `FirebaseApp`. + public func delete(completion: ((Bool) -> Void)?) { + FirebaseApp.lock.lock() + FirebaseApp._allApps.removeValue(forKey: name) + FirebaseApp.lock.unlock() + + _container.cleanup() + completion?(true) + } + + /// Registers a component for use by Firebase apps. + public static func register(_ component: Component) { + lock.lock() + defer { lock.unlock() } + registeredComponents.append(component) + } +} diff --git a/FirebaseCoreLinux/Sources/FirebaseComponentContainer.swift b/FirebaseCoreLinux/Sources/FirebaseComponentContainer.swift new file mode 100644 index 00000000000..c3a55d8d981 --- /dev/null +++ b/FirebaseCoreLinux/Sources/FirebaseComponentContainer.swift @@ -0,0 +1,98 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import Foundation + +internal class FirebaseComponentContainer: ComponentContainer { + unowned let app: FirebaseApp + private var instances: [ObjectIdentifier: Any] = [:] + private var components: [ObjectIdentifier: AnyComponent] = [:] + private let lock = NSLock() + + init(app: FirebaseApp, registeredComponents: [AnyComponent]) { + self.app = app + for component in registeredComponents { + components[component.serviceTypeID] = component + } + } + + func instance(for serviceType: T.Type) -> T? { + let key = ObjectIdentifier(serviceType) + + lock.lock() + // Check cache + if let instance = instances[key] as? T { + lock.unlock() + return instance + } + lock.unlock() + + // Check registration + // We assume components map is immutable after init, so technically thread safe to read? + // But better lock it or copy reference. + guard let component = components[key] else { + return nil + } + + // Instantiate + // Use lock for instantiation to avoid race conditions creating multiple instances + lock.lock() + // Double check + if let instance = instances[key] as? T { + lock.unlock() + return instance + } + + if let instance = component.instantiate(container: self) as? T { + instances[key] = instance + lock.unlock() + return instance + } + + lock.unlock() + return nil + } + + func instantiateEagerComponents() { + for component in components.values { + var shouldInstantiate = false + switch component.instantiationTiming { + case .alwaysEager: + shouldInstantiate = true + case .eagerInDefaultApp: + if app.isDefaultApp { + shouldInstantiate = true + } + case .lazy: + shouldInstantiate = false + } + + if shouldInstantiate { + _ = component.instantiate(container: self) + } + } + } + + func cleanup() { + lock.lock() + defer { lock.unlock() } + + for instance in instances.values { + if let maintainer = instance as? ComponentLifecycleMaintainer { + maintainer.appWillBeDeleted(app) + } + } + instances.removeAll() + } +} diff --git a/FirebaseCoreLinux/Sources/FirebaseOptions.swift b/FirebaseCoreLinux/Sources/FirebaseOptions.swift new file mode 100644 index 00000000000..1232f23e12f --- /dev/null +++ b/FirebaseCoreLinux/Sources/FirebaseOptions.swift @@ -0,0 +1,82 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import Foundation + +/// The options used to configure a Firebase app. +public struct FirebaseOptions: Equatable, Hashable { + /// An API key used for authenticating requests from your app. + public var apiKey: String? + + /// The bundle ID for the application. Defaults to `Bundle.main.bundleIdentifier` when not set. + public var bundleID: String + + /// The OAuth2 client ID for the application. + public var clientID: String? + + /// The Project Number from the Google Developer's console. + public var gcmSenderID: String + + /// The Project ID from the Firebase console. + public var projectID: String? + + /// The Google App ID that is used to uniquely identify an instance of an app. + public var googleAppID: String + + /// The database root URL. + public var databaseURL: String? + + /// The Google Cloud Storage bucket name. + public var storageBucket: String? + + /// The App Group identifier to share data between the application and the application extensions. + public var appGroupID: String? + + /// Returns the default options. The first time this is called it synchronously reads + /// GoogleService-Info.plist from disk. + public static func defaultOptions() -> FirebaseOptions? { + guard let path = Bundle.main.path(forResource: "GoogleService-Info", ofType: "plist") else { + return nil + } + return FirebaseOptions(contentsOfFile: path) + } + + /// Initializes a customized instance of `FirebaseOptions` with required fields. + public init(googleAppID: String, gcmSenderID: String) { + self.googleAppID = googleAppID + self.gcmSenderID = gcmSenderID + self.bundleID = Bundle.main.bundleIdentifier ?? "" + } + + /// Initializes a customized instance of `FirebaseOptions` from the file at the given plist file path. + public init?(contentsOfFile plistPath: String) { + guard let data = FileManager.default.contents(atPath: plistPath), + let plist = try? PropertyListSerialization.propertyList(from: data, options: [], format: nil) as? [String: Any] else { + return nil + } + + guard let googleAppID = plist["GOOGLE_APP_ID"] as? String else { + return nil + } + + self.googleAppID = googleAppID + self.gcmSenderID = plist["GCM_SENDER_ID"] as? String ?? "" + self.apiKey = plist["API_KEY"] as? String + self.bundleID = plist["BUNDLE_ID"] as? String ?? Bundle.main.bundleIdentifier ?? "" + self.clientID = plist["CLIENT_ID"] as? String + self.projectID = plist["PROJECT_ID"] as? String + self.databaseURL = plist["DATABASE_URL"] as? String + self.storageBucket = plist["STORAGE_BUCKET"] as? String + } +} diff --git a/FirebaseCoreLinux/Sources/HeartbeatLogger.swift b/FirebaseCoreLinux/Sources/HeartbeatLogger.swift new file mode 100644 index 00000000000..653d8f66862 --- /dev/null +++ b/FirebaseCoreLinux/Sources/HeartbeatLogger.swift @@ -0,0 +1,29 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import Foundation + +/// A logger that reports heartbeats. +/// Stubbed for Linux MVP to avoid filesystem/concurrency issues. +public class HeartbeatLogger { + public init(appID: String) {} + + public func log() { + // No-op + } + + public func headerValue() -> String? { + return nil + } +} diff --git a/FirebaseCoreLinux/Sources/Logger.swift b/FirebaseCoreLinux/Sources/Logger.swift new file mode 100644 index 00000000000..a1e582836f3 --- /dev/null +++ b/FirebaseCoreLinux/Sources/Logger.swift @@ -0,0 +1,65 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import Foundation + +/// The log levels used by internal logging. +public enum FirebaseLoggerLevel: Int { + /// Error level. + case error = 3 + /// Warning level. + case warning = 4 + /// Notice level. + case notice = 5 + /// Info level. + case info = 6 + /// Debug level. + case debug = 7 + /// Minimum log level. + case min = 3 + /// Maximum log level. + case max = 7 +} + +/// A wrapper for Firebase logging. +public class FirebaseLogger { + /// Logs a given message at a given log level. + /// + /// - Parameters: + /// - level: The log level to use. + /// - service: The service name. + /// - code: The message code. + /// - message: The message string. + public static func log(level: FirebaseLoggerLevel, + service: String, + code: String, + message: String) { + // TODO: Integrate with GULLogger if available or needed. + // For Linux, simple print to stderr/stdout is often sufficient or using standard Logger (SwiftLog). + + let levelStr: String + switch level { + case .error: levelStr = "ERROR" + case .warning: levelStr = "WARNING" + case .notice: levelStr = "NOTICE" + case .info: levelStr = "INFO" + case .debug: levelStr = "DEBUG" + default: levelStr = "UNKNOWN" + } + + // Format: [Service] Code - Message + let output = "[\(levelStr)] \(service) - \(code): \(message)" + print(output) + } +} diff --git a/FirebaseCoreLinux/Tests/Unit/FirebaseCoreLinuxTests.swift b/FirebaseCoreLinux/Tests/Unit/FirebaseCoreLinuxTests.swift new file mode 100644 index 00000000000..dc2cb4d7572 --- /dev/null +++ b/FirebaseCoreLinux/Tests/Unit/FirebaseCoreLinuxTests.swift @@ -0,0 +1,88 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import Foundation +import XCTest +@testable import FirebaseCoreLinux + +final class FirebaseCoreLinuxTests: XCTestCase { + + override func tearDown() { + // Cleanup apps + // Note: delete is async but we invoke nil completion. + // We might need to ensure cleanup happens synchronously or wait. + // However, delete removes from _allApps immediately. + + let apps = FirebaseApp.allApps + for name in apps.keys { + FirebaseApp.app(name: name)?.delete(completion: nil) + } + } + + func testConfigureDefault() { + // We explicitly pass options to avoid dependency on GoogleService-Info.plist in test bundle + let options = FirebaseOptions(googleAppID: "appID", gcmSenderID: "senderID") + FirebaseApp.configure(options: options) + XCTAssertNotNil(FirebaseApp.app()) + XCTAssertEqual(FirebaseApp.app()?.options.googleAppID, "appID") + XCTAssertTrue(FirebaseApp.app()?.isDefaultApp ?? false) + } + + func testConfigureNamed() { + let options = FirebaseOptions(googleAppID: "appID2", gcmSenderID: "senderID2") + FirebaseApp.configure(name: "testApp", options: options) + XCTAssertNotNil(FirebaseApp.app(name: "testApp")) + XCTAssertEqual(FirebaseApp.app(name: "testApp")?.name, "testApp") + XCTAssertFalse(FirebaseApp.app(name: "testApp")?.isDefaultApp ?? true) + } + + func testOptionsInit() { + let options = FirebaseOptions(googleAppID: "1:123:ios:abc", gcmSenderID: "123") + XCTAssertEqual(options.googleAppID, "1:123:ios:abc") + XCTAssertEqual(options.gcmSenderID, "123") + XCTAssertEqual(options.bundleID, Bundle.main.bundleIdentifier ?? "") + } + + // Define a dummy service outside + class TestService { + let id = UUID() + } + + func testComponentRegistrationAndResolution() { + // Register component + let component = Component(TestService.self) { container in + return TestService() + } + + FirebaseApp.register(component) + + // Configure app + let options = FirebaseOptions(googleAppID: "appID", gcmSenderID: "senderID") + FirebaseApp.configure(name: "compApp", options: options) + + guard let app = FirebaseApp.app(name: "compApp") else { + XCTFail("App not configured") + return + } + + // Resolve instance + let instance1 = app.container.instance(for: TestService.self) + XCTAssertNotNil(instance1) + + // Resolve again (should be same instance for lazy singleton) + let instance2 = app.container.instance(for: TestService.self) + XCTAssertNotNil(instance2) + XCTAssertTrue(instance1 === instance2) + } +} diff --git a/Package.swift b/Package.swift index 9bb25b5cce4..cf0cc806d10 100644 --- a/Package.swift +++ b/Package.swift @@ -87,6 +87,10 @@ let package = Package( name: "FirebaseCore", targets: ["FirebaseCore"] ), + .library( + name: "FirebaseCoreLinux", + targets: ["FirebaseCoreLinux"] + ), .library( name: "FirebaseCrashlytics", targets: ["FirebaseCrashlytics"] @@ -272,6 +276,21 @@ let package = Package( ] ), + // MARK: - Firebase Core Linux + + .target( + name: "FirebaseCoreLinux", + dependencies: [], + path: "FirebaseCoreLinux/Sources" + ), + .testTarget( + name: "FirebaseCoreLinuxTests", + dependencies: [ + "FirebaseCoreLinux", + ], + path: "FirebaseCoreLinux/Tests/Unit" + ), + // MARK: - Firebase Core Internal // Shared collection of APIs for internal FirebaseCore usage.