Skip to content

fix: harden and fix bundle hot-update across all platforms#10340

Draft
huhuanming wants to merge 46 commits intoxfrom
analyze/bundle-update-issues
Draft

fix: harden and fix bundle hot-update across all platforms#10340
huhuanming wants to merge 46 commits intoxfrom
analyze/bundle-update-issues

Conversation

@huhuanming
Copy link
Contributor

@huhuanming huhuanming commented Feb 25, 2026

Summary

Comprehensive bundle hot-update overhaul: fix critical bugs, harden security, add dev tooling and tests across iOS, Android, and Desktop.

Bug Fixes

Critical

Platform Issue Impact
iOS currentMetadataJson double path concatenation Metadata always fails → hot update unusable
Android downloadBundleASC never saves signature to SharedPreferences Signature verification fails on next launch
Desktop HTTP 416 handler returns from callback instead of resolving Promise Promise hangs forever, download stuck
Desktop verifyAndResolve calls both reject() and resolve() Unhandled rejection warnings
iOS nil currentSignature inserted into NSDictionary App crash (NSInvalidArgumentException)
Desktop fs.renameSync before writeStream finishes flushing Corrupted/incomplete file on disk
Android Double promise.resolve in verifyASC Crash on double resolve
Android Missing return after promise.reject in verifyAPK Double reject crash

Medium

  • iOS/Android: Version string split now handles 1.0.0-beta format (lastIndexOf("-"))
  • Android: Merged two SharedPreferences.Editor.apply() into one atomic operation
  • Desktop: Fixed relative redirect URL handling in bundle download
  • iOS: Removed dead partial-file resume code (incompatible with NSURLSessionDownloadTask)
  • iOS: Fixed validateAllFilesInDir duplicate recursive validation
  • iOS: Store bundle signature in installBundle to match Android behavior
  • Android: Removed premature promise.resolve in downloadASC
  • JS Bridge: Fixed swapped event subscription variable names

Low

  • Fixed method name typo valiateAllFilesInDirvalidateAllFilesInDir
  • Removed stray console.log in production code
  • Specified UTF-8 encoding in Android readFileContent
  • Reduced cache-hit delays from 10s/5s → 1s

Security Hardening

  • HTTPS enforcement: Reject non-HTTPS bundle download URLs on all platforms; prevent HTTPS→HTTP redirect downgrade (Desktop + Android OkHttp interceptor)
  • Path traversal protection: Validate extracted files stay within destination directory during zip extraction (Desktop + iOS)
  • Symlink rejection: Reject symbolic links in extracted bundles (Desktop + iOS)
  • Downgrade prevention: Prevent bundle version downgrade attacks on all platforms
  • Integrity verification: Verify all extracted files against metadata SHA256 on all platforms; cleanup extracted directory on verification failure
  • Input validation: Validate server config response schema and jsBundle URL scheme; validate downloadedEvent before use
  • Download timeout: Add 30-minute download timeout for native platforms (iOS/Android)
  • Cleanup: GPG verification temp file cleanup in finally block (Android)

New Features

  • Dev Bundle Switcher: Two-layer UI in dev settings (version list → bundle list) with download progress and bundle switching. GPG verification skippable in dev mode
  • Local Bundles page: Shows all bundles on device with offline switching. Verifies file integrity via verifyExtractedBundle before switching
  • listLocalBundles API: Scans bundle directory on all platforms to list extracted bundles
  • verifyExtractedBundle API: On-demand SHA256 integrity check for previously downloaded bundles
  • isBundleExists API: Check if a bundle directory exists on device

Tests

  • 126+ unit tests covering shared bundle update logic (version comparison, update detection, HTTPS validation, error mapping)
  • Desktop tests: SHA256/SHA512 verification, GPG signature, path traversal, downgrade prevention
  • Native e2e harness tests: SHA256, parameter validation, HTTPS enforcement

Test Plan

  • iOS hot update: download → verify → install → restart loads new bundle
  • Android hot update: download → verify → install → restart loads new bundle
  • Desktop hot update: download → verify → install → relaunch loads new bundle
  • Fallback bundle switching on all platforms
  • clearAllJSBundleData on all platforms
  • Download progress reporting
  • Resume download on Desktop (interrupt and restart)
  • CDN URL with redirect
  • Dev Bundle Switcher: download, switch, verify local bundles

