diff --git a/.github/workflows/windows.yaml b/.github/workflows/windows.yaml index 4aab47f6c032..6442264221b0 100644 --- a/.github/workflows/windows.yaml +++ b/.github/workflows/windows.yaml @@ -61,6 +61,7 @@ jobs: - name: "Install Tools" run: | npm install -g firebase-tools + cd .github/workflows/scripts/functions && npm install - name: "Build Windows (Release)" run: cd tests && flutter build windows --release - name: Start Firebase Emulator and run tests @@ -95,6 +96,7 @@ jobs: - name: "Install Tools" run: | npm install -g firebase-tools + cd .github/workflows/scripts/functions && npm install - name: Start Firebase Emulator and run tests run: | cd ./.github/workflows/scripts diff --git a/README.md b/README.md index ea77c522637c..c47cd3aec7fa 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,7 @@ and open source. | App Check | [![App Check pub.dev badge](https://img.shields.io/pub/v/firebase_app_check.svg)](https://pub.dev/packages/firebase_app_check) | [🔗](https://firebase.google.com/docs/app-check) | [📖](https://firebase.google.com/docs/app-check/flutter/default-providers) | [`firebase_app_check`](https://github.com/FirebaseExtended/flutterfire/tree/main/packages/firebase_app_check) | ✔ | ✔ | ✔ | β | N/A | | Authentication | [![Authentication pub.dev badge](https://img.shields.io/pub/v/firebase_auth.svg)](https://pub.dev/packages/firebase_auth) | [🔗](https://firebase.google.com/products/auth) | [📖](https://firebase.google.com/docs/auth/flutter/start) | [`firebase_auth`](https://github.com/FirebaseExtended/flutterfire/tree/main/packages/firebase_auth) | ✔ | ✔ | ✔ | β | (*) | | Cloud Firestore | [![Cloud Firestore pub.dev badge](https://img.shields.io/pub/v/cloud_firestore.svg)](https://pub.dev/packages/cloud_firestore) | [🔗](https://firebase.google.com/products/firestore) | [📖](https://firebase.google.com/docs/firestore/quickstart) | [`cloud_firestore`](https://github.com/FirebaseExtended/flutterfire/tree/main/packages/cloud_firestore) | ✔ | ✔ | ✔ | β | (*) | -| Cloud Functions | [![Cloud Functions pub.dev badge](https://img.shields.io/pub/v/cloud_functions.svg)](https://pub.dev/packages/cloud_functions) | [🔗](https://firebase.google.com/products/functions) | [📖](https://firebase.google.com/docs/functions/get-started?gen=2nd) | [`cloud_functions`](https://github.com/FirebaseExtended/flutterfire/tree/main/packages/cloud_functions) | ✔ | ✔ | ✔ | β | N/A | +| Cloud Functions | [![Cloud Functions pub.dev badge](https://img.shields.io/pub/v/cloud_functions.svg)](https://pub.dev/packages/cloud_functions) | [🔗](https://firebase.google.com/products/functions) | [📖](https://firebase.google.com/docs/functions/get-started?gen=2nd) | [`cloud_functions`](https://github.com/FirebaseExtended/flutterfire/tree/main/packages/cloud_functions) | ✔ | ✔ | ✔ | β | (*) | | Cloud Messaging | [![Cloud Messaging pub.dev badge](https://img.shields.io/pub/v/firebase_messaging.svg)](https://pub.dev/packages/firebase_messaging) | [🔗](https://firebase.google.com/products/cloud-messaging) | [📖](https://firebase.google.com/docs/cloud-messaging/flutter/client) | [`firebase_messaging`](https://github.com/FirebaseExtended/flutterfire/tree/main/packages/firebase_messaging) | ✔ | ✔ | ✔ | β | N/A | | Cloud Storage | [![Cloud Storage pub.dev badge](https://img.shields.io/pub/v/firebase_storage.svg)](https://pub.dev/packages/firebase_storage) | [🔗](https://firebase.google.com/products/storage) | [📖](https://firebase.google.com/docs/storage/flutter/start) | [`firebase_storage`](https://github.com/FirebaseExtended/flutterfire/tree/main/packages/firebase_storage) | ✔ | ✔ | ✔ | β | (*) | | Core | [![Core pub.dev badge](https://img.shields.io/pub/v/firebase_core.svg)](https://pub.dev/packages/firebase_core) | [🔗](https://firebase.google.com) | [📖](https://firebase.google.com) | [`firebase_core`](https://github.com/FirebaseExtended/flutterfire/tree/main/packages/firebase_core) | ✔ | ✔ | ✔ | β | (*) | diff --git a/packages/cloud_functions/cloud_functions/pubspec.yaml b/packages/cloud_functions/cloud_functions/pubspec.yaml index 6b84a9a33e03..c630f16ac371 100644 --- a/packages/cloud_functions/cloud_functions/pubspec.yaml +++ b/packages/cloud_functions/cloud_functions/pubspec.yaml @@ -42,3 +42,5 @@ flutter: pluginClass: FirebaseFunctionsPlugin web: default_package: cloud_functions_web + windows: + pluginClass: CloudFunctionsPluginCApi diff --git a/packages/cloud_functions/cloud_functions/windows/CMakeLists.txt b/packages/cloud_functions/cloud_functions/windows/CMakeLists.txt new file mode 100644 index 000000000000..f435f47c6634 --- /dev/null +++ b/packages/cloud_functions/cloud_functions/windows/CMakeLists.txt @@ -0,0 +1,80 @@ +# The Flutter tooling requires that developers have a version of Visual Studio +# installed that includes CMake 3.14 or later. You should not increase this +# version, as doing so will cause the plugin to fail to compile for some +# customers of the plugin. +cmake_minimum_required(VERSION 3.14) + +# Project-level configuration. +set(PROJECT_NAME "cloud_functions") +project(${PROJECT_NAME} LANGUAGES CXX) + +# This value is used when generating builds using this plugin, so it must +# not be changed +set(PLUGIN_NAME "cloud_functions_plugin") + +# Any new source files that you add to the plugin should be added here. +list(APPEND PLUGIN_SOURCES + "cloud_functions_plugin.cpp" + "cloud_functions_plugin.h" + "messages.g.cpp" + "messages.g.h" +) + +# Read version from pubspec.yaml +file(STRINGS "../pubspec.yaml" pubspec_content) +foreach(line ${pubspec_content}) + string(FIND ${line} "version: " has_version) + + if("${has_version}" STREQUAL "0") + string(FIND ${line} ": " version_start_pos) + math(EXPR version_start_pos "${version_start_pos} + 2") + string(LENGTH ${line} version_end_pos) + math(EXPR len "${version_end_pos} - ${version_start_pos}") + string(SUBSTRING ${line} ${version_start_pos} ${len} PLUGIN_VERSION) + break() + endif() +endforeach(line) + +configure_file(plugin_version.h.in ${CMAKE_BINARY_DIR}/generated/cloud_functions/plugin_version.h) +include_directories(${CMAKE_BINARY_DIR}/generated/) + +# Define the plugin library target. Its name must not be changed (see comment +# on PLUGIN_NAME above). +add_library(${PLUGIN_NAME} STATIC + "include/cloud_functions/cloud_functions_plugin_c_api.h" + "cloud_functions_plugin_c_api.cpp" + ${PLUGIN_SOURCES} + ${CMAKE_BINARY_DIR}/generated/cloud_functions/plugin_version.h +) + +# Apply a standard set of build settings that are configured in the +# application-level CMakeLists.txt. This can be removed for plugins that want +# full control over build settings. +apply_standard_settings(${PLUGIN_NAME}) + +# Symbols are hidden by default to reduce the chance of accidental conflicts +# between plugins. This should not be removed; any symbols that should be +# exported should be explicitly exported with the FLUTTER_PLUGIN_EXPORT macro. +set_target_properties(${PLUGIN_NAME} PROPERTIES + CXX_VISIBILITY_PRESET hidden) +target_compile_definitions(${PLUGIN_NAME} PUBLIC FLUTTER_PLUGIN_IMPL) +# Enable firebase-cpp-sdk's platform logging api. +target_compile_definitions(${PLUGIN_NAME} PRIVATE -DINTERNAL_EXPERIMENTAL=1) + +# Source include directories and library dependencies. Add any plugin-specific +# dependencies here. +set(MSVC_RUNTIME_MODE MD) +set(firebase_libs firebase_core_plugin firebase_functions) +target_link_libraries(${PLUGIN_NAME} PRIVATE "${firebase_libs}") + +target_include_directories(${PLUGIN_NAME} INTERFACE + "${CMAKE_CURRENT_SOURCE_DIR}/include") +target_link_libraries(${PLUGIN_NAME} PUBLIC flutter flutter_wrapper_plugin) + +# List of absolute paths to libraries that should be bundled with the plugin. +# This list could contain prebuilt libraries, or libraries created by an +# external build triggered from this build file. +set(cloud_functions_bundled_libraries + "" + PARENT_SCOPE +) diff --git a/packages/cloud_functions/cloud_functions/windows/cloud_functions_plugin.cpp b/packages/cloud_functions/cloud_functions/windows/cloud_functions_plugin.cpp new file mode 100644 index 000000000000..5888a95b102a --- /dev/null +++ b/packages/cloud_functions/cloud_functions/windows/cloud_functions_plugin.cpp @@ -0,0 +1,379 @@ +// Copyright 2026, the Chromium project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +#include "cloud_functions_plugin.h" + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "cloud_functions/plugin_version.h" +#include "firebase/app.h" +#include "firebase/functions.h" +#include "firebase/functions/callable_reference.h" +#include "firebase/functions/callable_result.h" +#include "firebase/functions/common.h" +#include "firebase/future.h" +#include "firebase/variant.h" +#include "messages.g.h" + +using ::firebase::App; +using ::firebase::Future; +using ::firebase::Variant; +using ::firebase::functions::Error; +using ::firebase::functions::Functions; +using ::firebase::functions::HttpsCallableReference; +using ::firebase::functions::HttpsCallableResult; + +namespace cloud_functions_windows { + +static std::string kLibraryName = "flutter-fire-fn"; + +// --- Helper: Convert firebase::Variant to EncodableValue --- +static flutter::EncodableValue VariantToEncodableValue(const Variant& variant) { + switch (variant.type()) { + case Variant::kTypeNull: + return flutter::EncodableValue(); + case Variant::kTypeInt64: + return flutter::EncodableValue(variant.int64_value()); + case Variant::kTypeDouble: + return flutter::EncodableValue(variant.double_value()); + case Variant::kTypeBool: + return flutter::EncodableValue(variant.bool_value()); + case Variant::kTypeStaticString: + return flutter::EncodableValue(std::string(variant.string_value())); + case Variant::kTypeMutableString: + return flutter::EncodableValue(variant.mutable_string()); + case Variant::kTypeVector: { + flutter::EncodableList list; + for (const auto& item : variant.vector()) { + list.push_back(VariantToEncodableValue(item)); + } + return flutter::EncodableValue(list); + } + case Variant::kTypeMap: { + flutter::EncodableMap map; + for (const auto& kv : variant.map()) { + map[VariantToEncodableValue(kv.first)] = + VariantToEncodableValue(kv.second); + } + return flutter::EncodableValue(map); + } + case Variant::kTypeStaticBlob: + case Variant::kTypeMutableBlob: { + const uint8_t* data = static_cast(variant.blob_data()); + if (data == nullptr || variant.blob_size() == 0) { + return flutter::EncodableValue(std::vector()); + } + return flutter::EncodableValue( + std::vector(data, data + variant.blob_size())); + } + default: + return flutter::EncodableValue(); + } +} + +// --- Helper: Convert EncodableValue to firebase::Variant --- +static Variant EncodableValueToVariant(const flutter::EncodableValue& value) { + if (std::holds_alternative(value)) { + return Variant::Null(); + } else if (std::holds_alternative(value)) { + return Variant(std::get(value)); + } else if (std::holds_alternative(value)) { + return Variant(static_cast(std::get(value))); + } else if (std::holds_alternative(value)) { + return Variant(std::get(value)); + } else if (std::holds_alternative(value)) { + return Variant(std::get(value)); + } else if (std::holds_alternative(value)) { + return Variant(std::get(value)); + } else if (std::holds_alternative>(value)) { + const auto& bytes = std::get>(value); + return Variant::FromMutableBlob(bytes.data(), bytes.size()); + } else if (std::holds_alternative>(value)) { + std::vector vec; + for (int32_t item : std::get>(value)) { + vec.push_back(Variant(static_cast(item))); + } + return Variant(vec); + } else if (std::holds_alternative>(value)) { + std::vector vec; + for (int64_t item : std::get>(value)) { + vec.push_back(Variant(item)); + } + return Variant(vec); + } else if (std::holds_alternative>(value)) { + std::vector vec; + for (float item : std::get>(value)) { + vec.push_back(Variant(static_cast(item))); + } + return Variant(vec); + } else if (std::holds_alternative>(value)) { + std::vector vec; + for (double item : std::get>(value)) { + vec.push_back(Variant(item)); + } + return Variant(vec); + } else if (std::holds_alternative(value)) { + std::vector vec; + for (const auto& item : std::get(value)) { + vec.push_back(EncodableValueToVariant(item)); + } + return Variant(vec); + } else if (std::holds_alternative(value)) { + std::map variant_map; + for (const auto& kv : std::get(value)) { + variant_map[EncodableValueToVariant(kv.first)] = + EncodableValueToVariant(kv.second); + } + return Variant(variant_map); + } + return Variant::Null(); +} + +// --- Helper: map a Cloud Functions error enum to its canonical string code --- +static std::string GetFunctionsErrorCode(Error error) { + switch (error) { + case Error::kErrorNone: + return "none"; + case Error::kErrorCancelled: + return "cancelled"; + case Error::kErrorInvalidArgument: + return "invalid-argument"; + case Error::kErrorDeadlineExceeded: + return "deadline-exceeded"; + case Error::kErrorNotFound: + return "not-found"; + case Error::kErrorAlreadyExists: + return "already-exists"; + case Error::kErrorPermissionDenied: + return "permission-denied"; + case Error::kErrorResourceExhausted: + return "resource-exhausted"; + case Error::kErrorFailedPrecondition: + return "failed-precondition"; + case Error::kErrorAborted: + return "aborted"; + case Error::kErrorOutOfRange: + return "out-of-range"; + case Error::kErrorUnimplemented: + return "unimplemented"; + case Error::kErrorInternal: + return "internal"; + case Error::kErrorUnavailable: + return "unavailable"; + case Error::kErrorDataLoss: + return "data-loss"; + case Error::kErrorUnauthenticated: + return "unauthenticated"; + case Error::kErrorUnknown: + default: + return "unknown"; + } +} + +// --- Helper: read a string entry from an EncodableMap (or empty) --- +static std::string GetStringArg(const flutter::EncodableMap& map, + const char* key) { + auto it = map.find(flutter::EncodableValue(key)); + if (it == map.end() || !std::holds_alternative(it->second)) { + return std::string(); + } + return std::get(it->second); +} + +// --- Helper: resolve a Functions instance for app + region --- +// The C++ SDK caches instances internally per (app, region), so no local +// cache is kept; a local raw-pointer cache would dangle if an app were +// deleted and recreated. +static Functions* GetFunctionsInstance(const std::string& app_name, + const std::string& region) { + App* app = App::GetInstance(app_name.c_str()); + if (app == nullptr) { + return nullptr; + } + return region.empty() ? Functions::GetInstance(app) + : Functions::GetInstance(app, region.c_str()); +} + +static FlutterError MakeFunctionsError(const std::string& code, + const std::string& message) { + // Dart's platformExceptionToFirebaseFunctionsException reads the canonical + // code/message out of the error details map. + flutter::EncodableMap details; + details[flutter::EncodableValue("code")] = flutter::EncodableValue(code); + details[flutter::EncodableValue("message")] = + flutter::EncodableValue(message); + return FlutterError(code, message, flutter::EncodableValue(details)); +} + +// static +void CloudFunctionsPlugin::RegisterWithRegistrar( + flutter::PluginRegistrarWindows* registrar) { + auto plugin = std::make_unique(); + + CloudFunctionsHostApi::SetUp(registrar->messenger(), plugin.get()); + + registrar->AddPlugin(std::move(plugin)); + + App::RegisterLibrary(kLibraryName.c_str(), getPluginVersion().c_str(), + nullptr); +} + +CloudFunctionsPlugin::CloudFunctionsPlugin() {} + +CloudFunctionsPlugin::~CloudFunctionsPlugin() = default; + +void CloudFunctionsPlugin::Call( + const flutter::EncodableMap& arguments, + std::function> reply)> + result) { + std::string app_name = GetStringArg(arguments, "appName"); + std::string region = GetStringArg(arguments, "region"); + std::string function_name = GetStringArg(arguments, "functionName"); + std::string function_uri = GetStringArg(arguments, "functionUri"); + std::string origin = GetStringArg(arguments, "origin"); + + if (app_name.empty()) { + result(MakeFunctionsError("invalid-argument", "Missing appName")); + return; + } + + Functions* functions = GetFunctionsInstance(app_name, region); + if (functions == nullptr) { + result(MakeFunctionsError("internal", "Functions instance not found")); + return; + } + + if (!origin.empty()) { + functions->UseFunctionsEmulator(origin.c_str()); + } + + // The HttpsCallableReference is heap-allocated and only deleted once the + // SDK completion has fired. The reference's internal owns the transport + // that runs the request on an SDK background thread; if the reference were + // a local and went out of scope when this handler returns, that transport + // would be destroyed mid-request and crash (use-after-free). + auto* ref = new HttpsCallableReference(); + if (!function_name.empty()) { + *ref = functions->GetHttpsCallable(function_name.c_str()); + } else if (!function_uri.empty()) { + *ref = functions->GetHttpsCallableFromURL(function_uri.c_str()); + } else { + delete ref; + result(MakeFunctionsError( + "invalid-argument", + "Either functionName or functionUri must be provided")); + return; + } + + Variant parameters = Variant::Null(); + auto parameters_it = arguments.find(flutter::EncodableValue("parameters")); + if (parameters_it != arguments.end()) { + parameters = EncodableValueToVariant(parameters_it->second); + } + + // The C++ SDK has no per-call deadline API, so the plugin enforces the + // Dart-provided timeout itself. Whichever of the deadline thread and the + // SDK completion runs first delivers the response; the loser only cleans + // up. Cleanup (reference delete) always happens in the completion path + // because deleting the reference while the request is in flight would + // destroy the transport mid-request. + struct CallState { + std::mutex mutex; + std::condition_variable cv; + bool responded = false; + HttpsCallableReference* ref; + }; + auto state = std::make_shared(); + state->ref = ref; + + int64_t timeout_ms = 0; + auto timeout_it = arguments.find(flutter::EncodableValue("timeout")); + if (timeout_it != arguments.end()) { + if (std::holds_alternative(timeout_it->second)) { + timeout_ms = std::get(timeout_it->second); + } else if (std::holds_alternative(timeout_it->second)) { + timeout_ms = std::get(timeout_it->second); + } + } + if (timeout_ms > 0) { + std::thread([state, result, timeout_ms]() { + std::unique_lock lock(state->mutex); + // wait_for returns false only on timeout; a completed request notifies + // the condition variable so this thread exits (and releases its + // captures) immediately instead of sleeping out the full deadline. + if (!state->cv.wait_for(lock, std::chrono::milliseconds(timeout_ms), + [&state] { return state->responded; })) { + state->responded = true; + lock.unlock(); + result(MakeFunctionsError("deadline-exceeded", + "The operation timed out.")); + } + }).detach(); + } + + ref->Call(parameters) + .OnCompletion([state, result](const Future& future) { + if (future.error() == Error::kErrorNone) { + const HttpsCallableResult* callable_result = future.result(); + flutter::EncodableValue data = + callable_result != nullptr + ? VariantToEncodableValue(callable_result->data()) + : flutter::EncodableValue(); + bool deliver = false; + { + std::lock_guard lock(state->mutex); + if (!state->responded) { + state->responded = true; + deliver = true; + } + } + state->cv.notify_all(); + if (deliver) { + result(ErrorOr>( + std::optional(data))); + } + } else { + std::string code = + GetFunctionsErrorCode(static_cast(future.error())); + std::string message = + future.error_message() ? future.error_message() : "Unknown error"; + bool deliver = false; + { + std::lock_guard lock(state->mutex); + if (!state->responded) { + state->responded = true; + deliver = true; + } + } + state->cv.notify_all(); + if (deliver) { + result(MakeFunctionsError(code, message)); + } + } + delete state->ref; + state->ref = nullptr; + }); +} + +// Streaming callable functions (httpsCallable().stream()) are not supported +// by the Firebase C++ SDK. Fail explicitly rather than silently hanging. +void CloudFunctionsPlugin::RegisterEventChannel( + const flutter::EncodableMap& arguments, + std::function reply)> result) { + result(MakeFunctionsError( + "unimplemented", + "Streaming callable functions are not supported on Windows.")); +} + +} // namespace cloud_functions_windows diff --git a/packages/cloud_functions/cloud_functions/windows/cloud_functions_plugin.h b/packages/cloud_functions/cloud_functions/windows/cloud_functions_plugin.h new file mode 100644 index 000000000000..139daf3319d1 --- /dev/null +++ b/packages/cloud_functions/cloud_functions/windows/cloud_functions_plugin.h @@ -0,0 +1,42 @@ +// Copyright 2026, the Chromium project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +#ifndef FLUTTER_PLUGIN_CLOUD_FUNCTIONS_PLUGIN_H_ +#define FLUTTER_PLUGIN_CLOUD_FUNCTIONS_PLUGIN_H_ + +#include +#include + +#include + +#include "messages.g.h" + +namespace cloud_functions_windows { + +class CloudFunctionsPlugin : public flutter::Plugin, + public CloudFunctionsHostApi { + public: + static void RegisterWithRegistrar(flutter::PluginRegistrarWindows* registrar); + + CloudFunctionsPlugin(); + + virtual ~CloudFunctionsPlugin(); + + // Disallow copy and assign. + CloudFunctionsPlugin(const CloudFunctionsPlugin&) = delete; + CloudFunctionsPlugin& operator=(const CloudFunctionsPlugin&) = delete; + + // CloudFunctionsHostApi + void Call( + const flutter::EncodableMap& arguments, + std::function> reply)> + result) override; + void RegisterEventChannel( + const flutter::EncodableMap& arguments, + std::function reply)> result) override; +}; + +} // namespace cloud_functions_windows + +#endif // FLUTTER_PLUGIN_CLOUD_FUNCTIONS_PLUGIN_H_ diff --git a/packages/cloud_functions/cloud_functions/windows/cloud_functions_plugin_c_api.cpp b/packages/cloud_functions/cloud_functions/windows/cloud_functions_plugin_c_api.cpp new file mode 100644 index 000000000000..6f3ddba63318 --- /dev/null +++ b/packages/cloud_functions/cloud_functions/windows/cloud_functions_plugin_c_api.cpp @@ -0,0 +1,16 @@ +// Copyright 2026, the Chromium project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +#include "include/cloud_functions/cloud_functions_plugin_c_api.h" + +#include + +#include "cloud_functions_plugin.h" + +void CloudFunctionsPluginCApiRegisterWithRegistrar( + FlutterDesktopPluginRegistrarRef registrar) { + cloud_functions_windows::CloudFunctionsPlugin::RegisterWithRegistrar( + flutter::PluginRegistrarManager::GetInstance() + ->GetRegistrar(registrar)); +} diff --git a/packages/cloud_functions/cloud_functions/windows/include/cloud_functions/cloud_functions_plugin_c_api.h b/packages/cloud_functions/cloud_functions/windows/include/cloud_functions/cloud_functions_plugin_c_api.h new file mode 100644 index 000000000000..e8c185de54f8 --- /dev/null +++ b/packages/cloud_functions/cloud_functions/windows/include/cloud_functions/cloud_functions_plugin_c_api.h @@ -0,0 +1,27 @@ +// Copyright 2026, the Chromium project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +#ifndef FLUTTER_PLUGIN_CLOUD_FUNCTIONS_PLUGIN_C_API_H_ +#define FLUTTER_PLUGIN_CLOUD_FUNCTIONS_PLUGIN_C_API_H_ + +#include + +#ifdef FLUTTER_PLUGIN_IMPL +#define FLUTTER_PLUGIN_EXPORT __declspec(dllexport) +#else +#define FLUTTER_PLUGIN_EXPORT __declspec(dllimport) +#endif + +#if defined(__cplusplus) +extern "C" { +#endif + +FLUTTER_PLUGIN_EXPORT void CloudFunctionsPluginCApiRegisterWithRegistrar( + FlutterDesktopPluginRegistrarRef registrar); + +#if defined(__cplusplus) +} // extern "C" +#endif + +#endif // FLUTTER_PLUGIN_CLOUD_FUNCTIONS_PLUGIN_C_API_H_ diff --git a/packages/cloud_functions/cloud_functions/windows/plugin_version.h.in b/packages/cloud_functions/cloud_functions/windows/plugin_version.h.in new file mode 100644 index 000000000000..78aea8c9c299 --- /dev/null +++ b/packages/cloud_functions/cloud_functions/windows/plugin_version.h.in @@ -0,0 +1,13 @@ +// Copyright 2026, the Chromium project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +#ifndef PLUGIN_VERSION_CONFIG_H +#define PLUGIN_VERSION_CONFIG_H + +namespace cloud_functions_windows { + +std::string getPluginVersion() { return "@PLUGIN_VERSION@"; } +} // namespace cloud_functions_windows + +#endif // PLUGIN_VERSION_CONFIG_H diff --git a/packages/firebase_core/firebase_core/windows/CMakeLists.txt b/packages/firebase_core/firebase_core/windows/CMakeLists.txt index 3d856893cd65..522c7273f1fa 100644 --- a/packages/firebase_core/firebase_core/windows/CMakeLists.txt +++ b/packages/firebase_core/firebase_core/windows/CMakeLists.txt @@ -122,7 +122,7 @@ add_subdirectory(${FIREBASE_CPP_SDK_DIR} bin/ EXCLUDE_FROM_ALL) target_include_directories(${PLUGIN_NAME} INTERFACE "${FIREBASE_CPP_SDK_DIR}/include") -set(FIREBASE_RELEASE_PATH_LIBS firebase_app firebase_auth firebase_remote_config firebase_storage firebase_firestore firebase_database firebase_app_check) +set(FIREBASE_RELEASE_PATH_LIBS firebase_app firebase_auth firebase_remote_config firebase_storage firebase_firestore firebase_database firebase_app_check firebase_functions) foreach(firebase_lib IN ITEMS ${FIREBASE_RELEASE_PATH_LIBS}) get_target_property(firebase_lib_path ${firebase_lib} IMPORTED_LOCATION) string(REPLACE "Debug" "Release" firebase_lib_release_path ${firebase_lib_path}) diff --git a/tests/integration_test/cloud_functions/cloud_functions_e2e_test.dart b/tests/integration_test/cloud_functions/cloud_functions_e2e_test.dart index 66eed657b3b3..f10298aadee4 100644 --- a/tests/integration_test/cloud_functions/cloud_functions_e2e_test.dart +++ b/tests/integration_test/cloud_functions/cloud_functions_e2e_test.dart @@ -12,6 +12,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import 'package:tests/firebase_options.dart'; +import '../platform_utils.dart'; import 'sample_data.dart' as data; String kTestFunctionDefaultRegion = 'testFunctionDefaultRegion'; @@ -31,8 +32,9 @@ void main() { options: DefaultFirebaseOptions.currentPlatform, ); FirebaseFunctions.instance.useFunctionsEmulator('localhost', 5001); - callable = - FirebaseFunctions.instance.httpsCallable(kTestFunctionDefaultRegion); + callable = FirebaseFunctions.instance.httpsCallable( + kTestFunctionDefaultRegion, + ); }); group('HttpsCallable', () { @@ -138,8 +140,9 @@ void main() { test( '[HttpsCallableResult.data] should return Map type for returned objects', () async { - HttpsCallable callable = - FirebaseFunctions.instance.httpsCallable(kTestMapConvertType); + HttpsCallable callable = FirebaseFunctions.instance.httpsCallable( + kTestMapConvertType, + ); var result = await callable(); @@ -148,11 +151,12 @@ void main() { ); test('can be called using an String url', () async { - final localhostMapped = - kIsWeb || !Platform.isAndroid ? 'localhost' : '10.0.2.2'; + final localhostMapped = kIsWeb || !Platform.isAndroid + ? 'localhost' + : '10.0.2.2'; - HttpsCallable callable = - FirebaseFunctions.instance.httpsCallableFromUrl( + HttpsCallable + callable = FirebaseFunctions.instance.httpsCallableFromUrl( 'http://$localhostMapped:5001/flutterfire-e2e-tests/us-central1/listfruits2ndgen', ); @@ -161,11 +165,12 @@ void main() { }); test('can be called using an Uri url', () async { - final localhostMapped = - kIsWeb || !Platform.isAndroid ? 'localhost' : '10.0.2.2'; + final localhostMapped = kIsWeb || !Platform.isAndroid + ? 'localhost' + : '10.0.2.2'; - HttpsCallable callable = - FirebaseFunctions.instance.httpsCallableFromUri( + HttpsCallable + callable = FirebaseFunctions.instance.httpsCallableFromUri( Uri.parse( 'http://$localhostMapped:5001/flutterfire-e2e-tests/us-central1/listfruits2ndgen', ), @@ -177,49 +182,58 @@ void main() { }); group('FirebaseFunctionsException', () { - test('HttpsCallable returns a FirebaseFunctionsException on error', - () async { - try { - await callable({}); - fail('Should have thrown'); - } on FirebaseFunctionsException catch (e) { - expect(e.code, equals('invalid-argument')); - expect(e.message, equals('Invalid test requested.')); - return; - } catch (e) { - fail('$e'); - } - }); + test( + 'HttpsCallable returns a FirebaseFunctionsException on error', + () async { + try { + await callable({}); + fail('Should have thrown'); + } on FirebaseFunctionsException catch (e) { + expect(e.code, equals('invalid-argument')); + expect(e.message, equals('Invalid test requested.')); + return; + } catch (e) { + fail('$e'); + } + }, + ); - test('it returns "details" value as part of the exception', () async { - try { - await callable({ - 'type': 'deepMap', - 'inputData': data.deepMap, - 'asError': true, - }); - fail('Should have thrown'); - } on FirebaseFunctionsException catch (e) { - expect(e.code, equals('cancelled')); - expect( - e.message, - equals( - 'Response data was requested to be sent as part of an Error payload, so here we are!', - ), - ); - expect(e.details, equals(data.deepMap)); - } catch (e) { - fail('$e'); - } - }); + test( + 'it returns "details" value as part of the exception', + () async { + try { + await callable({ + 'type': 'deepMap', + 'inputData': data.deepMap, + 'asError': true, + }); + fail('Should have thrown'); + } on FirebaseFunctionsException catch (e) { + expect(e.code, equals('cancelled')); + expect( + e.message, + equals( + 'Response data was requested to be sent as part of an Error payload, so here we are!', + ), + ); + expect(e.details, equals(data.deepMap)); + } catch (e) { + fail('$e'); + } + }, + // Desktop C++ SDK skip: exception `details` payloads are not + // propagated by the firebase-cpp-sdk. + skip: isDesktopCppSdk, + ); }); group('instanceFor', () { test('accepts a custom region', () async { final instance = FirebaseFunctions.instanceFor(region: 'europe-west1'); instance.useFunctionsEmulator('localhost', 5001); - final customRegionCallable = - instance.httpsCallable(kTestFunctionCustomRegion); + final customRegionCallable = instance.httpsCallable( + kTestFunctionCustomRegion, + ); final result = await customRegionCallable(); expect(result.data, equals('europe-west1')); }); @@ -237,8 +251,9 @@ void main() { ); try { await timeoutCallable({ - 'testTimeout': - const Duration(seconds: 6).inMilliseconds.toString(), + 'testTimeout': const Duration( + seconds: 6, + ).inMilliseconds.toString(), }); fail('Should have thrown'); } on FirebaseFunctionsException catch (e) { @@ -246,131 +261,155 @@ void main() { } catch (e) { fail('$e'); } + if (isDesktopCppSdk) { + // The C++ SDK cannot cancel the timed-out request; it keeps + // running on the transport and delays subsequent calls. Wait for + // it to drain so the next test is not queued behind it. + await Future.delayed(const Duration(seconds: 4)); + } }, // Android skip because it's flaky. See: // https://github.com/firebase/flutterfire/issues/9652 skip: defaultTargetPlatform == TargetPlatform.android, ); - test( - 'allow passing of `limitedUseAppCheckToken` as option', - () async { - final instance = FirebaseFunctions.instance; - instance.useFunctionsEmulator('localhost', 5001); - final timeoutCallable = FirebaseFunctions.instance.httpsCallable( - kTestFunctionDefaultRegion, - options: HttpsCallableOptions( - timeout: const Duration(seconds: 3), - limitedUseAppCheckToken: true, - ), - ); - - HttpsCallableResult results = await timeoutCallable(); - expect(results.data, equals('null')); - }, - ); - }); - - group('HttpsCallable Stream', () { - test('returns a [StreamResponse]', () { - final streamResponseCallable = - FirebaseFunctions.instance.httpsCallable(kTestStreamResponse); - final stream = streamResponseCallable.stream(); - expect(stream, emits(isA())); - }); - - test('accepts a string value', () async { - final stream = callable.stream('foo').where((event) => event is Chunk); - await expectLater( - stream, - emits( - isA() - .having((e) => e.partialData, 'partialData', equals('string')), + test('allow passing of `limitedUseAppCheckToken` as option', () async { + final instance = FirebaseFunctions.instance; + instance.useFunctionsEmulator('localhost', 5001); + final timeoutCallable = FirebaseFunctions.instance.httpsCallable( + kTestFunctionDefaultRegion, + options: HttpsCallableOptions( + timeout: const Duration(seconds: 3), + limitedUseAppCheckToken: true, ), ); - }); - test('accepts a number value', () async { - final stream = callable - .stream(123) - .where((event) => event is Chunk) - .asBroadcastStream(); - await expectLater( - stream, - emits( - isA() - .having((e) => e.partialData, 'partialData', equals('number')), - ), - ); + HttpsCallableResult results = await timeoutCallable(); + expect(results.data, equals('null')); }); + }); - test('accepts no arguments', () async { - final stream = callable - .stream() - .where((event) => event is Chunk) - .asBroadcastStream(); - await expectLater( - stream, - emits( - isA() - .having((e) => e.partialData, 'partialData', equals('null')), - ), - ); - }); + group( + 'HttpsCallable Stream', + () { + test('returns a [StreamResponse]', () { + final streamResponseCallable = FirebaseFunctions.instance + .httpsCallable(kTestStreamResponse); + final stream = streamResponseCallable.stream(); + expect(stream, emits(isA())); + }); - test('accepts a false boolean value', () async { - final stream = callable.stream(false).where((event) => event is Chunk); - await expectLater( - stream, - emits( - isA() - .having((e) => e.partialData, 'partialData', equals('boolean')), - ), - ); - }); + test('accepts a string value', () async { + final stream = callable + .stream('foo') + .where((event) => event is Chunk); + await expectLater( + stream, + emits( + isA().having( + (e) => e.partialData, + 'partialData', + equals('string'), + ), + ), + ); + }); - test('accepts a true boolean value', () async { - final stream = callable.stream(true).where((event) => event is Chunk); - await expectLater( - stream, - emits( - isA() - .having((e) => e.partialData, 'partialData', equals('boolean')), - ), - ); - }); + test('accepts a number value', () async { + final stream = callable + .stream(123) + .where((event) => event is Chunk) + .asBroadcastStream(); + await expectLater( + stream, + emits( + isA().having( + (e) => e.partialData, + 'partialData', + equals('number'), + ), + ), + ); + }); - test('can be called using an String url', () async { - final localhostMapped = - kIsWeb || !Platform.isAndroid ? 'localhost' : '10.0.2.2'; + test('accepts no arguments', () async { + final stream = callable + .stream() + .where((event) => event is Chunk) + .asBroadcastStream(); + await expectLater( + stream, + emits( + isA().having( + (e) => e.partialData, + 'partialData', + equals('null'), + ), + ), + ); + }); - HttpsCallable callable = - FirebaseFunctions.instance.httpsCallableFromUrl( - 'http://$localhostMapped:5001/flutterfire-e2e-tests/us-central1/listfruits2ndgen', - ); + test('accepts a false boolean value', () async { + final stream = callable + .stream(false) + .where((event) => event is Chunk); + await expectLater( + stream, + emits( + isA().having( + (e) => e.partialData, + 'partialData', + equals('boolean'), + ), + ), + ); + }); - final stream = callable.stream(); - await expectLater(stream, emits(isA())); - }); + test('accepts a true boolean value', () async { + final stream = callable.stream(true).where((event) => event is Chunk); + await expectLater( + stream, + emits( + isA().having( + (e) => e.partialData, + 'partialData', + equals('boolean'), + ), + ), + ); + }); - test('can be called using an Uri url', () async { - final localhostMapped = - kIsWeb || !Platform.isAndroid ? 'localhost' : '10.0.2.2'; + test('can be called using an String url', () async { + final localhostMapped = kIsWeb || !Platform.isAndroid + ? 'localhost' + : '10.0.2.2'; - HttpsCallable callable = - FirebaseFunctions.instance.httpsCallableFromUri( - Uri.parse( + HttpsCallable + callable = FirebaseFunctions.instance.httpsCallableFromUrl( 'http://$localhostMapped:5001/flutterfire-e2e-tests/us-central1/listfruits2ndgen', - ), - ); + ); - final stream = callable.stream(); - await expectLater(stream, emits(isA())); - }); + final stream = callable.stream(); + await expectLater(stream, emits(isA())); + }); - test( - 'concurrent streams on the same callable do not collide', - () async { + test('can be called using an Uri url', () async { + final localhostMapped = kIsWeb || !Platform.isAndroid + ? 'localhost' + : '10.0.2.2'; + + HttpsCallable + callable = FirebaseFunctions.instance.httpsCallableFromUri( + Uri.parse( + 'http://$localhostMapped:5001/flutterfire-e2e-tests/us-central1/listfruits2ndgen', + ), + ); + + final stream = callable.stream(); + await expectLater(stream, emits(isA())); + }); + + test('concurrent streams on the same callable do not collide', () async { // Regression test for https://github.com/firebase/flutterfire/issues/18036 final stream1 = callable .stream('foo') @@ -386,67 +425,61 @@ void main() { final results = await Future.wait([stream1, stream2]); expect(results[0], equals('string')); expect(results[1], equals('number')); - }, - ); + }); - test('should emit a [Result] as last value', () async { - final stream = await callable.stream().last; - expect( - stream, - isA(), + test('should emit a [Result] as last value', () async { + final stream = await callable.stream().last; + expect(stream, isA()); + }); + + test( + 'Result.data is Map for object-shaped JSON', + () async { + final stream = callable.stream({ + 'type': 'deepMap', + 'inputData': data.deepMap, + }); + final terminalEvent = await stream.where((e) => e is Result).last; + expect(terminalEvent, isA()); + final result = (terminalEvent as Result).result; + expect(result.data, isA>()); + }, + skip: !kIsWeb, ); - }); - test( - 'Result.data is Map for object-shaped JSON', - () async { - final stream = callable.stream({ - 'type': 'deepMap', - 'inputData': data.deepMap, - }); - final terminalEvent = await stream.where((e) => e is Result).last; - expect(terminalEvent, isA()); - final result = (terminalEvent as Result).result; - expect( - result.data, - isA>(), + test('accepts a [List]', () async { + final stream = callable + .stream(data.list) + .where((event) => event is Chunk); + await expectLater( + stream, + emits( + isA().having( + (e) => e.partialData, + 'partialData', + equals('array'), + ), + ), ); - }, - skip: !kIsWeb, - ); - - test('accepts a [List]', () async { - final stream = - callable.stream(data.list).where((event) => event is Chunk); - await expectLater( - stream, - emits( - isA() - .having((e) => e.partialData, 'partialData', equals('array')), - ), - ); - }); + }); - test('accepts a deeply nested [Map]', () async { - final stream = callable.stream({ - 'type': 'deepMap', - 'inputData': data.deepMap, - }).where((event) => event is Chunk); - await expectLater( - stream, - emits( - isA().having( - (e) => e.partialData, - 'partialData', - equals(data.deepMap), + test('accepts a deeply nested [Map]', () async { + final stream = callable + .stream({'type': 'deepMap', 'inputData': data.deepMap}) + .where((event) => event is Chunk); + await expectLater( + stream, + emits( + isA().having( + (e) => e.partialData, + 'partialData', + equals(data.deepMap), + ), ), - ), - ); - }); + ); + }); - test( - 'throws error when aborted with TimeLimit signal', - () async { + test('throws error when aborted with TimeLimit signal', () async { final instance = FirebaseFunctions.instance; instance.useFunctionsEmulator('localhost', 5001); @@ -459,29 +492,29 @@ void main() { ), ); - timeoutCallable.stream({ - 'testTimeout': const Duration(seconds: 6).inMilliseconds.toString(), - }).listen( - (data) { - completer.completeError('Should have thrown'); - }, - onError: (error) { - if (error is FirebaseFunctionsException) { - expect(error.code, equals('internal')); - completer.complete(); - } else { - completer.completeError('Unexpected error type: $error'); - } - }, - ); + timeoutCallable + .stream({ + 'testTimeout': const Duration( + seconds: 6, + ).inMilliseconds.toString(), + }) + .listen( + (data) { + completer.completeError('Should have thrown'); + }, + onError: (error) { + if (error is FirebaseFunctionsException) { + expect(error.code, equals('internal')); + completer.complete(); + } else { + completer.completeError('Unexpected error type: $error'); + } + }, + ); await completer.future; - }, - skip: !kIsWeb, - ); + }, skip: !kIsWeb); - test( - 'throws error when aborted with Abort signal', - () async { + test('throws error when aborted with Abort signal', () async { final instance = FirebaseFunctions.instance; instance.useFunctionsEmulator('localhost', 5001); @@ -489,30 +522,34 @@ void main() { final timeoutCallable = FirebaseFunctions.instance.httpsCallable( kTestFunctionTimeout, - options: HttpsCallableOptions( - webAbortSignal: Abort('aborted'), - ), + options: HttpsCallableOptions(webAbortSignal: Abort('aborted')), ); - timeoutCallable.stream({ - 'testTimeout': const Duration(seconds: 6).inMilliseconds.toString(), - }).listen( - (data) { - completer.completeError('Should have thrown'); - }, - onError: (error) { - if (error is FirebaseFunctionsException) { - expect(error.code, equals('internal')); - completer.complete(); - } else { - completer.completeError('Unexpected error type: $error'); - } - }, - ); + timeoutCallable + .stream({ + 'testTimeout': const Duration( + seconds: 6, + ).inMilliseconds.toString(), + }) + .listen( + (data) { + completer.completeError('Should have thrown'); + }, + onError: (error) { + if (error is FirebaseFunctionsException) { + expect(error.code, equals('internal')); + completer.complete(); + } else { + completer.completeError('Unexpected error type: $error'); + } + }, + ); await completer.future; - }, - skip: !kIsWeb, - ); - }); + }, skip: !kIsWeb); + }, + // Desktop C++ SDK skip: streaming callables are not supported by the + // firebase-cpp-sdk. + skip: isDesktopCppSdk, + ); }); } diff --git a/tests/integration_test/e2e_test.dart b/tests/integration_test/e2e_test.dart index e4ef09e84009..f0fba6328138 100644 --- a/tests/integration_test/e2e_test.dart +++ b/tests/integration_test/e2e_test.dart @@ -79,6 +79,7 @@ void main() { case TargetPlatform.windows: firebase_core.main(); firebase_auth.main(); + cloud_functions.main(); firebase_remote_config.main(); firebase_storage.main(); firebase_app_check.main(); diff --git a/tests/integration_test/platform_utils.dart b/tests/integration_test/platform_utils.dart new file mode 100644 index 000000000000..a57794a5311b --- /dev/null +++ b/tests/integration_test/platform_utils.dart @@ -0,0 +1,16 @@ +// Copyright 2026, the Chromium project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'package:flutter/foundation.dart'; + +/// Whether the tests are running against the desktop implementations backed by +/// the Firebase C++ SDK (Windows and Linux). Both platforms share the same +/// C++ SDK, so they have identical feature limitations in the e2e suites. +/// +/// The `!kIsWeb` check matters because web e2e tests run on Linux CI runners, +/// where `defaultTargetPlatform` reports [TargetPlatform.linux]. +bool get isDesktopCppSdk => + !kIsWeb && + (defaultTargetPlatform == TargetPlatform.windows || + defaultTargetPlatform == TargetPlatform.linux);