Skip to content

Commit c1b037d

Browse files
authored
Bundle update: use native skip-GPG capability and commit-hash matching (#10523)
1 parent 16f083a commit c1b037d

File tree

24 files changed

+382
-127
lines changed

24 files changed

+382
-127
lines changed

.claude/skills/1k-feature-guides/references/rules/page-and-route.md

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -371,6 +371,67 @@ navigation.navigate(ERootRoutes.Main, {
371371

372372
---
373373

374+
## Native Tab View Navigation Safety
375+
376+
### ⚠️ CRITICAL: Overlay Dismissal with Native UITabBarController
377+
378+
When the app uses native `UITabBarController` (`@onekeyfe/react-native-tab-view`), **non-selected tabs' views are removed from the iOS window hierarchy**. This means any `RNSScreenStack` inside an inactive tab has `window=NIL` and cannot process navigation updates.
379+
380+
**Problem**: Calling `goBack()` to pop overlay routes (Modal, FullScreenPush) triggers React Navigation to reconcile nested stacks inside those routes. If a nested stack is inside a tab that has lost its window, the native `setPushViewControllers` is SKIPPED and retries 50 times (~5 seconds) before giving up. The navigation appears frozen until the user touches the screen.
381+
382+
**Rule**: When navigating from an overlay route to a tab page, **never use sequential `goBack()` calls**. Use `navigateFromOverlayToTab()` or `resetAboveMainRoute()` instead.
383+
384+
```typescript
385+
// ❌ WRONG: Sequential goBack() causes window-nil race condition
386+
await popScanModalPages();
387+
await popActionCenterPages(); // Stack inside detached tab = window NIL = FAIL
388+
navigation.switchTab(ETabRoutes.Home);
389+
navigation.push(targetPage);
390+
391+
// ✅ CORRECT: Use navigateFromOverlayToTab utility
392+
await popScanModalPages(); // Dismiss modal with animation
393+
await waitForScanModalClosed();
394+
await navigateFromOverlayToTab({ // Atomically reset + switch tab
395+
targetTab: ETabRoutes.Home,
396+
switchTab: (tab) => navigation.switchTab(tab),
397+
});
398+
navigation.push(targetPage); // Safe to push now
399+
400+
// ✅ ALSO CORRECT: Use resetAboveMainRoute directly
401+
await popScanModalPages();
402+
await waitForScanModalClosed();
403+
resetAboveMainRoute(); // Atomically remove all overlay routes
404+
navigation.switchTab(ETabRoutes.Home);
405+
await timerUtils.wait(100); // Wait for navigator to settle
406+
navigation.push(targetPage);
407+
```
408+
409+
**Key utilities** (exported from `@onekeyhq/components`):
410+
- `navigateFromOverlayToTab()` — Safe overlay-to-tab navigation with atomic reset
411+
- `resetAboveMainRoute()` — Atomically remove all routes above Main via `CommonActions.reset`
412+
413+
### Why `switchTab()` alone cannot activate the target tab
414+
415+
When overlay routes (FullScreenPush, Modal) are stacked above Main, calling `switchTab()` only changes `UITabBarController.selectedIndex` internally. The target tab's view is **NOT** added to the window hierarchy because the overlay route's view is still the topmost visible layer. The `UITabBarController` only manages views within its own container — if that container is obscured by an overlay, the tab view stays detached.
416+
417+
```
418+
Root State: [Main, FullScreenPush, Modal]
419+
↑ overlay is topmost visible view
420+
UITabBarController's tab views are underneath, not in window
421+
422+
switchTab(Home) → selectedIndex changes, but Home tab's view still has window=NIL
423+
goBack() to pop FullScreenPush → nested stack update fails (window=NIL)
424+
```
425+
426+
Therefore, **you must use `resetAboveMainRoute()` to atomically remove all overlays first**, making Main the topmost route. Only then will `switchTab()` cause the target tab's view to enter the window hierarchy.
427+
428+
**When to watch out**:
429+
- Any code that calls `goBack()` on root navigator while a FullScreenPush or Modal is active
430+
- Any flow that dismisses overlay pages and then navigates to a different tab
431+
- Cross-tab navigation after closing settings/action center pages
432+
433+
---
434+
374435
## Files Reference
375436

376437
| Purpose | Location |

.github/workflows/release-desktop-bundle.yml

Lines changed: 0 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -7,17 +7,10 @@ on:
77
types:
88
- completed
99
workflow_dispatch:
10-
inputs:
11-
ONEKEY_ALLOW_SKIP_GPG_VERIFICATION:
12-
description: "Allow skipping GPG verification for bundle updates (default: false)"
13-
required: false
14-
type: boolean
15-
default: false
1610

1711
env:
1812
NODE_ENV: production
1913
YARN_ENABLE_GLOBAL_CACHE: true
20-
ONEKEY_ALLOW_SKIP_GPG_VERIFICATION: ${{ github.event.inputs.ONEKEY_ALLOW_SKIP_GPG_VERIFICATION || 'false' }}
2114

2215
jobs:
2316
release-desktop-bundle:
@@ -52,11 +45,6 @@ jobs:
5245
sentry_dsn_winms: ${{ secrets.SENTRY_DSN_WINMS }}
5346
sentry_dsn_ext: ${{ secrets.SENTRY_DSN_EXT }}
5447

55-
- name: Warn if GPG verification is skipped
56-
if: ${{ env.ONEKEY_ALLOW_SKIP_GPG_VERIFICATION == 'true' }}
57-
run: |
58-
echo "::warning::GPG verification is SKIPPED for this build. This should only be used in CI/dev builds."
59-
6048
- name: 'Setup ENV'
6149
run: |
6250
eval "$(node -e 'const v=require("./apps/desktop/package.json").version; console.log("pkg_version="+v)')"

.github/workflows/release-native-bundle.yml

Lines changed: 0 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -7,17 +7,10 @@ on:
77
types:
88
- completed
99
workflow_dispatch:
10-
inputs:
11-
ONEKEY_ALLOW_SKIP_GPG_VERIFICATION:
12-
description: "Allow skipping GPG verification for bundle updates (default: false)"
13-
required: false
14-
type: boolean
15-
default: false
1610

1711
env:
1812
NODE_ENV: production
1913
YARN_ENABLE_GLOBAL_CACHE: true
20-
ONEKEY_ALLOW_SKIP_GPG_VERIFICATION: ${{ github.event.inputs.ONEKEY_ALLOW_SKIP_GPG_VERIFICATION || 'false' }}
2114

2215
jobs:
2316
release-native-bundle:
@@ -52,11 +45,6 @@ jobs:
5245
sentry_dsn_winms: ${{ secrets.SENTRY_DSN_WINMS }}
5346
sentry_dsn_ext: ${{ secrets.SENTRY_DSN_EXT }}
5447

55-
- name: Warn if GPG verification is skipped
56-
if: ${{ env.ONEKEY_ALLOW_SKIP_GPG_VERIFICATION == 'true' }}
57-
run: |
58-
echo "::warning::GPG verification is SKIPPED for this build. This should only be used in CI/dev builds."
59-
6048
- name: 'Setup ENV'
6149
run: |
6250
eval "$(node -e 'const v=require("./apps/desktop/package.json").version; console.log("pkg_version="+v)')"

apps/mobile/android/app/src/main/java/so/onekey/app/wallet/CustomReactNativeHost.java

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import android.app.Application;
44
import android.content.Context;
55
import com.facebook.react.defaults.DefaultReactNativeHost;
6+
import com.margelo.nitro.nativelogger.OneKeyLog;
67
import com.margelo.nitro.reactnativebundleupdate.BundleUpdateStoreAndroid;
78
import java.io.File;
89

@@ -26,11 +27,17 @@ public String getJSBundleFile() {
2627
if (bundlePath != null) {
2728
File bundleFile = new File(bundlePath);
2829
if (bundleFile.exists()) {
30+
OneKeyLog.info("BundleUpdate", "getJSBundleFile: using OTA bundle path=" + bundlePath);
2931
return bundlePath;
3032
}
33+
OneKeyLog.warn("BundleUpdate", "getJSBundleFile: OTA path not found, path=" + bundlePath);
34+
} else {
35+
OneKeyLog.info("BundleUpdate", "getJSBundleFile: OTA path is null");
3136
}
3237

3338
// Fallback to default bundle
34-
return super.getJSBundleFile();
39+
String fallbackPath = super.getJSBundleFile();
40+
OneKeyLog.info("BundleUpdate", "getJSBundleFile: fallback bundle path=" + fallbackPath);
41+
return fallbackPath;
3542
}
3643
}

apps/mobile/android/build.gradle

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,11 @@ allprojects {
8484
mavenCentral()
8585
maven { url 'https://www.jitpack.io' }
8686
}
87+
// Pin app.notifee:core to the version bundled in node_modules to avoid
88+
// querying remote repos (jitpack.io) which can time out on EAS builds.
89+
configurations.all {
90+
resolutionStrategy.force 'app.notifee:core:202108261754'
91+
}
8792
}
8893

8994
apply plugin: "expo-root-project"

apps/mobile/ios/AppDelegate.swift

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -118,13 +118,32 @@ class ReactNativeDelegate: ExpoReactNativeFactoryDelegate {
118118

119119
override func bundleURL() -> URL? {
120120
#if DEBUG
121-
return RCTBundleURLProvider.sharedSettings().jsBundleURL(forBundleRoot: ".expo/.virtual-metro-entry")
121+
let metroURL = RCTBundleURLProvider.sharedSettings().jsBundleURL(forBundleRoot: ".expo/.virtual-metro-entry")
122+
NitroModuleBridge.logInfo("BundleUpdate", "bundleURL(DEBUG): metroURL=\(metroURL?.absoluteString ?? "nil")")
123+
return metroURL
122124
#else
123125
// Check for updated bundle via dynamic bridge (avoids Nitro module import)
124-
if let bundlePath = NitroModuleBridge.currentBundleMainJSBundle() {
125-
return URL(string: bundlePath)
126+
if let bundlePath = NitroModuleBridge.currentBundleMainJSBundle(), !bundlePath.isEmpty {
127+
let isFileURL = bundlePath.hasPrefix("file://")
128+
let bundleFilePath = isFileURL ? (URL(string: bundlePath)?.path ?? bundlePath) : bundlePath
129+
let exists = FileManager.default.fileExists(atPath: bundleFilePath)
130+
NitroModuleBridge.logInfo("BundleUpdate", "bundleURL(RELEASE): otaPath=\(bundlePath), exists=\(exists)")
131+
132+
if exists {
133+
if isFileURL, let fileURL = URL(string: bundlePath) {
134+
NitroModuleBridge.logInfo("BundleUpdate", "bundleURL(RELEASE): using OTA file URL=\(fileURL.absoluteString)")
135+
return fileURL
136+
}
137+
let fileURL = URL(fileURLWithPath: bundlePath)
138+
NitroModuleBridge.logInfo("BundleUpdate", "bundleURL(RELEASE): using OTA file path=\(fileURL.absoluteString)")
139+
return fileURL
140+
}
141+
142+
NitroModuleBridge.logInfo("BundleUpdate", "bundleURL(RELEASE): OTA path not found, will fallback")
126143
}
127-
return Bundle.main.url(forResource: "main", withExtension: "jsbundle")
144+
let fallbackURL = Bundle.main.url(forResource: "main", withExtension: "jsbundle")
145+
NitroModuleBridge.logInfo("BundleUpdate", "bundleURL(RELEASE): fallback main.jsbundle=\(fallbackURL?.absoluteString ?? "nil")")
146+
return fallbackURL
128147
#endif
129148
}
130149
}

apps/mobile/ios/Podfile.lock

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4218,9 +4218,9 @@ PODS:
42184218
- RNZipArchive/Core (7.0.2):
42194219
- React-Core
42204220
- SSZipArchive (~> 2.5.5)
4221-
- SDWebImage (5.21.5):
4222-
- SDWebImage/Core (= 5.21.5)
4223-
- SDWebImage/Core (5.21.5)
4221+
- SDWebImage (5.21.7):
4222+
- SDWebImage/Core (= 5.21.7)
4223+
- SDWebImage/Core (5.21.7)
42244224
- SDWebImageSVGCoder (1.7.0):
42254225
- SDWebImage/Core (~> 5.6)
42264226
- SDWebImageWebPCoder (0.14.6):
@@ -5026,7 +5026,7 @@ SPEC CHECKSUMS:
50265026
RNSVG: d260beec1775108e1bd065a0ed666ff2c5565158
50275027
RNWorklets: 8068c8af4b241eb2c19221310729e4c440bee023
50285028
RNZipArchive: 4304f5100eab004eeb7349adc51997b3a28deb76
5029-
SDWebImage: e9c98383c7572d713c1a0d7dd2783b10599b9838
5029+
SDWebImage: e9fc87c1aab89a8ab1bbd74eba378c6f53be8abf
50305030
SDWebImageSVGCoder: 15a300a97ec1c8ac958f009c02220ac0402e936c
50315031
SDWebImageWebPCoder: e38c0a70396191361d60c092933e22c20d5b1380
50325032
Sentry: b53951377b78e21a734f5dc8318e333dbfc682d7

development/babelTools.js

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -118,10 +118,6 @@ function normalizeConfig({ platform, config }) {
118118
'platformEnv.isNative': isNative,
119119
'platformEnv.isExtChrome': isExtChrome,
120120
'platformEnv.isExtFirefox': isExtFirefox,
121-
'process.env.ONEKEY_ALLOW_SKIP_GPG_VERIFICATION': process.env
122-
.ONEKEY_ALLOW_SKIP_GPG_VERIFICATION
123-
? process.env.ONEKEY_ALLOW_SKIP_GPG_VERIFICATION === 'true'
124-
: process.env.NODE_ENV !== 'production',
125121
},
126122
],
127123
/*

development/webpack/webpack.base.config.js

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -110,10 +110,6 @@ const basePlugins = [
110110
PERF_FUNCTION_WARN_MS: JSON.stringify(
111111
process.env.PERF_FUNCTION_WARN_MS || '',
112112
),
113-
ONEKEY_ALLOW_SKIP_GPG_VERIFICATION: process.env
114-
.ONEKEY_ALLOW_SKIP_GPG_VERIFICATION
115-
? process.env.ONEKEY_ALLOW_SKIP_GPG_VERIFICATION === 'true'
116-
: process.env.NODE_ENV !== 'production',
117113
},
118114
},
119115
}),

packages/components/src/layouts/Navigation/Navigator/NavigationContainer.tsx

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
} from 'react';
1010

1111
import {
12+
CommonActions,
1213
DarkTheme,
1314
DefaultTheme,
1415
NavigationContainer as RNNavigationContainer,
@@ -304,6 +305,62 @@ export const popActionCenterPages = async (maxRetryTimes = 99) => {
304305
await popActionCenterPages(maxRetryTimes - 1);
305306
};
306307

308+
/**
309+
* Atomically remove all routes above the Main route (Modal, FullScreenPush, etc.)
310+
* using CommonActions.reset. No transition animation, but avoids the native
311+
* UITabBarController window-nil race condition where RNSScreenStack retries
312+
* exhaust when goBack() is called on a stack inside a detached tab view.
313+
*
314+
* Prefer this over sequential goBack() calls when you need to dismiss multiple
315+
* overlay routes and the intermediate animation is not important.
316+
*/
317+
export const resetAboveMainRoute = () => {
318+
const state = rootNavigationRef.current?.getRootState();
319+
if (!state) {
320+
return;
321+
}
322+
const mainRoutes = state.routes.filter(
323+
(route) => route.name === ERootRoutes.Main,
324+
);
325+
if (mainRoutes.length === 0 || mainRoutes.length === state.routes.length) {
326+
return;
327+
}
328+
rootNavigationRef.current?.dispatch(
329+
CommonActions.reset({
330+
...state,
331+
routes: mainRoutes,
332+
index: mainRoutes.length - 1,
333+
}),
334+
);
335+
};
336+
337+
/**
338+
* Safely navigate from an overlay route (Modal/FullScreenPush) to a tab page.
339+
*
340+
* When using native UITabBarController, calling goBack() on overlay routes
341+
* can trigger RNSScreenStack updates on stacks inside detached tab views,
342+
* where window=NIL causes the update to fail after 50 retries (~5 seconds).
343+
*
344+
* This utility atomically removes all overlay routes via reset, switches
345+
* to the target tab, and waits for the navigator to settle before returning.
346+
*
347+
* Usage:
348+
* await navigateFromOverlayToTab({
349+
* targetTab: ETabRoutes.Home,
350+
* switchTab: (tab) => navigation.switchTab(tab),
351+
* });
352+
* // Now safe to push/navigate within the target tab
353+
*/
354+
export const navigateFromOverlayToTab = async (options: {
355+
targetTab: ETabRoutes;
356+
switchTab: (tab: ETabRoutes) => void;
357+
}) => {
358+
resetAboveMainRoute();
359+
options.switchTab(options.targetTab);
360+
// Wait for navigator to fully reconcile after reset + tab switch
361+
await timerUtils.wait(100);
362+
};
363+
307364
export const popToTabRootScreen = async () => {
308365
const rootState = rootNavigationRef.current?.getRootState();
309366
const tabRoute = rootState?.routes?.[rootState.index];

0 commit comments

Comments
 (0)