- Fix swapped event subscription variable names in native JS bridge
- Fix iOS currentMetadataJson double path concatenation causing metadata lookup failure
- Fix iOS nil signature crash in installBundle fallback data
- Fix iOS dead partial-file resume code that never worked with NSURLSessionDownloadTask
- Fix iOS validateAllFilesInDir duplicate recursive validation
- Fix Android downloadBundleASC not saving signature to SharedPreferences
- Fix Android SharedPreferences race condition with two separate apply() calls
- Fix Android version string split using lastIndexOf for dash-containing versions
- Fix Desktop 416 status Promise leak (return in callback instead of resolve)
- Fix Desktop verifyAndResolve executing resolve after reject
- Fix Desktop writeStream race condition (rename before stream fully flushed)
- Add Desktop HTTP redirect following (301/302/307/308)
- Reduce unreasonable cache-hit delays from 10s/5s to 1s
- Remove stray console.log in production code
- Fix method name typo valiateAllFilesInDir -> validateAllFilesInDir
- Specify UTF-8 encoding in Android readFileContent
@revan-zhang
Copy link
Contributor

revan-zhang commented Feb 25, 2026

Snyk checks have passed. No issues have been found so far.

Status Scanner Critical High Medium Low Total (0)
Open Source Security 0 0 0 0 0 issues
Licenses 0 0 0 0 0 issues

💻 Catch issues earlier using the plugins for VS Code, JetBrains IDEs, Visual Studio, and Eclipse.

devin-ai-integration[bot]

This comment was marked as resolved.

- Enforce HTTPS for bundle download URLs (desktop/iOS/Android)
- Add path traversal protection for zip extraction (desktop)
- Verify all extracted files against metadata SHA256 (all platforms)
- Validate all bundle files on startup, not just main.jsbundle.hbc (iOS/Android)
- Prevent bundle version downgrade attacks (all platforms)
- Require signature and sha256 before seamless install (SplashProvider/hooks)
- Validate server config response schema and jsBundle URL scheme
devin-ai-integration[bot]

This comment was marked as resolved.

- Add try-catch around cached file verification to prevent Promise hang
  and isDownloading getting stuck when SHA256 check fails
- Enforce HTTPS on redirect URLs to prevent downgrade via Location header
…load

Add OkHttp network interceptor to reject any request redirected to
a non-HTTPS URL, matching the desktop redirect protection.
Android has usesCleartextTraffic=true in manifest, so OkHttp would
otherwise silently follow HTTPS→HTTP redirects.
devin-ai-integration[bot]

This comment was marked as resolved.

- Reject symbolic links during extracted file verification (desktop)
- Cleanup extracted directory on verification failure (all platforms)
- Check SSZipArchive return value before proceeding (iOS)
- Add writeStream error handler to prevent download hang (desktop)
- Validate HTTPS in updateDownloadUrl (ServiceAppUpdate)
- Add 30-minute download timeout for native platforms (iOS/Android)
- Cleanup GPG verification temp file in finally block (Android)
Add 126 unit tests and ~30 harness tests covering bundle update logic:
- Shared: version comparison, update detection, HTTPS validation, error mapping
- Desktop: SHA256/SHA512 verification, GPG signature, path traversal, downgrade prevention
- Native (harness): SHA256, parameter validation, HTTPS enforcement, test helpers
Two-layer UI in dev settings: version list → bundle list with
download progress tracking and bundle switching. GPG verification
can be skipped in dev mode. Uses mock API data (to be replaced
with real endpoints).
Add isBundleExists API across all platforms (Desktop/iOS/Android)
to check if a bundle directory already exists. BundleList UI now
checks on mount and shows "Switch to this bundle" directly for
bundles that were previously downloaded and extracted.
… mode

isBundleExists now reads metadata.json and validates all extracted
files against their SHA256 hashes before reporting a bundle as
existing. installBundle now accepts skipGPGVerification to bypass
the version downgrade check in dev bundle switcher mode.
…le download limit

