diff --git a/engine/src/flutter/shell/common/shorebird/BUILD.gn b/engine/src/flutter/shell/common/shorebird/BUILD.gn index 04ff926fa67e8..e1d03746e198b 100644 --- a/engine/src/flutter/shell/common/shorebird/BUILD.gn +++ b/engine/src/flutter/shell/common/shorebird/BUILD.gn @@ -89,6 +89,8 @@ if (shorebird_updater_supported) { # This provides a testable abstraction layer that can be mocked for testing. source_set("updater") { sources = [ + "protected_data.cc", + "protected_data.h", "updater.cc", "updater.h", ] diff --git a/engine/src/flutter/shell/common/shorebird/protected_data.cc b/engine/src/flutter/shell/common/shorebird/protected_data.cc new file mode 100644 index 0000000000000..95e25e318935a --- /dev/null +++ b/engine/src/flutter/shell/common/shorebird/protected_data.cc @@ -0,0 +1,28 @@ +#include "flutter/shell/common/shorebird/protected_data.h" + +#include + +namespace flutter { +namespace shorebird { + +namespace { + +// Default gate implementation. Invokes `start_fn` synchronously on the +// caller's thread. Used on every platform that does not install its own +// gate: desktop, Android, embedded, and tests. +class ImmediateProtectedDataGate : public ProtectedDataGate { + public: + void StartWhenAvailable(std::function start_fn) override { + start_fn(); + } + void CancelPending() override {} +}; + +} // namespace + +std::unique_ptr MakeImmediateProtectedDataGate() { + return std::make_unique(); +} + +} // namespace shorebird +} // namespace flutter diff --git a/engine/src/flutter/shell/common/shorebird/protected_data.h b/engine/src/flutter/shell/common/shorebird/protected_data.h new file mode 100644 index 0000000000000..688ce6a4cce4a --- /dev/null +++ b/engine/src/flutter/shell/common/shorebird/protected_data.h @@ -0,0 +1,70 @@ +#ifndef FLUTTER_SHELL_COMMON_SHOREBIRD_PROTECTED_DATA_H_ +#define FLUTTER_SHELL_COMMON_SHOREBIRD_PROTECTED_DATA_H_ + +#include +#include + +namespace flutter { +namespace shorebird { + +/// Abstraction over "wait until the platform is ready to let the Shorebird +/// updater write its state files, then start the update." +/// +/// On iOS, files under `Library/Application Support/` inherit the default +/// `NSFileProtectionCompleteUntilFirstUserAuthentication` class. Before the +/// user has unlocked the device for the first time since boot, the OS +/// refuses writes under that directory with EPERM/EACCES and any update +/// the updater starts during that window will fail at the state-write +/// step. The iOS implementation of this interface defers the start until +/// `UIApplication.protectedDataAvailable` is true (either immediately, if +/// it already is, or via a one-shot observer registered with +/// `NSNotificationCenter` for +/// `UIApplicationProtectedDataDidBecomeAvailableNotification`). +/// +/// Every other platform uses the default implementation, which starts +/// immediately. There is nothing to wait for on Android, desktop, or +/// embedded. +/// +/// Ownership: an instance of `ProtectedDataGate` is owned by the +/// `Updater` singleton. Installing a new gate via +/// `Updater::SetProtectedDataGate` cancels any pending start on the +/// previous gate. +/// +/// Thread safety: implementations must be safe to call from any thread. +/// `start_fn` may be invoked on a different thread than the one that +/// called `StartWhenAvailable` (the iOS impl dispatches to the main +/// queue). +/// +/// `start_fn` warnings: +/// - Must be non-blocking — it is expected to hand real work off to a +/// background worker, as `Updater::StartUpdateThread()` does. +/// - Must not capture anything with shorter-than-process lifetime. The +/// gate may hold `start_fn` indefinitely (until the user unlocks the +/// device for the first time since boot). Capturing a short-lived +/// pointer or reference in the lambda is a dangling-reference bug +/// waiting to happen. +class ProtectedDataGate { + public: + virtual ~ProtectedDataGate() = default; + + /// Arrange for `start_fn` to be invoked when the platform is ready. + /// Implementations may invoke it synchronously. If a previous + /// `start_fn` on this gate is still pending, the implementation must + /// cancel it before accepting the new one — there is only ever one + /// pending start per gate. + virtual void StartWhenAvailable(std::function start_fn) = 0; + + /// Cancel any pending invocation from a prior `StartWhenAvailable` + /// call. No-op if none is pending. Safe to call during destruction. + virtual void CancelPending() = 0; +}; + +/// Creates the default `ProtectedDataGate` used on every platform that +/// does not install its own. Invokes `start_fn` synchronously; +/// `CancelPending` is a no-op. +std::unique_ptr MakeImmediateProtectedDataGate(); + +} // namespace shorebird +} // namespace flutter + +#endif // FLUTTER_SHELL_COMMON_SHOREBIRD_PROTECTED_DATA_H_ diff --git a/engine/src/flutter/shell/common/shorebird/shorebird.cc b/engine/src/flutter/shell/common/shorebird/shorebird.cc index 71d336e90f8b0..50a689f2e7fec 100644 --- a/engine/src/flutter/shell/common/shorebird/shorebird.cc +++ b/engine/src/flutter/shell/common/shorebird/shorebird.cc @@ -177,8 +177,14 @@ bool ConfigureShorebird(const ShorebirdConfigArgs& args, } if (shorebird::Updater::Instance().ShouldAutoUpdate()) { - FML_LOG(INFO) << "Starting Shorebird update"; - shorebird::Updater::Instance().StartUpdateThread(); + // Route the kickoff through the Updater's ProtectedDataGate. On + // platforms with the default immediate gate this is equivalent to + // calling StartUpdateThread() directly. On iOS the installed gate + // waits for UIApplication.protectedDataAvailable so the updater + // does not try to write its state file under Library/Application + // Support/ and fail with EPERM/EACCES before first unlock after + // boot. See protected_data.h. + shorebird::Updater::Instance().StartUpdateThreadWhenReady(); } else { FML_LOG(INFO) << "Shorebird auto_update disabled, not checking for updates."; @@ -264,8 +270,14 @@ void ConfigureShorebird(std::string code_cache_path, } if (shorebird::Updater::Instance().ShouldAutoUpdate()) { - FML_LOG(INFO) << "Starting Shorebird update"; - shorebird::Updater::Instance().StartUpdateThread(); + // Route the kickoff through the Updater's ProtectedDataGate. On + // platforms with the default immediate gate this is equivalent to + // calling StartUpdateThread() directly. On iOS the installed gate + // waits for UIApplication.protectedDataAvailable so the updater + // does not try to write its state file under Library/Application + // Support/ and fail with EPERM/EACCES before first unlock after + // boot. See protected_data.h. + shorebird::Updater::Instance().StartUpdateThreadWhenReady(); } else { FML_LOG(INFO) << "Shorebird auto_update disabled, not checking for updates."; diff --git a/engine/src/flutter/shell/common/shorebird/shorebird_unittests.cc b/engine/src/flutter/shell/common/shorebird/shorebird_unittests.cc index 7c108ac1e6231..1eac1f75cc6ca 100644 --- a/engine/src/flutter/shell/common/shorebird/shorebird_unittests.cc +++ b/engine/src/flutter/shell/common/shorebird/shorebird_unittests.cc @@ -1,9 +1,14 @@ #include "flutter/shell/common/shorebird/shorebird.h" +#include + +#include "flutter/shell/common/shorebird/protected_data.h" +#include "flutter/shell/common/shorebird/updater.h" #include "gtest/gtest.h" namespace flutter { namespace testing { + TEST(Shorebird, GetValueFromYamlValueExists) { std::string yaml = "appid: com.example.app\nversion: 1.0.0\n"; std::string key = "appid"; @@ -17,5 +22,127 @@ TEST(Shorebird, GetValueFromYamlValueDoesNotExist) { std::string value = GetValueFromYaml(yaml, key); EXPECT_EQ(value, ""); } + +// ----- ProtectedDataGate / Updater integration tests ----- + +// A test gate that captures the pending start_fn instead of invoking +// it, and tracks CancelPending calls. +class CapturingProtectedDataGate : public shorebird::ProtectedDataGate { + public: + void StartWhenAvailable(std::function start_fn) override { + ++start_when_available_calls; + // Cancel any previous pending start before accepting the new one. + pending = std::move(start_fn); + } + void CancelPending() override { + ++cancel_pending_calls; + pending = nullptr; + } + + void FirePending() { + ASSERT_TRUE(static_cast(pending)); + auto to_invoke = std::move(pending); + to_invoke(); + } + + int start_when_available_calls = 0; + int cancel_pending_calls = 0; + std::function pending; +}; + +class ShorebirdUpdaterGateTest : public ::testing::Test { + protected: + void SetUp() override { + // Give each test a fresh MockUpdater as the singleton so that + // StartUpdateThread() calls from the gate do not hit the real + // Rust updater. + shorebird::Updater::SetInstanceForTesting( + std::make_unique()); + } + void TearDown() override { shorebird::Updater::ResetInstanceForTesting(); } +}; + +TEST_F(ShorebirdUpdaterGateTest, DefaultGateStartsImmediately) { + auto& updater = shorebird::Updater::Instance(); + auto* mock = dynamic_cast(&updater); + ASSERT_NE(mock, nullptr); + + // No gate has been explicitly installed; the default immediate gate + // should be used. + updater.StartUpdateThreadWhenReady(); + EXPECT_EQ(mock->start_update_thread_count(), 1); +} + +TEST_F(ShorebirdUpdaterGateTest, InstalledGateReceivesStartFn) { + auto& updater = shorebird::Updater::Instance(); + auto* mock = dynamic_cast(&updater); + ASSERT_NE(mock, nullptr); + + auto gate = std::make_unique(); + auto* gate_ptr = gate.get(); + updater.SetProtectedDataGate(std::move(gate)); + + updater.StartUpdateThreadWhenReady(); + + // The gate was asked but has not fired — the mock should not have + // been called yet. + EXPECT_EQ(gate_ptr->start_when_available_calls, 1); + EXPECT_EQ(mock->start_update_thread_count(), 0); + + // Firing the gate's pending callback should invoke + // StartUpdateThread on the mock. + gate_ptr->FirePending(); + EXPECT_EQ(mock->start_update_thread_count(), 1); +} + +TEST_F(ShorebirdUpdaterGateTest, CancelPendingUpdateStartForwardsToGate) { + auto& updater = shorebird::Updater::Instance(); + auto gate = std::make_unique(); + auto* gate_ptr = gate.get(); + updater.SetProtectedDataGate(std::move(gate)); + + updater.StartUpdateThreadWhenReady(); + EXPECT_EQ(gate_ptr->cancel_pending_calls, 0); + + updater.CancelPendingUpdateStart(); + EXPECT_EQ(gate_ptr->cancel_pending_calls, 1); + EXPECT_FALSE(static_cast(gate_ptr->pending)); +} + +TEST_F(ShorebirdUpdaterGateTest, ReplacingGateCancelsPreviousPending) { + auto& updater = shorebird::Updater::Instance(); + + auto first = std::make_unique(); + auto* first_ptr = first.get(); + updater.SetProtectedDataGate(std::move(first)); + updater.StartUpdateThreadWhenReady(); + + EXPECT_EQ(first_ptr->start_when_available_calls, 1); + EXPECT_EQ(first_ptr->cancel_pending_calls, 0); + + // Replacing the gate should cancel pending on the one being + // replaced. The old gate pointer is about to be destroyed — observe + // the cancel call before replacement. + auto second = std::make_unique(); + updater.SetProtectedDataGate(std::move(second)); + EXPECT_EQ(first_ptr->cancel_pending_calls, 1); +} + +TEST_F(ShorebirdUpdaterGateTest, SettingGateToNullptrRestoresImmediateGate) { + auto& updater = shorebird::Updater::Instance(); + auto* mock = dynamic_cast(&updater); + ASSERT_NE(mock, nullptr); + + updater.SetProtectedDataGate( + std::make_unique()); + // Install nullptr to request restoration of the immediate gate. + updater.SetProtectedDataGate(nullptr); + + updater.StartUpdateThreadWhenReady(); + // The immediate gate fires synchronously, so the mock should have + // been started exactly once. + EXPECT_EQ(mock->start_update_thread_count(), 1); +} + } // namespace testing -} // namespace flutter \ No newline at end of file +} // namespace flutter diff --git a/engine/src/flutter/shell/common/shorebird/updater.cc b/engine/src/flutter/shell/common/shorebird/updater.cc index 63d993613c3f9..477c3a1a5cb61 100644 --- a/engine/src/flutter/shell/common/shorebird/updater.cc +++ b/engine/src/flutter/shell/common/shorebird/updater.cc @@ -46,6 +46,32 @@ void Updater::ResetLaunchStateForTesting() { launch_completed_.store(false); } +void Updater::SetProtectedDataGate(std::unique_ptr gate) { + // Cancel any pending start on the previous gate so we don't leak + // observers or fire into a torn-down gate. + if (protected_data_gate_) { + protected_data_gate_->CancelPending(); + } + protected_data_gate_ = + gate ? std::move(gate) : MakeImmediateProtectedDataGate(); +} + +void Updater::StartUpdateThreadWhenReady() { + if (!protected_data_gate_) { + protected_data_gate_ = MakeImmediateProtectedDataGate(); + } + protected_data_gate_->StartWhenAvailable([] { + FML_LOG(INFO) << "Starting Shorebird update"; + Updater::Instance().StartUpdateThread(); + }); +} + +void Updater::CancelPendingUpdateStart() { + if (protected_data_gate_) { + protected_data_gate_->CancelPending(); + } +} + void Updater::ReportLaunchStart() { // Guard: only the first engine in a process should promote next_boot → // current_boot in the Rust updater. See class-level comment for rationale. diff --git a/engine/src/flutter/shell/common/shorebird/updater.h b/engine/src/flutter/shell/common/shorebird/updater.h index aeb2d1f2d90cc..f1461b59796b2 100644 --- a/engine/src/flutter/shell/common/shorebird/updater.h +++ b/engine/src/flutter/shell/common/shorebird/updater.h @@ -13,6 +13,8 @@ #include #include +#include "flutter/shell/common/shorebird/protected_data.h" + namespace flutter { namespace shorebird { @@ -104,6 +106,38 @@ class Updater { virtual bool ShouldAutoUpdate() = 0; virtual void StartUpdateThread() = 0; + /// Installs a `ProtectedDataGate` used by `StartUpdateThreadWhenReady` + /// to decide when the updater thread may actually run. Passing + /// `nullptr` restores the default immediate gate. + /// + /// If a previous gate had a pending `StartWhenAvailable` callback, + /// its `CancelPending()` is called before the gate is replaced so + /// we do not leak observers. + /// + /// Intended to be called once per process during engine + /// initialization by the platform embedder (e.g. iOS installs a gate + /// that waits for `UIApplication.protectedDataAvailable`). Must be + /// called before any concurrent access; the updater does not + /// synchronize gate installation against `StartUpdateThreadWhenReady`. + void SetProtectedDataGate(std::unique_ptr gate); + + /// Starts the updater thread as soon as the installed + /// `ProtectedDataGate` says it is safe to do so. On platforms with + /// the default immediate gate this is equivalent to calling + /// `StartUpdateThread()` directly. On iOS (or any platform that has + /// installed a deferring gate) the actual `StartUpdateThread()` call + /// is delayed until the gate fires. + /// + /// Can be called while a previous start is still pending on the gate; + /// the gate will cancel the prior pending start before queuing a + /// new one. + void StartUpdateThreadWhenReady(); + + /// Cancels any pending `StartUpdateThreadWhenReady` that is still + /// waiting on the gate. No-op if nothing is pending. Does not affect + /// an updater thread that has already been started. + void CancelPendingUpdateStart(); + // Singleton access static Updater& Instance(); @@ -130,6 +164,11 @@ class Updater { // Once-per-process guards for launch lifecycle. static std::atomic launch_started_; static std::atomic launch_completed_; + + // Gate used by StartUpdateThreadWhenReady / CancelPendingUpdateStart. + // Initialized lazily to the immediate gate on first access; replaced + // via SetProtectedDataGate. Never null after first access. + std::unique_ptr protected_data_gate_; }; /// No-op implementation for unsupported platforms. diff --git a/engine/src/flutter/shell/platform/darwin/ios/BUILD.gn b/engine/src/flutter/shell/platform/darwin/ios/BUILD.gn index 777b6ae4a64bb..e081c77f789c2 100644 --- a/engine/src/flutter/shell/platform/darwin/ios/BUILD.gn +++ b/engine/src/flutter/shell/platform/darwin/ios/BUILD.gn @@ -118,6 +118,8 @@ source_set("flutter_framework_source") { "framework/Source/FlutterSemanticsScrollView.mm", "framework/Source/FlutterSharedApplication.h", "framework/Source/FlutterSharedApplication.mm", + "framework/Source/FlutterShorebirdProtectedDataGate.h", + "framework/Source/FlutterShorebirdProtectedDataGate.mm", "framework/Source/FlutterSpellCheckPlugin.h", "framework/Source/FlutterSpellCheckPlugin.mm", "framework/Source/FlutterTextInputDelegate.h", diff --git a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterDartProject.mm b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterDartProject.mm index 45660d1cd637d..6d7e0f94957bf 100644 --- a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterDartProject.mm +++ b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterDartProject.mm @@ -15,7 +15,9 @@ #include "flutter/fml/build_config.h" #include "flutter/fml/paths.h" #include "flutter/shell/common/shorebird/shorebird.h" +#include "flutter/shell/common/shorebird/updater.h" #include "flutter/shell/common/switches.h" +#import "flutter/shell/platform/darwin/ios/framework/Source/FlutterShorebirdProtectedDataGate.h" #import "flutter/shell/platform/darwin/common/InternalFlutterSwiftCommon/InternalFlutterSwiftCommon.h" #include "flutter/shell/platform/darwin/common/command_line.h" @@ -176,6 +178,19 @@ static BOOL DoesHardwareSupportWideGamut() { encoding:NSUTF8StringEncoding error:nil]; if (shorebirdYamlContents != nil) { + // Install the iOS gate onto the Updater singleton. The gate defers the + // updater's kickoff until `UIApplication.protectedDataAvailable` is true, + // so the updater does not attempt to write its state files under + // `Library/Application Support/` before the device has been unlocked + // for the first time since boot (when the default Data Protection class + // rejects writes with EPERM/EACCES). The Updater owns the gate, so + // CancelPendingUpdateStart() is available on the updater if we ever + // need to tear down a pending observer. See + // FlutterShorebirdProtectedDataGate.h and + // shell/common/shorebird/protected_data.h. + flutter::shorebird::Updater::Instance().SetProtectedDataGate( + flutter::shorebird::MakeIOSProtectedDataGate()); + // Note: we intentionally pass cache_path twice. We provide two different directories // to ConfigureShorebird because Android differentiates between data that persists // between releases and data that does not. iOS does not make this distinction. diff --git a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterShorebirdProtectedDataGate.h b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterShorebirdProtectedDataGate.h new file mode 100644 index 0000000000000..d8dc2d9b3347f --- /dev/null +++ b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterShorebirdProtectedDataGate.h @@ -0,0 +1,40 @@ +#ifndef FLUTTER_SHELL_PLATFORM_DARWIN_IOS_FRAMEWORK_SOURCE_FLUTTERSHOREBIRDPROTECTEDDATAGATE_H_ +#define FLUTTER_SHELL_PLATFORM_DARWIN_IOS_FRAMEWORK_SOURCE_FLUTTERSHOREBIRDPROTECTEDDATAGATE_H_ + +#include + +#include "flutter/shell/common/shorebird/protected_data.h" + +namespace flutter { +namespace shorebird { + +/// Creates an iOS `ProtectedDataGate` that defers a pending +/// `StartWhenAvailable` callback until +/// `UIApplication.protectedDataAvailable` is true. +/// +/// Behavior: +/// - Dispatches to the main queue (required for `UIApplication` +/// access). +/// - If protected data is already available, invokes `start_fn` +/// synchronously on the main queue. +/// - Otherwise registers a one-shot observer for +/// `UIApplicationProtectedDataDidBecomeAvailableNotification`. +/// When the notification fires (typically the first time the user +/// unlocks the device after boot), the observer unregisters itself +/// and calls `start_fn` on the main queue. +/// - If `StartWhenAvailable` is called again while a prior callback +/// is still pending, the prior observer is removed before the new +/// one is registered. +/// - `CancelPending()` removes any currently-registered observer. +/// - Destruction calls `CancelPending()` so the gate never leaks an +/// observer past its lifetime. +/// +/// Falls back to invoking `start_fn` immediately if +/// `UIApplication.sharedApplication` is nil, which happens inside app +/// extensions where there is no full `UIApplication` lifecycle. +std::unique_ptr MakeIOSProtectedDataGate(); + +} // namespace shorebird +} // namespace flutter + +#endif // FLUTTER_SHELL_PLATFORM_DARWIN_IOS_FRAMEWORK_SOURCE_FLUTTERSHOREBIRDPROTECTEDDATAGATE_H_ diff --git a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterShorebirdProtectedDataGate.mm b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterShorebirdProtectedDataGate.mm new file mode 100644 index 0000000000000..f2fb746f2352c --- /dev/null +++ b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterShorebirdProtectedDataGate.mm @@ -0,0 +1,129 @@ +#import "flutter/shell/platform/darwin/ios/framework/Source/FlutterShorebirdProtectedDataGate.h" + +#import +#import + +#include + +#include "flutter/fml/logging.h" + +namespace flutter { +namespace shorebird { +namespace { + +// Concrete iOS gate. Holds at most one pending +// `NSNotificationCenter` observer; installing a new pending start +// cancels the prior one first, and destruction releases the observer +// before deallocation. +// +// Thread-safety: `StartWhenAvailable` and `CancelPending` may be +// called from any thread. Both hop to the main queue before touching +// the observer handle or `UIApplication`, so all internal state +// mutation happens on the main queue. +class IOSProtectedDataGate : public ProtectedDataGate { + public: + IOSProtectedDataGate() = default; + + ~IOSProtectedDataGate() override { + // Tear down synchronously on the main queue if needed. If the gate + // is destroyed from the main thread this runs inline; otherwise it + // dispatches sync to the main queue, which is safe because the + // destructor blocks here anyway. + if ([NSThread isMainThread]) { + RemoveObserverOnMainQueue(); + } else { + __block id observer_to_remove = observer_; + observer_ = nil; + dispatch_sync(dispatch_get_main_queue(), ^{ + if (observer_to_remove != nil) { + [[NSNotificationCenter defaultCenter] removeObserver:observer_to_remove]; + } + }); + } + } + + void StartWhenAvailable(std::function start_fn) override { + // Copy into a block-captured value; the __block lets the notification + // block capture start_fn without a const copy. + __block std::function captured_start_fn = std::move(start_fn); + dispatch_async(dispatch_get_main_queue(), ^{ + // Cancel any prior pending start before queuing the new one. + RemoveObserverOnMainQueue(); + + UIApplication* app = [UIApplication sharedApplication]; + if (app == nil) { + // Inside app extensions. No UIApplication lifecycle to observe, + // and Data Protection is managed differently; fall back to + // starting immediately so we do not silently block updates + // forever. + FML_LOG(INFO) << "Shorebird: UIApplication unavailable (likely app " + "extension); starting updater without data " + "protection gating."; + captured_start_fn(); + return; + } + + if (app.protectedDataAvailable) { + captured_start_fn(); + return; + } + + FML_LOG(INFO) << "Shorebird: protected data unavailable; deferring " + "updater kickoff until first unlock."; + + // Register a one-shot observer. The block retains captured_start_fn + // until it fires, at which point we unregister and invoke it. + observer_ = [[NSNotificationCenter defaultCenter] + addObserverForName:UIApplicationProtectedDataDidBecomeAvailableNotification + object:nil + queue:[NSOperationQueue mainQueue] + usingBlock:^(NSNotification* _Nonnull note) { + // Move start_fn out before unregistering so releasing + // the block (which owns the closure containing + // captured_start_fn's storage) cannot destroy the + // function while we are still about to call it. + std::function to_invoke = + std::move(captured_start_fn); + RemoveObserverOnMainQueue(); + FML_LOG(INFO) + << "Shorebird: protected data became available; " + "starting updater."; + to_invoke(); + }]; + }); + } + + void CancelPending() override { + if ([NSThread isMainThread]) { + RemoveObserverOnMainQueue(); + } else { + dispatch_async(dispatch_get_main_queue(), ^{ + RemoveObserverOnMainQueue(); + }); + } + } + + private: + // Main-queue-only: unregister any currently-held observer and clear + // the handle. Safe to call when no observer is held. + void RemoveObserverOnMainQueue() { + if (observer_ != nil) { + [[NSNotificationCenter defaultCenter] removeObserver:observer_]; + observer_ = nil; + } + } + + // __strong Obj-C pointer member inside a C++ class is valid in ARC + // .mm files; ARC manages the retain/release across ctor/dtor of the + // enclosing class. All reads/writes happen on the main queue. + __strong id observer_ = nil; +}; + +} // namespace + +std::unique_ptr MakeIOSProtectedDataGate() { + return std::make_unique(); +} + +} // namespace shorebird +} // namespace flutter