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..c4396982 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; @@ -742,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 @@ -755,6 +768,15 @@ + (BOOL)commitRoundWithGeneration:(uint64_t)generation // it downloads (§11.3). static std::atomic pushyCrashRescueActive{false}; static std::atomic pushyRescueAttempted{false}; +// 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 @@ -785,6 +807,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 +960,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 +1054,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 +1085,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 +1995,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 +2003,7 @@ + (void)excludeFromBackup:(NSString *)path error:&error]) { RCTLogWarn(@"Pushy exclude from backup error: %@", error.localizedDescription); } +#endif } - (void)unzipFileAtPath:(NSString *)path @@ -2047,7 +2125,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 +2334,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 +2345,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 +2379,52 @@ + (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. 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]; + if (![self hasRunnableConfig]) { + RCTLogWarn(@"RCTPushy -- version %@ was purged and no native check is " + @"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); + dispatch_async(dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0), ^{ + [self startRoundWithDeadline:deadline]; + dispatch_semaphore_signal(done); + }); + 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"]); + } + return YES; +} +#endif + + (NSDictionary *)checkAndUpdate { #if DEBUG return PushyHostResult(@"skipped", @"debug", nil, NO); @@ -2430,7 +2571,8 @@ + (void)activatePendingVersion { responseText:nil request:nil config:nil - responseAt:0]; + responseAt:0 + activated:NULL]; if (committed) { @synchronized (self) { pushyUnactivatedHash = nil; @@ -2532,7 +2674,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(); @@ -2580,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); @@ -2609,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); @@ -2639,15 +2783,21 @@ + (void)runConfiguredRound:(const flowjson::Value &)config // 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 — + // 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) { @@ -2680,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; @@ -2694,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) { @@ -2708,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] @@ -2717,6 +2887,9 @@ + (BOOL)commitRoundWithGeneration:(uint64_t)generation } committed = YES; }); + if (activatedOut != NULL) { + *activatedOut = activated; + } return committed; } diff --git a/package.json b/package.json index f3f27581..ed239641 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 76037a2a..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 @@ -82,7 +91,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'] @@ -178,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. '' 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..bc0b45ca 100644 --- a/src/__tests__/metadata.test.ts +++ b/src/__tests__/metadata.test.ts @@ -57,6 +57,17 @@ 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'); + + // 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/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..0d5abf6e 100644 --- a/src/metadata.ts +++ b/src/metadata.ts @@ -46,9 +46,12 @@ 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. + * 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' | null; + rescueSource: 'forceBoot' | 'crashRescue' | 'purgeRestore' | null; /** Stable per-install client id (gray-release bucketing key). */ uuid: string; os: string; @@ -74,7 +77,9 @@ export function getUpdateMetadata(): UpdateMetadata { ? 'forceBoot' : info.crashRescue ? 'crashRescue' - : null, + : info.purgeRestore + ? 'purgeRestore' + : null, uuid: cInfo.uuid, os: cInfo.os, };