Revert isBundleExists to simple directory existence check on all
platforms. Replace Download button with a small download icon.
Only one bundle can be downloaded at a time - other download icons
are disabled while a download is in progress.
Add verifyExtractedBundle(appVersion, bundleVersion) across all
platforms. Reads metadata.json and validates every file's SHA256.
Called only when user clicks "Switch to this bundle" for previously
downloaded bundles, avoiding slow checks on page load.
devin-ai-integration[bot]

This comment was marked as resolved.

Shows all bundles on device (current + fallback list). Each bundle
can be switched to directly without downloading. Verifies file
integrity before switching via verifyExtractedBundle, then calls
switchBundle to update and restart.
Replace getFallbackBundles with listLocalBundles in LocalBundleList page.
The new API scans the bundle directory on all platforms (Desktop/iOS/Android)
to accurately list all extracted bundles on device for offline switching.
devin-ai-integration[bot]

This comment was marked as resolved.

Post-extraction security check validates all extracted files are within
the destination directory and rejects symbolic links to prevent attacks.
Aligns iOS with Desktop and Android which already have this protection.
- Remove duplicate promise.resolve in Android verifyASC (double resolve crash)
- Add missing return after promise.reject in Android verifyAPK (double reject crash)
- Remove redundant else reject in verifyAPK since checkFilePackage already rejects
- Add bundle directory existence check before updating store in Desktop installBundle
- Add bundle directory existence check before updating UserDefaults in iOS installBundle
Android installAPK:
- Add file existence check before checkFilePackage
- Remove double promise.reject when checkFilePackage fails
- Move promise.resolve after startActivity so failures reject properly
- Reject with INVALID_PACKAGE when APK cannot be parsed (info==null)
- Early return with correct error when ASC signature file is missing
- Add FLAG_ACTIVITY_NEW_TASK for all Android versions
- Add getCurrentActivity() null check before startActivity

Desktop installPackage:
- Throw error instead of silent return when verification fails
- Validate downloadedEvent before spreading to main process

JS layer:
- Call onFail for all install errors, not just NOT_FOUND_PACKAGE
- Add downloadedEvent existence check in SplashProvider for all file types
devin-ai-integration[bot]

This comment was marked as resolved.

- Fix relative redirect URL handling in Desktop bundle download
- Store bundle signature in iOS installBundle to match Android behavior
- Remove premature promise.resolve in Android downloadASC
- Fix TypeScript errors in e2e harness tests
devin-ai-integration[bot]

This comment was marked as resolved.

- Split "Dev App Update Settings" into separate "App Update Test" and
  "JS Bundle Manager" pages
- Add new "App Update" accordion section in dev settings grouping
  app update test, JS bundle manager, and firmware update
- Redesign all bundle manager pages with card-based sections, icon
  badges, status indicators, and proper visual hierarchy
- Pin current app version to top in Remote Bundles version list
- Add 6.1.0 mock data for bundle version testing
Previously the desktop implementation silently skipped file integrity
verification when metadata.json was absent from the extracted zip.
iOS and Android both explicitly reject this case. Now desktop throws
an error to match, preventing unverified bundles from being installed.
@huhuanming huhuanming enabled auto-merge (squash) February 25, 2026 16:54
GPG signature and SHA256 verification are already covered by unit tests
(desktop Jest + RN harness), making the UI button redundant.
…e registration

Remove unnecessary 1200ms setTimeout in installBundle and setCurrentUpdateBundleData.
Destroy the BrowserWindow before app.relaunch() to ensure the renderer process is
fully terminated, preventing the "webview" custom element from being registered twice
which causes NotSupportedError on all desktop platforms (MAS/WinMS/Snap).

Fixes DESKTOP-MAS-1, DESKTOP-WINMS-PY, DESKTOP-SNAP-8R
Cover the state machine that drives bundle/app updates:
- Happy path (notify → download → ASC → verify → ready)
- Error paths with error message mapping
- Download timeout (30 min)
- Recovery and reset flows
- Security validations (HTTPS enforcement, updateStrategy)
- fetchAppUpdateInfo integration and caching
Copy link
Contributor

@devin-ai-integration devin-ai-integration bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 1 new potential issue.

View 33 additional findings in Devin Review.

Open in Devin Review

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Android downloadBundle: promise.reject called after promise already resolved causes crash

