From 9e7e767e713b9f96e1b424cdef38080881358bda Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Sat, 26 Sep 2026 09:34:55 +0800 Subject: [PATCH 1/3] feat(ios): support tvOS (react-native-tvos) - podspec: add the tvOS platform; the deployment target follows React Native's min_ios_version_supported. - Store updates under Caches on tvOS: apps cannot write Application Support there, and Caches is the only non-temporary writable location. - tvOS may purge Caches while the app is not running. A missing installed version is no longer a rollback there: ForgetPurgedVersions clears the state without a rolled-back mark, and +bundleURL blocks (up to 12s) on a native round that reinstalls and activates the latest version for the packaged bundle. rescueSource reports 'purgeRestore'. - Report cInfo.os as "tvos " so the server buckets tvOS devices apart from iOS ones. - peerDependencies: react-native "*". react-native-tvos versions are prereleases (0.83.0-0) and never satisfy ">=0.59.0", so npm refused to install. Co-Authored-By: Claude Opus 5.5 --- cpp/patch_core/state_core.cpp | 9 ++ cpp/patch_core/state_core.h | 6 + cpp/patch_core/tests/patch_core_test.cpp | 26 ++++ ios/RCTPushy/RCTPushy.mm | 164 ++++++++++++++++++++--- package.json | 2 +- react-native-update.podspec | 13 +- src/__tests__/core.test.ts | 42 ++++++ src/__tests__/metadata.test.ts | 4 + src/core.ts | 4 +- src/metadata.ts | 9 +- 10 files changed, 255 insertions(+), 24 deletions(-) diff --git a/cpp/patch_core/state_core.cpp b/cpp/patch_core/state_core.cpp index db01675c..c91305ef 100644 --- a/cpp/patch_core/state_core.cpp +++ b/cpp/patch_core/state_core.cpp @@ -76,6 +76,15 @@ State Rollback(const State& state) { return next; } +State ForgetPurgedVersions(const State& state) { + State next = state; + next.current_version.clear(); + next.last_version.clear(); + next.first_time = false; + next.first_time_ok = true; + return next; +} + bool ShouldRollbackForBrokenFirstLoad(const State& state) { return !state.first_time && !state.first_time_ok; } diff --git a/cpp/patch_core/state_core.h b/cpp/patch_core/state_core.h index ef78b285..e44e3c77 100644 --- a/cpp/patch_core/state_core.h +++ b/cpp/patch_core/state_core.h @@ -47,6 +47,12 @@ State ClearRollbackMark(const State& state); State Rollback(const State& state); +// The OS deleted the installed versions' files (tvOS purges Caches while the +// app is not running): they were not rejected, so unlike Rollback this leaves +// no rolled-back mark that would make the next check skip reinstalling them. +// An existing mark (a rollback earlier in this launch) is kept. +State ForgetPurgedVersions(const State& state); + bool ShouldRollbackForBrokenFirstLoad(const State& state); LaunchDecision ResolveLaunchState( diff --git a/cpp/patch_core/tests/patch_core_test.cpp b/cpp/patch_core/tests/patch_core_test.cpp index e1f3978e..83df8a27 100644 --- a/cpp/patch_core/tests/patch_core_test.cpp +++ b/cpp/patch_core/tests/patch_core_test.cpp @@ -863,6 +863,31 @@ void TestStateCoreRollbackToEmptyVersion() { Expect(rolled.first_time_ok, "first_time_ok should be true after rollback"); } +void TestStateCoreForgetPurgedVersions() { + State state; + state.package_version = "1.0"; + state.build_time = "123"; + state.current_version = "current"; + state.last_version = "previous"; + state.first_time = true; + state.first_time_ok = false; + + State forgot = pushy::state::ForgetPurgedVersions(state); + Expect(forgot.current_version.empty(), "forget should clear current_version"); + Expect(forgot.last_version.empty(), "forget should clear last_version"); + Expect(forgot.rolled_back_version.empty(), "forget must not leave a rollback marker"); + Expect(!forgot.first_time, "forget should clear first_time"); + Expect(forgot.first_time_ok, "forget should reset first_time_ok"); + ExpectEq(forgot.package_version, "1.0", "forget should keep package_version"); + ExpectEq(forgot.build_time, "123", "forget should keep build_time"); + + state.rolled_back_version = "rolled"; + ExpectEq( + pushy::state::ForgetPurgedVersions(state).rolled_back_version, + "rolled", + "forget should keep an existing rollback marker"); +} + void TestStateCoreResolveLaunchNoCurrentVersion() { State state; state.current_version = ""; @@ -1082,6 +1107,7 @@ int main(int argc, char** argv) { {"ArchivePatchCoreSupportsCustomBundlePatchEntry", TestArchivePatchCoreSupportsCustomBundlePatchEntry}, {"ArchivePatchCoreHarmonyBundlePatchFromPackage", TestArchivePatchCoreHarmonyBundlePatchFromPackage}, {"StateCoreRollbackToEmptyVersion", TestStateCoreRollbackToEmptyVersion}, + {"StateCoreForgetPurgedVersions", TestStateCoreForgetPurgedVersions}, {"StateCoreResolveLaunchNoCurrentVersion", TestStateCoreResolveLaunchNoCurrentVersion}, {"StateCoreSwitchToSameVersion", TestStateCoreSwitchToSameVersion}, {"Sha256KnownVectors", TestSha256KnownVectors}, diff --git a/ios/RCTPushy/RCTPushy.mm b/ios/RCTPushy/RCTPushy.mm index dcc852c3..e9bbe58a 100644 --- a/ios/RCTPushy/RCTPushy.mm +++ b/ios/RCTPushy/RCTPushy.mm @@ -98,6 +98,14 @@ return [NSString stringWithUTF8String:code]; } +// cInfo.os label prefix; must match the JS side (core.ts) so the server +// buckets tvOS devices apart from iOS ones. +#if TARGET_OS_TV +static NSString * const PushyOsName = @"tvos"; +#else +static NSString * const PushyOsName = @"ios"; +#endif + // event def static NSString * const EVENT_PROGRESS_DOWNLOAD = @"RCTPushyDownloadProgress"; static NSString * const PARAM_PROGRESS_HASH = @"hash"; @@ -732,6 +740,10 @@ + (void)persistConfiguration:(NSString *)config; + (BOOL)hasRunnableConfig; + (NSDictionary *)checkAndUpdate; + (void)scheduleFromColdStart:(NSString *)launchRolledBackVersion; +#if TARGET_OS_TV && !DEBUG ++ (BOOL)restorePurgedLaunch:(NSString *)purgedVersion + rolledBack:(NSString *)launchRolledBackVersion; +#endif + (void)markJsCheckCompleted:(NSString *)config; + (void)startRoundWithDeadline:(NSTimeInterval)deadlineUptime; + (void)runOnce:(NSString *)launchRolledBackVersion deadline:(NSTimeInterval)deadlineUptime; @@ -755,6 +767,9 @@ + (BOOL)commitRoundWithGeneration:(uint64_t)generation // it downloads (§11.3). static std::atomic pushyCrashRescueActive{false}; static std::atomic pushyRescueAttempted{false}; +// Set when a launch blocks on reinstalling a version tvOS purged: the round +// activates what it downloads, JS has not started to decide. +static std::atomic pushyPurgeRestoreActive{false}; static dispatch_semaphore_t pushyRoundDone; static NSString *pushyLaunchRolledBackForRescue = nil; // A version this process downloaded but left for JS to activate. If the @@ -785,6 +800,9 @@ + (BOOL)commitRoundWithGeneration:(uint64_t)generation static const NSTimeInterval kPushyRescueBudgetBackgroundThread = 10; // A held main thread freezes UI teardown; stay well under the watchdog. static const NSTimeInterval kPushyRescueBudgetMainThread = 3.5; +// The purged-version restore blocks +bundleURL, usually inside +// application:didFinishLaunching: — stay clear of the launch watchdog. +static const NSTimeInterval kPushyPurgeRestoreBudget = 12; static void PushyMaybeHoldForRescue(void) { // Once per process; a second crashing thread passes straight through @@ -935,8 +953,42 @@ @implementation RCTPushy { + (NSURL *)bundleURL { pushyIsUsingBundleUrl = true; + NSString *launchRolledBackVersion = nil; + @try { + NSString *purgedVersion = nil; + NSURL *resolvedURL = [RCTPushy resolveLaunchBundleURL:&launchRolledBackVersion + purgedVersion:&purgedVersion]; +#if TARGET_OS_TV && !DEBUG + // tvOS deleted the installed version while the app was not running. + // The app is expected to be online: block the launch on a native + // round that reinstalls the latest version, instead of showing the + // packaged bundle and jumping forward again on a later launch. + if (purgedVersion != nil && + [RCTPushyOrchestrator restorePurgedLaunch:purgedVersion + rolledBack:launchRolledBackVersion]) { + resolvedURL = [RCTPushy resolveLaunchBundleURL:&launchRolledBackVersion + purgedVersion:&purgedVersion]; + } +#endif + return resolvedURL ?: [RCTPushy binaryBundleURL]; + } @finally { + // State corruption is exactly when the rescue path matters most. If + // resolution throws before a snapshot exists, nil safely omits only + // this launch's rollback guard instead of disabling the check. + [RCTPushyOrchestrator scheduleFromColdStart:launchRolledBackVersion]; + } +} + +// Resolves the bundle to launch from the persisted state; nil means the +// packaged bundle. Consumes the launch's one-shot state (first_time), so a +// second call is only valid after something re-armed it (a switchVersion). +// purgedVersion is set on tvOS when the installed version's files are gone. ++ (NSURL *)resolveLaunchBundleURL:(NSString **)launchRolledBackVersionOut + purgedVersion:(NSString **)purgedVersionOut +{ __block NSURL *resolvedURL = nil; __block NSString *launchRolledBackVersion = nil; + __block NSString *purgedVersion = nil; @try { PushyWithStateLock(^{ NSUserDefaults *defaults = PushyDefaults(); @@ -995,10 +1047,29 @@ + (NSURL *)bundleURL resolvedURL = [NSURL fileURLWithPath:bundlePath]; break; } else { +#if TARGET_OS_TV + // Caches is purgeable on tvOS: a missing bundle is the OS + // reclaiming space, not a bad release. Everything under + // rctpushy went with it, so walking back to last_version + // is pointless, and a rollback mark would make this + // launch's check refuse to reinstall the version. + RCTLogWarn(@"RCTPushy -- bundle version %@ was purged by the system", loadVersion); + purgedVersion = loadVersion; + state = pushy::state::ForgetPurgedVersions(state); + PushyApplyStateToDefaults(defaults, state); + if (decision.consumed_first_time) { + // The first load being consumed never happens: the + // packaged bundle must not report isFirstTime. + ignoreRollback = false; + [defaults removeObjectForKey:keyFirstLoadMarked]; + } + break; +#else RCTLogError(@"RCTPushy -- bundle version %@ not found, rolling back", loadVersion); state = pushy::state::Rollback(state); PushyApplyStateToDefaults(defaults, state); loadVersion = PushyFromStdString(state.current_version); +#endif } } } @@ -1007,13 +1078,11 @@ + (NSURL *)bundleURL // launch just rolled back. launchRolledBackVersion = PushyFromStdString(state.rolled_back_version); }); - return resolvedURL ?: [RCTPushy binaryBundleURL]; } @finally { - // State corruption is exactly when the rescue path matters most. If - // resolution throws before a snapshot exists, nil safely omits only - // this launch's rollback guard instead of disabling the check. - [RCTPushyOrchestrator scheduleFromColdStart:launchRolledBackVersion]; + *launchRolledBackVersionOut = launchRolledBackVersion; + *purgedVersionOut = purgedVersion; } + return resolvedURL; } + (NSString *) rollback { @@ -1919,6 +1988,7 @@ - (BOOL)ensureDirectoryExistsAtPath:(NSString *)path // excluded from backups. + (void)excludeFromBackup:(NSString *)path { +#if !TARGET_OS_TV // tvOS stores under Caches, which is never backed up. NSURL *url = [NSURL fileURLWithPath:path isDirectory:YES]; NSError *error = nil; if (![url setResourceValue:@YES @@ -1926,6 +1996,7 @@ + (void)excludeFromBackup:(NSString *)path error:&error]) { RCTLogWarn(@"Pushy exclude from backup error: %@", error.localizedDescription); } +#endif } - (void)unzipFileAtPath:(NSString *)path @@ -2047,7 +2118,15 @@ - (NSString *)zipExtension:(PushyType)type + (NSString *)downloadDir { - NSString *directory = [NSSearchPathForDirectoriesInDomains(NSApplicationSupportDirectory, NSUserDomainMask, YES) firstObject]; +#if TARGET_OS_TV + // tvOS apps cannot write Application Support; Caches is the only + // writable, non-temporary location, and the system may purge it while + // the app is not running (see the purged-version path in +bundleURL). + NSSearchPathDirectory base = NSCachesDirectory; +#else + NSSearchPathDirectory base = NSApplicationSupportDirectory; +#endif + NSString *directory = [NSSearchPathForDirectoriesInDomains(base, NSUserDomainMask, YES) firstObject]; return [directory stringByAppendingPathComponent:@"rctpushy"]; } @@ -2248,12 +2327,9 @@ + (BOOL)hasRunnableConfig { && !config.Get("appKey").AsString().empty(); } -+ (void)scheduleFromColdStart:(NSString *)launchRolledBackVersion { -#if !DEBUG - // Once per process; a few seconds of delay keeps the check away from the - // cold-start critical path (§7 R5) — its result targets the NEXT launch. - // Unless the previous process died mid-round (residual incomplete - // marker), in which case every launch second counts (§11.4). +// Process-wide round state, set up by whichever launch path needs a round +// first: the delayed cold-start check or the tvOS purged-version restore. ++ (void)prepareProcess:(NSString *)launchRolledBackVersion { static dispatch_once_t onceToken; dispatch_once(&onceToken, ^{ pushyRoundDone = dispatch_semaphore_create(0); @@ -2262,12 +2338,24 @@ + (void)scheduleFromColdStart:(NSString *)launchRolledBackVersion { pushyProcessAnchorUptime = PushyMonotonicNow(); pushyLaunchRolledBackForRescue = [launchRolledBackVersion copy]; pushyNativeCheckReady.store(true); - NSUserDefaults *defaults = PushyDefaults(); // The crash-hold rescue shares the orchestrator's rollout gate: no // persisted config, no handler (§11.3). - if ([defaults stringForKey:keyNativeConfig].length > 0) { + if ([PushyDefaults() stringForKey:keyNativeConfig].length > 0) { PushyInstallCrashRescueHandler(); } + }); +} + ++ (void)scheduleFromColdStart:(NSString *)launchRolledBackVersion { +#if !DEBUG + // Once per process; a few seconds of delay keeps the check away from the + // cold-start critical path (§7 R5) — its result targets the NEXT launch. + // Unless the previous process died mid-round (residual incomplete + // marker), in which case every launch second counts (§11.4). + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + [self prepareProcess:launchRolledBackVersion]; + NSUserDefaults *defaults = PushyDefaults(); int64_t delaySeconds = [defaults objectForKey:keyNativeCheckIncomplete] != nil ? 0 : 5; dispatch_after(dispatch_time(DISPATCH_TIME_NOW, delaySeconds * NSEC_PER_SEC), @@ -2284,6 +2372,43 @@ + (void)scheduleFromColdStart:(NSString *)launchRolledBackVersion { #endif } +#if TARGET_OS_TV && !DEBUG +// Runs this process's round before the first bundle loads, blocking the +// caller for at most kPushyPurgeRestoreBudget, and activates whatever it +// installs. The state no longer has a current version, so the server answers +// with the latest version for the packaged bundle. Returns YES when the +// round finished in time — the caller then resolves the launch bundle again. +// The round stops at the same deadline, keeping a partial download for the +// resumable retry of the next check; the launch then uses the packaged bundle. ++ (BOOL)restorePurgedLaunch:(NSString *)purgedVersion + rolledBack:(NSString *)launchRolledBackVersion { + [self prepareProcess:launchRolledBackVersion]; + if (![self hasRunnableConfig]) { + RCTLogWarn(@"RCTPushy -- version %@ was purged and no native check is " + @"configured; launching the packaged bundle", purgedVersion); + return NO; + } + pushyPurgeRestoreActive.store(true); + NSTimeInterval deadline = PushyMonotonicNow() + kPushyPurgeRestoreBudget; + dispatch_semaphore_t done = dispatch_semaphore_create(0); + dispatch_async(dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0), ^{ + [self startRoundWithDeadline:deadline]; + dispatch_semaphore_signal(done); + }); + // The round honours the deadline itself; the extra second only covers + // its last commit. + if (dispatch_semaphore_wait(done, dispatch_time(DISPATCH_TIME_NOW, + (int64_t)((kPushyPurgeRestoreBudget + 1) * NSEC_PER_SEC))) != 0) { + RCTLogWarn(@"RCTPushy -- purged version restore timed out; launching the packaged bundle"); + return NO; + } + NSDictionary *result = pushyHostRoundResult; + RCTLogInfo(@"RCTPushy -- purged version %@ restore: %@ %@ %@", purgedVersion, + result[@"status"], result[@"reason"], result[@"hash"]); + return YES; +} +#endif + + (NSDictionary *)checkAndUpdate { #if DEBUG return PushyHostResult(@"skipped", @"debug", nil, NO); @@ -2532,7 +2657,7 @@ + (void)runConfiguredRound:(const flowjson::Value &)config cInfo.Set("rnu", config.Get("rnu")); cInfo.Set("rn", config.Get("rn")); cInfo.Set("os", flowjson::Value::String(PushyToStdString([NSString - stringWithFormat:@"ios %@", [[UIDevice currentDevice] systemVersion]]))); + stringWithFormat:@"%@ %@", PushyOsName, [[UIDevice currentDevice] systemVersion]]))); cInfo.Set("uuid", flowjson::Value::String(PushyToStdString(uuid))); flowjson::Value input = flowjson::Value::Object(); @@ -2635,12 +2760,17 @@ + (void)runConfiguredRound:(const flowjson::Value &)config if (pushyCrashRescueActive.load()) { versionInfo[@"crashRescue"] = @YES; } + if (pushyPurgeRestoreActive.load()) { + versionInfo[@"purgeRestore"] = @YES; + } // Silent strategies or a server-marked forceBoot version (per-version // remote override — the brick rescue) activate for the next launch; // otherwise activation stays with the JS side (§6/§10.1). Unless a crash // is being held: JS is dead, deferring to it would leave the fix on disk - // forever (§11.3). - BOOL activate = decision.Get("activate").Truthy() || pushyCrashRescueActive.load(); + // forever (§11.3). Or the launch is blocked on reinstalling a version + // tvOS purged: JS has not started, and the whole point is to boot it. + BOOL activate = decision.Get("activate").Truthy() || pushyCrashRescueActive.load() + || pushyPurgeRestoreActive.load(); BOOL committed = [self commitRoundWithGeneration:resetGeneration hashInfo:@{@"hash": hash, @"info": versionInfo} activate:activate ? hash : nil diff --git a/package.json b/package.json index cff3b3ea..094fabdd 100644 --- a/package.json +++ b/package.json @@ -72,7 +72,7 @@ }, "peerDependencies": { "react": ">=16.8.0", - "react-native": ">=0.59.0" + "react-native": "*" }, "homepage": "https://github.com/reactnativecn/react-native-update#readme", "dependencies": { diff --git a/react-native-update.podspec b/react-native-update.podspec index f7f0a3ad..b7d9da70 100644 --- a/react-native-update.podspec +++ b/react-native-update.podspec @@ -43,7 +43,13 @@ Pod::Spec.new do |s| end # Set platform based on whether it's a valid Expo project and if we can parse its target - final_ios_deployment_target = '11.0' # Default target + # React Native's own minimum (defined by react_native_pods.rb, which the + # Podfile loads before evaluating podspecs). The pod platform version also + # becomes the deployment target of the privacy resource bundle target, + # which React Native's post_install does not raise: Xcode 27 rejects + # anything below 15.0 there, so a hard-coded 11.0 fails the build. + final_ios_deployment_target = + defined?(min_ios_version_supported) ? min_ios_version_supported : '11.0' if valid_expo_project # --- Try to find and parse ExpoModulesCore.podspec only if it's an Expo project --- @@ -76,7 +82,10 @@ Pod::Spec.new do |s| end end - s.platforms = { :ios => final_ios_deployment_target } + # tvOS (react-native-tvos) shares the iOS sources; the only platform + # differences live behind TARGET_OS_TV in RCTPushy.mm (download directory, + # purged-cache recovery). + s.platforms = { :ios => final_ios_deployment_target, :tvos => final_ios_deployment_target } s.name = package['name'] s.version = package['version'] diff --git a/src/__tests__/core.test.ts b/src/__tests__/core.test.ts index 2241a7f4..0a09dd1e 100644 --- a/src/__tests__/core.test.ts +++ b/src/__tests__/core.test.ts @@ -105,6 +105,48 @@ describe('core info parsing', () => { }); }); +describe('client os label', () => { + const mockPlatform = (platform: Record) => { + mock.module('react-native', () => ({ + Platform: platform, + DeviceEventEmitter: { + addListener: mock(() => ({ remove: mock(() => {}) })), + }, + NativeModules: { + Pushy: { + currentVersionInfo: '{}', + downloadRootDir: '/tmp', + packageVersion: '1.0.0', + currentVersion: '', + isFirstTime: false, + rolledBackVersion: '', + buildTime: '1', + uuid: 'existing-uuid', + setLocalHashInfo: mock(() => {}), + getLocalHashInfo: mock(() => Promise.resolve('{}')), + setUuid: mock(() => {}), + }, + }, + NativeEventEmitter: class { + addListener = mock(() => ({ remove: mock(() => {}) })); + }, + })); + }; + + test('labels tvOS apart from iOS', async () => { + mockPlatform({ OS: 'ios', Version: '18.0', isTV: true }); + const { cInfo } = await importFreshCore('os-tvos'); + expect(cInfo.os).toBe('tvos 18.0'); + }); + + test('keeps the platform name elsewhere, Android TV included', async () => { + mockPlatform({ OS: 'ios', Version: '17.5', isTV: false }); + expect((await importFreshCore('os-ios')).cInfo.os).toBe('ios 17.5'); + mockPlatform({ OS: 'android', Version: 34, isTV: true }); + expect((await importFreshCore('os-androidtv')).cInfo.os).toBe('android 34'); + }); +}); + describe('web platform', () => { const origDev = (globalThis as any).__DEV__; afterEach(() => { diff --git a/src/__tests__/metadata.test.ts b/src/__tests__/metadata.test.ts index 8acf4d83..fa1012c1 100644 --- a/src/__tests__/metadata.test.ts +++ b/src/__tests__/metadata.test.ts @@ -57,6 +57,10 @@ describe('getUpdateMetadata', () => { mockCore({ currentVersionInfo: { forceBootRescue: true } }); const forced = await importFreshMetadata('meta-forceboot'); expect(forced.getUpdateMetadata().rescueSource).toBe('forceBoot'); + + mockCore({ currentVersionInfo: { purgeRestore: true } }); + const restored = await importFreshMetadata('meta-purge-restore'); + expect(restored.getUpdateMetadata().rescueSource).toBe('purgeRestore'); }); test('tolerates the embedded bundle and a rollback launch', async () => { diff --git a/src/core.ts b/src/core.ts index 4c16aca8..eba4fca0 100644 --- a/src/core.ts +++ b/src/core.ts @@ -164,7 +164,9 @@ if (!uuid) { export const cInfo = { rnu: require('../package.json').version, rn: RNVersion, - os: `${Platform.OS} ${Platform.Version}`, + // tvOS reports Platform.OS 'ios'; label it apart so the server can bucket + // tvOS devices separately. Must match PushyOsName in RCTPushy.mm. + os: `${Platform.OS === 'ios' && Platform.isTV ? 'tvos' : Platform.OS} ${Platform.Version}`, uuid, }; diff --git a/src/metadata.ts b/src/metadata.ts index 77ea5cfb..317ba5a7 100644 --- a/src/metadata.ts +++ b/src/metadata.ts @@ -46,9 +46,10 @@ export interface UpdateMetadata { /** * How the running version got activated when not by the JS flow: * 'forceBoot' — the server's per-version override applied by the native - * cold-start check; 'crashRescue' — activated by the crash-time rescue. + * cold-start check; 'crashRescue' — activated by the crash-time rescue; + * 'purgeRestore' — reinstalled at launch after tvOS purged the cache. */ - rescueSource: 'forceBoot' | 'crashRescue' | null; + rescueSource: 'forceBoot' | 'crashRescue' | 'purgeRestore' | null; /** Stable per-install client id (gray-release bucketing key). */ uuid: string; os: string; @@ -74,7 +75,9 @@ export function getUpdateMetadata(): UpdateMetadata { ? 'forceBoot' : info.crashRescue ? 'crashRescue' - : null, + : info.purgeRestore + ? 'purgeRestore' + : null, uuid: cInfo.uuid, os: cInfo.os, }; From bcebf9c70813cdc4902e6155da2c8cff92bdcf43 Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Sat, 26 Sep 2026 10:55:16 +0800 Subject: [PATCH 2/3] fix(ios): resolve expo-modules-core from expo's own location The podspec required expo-modules-core/package.json from its own directory, which only works when the package manager hoists expo-modules-core. With react-native-tvos, npm nests it under expo/node_modules; the lookup then failed, EXPO_SUPPORTS_BUNDLEURL stayed unset, and ExpoPushyReactDelegateHandler fell back to overriding createBridge, which ExpoModulesCore (SDK 57) no longer declares. Resolve it relative to expo instead; hoisted npm/bun and pnpm layouts resolve the same package as before. Co-Authored-By: Claude Opus 5.5 --- react-native-update.podspec | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/react-native-update.podspec b/react-native-update.podspec index b7d9da70..98643ce1 100644 --- a/react-native-update.podspec +++ b/react-native-update.podspec @@ -20,6 +20,15 @@ Pod::Spec.new do |s| # Silently ignore errors during check end + # expo-modules-core is a dependency of expo, not of the app: resolve it from + # expo's own location. A bare require from this podspec's directory misses + # it when npm nests it under expo/node_modules (seen with react-native-tvos), + # which left EXPO_SUPPORTS_BUNDLEURL unset; the createBridge fallback that + # selects no longer exists in ExpoModulesCore (SDK 57), so the build failed. + expo_modules_core_package_json_js = + "require.resolve('expo-modules-core/package.json', " \ + "{ paths: [require('path').dirname(require.resolve('expo/package.json'))] })" + # Determine final validity by checking Podfile presence AND Expo version valid_expo_project = false # Default if is_expo_in_podfile @@ -55,7 +64,7 @@ Pod::Spec.new do |s| # --- Try to find and parse ExpoModulesCore.podspec only if it's an Expo project --- parsed_expo_ios_target = nil expo_modules_core_podspec_path = begin - package_json_path = `node -p "require.resolve('expo-modules-core/package.json')"`.strip + package_json_path = `node -p "#{expo_modules_core_package_json_js}"`.strip File.join(File.dirname(package_json_path), 'ExpoModulesCore.podspec') if $?.success? && package_json_path && !package_json_path.empty? rescue nil @@ -181,8 +190,7 @@ Pod::Spec.new do |s| # 1. Try executing node to get the version string expo_modules_core_version_str = begin - # Use node to directly require expo-modules-core/package.json and get its version - `node --print \"require('expo-modules-core/package.json').version\"` # Execute, keep raw output + `node --print "require(#{expo_modules_core_package_json_js}).version"` # Execute, keep raw output rescue # Node command failed (e.g., node not found, package not found). Return empty string. '' From ec1ad6dd473b0c12e94ccda9ddac61272066af54 Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Sat, 26 Sep 2026 11:49:45 +0800 Subject: [PATCH 3/3] fix(ios): stop a late purge restore from activating behind the fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When +bundleURL's wait for the tvOS purged-version restore timed out, the launch fell back to the packaged bundle but the round kept its activation right: its commit only checked the reset generation. A check response can outlast the round deadline (the request timeout is an idle timeout and the wait backstop is timeout + 5s), and a version already complete on disk skips the download phase, so the round could switch the persisted state to that version while the packaged bundle was launching — JS could then read a currentVersion that is not the bundle it runs. The restore now owns an explicit window, guarded by the state lock every activation commits under. +bundleURL closes it when its wait ends (timeout or not) and always resolves the launch bundle again: a commit either landed before the close and launches, or sees the window closed and leaves the version to JS (a held crash may still activate it). purgeRestore is marked under the same lock, only on an activation that takes over the launch. The wait is now exactly the 12s budget. Also document that rescueSource reports the highest-priority marker when several apply, with a test for forceBootRescue + purgeRestore. Co-Authored-By: Claude Opus 5.5 --- ios/RCTPushy/RCTPushy.mm | 103 +++++++++++++++++++++++---------- src/__tests__/metadata.test.ts | 7 +++ src/metadata.ts | 2 + 3 files changed, 82 insertions(+), 30 deletions(-) diff --git a/ios/RCTPushy/RCTPushy.mm b/ios/RCTPushy/RCTPushy.mm index e9bbe58a..c4396982 100644 --- a/ios/RCTPushy/RCTPushy.mm +++ b/ios/RCTPushy/RCTPushy.mm @@ -754,7 +754,8 @@ + (BOOL)commitRoundWithGeneration:(uint64_t)generation responseText:(NSString *)responseText request:(NSString *)requestBody config:(NSString *)configJson - responseAt:(long long)responseAtSeconds; + responseAt:(long long)responseAtSeconds + activated:(BOOL *)activatedOut; @end // One round per process, whoever starts it first — the delayed cold-start @@ -767,9 +768,15 @@ + (BOOL)commitRoundWithGeneration:(uint64_t)generation // it downloads (§11.3). static std::atomic pushyCrashRescueActive{false}; static std::atomic pushyRescueAttempted{false}; -// Set when a launch blocks on reinstalling a version tvOS purged: the round -// activates what it downloads, JS has not started to decide. +// Set when this process's round is the tvOS purged-version restore. static std::atomic pushyPurgeRestoreActive{false}; +// True while +bundleURL waits for that restore. Only then may the round +// activate what it downloads on its own (JS has not started to decide), since +// the launch resolves the bundle again afterwards. Closed once the wait ends, +// timeout or not; guarded by the state lock that every activation commits +// under, so a commit lands either before the close (and launches) or after it +// (and is left to JS) — never selected behind the packaged bundle's back. +static bool pushyPurgeRestoreWindowOpen = false; static dispatch_semaphore_t pushyRoundDone; static NSString *pushyLaunchRolledBackForRescue = nil; // A version this process downloaded but left for JS to activate. If the @@ -2374,12 +2381,13 @@ + (void)scheduleFromColdStart:(NSString *)launchRolledBackVersion { #if TARGET_OS_TV && !DEBUG // Runs this process's round before the first bundle loads, blocking the -// caller for at most kPushyPurgeRestoreBudget, and activates whatever it -// installs. The state no longer has a current version, so the server answers -// with the latest version for the packaged bundle. Returns YES when the -// round finished in time — the caller then resolves the launch bundle again. -// The round stops at the same deadline, keeping a partial download for the -// resumable retry of the next check; the launch then uses the packaged bundle. +// caller for at most kPushyPurgeRestoreBudget. The state no longer has a +// current version, so the server answers with the latest version for the +// packaged bundle, and the round activates it while the restore window is +// open. Returns NO when no round can run; otherwise the caller must resolve +// the launch bundle again: it launches whatever was activated before the +// window closed, or the packaged bundle. The round stops at the same +// deadline, keeping a partial download for the next check to resume. + (BOOL)restorePurgedLaunch:(NSString *)purgedVersion rolledBack:(NSString *)launchRolledBackVersion { [self prepareProcess:launchRolledBackVersion]; @@ -2388,6 +2396,9 @@ + (BOOL)restorePurgedLaunch:(NSString *)purgedVersion @"configured; launching the packaged bundle", purgedVersion); return NO; } + PushyWithStateLock(^{ + pushyPurgeRestoreWindowOpen = true; + }); pushyPurgeRestoreActive.store(true); NSTimeInterval deadline = PushyMonotonicNow() + kPushyPurgeRestoreBudget; dispatch_semaphore_t done = dispatch_semaphore_create(0); @@ -2395,16 +2406,21 @@ + (BOOL)restorePurgedLaunch:(NSString *)purgedVersion [self startRoundWithDeadline:deadline]; dispatch_semaphore_signal(done); }); - // The round honours the deadline itself; the extra second only covers - // its last commit. - if (dispatch_semaphore_wait(done, dispatch_time(DISPATCH_TIME_NOW, - (int64_t)((kPushyPurgeRestoreBudget + 1) * NSEC_PER_SEC))) != 0) { - RCTLogWarn(@"RCTPushy -- purged version restore timed out; launching the packaged bundle"); - return NO; + BOOL timedOut = dispatch_semaphore_wait(done, dispatch_time(DISPATCH_TIME_NOW, + (int64_t)(kPushyPurgeRestoreBudget * NSEC_PER_SEC))) != 0; + // The round's deadline does not bound a slow response (the request + // timeout is an idle timeout) nor a version already on disk, so the close + // is what stops a late activation — not the deadline. + PushyWithStateLock(^{ + pushyPurgeRestoreWindowOpen = false; + }); + if (timedOut) { + RCTLogWarn(@"RCTPushy -- purged version %@ restore timed out", purgedVersion); + } else { + NSDictionary *result = pushyHostRoundResult; + RCTLogInfo(@"RCTPushy -- purged version %@ restore: %@ %@ %@", purgedVersion, + result[@"status"], result[@"reason"], result[@"hash"]); } - NSDictionary *result = pushyHostRoundResult; - RCTLogInfo(@"RCTPushy -- purged version %@ restore: %@ %@ %@", purgedVersion, - result[@"status"], result[@"reason"], result[@"hash"]); return YES; } #endif @@ -2555,7 +2571,8 @@ + (void)activatePendingVersion { responseText:nil request:nil config:nil - responseAt:0]; + responseAt:0 + activated:NULL]; if (committed) { @synchronized (self) { pushyUnactivatedHash = nil; @@ -2705,7 +2722,8 @@ + (void)runConfiguredRound:(const flowjson::Value &)config responseText:responseText request:body config:configJson - responseAt:responseAtSeconds]; + responseAt:responseAtSeconds + activated:NULL]; pushyHostRoundResult = committed ? PushyHostResult(@"noUpdate", PushyFromStdString(decision.Get("reason").AsString()), nil, NO) : PushyHostResult(@"cancelled", @"reset", nil, NO); @@ -2734,7 +2752,8 @@ + (void)runConfiguredRound:(const flowjson::Value &)config responseText:responseText request:body config:configJson - responseAt:responseAtSeconds]; + responseAt:responseAtSeconds + activated:NULL]; pushyHostRoundResult = committed ? PushyHostResult(@"failed", @"download_failed", nil, NO) : PushyHostResult(@"cancelled", @"reset", nil, NO); @@ -2760,24 +2779,25 @@ + (void)runConfiguredRound:(const flowjson::Value &)config if (pushyCrashRescueActive.load()) { versionInfo[@"crashRescue"] = @YES; } - if (pushyPurgeRestoreActive.load()) { - versionInfo[@"purgeRestore"] = @YES; - } // Silent strategies or a server-marked forceBoot version (per-version // remote override — the brick rescue) activate for the next launch; // otherwise activation stays with the JS side (§6/§10.1). Unless a crash // is being held: JS is dead, deferring to it would leave the fix on disk // forever (§11.3). Or the launch is blocked on reinstalling a version - // tvOS purged: JS has not started, and the whole point is to boot it. + // tvOS purged: JS has not started, and the whole point is to boot it — + // commitRoundWithGeneration drops that one once the restore window closed. BOOL activate = decision.Get("activate").Truthy() || pushyCrashRescueActive.load() || pushyPurgeRestoreActive.load(); + BOOL activated = NO; BOOL committed = [self commitRoundWithGeneration:resetGeneration hashInfo:@{@"hash": hash, @"info": versionInfo} activate:activate ? hash : nil responseText:responseText request:body config:configJson - responseAt:responseAtSeconds]; + responseAt:responseAtSeconds + activated:&activated]; + activate = activated; if (!committed) { RCTLogInfo(@"RCTPushy -- native check: reset during round, dropping result"); } else if (activate) { @@ -2810,7 +2830,8 @@ + (BOOL)commitRoundWithGeneration:(uint64_t)generation responseText:(NSString *)responseText request:(NSString *)requestBody config:(NSString *)configJson - responseAt:(long long)responseAtSeconds { + responseAt:(long long)responseAtSeconds + activated:(BOOL *)activatedOut { // responseText is nil for the crash handler's activation-only commit // (activatePendingVersion): no round ran, so there is no cache to write. NSData *cacheData = nil; @@ -2824,13 +2845,31 @@ + (BOOL)commitRoundWithGeneration:(uint64_t)generation cacheData = [NSJSONSerialization dataWithJSONObject:cacheEntry options:0 error:nil]; } __block BOOL committed = NO; + __block BOOL activated = NO; PushyWithStateLock(^{ if (pushyResetGeneration.load() != generation) { return; } + NSString *activation = hashToActivate; + NSDictionary *info = hashInfoEntry[@"info"]; + // The tvOS purged-version restore round (a round commit, not the + // crash handler's activation-only one): it may take over this launch + // only while +bundleURL still waits for it; after that the version is + // left to JS unless a crash is being held (JS is dead then). + if (responseText != nil && pushyPurgeRestoreActive.load()) { + if (pushyPurgeRestoreWindowOpen) { + if (activation != nil && info != nil) { + NSMutableDictionary *marked = [info mutableCopy]; + marked[@"purgeRestore"] = @YES; + info = marked; + } + } else if (!pushyCrashRescueActive.load()) { + activation = nil; + } + } NSUserDefaults *defaults = PushyDefaults(); if (hashInfoEntry != nil) { - NSData *infoData = [NSJSONSerialization dataWithJSONObject:hashInfoEntry[@"info"] + NSData *infoData = [NSJSONSerialization dataWithJSONObject:info options:0 error:nil]; if (infoData != nil) { @@ -2838,8 +2877,9 @@ + (BOOL)commitRoundWithGeneration:(uint64_t)generation forKey:PushyHashInfoKey(hashInfoEntry[@"hash"])]; } } - if (hashToActivate != nil) { - PushySwitchVersionLocked(hashToActivate); + if (activation != nil) { + PushySwitchVersionLocked(activation); + activated = YES; } if (cacheData != nil) { [defaults setObject:[[NSString alloc] initWithData:cacheData encoding:NSUTF8StringEncoding] @@ -2847,6 +2887,9 @@ + (BOOL)commitRoundWithGeneration:(uint64_t)generation } committed = YES; }); + if (activatedOut != NULL) { + *activatedOut = activated; + } return committed; } diff --git a/src/__tests__/metadata.test.ts b/src/__tests__/metadata.test.ts index fa1012c1..bc0b45ca 100644 --- a/src/__tests__/metadata.test.ts +++ b/src/__tests__/metadata.test.ts @@ -61,6 +61,13 @@ describe('getUpdateMetadata', () => { mockCore({ currentVersionInfo: { purgeRestore: true } }); const restored = await importFreshMetadata('meta-purge-restore'); expect(restored.getUpdateMetadata().rescueSource).toBe('purgeRestore'); + + // One activation can carry several markers; the highest-priority wins. + mockCore({ + currentVersionInfo: { forceBootRescue: true, purgeRestore: true }, + }); + const both = await importFreshMetadata('meta-forceboot-purge'); + expect(both.getUpdateMetadata().rescueSource).toBe('forceBoot'); }); test('tolerates the embedded bundle and a rollback launch', async () => { diff --git a/src/metadata.ts b/src/metadata.ts index 317ba5a7..0d5abf6e 100644 --- a/src/metadata.ts +++ b/src/metadata.ts @@ -48,6 +48,8 @@ export interface UpdateMetadata { * 'forceBoot' — the server's per-version override applied by the native * cold-start check; 'crashRescue' — activated by the crash-time rescue; * 'purgeRestore' — reinstalled at launch after tvOS purged the cache. + * Several can apply to one activation (a purge restore of a forceBoot + * version); this reports the highest-priority one, in the order listed. */ rescueSource: 'forceBoot' | 'crashRescue' | 'purgeRestore' | null; /** Stable per-install client id (gray-release bucketing key). */