In BundleUpdateModule.java, downloadBundle calls promise.resolve(result) at line 824 immediately after enqueuing the asynchronous OkHttp callback. If the download subsequently fails, the callback invokes promise.reject() at lines 774, 784, or 815 — but the promise was already resolved. On React Native, calling reject on an already-resolved promise throws an IllegalStateException and crashes the app.

Root Cause and Impact

The method follows a pattern where the promise resolves eagerly with the file-path result, and download completion/error is communicated via events (update/error, update/complete). This is the correct pattern used by the iOS side — iOS's downloadBundle resolves the promise at BundleUpdateModule.m:811 and then only uses delegate callbacks to send events, never touching the promise again.

However, the Android implementation also calls promise.reject(...) inside the Callback.onFailure (line 774), Callback.onResponse for non-success (line 784), and after SHA256 verification failure (line 815). Since the promise was already resolved at line 824, these reject calls will crash the app.

For example, if the server returns an HTTP error:

// Line 824 - promise already resolved
promise.resolve(result);

// Later, async callback fires:
// Line 784 - CRASH: promise already settled
promise.reject("DOWNLOAD_ERROR", "HTTP " + response.code());

Impact: Any download failure (network error, HTTP error, SHA256 mismatch) will crash the Android app with an unhandled native exception.

(Refers to lines 774-824)

Prompt for agents
In apps/mobile/android/app/src/main/java/so/onekey/app/wallet/BundleUpdateModule.java, the downloadBundle method (around line 708) calls promise.resolve(result) at line 824 after enqueuing the async OkHttp callback. The async callback also calls promise.reject() on failure at lines 774, 784, and 815. Since the promise is already resolved, these reject calls will crash the app.

Fix: Remove all promise.reject() calls inside the enqueued Callback (onFailure and onResponse error paths). The error handling is already done correctly via sendEvent("update/error", ...) which the JS side listens for. Similarly, remove the promise.reject at line 815 for verification failure. The callback should only use events (sendEvent) to communicate results, not the promise, since the promise is resolved immediately.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. Moved promise.resolve(result) from after enqueue() into the async callback's success path (after sendEvent("update/complete")). Now the promise is settled exactly once: resolve on success, reject on failure.

@huhuanming huhuanming marked this pull request as draft February 26, 2026 04:27
auto-merge was automatically disabled February 26, 2026 04:27

Pull request was converted to draft

…pdate flow

- Add state transition guards on all intermediate methods (downloadASC,
  verifyASC, verifyPackage, readyToInstall) to prevent state regression
- Add guards on failed methods (downloadASCFailed, verifyASCFailed,
  verifyPackageFailed) to only accept transitions from correct states
- Allow current-state re-entry for breakpoint recovery on app restart
- Add failed state auto-recovery timer (2h) resetting to notify
- Fix fetchAppUpdateInfo to clear downloadedEvent for verify failures
  when a newer version resets the state to notify
- Fix refreshUpdateStatus to reset failed states on app launch
- Add 147 ServiceAppUpdate unit tests covering all state transitions
- Add 52 hooks.test.ts tests for useDownloadPackage, useAppUpdateInfo
  useEffect startup logic, and onUpdateAction routing
Cover all branches of the seamless update install-at-launch logic:
- Non-seamless strategies (manual/force/silent) show splash immediately
- Seamless + first launch after update → refreshUpdateStatus + splash
- Seamless + ready + missing downloadedEvent → reset + splash
- Seamless + ready + jsBundle missing signature/sha256 → reset + splash
- Seamless + ready + jsBundle valid → BundleUpdate.installBundle
- Seamless + ready + appShell → AppUpdate.installPackage
- Install failure → reset + splash
- Seamless + non-ready status → splash without install
- Idempotency: useLayoutEffect fires only once
- Web/extension platform → always returns true
The useDisplaySplash hook had several paths where displaySplash could
stay false forever, leaving the user stuck on the native splash screen:

1. getUpdateInfo() throws — no outer catch, async rejection swallowed
2. getUpdateInfo() hangs — async never resolves, no timeout fallback
3. refreshUpdateStatus() throws — setDisplaySplash called after await
4. reset() throws in guard paths — setDisplaySplash called after await
5. Install succeeds but app doesn't restart — no fallback

Fixes:
- Wrap entire launchCallback body in try/catch with setDisplaySplash(true)
- Move setDisplaySplash(true) before all await calls in guard paths
- Add 10s safety timeout as last-resort fallback

Added 6 error resilience tests covering all identified stuck paths.
Add debug logging across all bundle update code paths to aid
troubleshooting: download guards, HTTPS rejections, redirect
errors, HTTP status errors, timeouts, verification failures,
state guard rejections, recovery timer, splash safety timeout,
and install parameter validation. Also add missing test coverage
for new code branches and fix test mocks.
@socket-security
Copy link

socket-security bot commented Feb 28, 2026

Warning

Review the following alerts detected in dependencies.

According to your organization's Security Policy, it is recommended to resolve "Warn" alerts. Learn more about Socket for GitHub.

Action Severity Alert  (click "▶" to expand/collapse)
Warn High
Obfuscated code: npm @polkadot/util-crypto is 91.0% likely obfuscated

Confidence: 0.91

Location: Package overview

From: ?npm/@polkadot/util-crypto@13.5.9

ℹ Read more on: This package | This alert | What is obfuscated code?

Next steps: Take a moment to review the security alert above. Review the linked package source code to understand the potential risk. Ensure the package is not malicious before proceeding. If you're unsure how to proceed, reach out to your security team or ask the Socket team for help at support@socket.dev.

Suggestion: Packages should not obfuscate their code. Consider not using packages with obfuscated code.

Mark the package as acceptable risk. To ignore this alert only in this pull request, reply with the comment @SocketSecurity ignore npm/@polkadot/util-crypto@13.5.9. You can also ignore all packages with @SocketSecurity ignore-all. To ignore an alert for all future pull requests, use Socket's Dashboard to change the triage state of this alert.

View full report

Remove all legacy RCTBridgeModule (iOS) and ReactContextBaseJavaModule
(Android) implementations, replacing them with Nitro Module imports from
the @onekeyfe scoped packages in app-modules.

Deleted legacy native files:
- iOS: BundleUpdateModule, Verification, LaunchOptionsManager, PerfMemoryModule
- Android: BundleUpdateModule, AutoUpdateModule, Verification, PerfMemoryModule,
  WebViewCheckerModule, ExitModule, LaunchOptionModule, SplashScreen (full dir),
  all associated Package files, and flavor-specific AutoUpdateModulePackage

Updated native entry points:
- AppDelegate.swift: BundleUpdateStore + DeviceUtilsStore (Nitro)
- CustomReactNativeHost.java: BundleUpdateStoreAndroid (Nitro)
- MainApplication.java: removed all legacy package registrations
- Bridging-Header: removed legacy ObjC imports
- project.pbxproj: removed deleted file references

Updated JS/TS wrappers to use Nitro imports:
- auto-update/index.native.ts: NativeEventEmitter → callback listeners
- useJsBundle.native.ts, androidNativeEnv.android.ts
- LaunchOptionsManager.native.ts → ReactNativeDeviceUtils
- SplashView.native.tsx → ReactNativeSplashScreen
- webview-checker/index.android.tsx → ReactNativeWebviewChecker
- memoryCollector.native.ts → ReactNativePerfMemory
- rnNativeModules.ts: removed migrated type definitions

Added new dependencies to apps/mobile/package.json:
- @onekeyfe/react-native-app-update
- @onekeyfe/react-native-bundle-update
- @onekeyfe/react-native-perf-memory
- @onekeyfe/react-native-splash-screen
- @onekeyfe/react-native-webview-checker
Update .oxlintrc.json with airbnb/wesbos rules, import rules, and React
rules to match eslint configuration. Add eslint-disable comments alongside
existing oxlint-disable comments across 117 source files to ensure both
linters produce consistent suppression results. Fix comment ordering so
eslint-disable-next-line appears before oxlint-disable-next-line.
Automatically fixed 75 violations across 24 files including
prettier formatting, no-var to const/let conversions, and
prefer-const fixes.
@socket-security
Copy link

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Addedtailwindcss@​3.4.18961008798100
Updatedws@​7.4.6 ⏵ 8.18.399 +1100 +1610088100

View full report

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants