diff --git a/.github/workflows/unittest.yml b/.github/workflows/unittest.yml index 5bc1efbf709f..8350ab801887 100644 --- a/.github/workflows/unittest.yml +++ b/.github/workflows/unittest.yml @@ -32,6 +32,7 @@ jobs: unittest: name: Unit Tests (${{ matrix.shard }}/3) runs-on: ubuntu-24.04 + timeout-minutes: 30 strategy: fail-fast: false diff --git a/.gitignore b/.gitignore index e93e2110b4b3..81029448c222 100644 --- a/.gitignore +++ b/.gitignore @@ -106,6 +106,7 @@ coverage/ /packages/kit/src/components/WebView/translateInject.text-js /packages/kit/src/components/LightweightChart/utils/lightweightChartsStandalone.text-js /packages/shared/src/web/index.html +/packages/kit/src/routes/generated/ /mobile/android/build-logic/privacy-security-plugins/bin/main/onekey/privacy/jpush apps/mobile/android/.kotlin/ apps/mobile/android/build-logic/privacy-security-plugins/bin/main/onekey/privacy/**/*.class diff --git a/apps/cli/src/__tests__/ci-gate-script.test.ts b/apps/cli/src/__tests__/ci-gate-script.test.ts index 9ff64a548578..f9d0ec548bc2 100644 --- a/apps/cli/src/__tests__/ci-gate-script.test.ts +++ b/apps/cli/src/__tests__/ci-gate-script.test.ts @@ -19,8 +19,9 @@ describe('BotWallet CI gate script', () => { ); expect(rootPackageJson.scripts['ci:gate']).toBe( [ + 'yarn routes:generate', 'yarn lint:staged', - 'yarn tsc:staged', + 'yarn tsc:staged:no-routes', 'yarn test:unit', 'bash apps/cli/scripts/audit-persistence-fields.sh', 'yarn test:integration:cli', diff --git a/apps/mobile/metro.config.js b/apps/mobile/metro.config.js index 7f1b26a03f67..7b7749aa380a 100644 --- a/apps/mobile/metro.config.js +++ b/apps/mobile/metro.config.js @@ -13,12 +13,21 @@ const { getSentryExpoConfig } = require('@sentry/react-native/metro'); const connect = require('connect'); const fs = require('fs-extra'); const { resolve } = require('metro-resolver'); + +const buildTimeEnv = require('@onekeyhq/shared/src/buildTimeEnv'); // const { withRozeniteExpoAtlasPlugin } = require('@rozenite/expo-atlas-plugin'); // Uncomment if needed const projectRoot = __dirname; // Pre-calculate monorepo root for use in multiple places const monorepoRoot = path.resolve(projectRoot, '../..'); +const { + ensureRoutePathConfig, + watchRoutePathConfig, +} = require('../../development/scripts/ensure-route-path-config'); + +ensureRoutePathConfig(['ios', 'android', 'native']); +watchRoutePathConfig(['ios', 'android', 'native']); // Get Metro's default config for the project const defaultConfig = getDefaultConfig(projectRoot); @@ -324,7 +333,6 @@ if (process.env.RN_HARNESS === 'true') { }; } -const buildTimeEnv = require('@onekeyhq/shared/src/buildTimeEnv'); const getMetroRuntimeTarget = (context) => context.customResolverOptions?.runtimeTarget || process.env.METRO_RUNTIME_TARGET || diff --git a/apps/web/scripts/check-startup-graph-budget.js b/apps/web/scripts/check-startup-graph-budget.js index 3e645e04fb5d..6153f562c54c 100644 --- a/apps/web/scripts/check-startup-graph-budget.js +++ b/apps/web/scripts/check-startup-graph-budget.js @@ -34,9 +34,15 @@ const { printAiTriageInstructions, writeAiHints, } = require(path.join(repoRoot, 'development/perf-ci/lib/budgetAiHints')); +const { resolveSourceGuardPolicies } = require( + path.join(repoRoot, 'development/perf-ci/lib/budgetConfig'), +); const { createStaticImportChainReport } = require( path.join(repoRoot, 'development/perf-ci/lib/importChain'), ); +const { findForbiddenModules, getMissingSourceMaps, getModuleRows } = require( + path.join(repoRoot, 'development/perf-ci/lib/sourceMapSourceGuard'), +); const DEFAULT_BUDGETS = { moduleCount: 3500, @@ -152,33 +158,6 @@ function walkFiles(dir) { }); } -function normalizeSource(source) { - return source - .replace(/^webpack:\/\/[^/]+\//, '') - .replace(/^webpack:\/\//, '') - .replace(/^(\.\.\/)+/, '') - .replace(/^\.\//, ''); -} - -function categorizeModule(source) { - if (source.includes('node_modules/')) return 'node_modules'; - if (source.includes('packages/components/')) return 'components'; - if (source.includes('packages/kit-bg/')) return 'kit-bg'; - if (source.includes('packages/kit/')) return 'kit'; - if (source.includes('packages/shared/')) return 'shared'; - if (source.includes('apps/web/')) return 'apps/web'; - return 'other'; -} - -function getPackageName(source) { - const marker = 'node_modules/'; - const index = source.indexOf(marker); - if (index < 0) return null; - const parts = source.slice(index + marker.length).split('/'); - if (!parts[0]) return null; - return parts[0].startsWith('@') ? parts.slice(0, 2).join('/') : parts[0]; -} - function getFileSizeRows(files) { return files.map((file) => { const filePath = path.join(buildDir, file); @@ -192,40 +171,6 @@ function getFileSizeRows(files) { }); } -function getModuleRows(scriptFiles) { - const modules = new Map(); - for (const file of scriptFiles) { - const mapPath = path.join(buildDir, `${file}.map`); - if (fs.existsSync(mapPath)) { - const map = JSON.parse(fs.readFileSync(mapPath, 'utf8')); - const sources = map.sources || []; - const contents = map.sourcesContent || []; - for (let index = 0; index < sources.length; index += 1) { - const source = normalizeSource(sources[index]); - const bytes = Buffer.byteLength(contents[index] || ''); - const existing = modules.get(source); - if (existing) { - existing.bytes = Math.max(existing.bytes, bytes); - existing.files.add(file); - } else { - modules.set(source, { - source, - bytes, - category: categorizeModule(source), - packageName: getPackageName(source), - files: new Set([file]), - }); - } - } - } - } - - return [...modules.values()].map((module) => ({ - ...module, - files: [...module.files], - })); -} - function sum(rows, key) { return rows.reduce((total, row) => total + (Number(row[key]) || 0), 0); } @@ -270,20 +215,28 @@ function main() { const budgetConfig = readJsonIfExists(budgetPath); const budgets = loadBudgets(); + const { initialForbiddenSources } = resolveSourceGuardPolicies({ + startupGraph: budgetConfig?.startupGraph, + defaultInitialForbiddenSources: DEFAULT_FORBIDDEN_SOURCES, + }); const forbiddenSources = csvEnv( 'WEB_STARTUP_FORBIDDEN_SOURCES', - budgetConfig?.startupGraph?.forbiddenSources || DEFAULT_FORBIDDEN_SOURCES, + initialForbiddenSources, ); const html = fs.readFileSync(htmlPath, 'utf8'); const initialScriptFiles = getInitialScriptFiles(html).filter((file) => fs.existsSync(path.join(buildDir, file)), ); - const missingSourceMaps = initialScriptFiles.filter( - (file) => !fs.existsSync(path.join(buildDir, `${file}.map`)), - ); + const missingSourceMaps = getMissingSourceMaps({ + buildDir, + scriptFiles: initialScriptFiles, + }); const initialScripts = getFileSizeRows(initialScriptFiles); - const moduleRows = getModuleRows(initialScriptFiles); + const moduleRows = getModuleRows({ + buildDir, + scriptFiles: initialScriptFiles, + }); const allScriptFiles = walkFiles(buildDir).filter( (file) => /\.m?js$/.test(file) && !file.endsWith('.map'), ); @@ -307,9 +260,10 @@ function main() { }; const packageBytes = groupBytes(moduleRows, 'packageName'); - const forbiddenModulesFound = moduleRows.filter((row) => - forbiddenSources.some((pattern) => row.source.includes(pattern)), - ); + const forbiddenModulesFound = findForbiddenModules({ + moduleRows, + forbiddenSources, + }); const budgetChecks = [ makeBudgetCheck('moduleCount', summary.moduleCount, budgets.moduleCount), diff --git a/development/lint/ts.js b/development/lint/ts.js index bb0541e84990..5deb9b2fae54 100644 --- a/development/lint/ts.js +++ b/development/lint/ts.js @@ -6,6 +6,12 @@ const { exit } = require('process'); const { parse } = require('@aivenio/tsc-output-parser'); +const { + ensureRoutePathConfig, +} = require('../scripts/ensure-route-path-config'); + +ensureRoutePathConfig(['native']); + const getTimestamp = () => new Date().toLocaleTimeString(); const startTime = Date.now(); diff --git a/development/perf-ci/lib/budgetConfig.js b/development/perf-ci/lib/budgetConfig.js new file mode 100644 index 000000000000..5679b712049f --- /dev/null +++ b/development/perf-ci/lib/budgetConfig.js @@ -0,0 +1,56 @@ +function normalizeBudgetConfig(raw, defaultBudgets) { + if (raw?.defaults || raw?.scenarios || raw?.startupGraph) { + return { + ...raw, + defaults: { + ...defaultBudgets, + ...raw.defaults, + }, + scenarios: raw.scenarios || {}, + }; + } + + return { + defaults: { + ...defaultBudgets, + ...raw, + }, + scenarios: {}, + }; +} + +function uniqueStrings(values) { + return [ + ...new Set(values.filter((value) => typeof value === 'string' && value)), + ]; +} + +function resolveSourceGuardPolicies({ + startupGraph, + defaultInitialForbiddenSources = [], +}) { + const initialOnlyForbiddenSources = Array.isArray( + startupGraph?.forbiddenSources, + ) + ? startupGraph.forbiddenSources + : defaultInitialForbiddenSources; + const requestedScriptForbiddenSources = uniqueStrings( + Array.isArray(startupGraph?.requestedScriptForbiddenSources) + ? startupGraph.requestedScriptForbiddenSources + : [], + ); + + // Sources forbidden at runtime must also stay out of the initial entry graph. + return { + initialForbiddenSources: uniqueStrings([ + ...initialOnlyForbiddenSources, + ...requestedScriptForbiddenSources, + ]), + requestedScriptForbiddenSources, + }; +} + +module.exports = { + normalizeBudgetConfig, + resolveSourceGuardPolicies, +}; diff --git a/development/perf-ci/lib/budgetConfig.test.js b/development/perf-ci/lib/budgetConfig.test.js new file mode 100644 index 000000000000..6df62e0c0021 --- /dev/null +++ b/development/perf-ci/lib/budgetConfig.test.js @@ -0,0 +1,58 @@ +const { + normalizeBudgetConfig, + resolveSourceGuardPolicies, +} = require('./budgetConfig'); + +describe('normalizeBudgetConfig', () => { + const defaults = { scriptCount: 10, resourceCount: 20 }; + + it('preserves startup graph guards in structured budget files', () => { + const startupGraph = { + forbiddenSources: ['packages/kit/src/views/Onboardingv2/pages/'], + moduleCount: 100, + }; + + expect( + normalizeBudgetConfig( + { + defaults: { scriptCount: 8 }, + startupGraph, + scenarios: { root: { resourceCount: 15 } }, + }, + defaults, + ), + ).toEqual({ + defaults: { scriptCount: 8, resourceCount: 20 }, + startupGraph, + scenarios: { root: { resourceCount: 15 } }, + }); + }); + + it('keeps legacy flat budget files compatible', () => { + expect(normalizeBudgetConfig({ scriptCount: 8 }, defaults)).toEqual({ + defaults: { scriptCount: 8, resourceCount: 20 }, + scenarios: {}, + }); + }); + + it('keeps initial-only sources out of the runtime requested-script policy', () => { + expect( + resolveSourceGuardPolicies({ + startupGraph: { + forbiddenSources: ['@reown/'], + requestedScriptForbiddenSources: [ + 'packages/kit/src/views/Onboardingv2/router/', + ], + }, + }), + ).toEqual({ + initialForbiddenSources: [ + '@reown/', + 'packages/kit/src/views/Onboardingv2/router/', + ], + requestedScriptForbiddenSources: [ + 'packages/kit/src/views/Onboardingv2/router/', + ], + }); + }); +}); diff --git a/development/perf-ci/lib/sourceMapSourceGuard.js b/development/perf-ci/lib/sourceMapSourceGuard.js new file mode 100644 index 000000000000..f075d4e190f6 --- /dev/null +++ b/development/perf-ci/lib/sourceMapSourceGuard.js @@ -0,0 +1,107 @@ +const fs = require('fs'); +const path = require('path'); + +function normalizeSource(source) { + return source + .replace(/^webpack:\/\/(?:[^/]+\/)?\/?/, '') + .replace(/^(\.\.\/)+/, '') + .replace(/^\.\//, ''); +} + +function categorizeModule(source) { + if (source.includes('node_modules/')) return 'node_modules'; + if (source.includes('packages/components/')) return 'components'; + if (source.includes('packages/kit-bg/')) return 'kit-bg'; + if (source.includes('packages/kit/')) return 'kit'; + if (source.includes('packages/shared/')) return 'shared'; + if (source.includes('apps/web/')) return 'apps/web'; + return 'other'; +} + +function getPackageName(source) { + const marker = 'node_modules/'; + const index = source.indexOf(marker); + if (index < 0) return null; + const parts = source.slice(index + marker.length).split('/'); + if (!parts[0]) return null; + return parts[0].startsWith('@') ? parts.slice(0, 2).join('/') : parts[0]; +} + +function getModuleRows({ buildDir, scriptFiles }) { + const modules = new Map(); + for (const file of scriptFiles) { + const mapPath = path.join(buildDir, `${file}.map`); + if (fs.existsSync(mapPath)) { + const map = JSON.parse(fs.readFileSync(mapPath, 'utf8')); + const sources = map.sources || []; + const contents = map.sourcesContent || []; + for (let index = 0; index < sources.length; index += 1) { + const source = normalizeSource(sources[index]); + const bytes = Buffer.byteLength(contents[index] || ''); + const existing = modules.get(source); + if (existing) { + existing.bytes = Math.max(existing.bytes, bytes); + existing.files.add(file); + } else { + modules.set(source, { + source, + bytes, + category: categorizeModule(source), + packageName: getPackageName(source), + files: new Set([file]), + }); + } + } + } + } + + return [...modules.values()].map((module) => ({ + ...module, + files: [...module.files], + })); +} + +function getMissingSourceMaps({ buildDir, scriptFiles }) { + return scriptFiles.filter( + (file) => !fs.existsSync(path.join(buildDir, `${file}.map`)), + ); +} + +function getScriptFilesFromUrls({ buildDir, scriptUrls }) { + const normalizedBuildDir = path.resolve(buildDir); + const scriptFiles = new Set(); + + for (const scriptUrl of scriptUrls) { + let pathname = ''; + try { + pathname = decodeURIComponent(new URL(scriptUrl).pathname); + } catch { + pathname = ''; + } + if (pathname) { + const relativePath = pathname.replace(/^\/+/, ''); + const resolvedPath = path.resolve(buildDir, relativePath); + if ( + resolvedPath.startsWith(`${normalizedBuildDir}${path.sep}`) && + fs.existsSync(resolvedPath) + ) { + scriptFiles.add(relativePath.replaceAll('\\', '/')); + } + } + } + + return [...scriptFiles].toSorted(); +} + +function findForbiddenModules({ moduleRows, forbiddenSources }) { + return moduleRows.filter((row) => + forbiddenSources.some((pattern) => row.source.includes(pattern)), + ); +} + +module.exports = { + findForbiddenModules, + getMissingSourceMaps, + getModuleRows, + getScriptFilesFromUrls, +}; diff --git a/development/perf-ci/lib/sourceMapSourceGuard.test.js b/development/perf-ci/lib/sourceMapSourceGuard.test.js new file mode 100644 index 000000000000..3fc15711fd79 --- /dev/null +++ b/development/perf-ci/lib/sourceMapSourceGuard.test.js @@ -0,0 +1,56 @@ +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const { + findForbiddenModules, + getMissingSourceMaps, + getModuleRows, + getScriptFilesFromUrls, +} = require('./sourceMapSourceGuard'); + +describe('sourceMapSourceGuard', () => { + let buildDir; + + beforeEach(() => { + buildDir = fs.mkdtempSync(path.join(os.tmpdir(), 'source-map-guard-')); + fs.mkdirSync(path.join(buildDir, 'assets'), { recursive: true }); + fs.writeFileSync(path.join(buildDir, 'assets', 'app.js'), 'app'); + fs.writeFileSync( + path.join(buildDir, 'assets', 'app.js.map'), + JSON.stringify({ + sources: [ + 'webpack:///../../packages/kit/src/views/Onboardingv2/components/Layout.tsx', + 'webpack:///../../packages/shared/src/routes/onboardingv2.ts', + ], + sourcesContent: ['layout', 'routes'], + }), + ); + }); + + afterEach(() => { + fs.rmSync(buildDir, { recursive: true, force: true }); + }); + + it('checks sources from scripts requested during cold start', () => { + const scriptFiles = getScriptFilesFromUrls({ + buildDir, + scriptUrls: [ + 'http://127.0.0.1:8080/assets/app.js?cache=1', + 'https://analytics.example/external.js', + ], + }); + const moduleRows = getModuleRows({ buildDir, scriptFiles }); + + expect(scriptFiles).toEqual(['assets/app.js']); + expect(getMissingSourceMaps({ buildDir, scriptFiles })).toEqual([]); + expect( + findForbiddenModules({ + moduleRows, + forbiddenSources: [ + 'packages/kit/src/views/Onboardingv2/components/Layout', + ], + }).map((row) => row.source), + ).toEqual(['packages/kit/src/views/Onboardingv2/components/Layout.tsx']); + }); +}); diff --git a/development/perf-ci/run-web-cold.js b/development/perf-ci/run-web-cold.js index f84b8ad7e16e..8610cf1acb26 100644 --- a/development/perf-ci/run-web-cold.js +++ b/development/perf-ci/run-web-cold.js @@ -21,12 +21,22 @@ const { printAiTriageInstructions, writeAiHints, } = require('./lib/budgetAiHints'); +const { + normalizeBudgetConfig, + resolveSourceGuardPolicies, +} = require('./lib/budgetConfig'); const { findChromiumExecutable } = require('./lib/chromium'); const { execCmd, formatExecResultError, withRepoNodeBin, } = require('./lib/exec'); +const { + findForbiddenModules, + getMissingSourceMaps, + getModuleRows, + getScriptFilesFromUrls, +} = require('./lib/sourceMapSourceGuard'); const { startStaticServer } = require('./lib/staticServer'); const { classifyPageErrors } = require('./lib/webColdPageErrors'); @@ -132,26 +142,6 @@ function readJsonIfExists(filePath) { return JSON.parse(fs.readFileSync(filePath, 'utf8')); } -function normalizeBudgetConfig(raw) { - if (raw?.defaults || raw?.scenarios) { - return { - defaults: { - ...DEFAULT_BUDGETS, - ...raw.defaults, - }, - scenarios: raw.scenarios || {}, - }; - } - - return { - defaults: { - ...DEFAULT_BUDGETS, - ...raw, - }, - scenarios: {}, - }; -} - function loadBudgetConfig(repoRoot) { const budgetPath = process.env.PERF_WEB_COLD_BUDGET_PATH || @@ -162,7 +152,7 @@ function loadBudgetConfig(repoRoot) { 'thresholds', 'web.cold.json', ); - return normalizeBudgetConfig(readJsonIfExists(budgetPath)); + return normalizeBudgetConfig(readJsonIfExists(budgetPath), DEFAULT_BUDGETS); } function expandScenarioNames(names) { @@ -1174,6 +1164,32 @@ function printReport({ ); } +function checkRequestedScriptSources({ + buildDir, + scenarioOutputs, + forbiddenSources, +}) { + const scriptUrls = scenarioOutputs.flatMap((scenario) => + scenario.runs.flatMap((run) => + run.uniqueScripts.map((script) => script.url), + ), + ); + const scriptFiles = getScriptFilesFromUrls({ buildDir, scriptUrls }); + const missingSourceMaps = getMissingSourceMaps({ buildDir, scriptFiles }); + const moduleRows = getModuleRows({ buildDir, scriptFiles }); + const forbiddenModulesFound = findForbiddenModules({ + moduleRows, + forbiddenSources, + }); + + return { + scriptFiles, + missingSourceMaps, + forbiddenModulesFound, + pass: missingSourceMaps.length === 0 && forbiddenModulesFound.length === 0, + }; +} + async function main() { const repoRoot = path.join(__dirname, '..', '..'); const buildDir = @@ -1205,6 +1221,9 @@ async function main() { }); const startupGraphBudget = startupGraphBudgetResult?.report || null; const budgetConfig = loadBudgetConfig(repoRoot); + const { requestedScriptForbiddenSources } = resolveSourceGuardPolicies({ + startupGraph: budgetConfig.startupGraph, + }); const scenarios = parseScenarios(); const initialScripts = parseInitialScriptFiles(buildDir); const staticServer = await startStaticServer({ rootDir: buildDir }); @@ -1279,6 +1298,26 @@ async function main() { }); } + const requestedScriptSourceGuard = checkRequestedScriptSources({ + buildDir, + scenarioOutputs, + forbiddenSources: requestedScriptForbiddenSources, + }); + log( + `requested script source guard: ${requestedScriptSourceGuard.pass ? 'PASS' : 'FAIL'} (${requestedScriptSourceGuard.scriptFiles.length} scripts)`, + ); + if (requestedScriptSourceGuard.missingSourceMaps.length > 0) { + log( + `missing requested script source maps: ${requestedScriptSourceGuard.missingSourceMaps.join(', ')}`, + ); + } + if (requestedScriptSourceGuard.forbiddenModulesFound.length > 0) { + log( + `forbidden requested script sources: ${requestedScriptSourceGuard.forbiddenModulesFound + .map((row) => row.source) + .join(', ')}`, + ); + } const firstScenario = scenarioOutputs[0]; const legacyTopLevelFieldsNote = 'Use scenarios[] as the source of truth. Top-level url/budgets/budgetChecks/healthChecks/summary/runs are legacy-compatible fields for the first scenario only.'; @@ -1311,6 +1350,7 @@ async function main() { profileDir: booleanEnv('PERF_WEB_COLD_CPU_PROFILE') ? profileDir : null, budgetConfig, startupGraphBudget, + requestedScriptSourceGuard, legacyTopLevelFieldsNote, metricDefinitions, scenarios: scenarioOutputs, @@ -1346,11 +1386,12 @@ async function main() { log(`wrote ${aiHintsJsonPath}`); log(`wrote ${aiHintsMarkdownPath}`); - const hasBlockingFailure = scenarioOutputs.some( - (scenarioOutput) => - scenarioOutput.healthChecks.some((check) => !check.pass) || - scenarioOutput.budgetChecks.some((check) => check.status === 'fail'), - ); + const hasBlockingFailure = + scenarioOutputs.some( + (scenarioOutput) => + scenarioOutput.healthChecks.some((check) => !check.pass) || + scenarioOutput.budgetChecks.some((check) => check.status === 'fail'), + ) || !requestedScriptSourceGuard.pass; if (hasBlockingFailure) { printAiTriageInstructions({ artifactName: WEB_BUDGET_ARTIFACT, diff --git a/development/perf-ci/thresholds/web.cold.json b/development/perf-ci/thresholds/web.cold.json index aef2b2cfdead..044493808abb 100644 --- a/development/perf-ci/thresholds/web.cold.json +++ b/development/perf-ci/thresholds/web.cold.json @@ -24,6 +24,23 @@ "localforage", "WebStorageLegacy", "translations.ts" + ], + "requestedScriptForbiddenSources": [ + "packages/kit/src/views/Onboardingv2/pages/", + "packages/kit/src/views/Onboardingv2/router/", + "packages/kit/src/views/Onboardingv2/components/Layout", + "packages/kit/src/views/Onboardingv2/components/OnboardingLayout", + "packages/kit/src/routes/Modal/router", + "packages/kit/src/routes/WebView/router", + "packages/kit/src/views/ActionCenter/router/", + "packages/kit/src/views/AppUpdate/router/", + "packages/kit/src/views/DAppConnection/router/", + "packages/kit/src/views/ReferFriends/router/", + "packages/kit/src/views/RewardCenter/router/", + "packages/kit/src/views/Setting/router/", + "packages/kit/src/views/SignatureConfirm/router/", + "packages/kit/src/views/Staking/router/", + "packages/kit/src/views/TestModal/router/" ] }, "scenarios": { diff --git a/development/rspack/rspack.base.config.ts b/development/rspack/rspack.base.config.ts index 7928610a95b8..9e50449b69dd 100644 --- a/development/rspack/rspack.base.config.ts +++ b/development/rspack/rspack.base.config.ts @@ -29,6 +29,12 @@ const { resolveCommitSha } = require('../utils/resolveCommitSha') as { resolveCommitSha: () => string; }; // eslint-disable-next-line @typescript-eslint/no-require-imports +const { ensureRoutePathConfig, watchRoutePathConfig } = + require('../scripts/ensure-route-path-config') as { + ensureRoutePathConfig: (targetNames: string[]) => void; + watchRoutePathConfig: (targetNames: string[]) => void; + }; +// eslint-disable-next-line @typescript-eslint/no-require-imports const { readOneKeyBootstrapDataCode } = require('../htmlBootstrapData') as { readOneKeyBootstrapDataCode: (opts: { basePath: string; @@ -351,6 +357,8 @@ export function createBaseConfig({ basePath, configName, }: IBaseConfigOptions): RspackOptions { + ensureRoutePathConfig([platform]); + watchRoutePathConfig([platform]); // platformEnv.* folding (mirrors webpack babel transform-define). Applied in // the first-party babel-loader pass below. const platformEnvDefineMap = buildPlatformEnvDefineMap(platform); diff --git a/development/scripts/agent-check.js b/development/scripts/agent-check.js index b14e0696585a..9fb48e74ace6 100644 --- a/development/scripts/agent-check.js +++ b/development/scripts/agent-check.js @@ -579,8 +579,9 @@ function runLocalChecks(logDir) { humanLog('\nLocal checks'); return [ ...runWorktreeLintChecks(logDir), + runCommand(logDir, 'routes-generated', 'yarn', ['routes:generate']), runCommand(logDir, 'lint-staged', 'yarn', ['lint:staged']), - runCommand(logDir, 'tsc-staged', 'yarn', ['tsc:staged']), + runCommand(logDir, 'tsc-staged', 'yarn', ['tsc:staged:no-routes']), ]; } diff --git a/development/scripts/compile-route-path-config.js b/development/scripts/compile-route-path-config.js new file mode 100644 index 000000000000..12797c55a794 --- /dev/null +++ b/development/scripts/compile-route-path-config.js @@ -0,0 +1,1344 @@ +#!/usr/bin/env node +/* eslint-disable no-continue */ + +const fs = require('node:fs'); +const path = require('node:path'); + +const ts = require('typescript'); + +const repoRoot = path.resolve(__dirname, '../..'); +const outputDirectory = path.join( + repoRoot, + 'packages/kit/src/routes/generated', +); +const rootRouterFile = path.join(repoRoot, 'packages/kit/src/routes/router.ts'); +const extensionColdStartSourceDirectories = [ + path.join(repoRoot, 'packages/kit/src'), + path.join(repoRoot, 'packages/kit-bg/src'), + path.join(repoRoot, 'packages/shared/src'), +]; +const extensionRouteInfoMethods = new Set([ + 'openExtensionExpandTab', + 'openExpandTab', + 'openExpandTabOrSidePanel', + 'openSidePanel', + 'openStandaloneWindow', +]); + +const targets = [ + 'web', + 'web-embed', + 'ext', + 'desktop', + 'ios', + 'android', + 'native', +]; +const modes = ['production', 'development']; +const routeProperties = new Set([ + 'name', + 'allowColdStart', + 'rewrite', + 'exact', + 'children', + 'pathConfigFile', + 'pathConfigExport', +]); +const generatedTargets = new Set(); +const sourceContentCache = new Map(); +const sourceFileCache = new Map(); +let enumRegistryCache; +let extensionColdStartContractsCache; +let temporaryFileCounter = 0; + +const normalizePath = (filePath) => path.normalize(filePath); + +const readSourceContent = (filePath) => { + const normalizedFile = normalizePath(filePath); + if (!sourceContentCache.has(normalizedFile)) { + sourceContentCache.set( + normalizedFile, + fs.readFileSync(normalizedFile, 'utf8'), + ); + } + return sourceContentCache.get(normalizedFile); +}; + +const unwrapExpression = (node) => { + let current = node; + while ( + ts.isAsExpression(current) || + ts.isTypeAssertionExpression(current) || + ts.isParenthesizedExpression(current) || + ts.isNonNullExpression(current) || + ts.isSatisfiesExpression(current) + ) { + current = current.expression; + } + return current; +}; + +const nodeLocation = (node) => { + const sourceFile = node.getSourceFile(); + const position = sourceFile.getLineAndCharacterOfPosition(node.getStart()); + return `${path.relative(repoRoot, sourceFile.fileName)}:${position.line + 1}`; +}; + +const fail = (node, message) => { + throw new Error(`${message} (${nodeLocation(node)})`); +}; + +const readSourceFile = (filePath) => { + const normalizedFile = normalizePath(filePath); + if (sourceFileCache.has(normalizedFile)) { + return sourceFileCache.get(normalizedFile); + } + const sourceFile = ts.createSourceFile( + normalizedFile, + readSourceContent(normalizedFile), + ts.ScriptTarget.Latest, + true, + normalizedFile.endsWith('x') ? ts.ScriptKind.TSX : ts.ScriptKind.TS, + ); + sourceFileCache.set(normalizedFile, sourceFile); + return sourceFile; +}; + +const walkFiles = (directory) => { + const files = []; + for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { + const entryPath = path.join(directory, entry.name); + if (entry.isDirectory()) { + files.push(...walkFiles(entryPath)); + } else if (/\.(?:ts|tsx)$/u.test(entry.name)) { + files.push(entryPath); + } + } + return files; +}; + +const propertyName = (node) => { + if (ts.isIdentifier(node) || ts.isPrivateIdentifier(node)) { + return node.text; + } + if (ts.isStringLiteral(node) || ts.isNumericLiteral(node)) { + return node.text; + } + return undefined; +}; + +const objectPropertyInitializer = (object, name) => { + for (const property of object.properties) { + if ( + ts.isPropertyAssignment(property) && + propertyName(property.name) === name + ) { + return property.initializer; + } + if ( + ts.isShorthandPropertyAssignment(property) && + property.name.text === name + ) { + return property.name; + } + } + return undefined; +}; + +const findEnclosingFunction = (node) => { + let current = node.parent; + while (current) { + if ( + ts.isArrowFunction(current) || + ts.isFunctionDeclaration(current) || + ts.isFunctionExpression(current) || + ts.isMethodDeclaration(current) + ) { + return current; + } + current = current.parent; + } + return undefined; +}; + +const isTestSourceFile = (filePath) => + /[\\/](?:__tests__|__mocks__)[\\/]/u.test(filePath) || + /\.(?:test|spec)\.[cm]?[jt]sx?$/u.test(filePath); + +const loadEnumRegistry = () => { + if (enumRegistryCache) { + return enumRegistryCache; + } + const enumDeclarations = new Map(); + const routeDirectory = path.join(repoRoot, 'packages/shared/src/routes'); + const viewDirectory = path.join(repoRoot, 'packages/kit/src/views'); + const files = [ + ...walkFiles(routeDirectory), + ...walkFiles(path.join(repoRoot, 'packages/kit/src/routes')).filter( + (filePath) => + !normalizePath(filePath).startsWith( + `${normalizePath(outputDirectory)}${path.sep}`, + ), + ), + ...walkFiles(viewDirectory).filter((filePath) => + filePath.includes(`${path.sep}router${path.sep}`), + ), + ]; + for (const filePath of files) { + const sourceFile = readSourceFile(filePath); + for (const statement of sourceFile.statements) { + if (!ts.isEnumDeclaration(statement)) { + continue; + } + const enumName = statement.name.text; + const members = enumDeclarations.get(enumName) || new Map(); + for (const member of statement.members) { + const memberName = propertyName(member.name); + if (!memberName) { + fail(member, 'Route enum members must have static names'); + } + if (members.has(memberName)) { + fail(member, `Duplicate route enum member ${enumName}.${memberName}`); + } + members.set(memberName, member); + } + enumDeclarations.set(enumName, members); + } + } + enumRegistryCache = { enumDeclarations }; + return enumRegistryCache; +}; + +class RouteCompiler { + constructor({ target, isDev }) { + this.target = target; + this.isDev = isDev; + this.moduleCache = new Map(); + this.exportCache = new Map(); + const enumRegistry = loadEnumRegistry(); + this.enumDeclarations = enumRegistry.enumDeclarations; + this.enumValueCache = new Map(); + } + + propertyName(node) { + return propertyName(node); + } + + enumValue(enumName, memberName, nodeForError) { + const cacheKey = `${enumName}.${memberName}`; + if (this.enumValueCache.has(cacheKey)) { + return this.enumValueCache.get(cacheKey); + } + const declaration = this.enumDeclarations.get(enumName)?.get(memberName); + if (!declaration) { + fail(nodeForError, `Cannot resolve route enum ${cacheKey}`); + } + if (!declaration.initializer) { + fail(declaration, `Route enum ${cacheKey} needs an explicit initializer`); + } + const value = this.evaluatePrimitive( + declaration.initializer, + this.contextFor(declaration.getSourceFile().fileName), + new Map(), + ); + if (typeof value !== 'string') { + fail(declaration, `Route enum ${cacheKey} must resolve to a string`); + } + this.enumValueCache.set(cacheKey, value); + return value; + } + + extensionOrder() { + const platformExtensions = { + web: ['.web', '.web-only'], + 'web-embed': ['.web-embed', '.web-only', '.web'], + ext: ['.ext', '.web'], + desktop: ['.desktop', '.web'], + ios: ['.ios', '.native'], + android: ['.android', '.native'], + native: ['.native'], + }[this.target]; + const suffixes = ['.ts', '.tsx', '.mjs', '.js', '.jsx']; + return [ + ...platformExtensions.flatMap((platform) => + suffixes.map((suffix) => `${platform}${suffix}`), + ), + ...suffixes, + ]; + } + + resolveModule(fromFile, specifier) { + let basePath; + if (specifier.startsWith('.')) { + basePath = path.resolve(path.dirname(fromFile), specifier); + } else { + const aliasMatch = specifier.match( + /^@onekeyhq\/(kit|shared|components|kit-bg)\/src\/(.+)$/u, + ); + if (!aliasMatch) { + return undefined; + } + basePath = path.join( + repoRoot, + 'packages', + aliasMatch[1], + 'src', + aliasMatch[2], + ); + } + + if (fs.existsSync(basePath) && fs.statSync(basePath).isFile()) { + return normalizePath(basePath); + } + + for (const extension of this.extensionOrder()) { + const candidate = `${basePath}${extension}`; + if (fs.existsSync(candidate)) { + return normalizePath(candidate); + } + } + if (fs.existsSync(basePath) && fs.statSync(basePath).isDirectory()) { + for (const extension of this.extensionOrder()) { + const candidate = path.join(basePath, `index${extension}`); + if (fs.existsSync(candidate)) { + return normalizePath(candidate); + } + } + } + return undefined; + } + + contextFor(filePath) { + const normalizedFile = normalizePath(filePath); + if (this.moduleCache.has(normalizedFile)) { + return this.moduleCache.get(normalizedFile); + } + + const sourceFile = readSourceFile(normalizedFile); + const context = { + filePath: normalizedFile, + sourceFile, + imports: new Map(), + locals: new Map(), + functions: new Map(), + exports: new Map(), + }; + this.moduleCache.set(normalizedFile, context); + + for (const statement of sourceFile.statements) { + if ( + ts.isImportDeclaration(statement) && + ts.isStringLiteral(statement.moduleSpecifier) + ) { + const resolvedFile = this.resolveModule( + normalizedFile, + statement.moduleSpecifier.text, + ); + const clause = statement.importClause; + if (!clause) { + continue; + } + if (clause.name) { + context.imports.set(clause.name.text, { + filePath: resolvedFile, + exportName: 'default', + }); + } + if (clause.namedBindings && ts.isNamedImports(clause.namedBindings)) { + for (const element of clause.namedBindings.elements) { + context.imports.set(element.name.text, { + filePath: resolvedFile, + exportName: element.propertyName?.text || element.name.text, + }); + } + } + continue; + } + + if (ts.isVariableStatement(statement)) { + const exported = statement.modifiers?.some( + (modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword, + ); + for (const declaration of statement.declarationList.declarations) { + if (!ts.isIdentifier(declaration.name)) { + continue; + } + context.locals.set(declaration.name.text, declaration); + if (exported) { + context.exports.set(declaration.name.text, { + localName: declaration.name.text, + }); + } + } + continue; + } + + if (ts.isFunctionDeclaration(statement) && statement.name) { + context.functions.set(statement.name.text, statement); + if ( + statement.modifiers?.some( + (modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword, + ) + ) { + context.exports.set(statement.name.text, { + localName: statement.name.text, + }); + } + continue; + } + + if (ts.isExportDeclaration(statement) && statement.exportClause) { + if (!ts.isNamedExports(statement.exportClause)) { + fail(statement, 'Namespace route exports are not supported'); + } + const resolvedFile = + statement.moduleSpecifier && + ts.isStringLiteral(statement.moduleSpecifier) + ? this.resolveModule(normalizedFile, statement.moduleSpecifier.text) + : undefined; + for (const element of statement.exportClause.elements) { + context.exports.set(element.name.text, { + filePath: resolvedFile, + localName: resolvedFile + ? undefined + : element.propertyName?.text || element.name.text, + exportName: element.propertyName?.text || element.name.text, + }); + } + continue; + } + + if (ts.isExportAssignment(statement)) { + context.exports.set('default', { expression: statement.expression }); + } + } + return context; + } + + resolveExport(filePath, exportName) { + const cacheKey = `${normalizePath(filePath)}#${exportName}#${this.isDev}`; + if (this.exportCache.has(cacheKey)) { + return this.exportCache.get(cacheKey); + } + const context = this.contextFor(filePath); + const exported = context.exports.get(exportName); + let value; + if (exported?.expression) { + value = this.evaluateAny(exported.expression, context, new Map()); + } else if (exported?.filePath) { + value = this.resolveExport(exported.filePath, exported.exportName); + } else { + const localName = exported?.localName || exportName; + value = this.resolveLocal(localName, context, new Map()); + } + this.exportCache.set(cacheKey, value); + return value; + } + + resolveLocal(name, context, environment) { + if (environment.has(name)) { + return environment.get(name); + } + if (name === 'undefined') { + return undefined; + } + if (name === 'true') { + return true; + } + if (name === 'false') { + return false; + } + + const declaration = context.locals.get(name); + if (declaration) { + if (!declaration.initializer) { + fail(declaration, `Route value ${name} has no initializer`); + } + const value = this.evaluateAny( + declaration.initializer, + context, + environment, + ); + if (Array.isArray(value)) { + return this.applyArrayMutations(name, value, context, environment); + } + return value; + } + + const imported = context.imports.get(name); + if (imported?.filePath) { + return this.resolveExport(imported.filePath, imported.exportName); + } + fail(context.sourceFile, `Cannot statically resolve route value ${name}`); + } + + applyArrayMutations(name, original, context, environment) { + const routes = [...original]; + const visitStatement = (statement, active) => { + if (ts.isIfStatement(statement)) { + const condition = this.evaluateBoolean( + statement.expression, + context, + environment, + ); + if (condition) { + visitStatement(statement.thenStatement, active); + } else if (statement.elseStatement) { + visitStatement(statement.elseStatement, active); + } + return; + } + if (ts.isBlock(statement)) { + for (const child of statement.statements) { + visitStatement(child, active); + } + return; + } + if (!active || !ts.isExpressionStatement(statement)) { + return; + } + const expression = unwrapExpression(statement.expression); + if ( + ts.isCallExpression(expression) && + ts.isPropertyAccessExpression(expression.expression) && + ts.isIdentifier(expression.expression.expression) && + expression.expression.expression.text === name && + expression.expression.name.text === 'push' + ) { + for (const argument of expression.arguments) { + const route = this.evaluateRoute(argument, context, environment); + if (route) { + routes.push(route); + } + } + } + }; + for (const statement of context.sourceFile.statements) { + visitStatement(statement, true); + } + return routes; + } + + evaluatePrimitive(node, context, environment) { + const expression = unwrapExpression(node); + if ( + ts.isStringLiteral(expression) || + ts.isNoSubstitutionTemplateLiteral(expression) + ) { + return expression.text; + } + if (ts.isNumericLiteral(expression)) { + return Number(expression.text); + } + if (expression.kind === ts.SyntaxKind.TrueKeyword) { + return true; + } + if (expression.kind === ts.SyntaxKind.FalseKeyword) { + return false; + } + if (expression.kind === ts.SyntaxKind.NullKeyword) { + return null; + } + if (ts.isIdentifier(expression)) { + return this.resolveLocal(expression.text, context, environment); + } + if (ts.isPropertyAccessExpression(expression)) { + if (ts.isIdentifier(expression.expression)) { + const objectName = expression.expression.text; + if (objectName === 'platformEnv') { + return this.platformValue(expression.name.text, expression); + } + const importedName = context.imports.get(objectName)?.exportName; + const enumName = importedName || objectName; + if (this.enumDeclarations.has(enumName)) { + return this.enumValue(enumName, expression.name.text, expression); + } + } + fail( + expression, + 'Only route enums and platformEnv may provide route metadata', + ); + } + if (ts.isPrefixUnaryExpression(expression)) { + if (expression.operator === ts.SyntaxKind.ExclamationToken) { + return !this.evaluateBoolean(expression.operand, context, environment); + } + if (expression.operator === ts.SyntaxKind.MinusToken) { + return -Number( + this.evaluatePrimitive(expression.operand, context, environment), + ); + } + } + if (ts.isConditionalExpression(expression)) { + return this.evaluateBoolean(expression.condition, context, environment) + ? this.evaluateAny(expression.whenTrue, context, environment) + : this.evaluateAny(expression.whenFalse, context, environment); + } + if (ts.isBinaryExpression(expression)) { + return this.evaluateBinary(expression, context, environment); + } + fail(expression, 'Route metadata must be statically evaluable'); + } + + platformValue(property, node) { + const isNative = ['ios', 'android', 'native'].includes(this.target); + const values = { + isDev: this.isDev, + isProduction: !this.isDev, + isWeb: this.target === 'web', + isWebEmbed: this.target === 'web-embed', + isExtension: this.target === 'ext', + isDesktop: this.target === 'desktop', + isNative, + isNativeIOS: this.target === 'ios', + isNativeAndroid: this.target === 'android', + isNativeIOS26Plus: false, + }; + if (!Object.hasOwn(values, property)) { + fail(node, `Unsupported platformEnv route condition ${property}`); + } + return values[property]; + } + + evaluateBinary(expression, context, environment) { + const operator = expression.operatorToken.kind; + if (operator === ts.SyntaxKind.AmpersandAmpersandToken) { + return this.evaluateBoolean(expression.left, context, environment) + ? this.evaluateAny(expression.right, context, environment) + : undefined; + } + if (operator === ts.SyntaxKind.BarBarToken) { + const left = this.evaluateAny(expression.left, context, environment); + return left || this.evaluateAny(expression.right, context, environment); + } + if ( + operator === ts.SyntaxKind.EqualsEqualsEqualsToken || + operator === ts.SyntaxKind.EqualsEqualsToken + ) { + return ( + this.evaluatePrimitive(expression.left, context, environment) === + this.evaluatePrimitive(expression.right, context, environment) + ); + } + if ( + operator === ts.SyntaxKind.ExclamationEqualsEqualsToken || + operator === ts.SyntaxKind.ExclamationEqualsToken + ) { + return ( + this.evaluatePrimitive(expression.left, context, environment) !== + this.evaluatePrimitive(expression.right, context, environment) + ); + } + fail(expression, 'Unsupported binary expression in route metadata'); + } + + evaluateBoolean(node, context, environment) { + return Boolean(this.evaluatePrimitive(node, context, environment)); + } + + evaluateAny(node, context, environment) { + const expression = unwrapExpression(node); + if (ts.isArrayLiteralExpression(expression)) { + return this.evaluateRoutes(expression, context, environment); + } + if (ts.isObjectLiteralExpression(expression)) { + return this.evaluateRoute(expression, context, environment); + } + if (ts.isCallExpression(expression)) { + return this.evaluateCall(expression, context, environment); + } + if (ts.isConditionalExpression(expression)) { + return this.evaluateBoolean(expression.condition, context, environment) + ? this.evaluateAny(expression.whenTrue, context, environment) + : this.evaluateAny(expression.whenFalse, context, environment); + } + return this.evaluatePrimitive(expression, context, environment); + } + + evaluateRoutes(node, context, environment) { + const routes = []; + for (const element of node.elements) { + if (ts.isSpreadElement(element)) { + const spread = this.evaluateAny( + element.expression, + context, + environment, + ); + if (!Array.isArray(spread)) { + fail(element, 'A route array spread must resolve to an array'); + } + routes.push(...spread); + continue; + } + const value = this.evaluateAny(element, context, environment); + if (value === undefined || value === null || value === false) { + continue; + } + if (Array.isArray(value)) { + routes.push(...value); + } else { + routes.push(value); + } + } + this.validateRoutes(routes, node); + return routes; + } + + evaluateRoute(node, context, environment) { + const expression = unwrapExpression(node); + if (!ts.isObjectLiteralExpression(expression)) { + const value = this.evaluateAny(expression, context, environment); + if (value === undefined || value === null || value === false) { + return undefined; + } + if (Array.isArray(value)) { + fail(expression, 'Expected one route but found a route array'); + } + return value; + } + + const route = {}; + for (const property of expression.properties) { + if (ts.isSpreadAssignment(property)) { + const spread = this.evaluateAny( + property.expression, + context, + environment, + ); + if (spread && !Array.isArray(spread)) { + Object.assign(route, spread); + } + continue; + } + if ( + !ts.isPropertyAssignment(property) && + !ts.isShorthandPropertyAssignment(property) + ) { + continue; + } + const name = this.propertyName(property.name); + if (!name || !routeProperties.has(name)) { + continue; + } + const initializer = ts.isShorthandPropertyAssignment(property) + ? property.name + : property.initializer; + if (name === 'children') { + const children = this.evaluateAny(initializer, context, environment); + if ( + children !== undefined && + children !== null && + !Array.isArray(children) + ) { + fail(initializer, 'Route children must resolve to an array'); + } + if (Array.isArray(children)) { + route.children = children; + } + } else { + route[name] = this.evaluatePrimitive(initializer, context, environment); + } + } + if (typeof route.name !== 'string' || route.name.length === 0) { + fail(expression, 'Every compiled route object must have a static name'); + } + if (route.rewrite !== undefined && typeof route.rewrite !== 'string') { + fail(expression, 'Route rewrite must resolve to a string'); + } + if ( + route.allowColdStart !== undefined && + typeof route.allowColdStart !== 'boolean' + ) { + fail(expression, 'Route allowColdStart must resolve to a boolean'); + } + if (route.exact !== undefined && typeof route.exact !== 'boolean') { + fail(expression, 'Route exact must resolve to a boolean'); + } + return route; + } + + evaluateCall(expression, context, environment) { + const callee = unwrapExpression(expression.expression); + if (ts.isPropertyAccessExpression(callee)) { + const method = callee.name.text; + if (method === 'map') { + const routes = this.evaluateAny( + callee.expression, + context, + environment, + ); + if (!Array.isArray(routes)) { + fail(expression, 'Route map() receiver must be an array'); + } + const callback = expression.arguments[0]; + if ( + !callback || + (!ts.isArrowFunction(callback) && !ts.isFunctionExpression(callback)) + ) { + fail(expression, 'Route map() needs an inline callback'); + } + const parameter = callback.parameters[0]; + if (!parameter || !ts.isIdentifier(parameter.name)) { + fail(callback, 'Route map() callback needs one identifier parameter'); + } + const result = routes.map((route) => { + const callbackEnvironment = new Map(environment); + callbackEnvironment.set(parameter.name.text, route); + const returned = ts.isBlock(callback.body) + ? this.findReturnExpression(callback.body) + : callback.body; + return this.evaluateRoute(returned, context, callbackEnvironment); + }); + this.validateRoutes(result, expression); + return result; + } + if (method === 'filter') { + const routes = this.evaluateAny( + callee.expression, + context, + environment, + ); + if (!Array.isArray(routes)) { + fail(expression, 'Route filter() receiver must be an array'); + } + const callback = expression.arguments[0]; + if ( + callback && + ts.isIdentifier(callback) && + callback.text === 'Boolean' + ) { + return routes.filter(Boolean); + } + fail(expression, 'Only routeArray.filter(Boolean) is supported'); + } + } + + if (ts.isIdentifier(callee)) { + const functionDeclaration = context.functions.get(callee.text); + const variableInitializer = context.locals.get(callee.text)?.initializer; + const variableFunction = variableInitializer + ? unwrapExpression(variableInitializer) + : undefined; + const declaration = + functionDeclaration || + (variableFunction && + (ts.isArrowFunction(variableFunction) || + ts.isFunctionExpression(variableFunction)) + ? variableFunction + : undefined); + if (!declaration?.body) { + fail( + expression, + `Cannot statically execute route factory ${callee.text}`, + ); + } + if (declaration.parameters.length !== expression.arguments.length) { + fail( + expression, + `Route factory ${callee.text} argument count is not static`, + ); + } + const functionEnvironment = new Map(environment); + declaration.parameters.forEach((parameter, index) => { + if (!ts.isIdentifier(parameter.name)) { + fail(parameter, 'Route factory parameters must be identifiers'); + } + functionEnvironment.set( + parameter.name.text, + this.evaluateAny(expression.arguments[index], context, environment), + ); + }); + const returned = ts.isBlock(declaration.body) + ? this.findReturnExpression(declaration.body) + : declaration.body; + return this.evaluateAny(returned, context, functionEnvironment); + } + fail(expression, 'Unsupported call expression in route metadata'); + } + + findReturnExpression(block) { + for (const statement of block.statements) { + if (ts.isReturnStatement(statement) && statement.expression) { + return statement.expression; + } + } + fail(block, 'Route factory must have one direct return statement'); + } + + validateRoutes(routes, node) { + const names = new Set(); + for (const route of routes) { + if (!route || typeof route.name !== 'string') { + fail(node, 'Compiled route arrays may contain only route objects'); + } + if (names.has(route.name)) { + fail(node, `Duplicate sibling route ${route.name}`); + } + names.add(route.name); + } + } + + projectColdStartRoutes(routes) { + return routes.flatMap((route) => { + if (route.allowColdStart !== true) { + return []; + } + const children = this.projectColdStartRoutes(route.children || []); + const projected = { name: route.name }; + if (route.rewrite !== undefined) { + projected.rewrite = route.rewrite; + } + if (route.exact !== undefined) { + projected.exact = route.exact; + } + if (children.length > 0) { + projected.children = children; + } + return [projected]; + }); + } + + evaluateStringArray(node, context) { + const expression = unwrapExpression(node); + if (!ts.isArrayLiteralExpression(expression)) { + return undefined; + } + const values = []; + for (const element of expression.elements) { + if (ts.isSpreadElement(element)) { + return undefined; + } + const value = this.evaluatePrimitive(element, context, new Map()); + if (typeof value !== 'string') { + fail(element, 'Extension cold-start route entries must be strings'); + } + values.push(value); + } + return values; + } + + collectAssignedStringArrays(identifier, call, context) { + const owner = findEnclosingFunction(call); + if (!owner?.body || !ts.isBlock(owner.body)) { + return []; + } + const arrays = []; + const visit = (node) => { + if ( + ts.isVariableDeclaration(node) && + ts.isIdentifier(node.name) && + node.name.text === identifier && + node.initializer + ) { + const value = this.evaluateStringArray(node.initializer, context); + if (value?.length) { + arrays.push({ node: node.initializer, routes: value }); + } + } else if ( + ts.isBinaryExpression(node) && + node.operatorToken.kind === ts.SyntaxKind.EqualsToken && + ts.isIdentifier(node.left) && + node.left.text === identifier + ) { + const value = this.evaluateStringArray(node.right, context); + if (value?.length) { + arrays.push({ node: node.right, routes: value }); + } + } + ts.forEachChild(node, visit); + }; + visit(owner.body); + return arrays; + } + + collectRouteInfoContracts(call, context) { + const argument = call.arguments[0] + ? unwrapExpression(call.arguments[0]) + : undefined; + if (!argument || !ts.isObjectLiteralExpression(argument)) { + return []; + } + const initializer = objectPropertyInitializer(argument, 'routes'); + if (!initializer) { + return []; + } + const routes = this.evaluateStringArray(initializer, context); + if (routes) { + return routes.length ? [{ node: initializer, routes }] : []; + } + const expression = unwrapExpression(initializer); + return ts.isIdentifier(expression) + ? this.collectAssignedStringArrays(expression.text, call, context) + : []; + } + + collectDAppModalContracts(call, context) { + const callee = unwrapExpression(call.expression); + if ( + !ts.isPropertyAccessExpression(callee) || + callee.name.text !== 'openModal' || + !callee.expression.getText().includes('serviceDApp') + ) { + return []; + } + const argument = call.arguments[0] + ? unwrapExpression(call.arguments[0]) + : undefined; + if (!argument || !ts.isObjectLiteralExpression(argument)) { + return []; + } + const initializer = objectPropertyInitializer(argument, 'screens'); + if (!initializer) { + return []; + } + const routes = this.evaluateStringArray(initializer, context); + return routes?.length ? [{ node: initializer, routes }] : []; + } + + assertRouteChain(routes, routeNames, node) { + let currentRoutes = routes; + for (const routeName of routeNames) { + const route = currentRoutes.find((item) => item.name === routeName); + if (!route) { + fail( + node, + `Cold-start entry is missing from the ${this.target} generated routes: ${routeNames.join( + ' > ', + )}`, + ); + } + currentRoutes = route.children || []; + } + } + + collectExtensionColdStartContracts() { + if (extensionColdStartContractsCache) { + return extensionColdStartContractsCache; + } + const contracts = []; + const visitSourceNode = (node, context) => { + if (ts.isCallExpression(node)) { + const callee = unwrapExpression(node.expression); + const method = ts.isPropertyAccessExpression(callee) + ? callee.name.text + : undefined; + if (method && extensionRouteInfoMethods.has(method)) { + contracts.push(...this.collectRouteInfoContracts(node, context)); + } + for (const contract of this.collectDAppModalContracts(node, context)) { + contracts.push( + { + node: contract.node, + routes: [ + this.enumValue('ERootRoutes', 'Modal', contract.node), + ...contract.routes, + ], + }, + { + node: contract.node, + routes: [ + this.enumValue('ERootRoutes', 'iOSFullScreen', contract.node), + ...contract.routes, + ], + }, + ); + } + } + ts.forEachChild(node, (child) => visitSourceNode(child, context)); + }; + + for (const directory of extensionColdStartSourceDirectories) { + for (const filePath of walkFiles(directory)) { + if (isTestSourceFile(filePath)) { + continue; + } + const source = readSourceContent(filePath); + if ( + !source.includes('openExtensionExpandTab') && + !source.includes('openExpandTab') && + !source.includes('openSidePanel') && + !source.includes('openStandaloneWindow') && + !source.includes('serviceDApp.openModal') + ) { + continue; + } + const context = this.contextFor(filePath); + visitSourceNode(context.sourceFile, context); + } + } + extensionColdStartContractsCache = contracts; + return contracts; + } + + validateExtensionColdStartEntries(routes) { + for (const contract of this.collectExtensionColdStartContracts()) { + this.assertRouteChain(routes, contract.routes, contract.node); + } + } + + compileRoot() { + const roots = this.resolveExport(rootRouterFile, 'rootRouter'); + if (!Array.isArray(roots)) { + throw new Error('rootRouter must resolve to an array'); + } + const childEntries = this.resolveExport( + rootRouterFile, + 'rootRouterPathConfigSources', + ); + if (!Array.isArray(childEntries)) { + throw new Error('rootRouterPathConfigSources must resolve to an array'); + } + for (const childEntry of childEntries) { + const root = roots.find((route) => route.name === childEntry.name); + if (!root) { + throw new Error(`Missing root route ${childEntry.name}`); + } + if ( + typeof childEntry.pathConfigFile !== 'string' || + typeof childEntry.pathConfigExport !== 'string' + ) { + throw new Error( + `Invalid path config source for root route ${childEntry.name}`, + ); + } + const childFile = this.resolveModule( + rootRouterFile, + childEntry.pathConfigFile, + ); + if (!childFile) { + throw new Error( + `Cannot resolve ${childEntry.pathConfigFile} for ${childEntry.name}`, + ); + } + const children = this.resolveExport( + childFile, + childEntry.pathConfigExport, + ); + if (!Array.isArray(children)) { + throw new Error( + `${childEntry.pathConfigExport} must resolve to an array`, + ); + } + root.children = children; + } + this.validateRoutes(roots, this.contextFor(rootRouterFile).sourceFile); + const coldStartRoutes = this.projectColdStartRoutes(roots); + // Extension entry points reveal routes that must cold-start correctly, but + // the route manifest itself is shared: every target must contain the same + // discovered route chain. + this.validateExtensionColdStartEntries(coldStartRoutes); + return coldStartRoutes; + } +} + +const stableJson = (value) => `${JSON.stringify(value, null, 2)}\n`; + +const compileTargetMode = (target, mode) => { + const compiler = new RouteCompiler({ + target, + isDev: mode === 'development', + }); + return { + schemaVersion: 2, + target, + mode, + routes: compiler.compileRoot(), + }; +}; + +const wrapperSource = (target) => + `// Generated by development/scripts/compile-route-path-config.js. Do not edit.\nimport platformEnv from '@onekeyhq/shared/src/platformEnv';\n\nconst routePathConfig = platformEnv.isDev\n ? require('./routePathConfig.generated.${target}.development.json')\n : require('./routePathConfig.generated.${target}.production.json');\n\nexport default routePathConfig;\n`; + +const normalizeTarget = (target) => { + if (target === 'webEmbed') { + return 'web-embed'; + } + if (!targets.includes(target)) { + throw new Error( + `Unknown route generation target "${target}". Expected: ${targets.join(', ')}`, + ); + } + return target; +}; + +const expectedFiles = (selectedTargets) => { + const files = new Map(); + for (const target of selectedTargets) { + for (const mode of modes) { + files.set( + path.join( + outputDirectory, + `routePathConfig.generated.${target}.${mode}.json`, + ), + stableJson(compileTargetMode(target, mode)), + ); + } + files.set( + path.join(outputDirectory, `routePathConfig.generated.${target}.ts`), + wrapperSource(target), + ); + } + if (selectedTargets.includes('native')) { + files.set( + path.join(outputDirectory, 'routePathConfig.generated.ts'), + wrapperSource('native'), + ); + } + return files; +}; + +const generateRoutePathConfig = ({ + targetNames = targets, + check = false, + silent = false, + force = false, +} = {}) => { + const startedAt = performance.now(); + if (force) { + generatedTargets.clear(); + sourceContentCache.clear(); + sourceFileCache.clear(); + enumRegistryCache = undefined; + extensionColdStartContractsCache = undefined; + } + const selectedTargets = [ + ...new Set(targetNames.map((target) => normalizeTarget(target))), + ]; + if (!check) { + for (const target of selectedTargets) { + const legacyFile = path.join( + outputDirectory, + `routePathConfig.generated.${target}.json`, + ); + if (fs.existsSync(legacyFile)) { + fs.unlinkSync(legacyFile); + } + } + } + const hasGeneratedOutput = (target) => + modes.every((mode) => + fs.existsSync( + path.join( + outputDirectory, + `routePathConfig.generated.${target}.${mode}.json`, + ), + ), + ) && + fs.existsSync( + path.join(outputDirectory, `routePathConfig.generated.${target}.ts`), + ) && + (target !== 'native' || + fs.existsSync( + path.join(outputDirectory, 'routePathConfig.generated.ts'), + )); + const pendingTargets = selectedTargets.filter( + (target) => !generatedTargets.has(target) || !hasGeneratedOutput(target), + ); + if (pendingTargets.length === 0) { + return { durationMs: 0, targets: [] }; + } + const files = expectedFiles(pendingTargets); + if (check) { + const stale = []; + for (const [filePath, expected] of files) { + if ( + !fs.existsSync(filePath) || + fs.readFileSync(filePath, 'utf8') !== expected + ) { + stale.push(path.relative(repoRoot, filePath)); + } + } + if (stale.length > 0) { + console.error('Generated cold-start route config is stale:'); + for (const filePath of stale) { + console.error(` ${filePath}`); + } + console.error('Run: yarn routes:generate'); + const error = new Error('Generated cold-start route config is stale'); + error.staleFiles = stale; + throw error; + } + if (!silent) { + console.log( + `Cold-start route config is up to date (${pendingTargets.join(', ')}).`, + ); + } + } else { + fs.mkdirSync(outputDirectory, { recursive: true }); + for (const [filePath, content] of files) { + if ( + !fs.existsSync(filePath) || + fs.readFileSync(filePath, 'utf8') !== content + ) { + temporaryFileCounter += 1; + const temporaryFile = `${filePath}.${process.pid}.${temporaryFileCounter}.tmp`; + try { + fs.writeFileSync(temporaryFile, content); + fs.renameSync(temporaryFile, filePath); + } finally { + if (fs.existsSync(temporaryFile)) { + fs.unlinkSync(temporaryFile); + } + } + } + } + if (!silent) { + console.log( + `Generated cold-start route config for ${pendingTargets.join(', ')}.`, + ); + } + } + for (const target of pendingTargets) { + generatedTargets.add(target); + } + return { + durationMs: performance.now() - startedAt, + targets: pendingTargets, + }; +}; + +const parseTargetNames = (argv) => { + const values = []; + for (let index = 0; index < argv.length; index += 1) { + const argument = argv[index]; + if (argument === '--target') { + values.push(argv[index + 1]); + index += 1; + } else if (argument.startsWith('--target=')) { + values.push(argument.slice('--target='.length)); + } + } + return values.length > 0 + ? values.flatMap((value) => value.split(',').filter(Boolean)) + : targets; +}; + +const main = () => { + const check = process.argv.includes('--check'); + try { + const result = generateRoutePathConfig({ + targetNames: parseTargetNames(process.argv.slice(2)), + check, + force: true, + }); + console.log( + `Route generation completed in ${result.durationMs.toFixed(0)} ms.`, + ); + } catch (error) { + if (!error.staleFiles) { + console.error(error); + } + process.exitCode = 1; + } +}; + +module.exports = { + compileTargetMode, + generateRoutePathConfig, + normalizeTarget, + targets, +}; + +if (require.main === module) { + main(); +} diff --git a/development/scripts/ensure-route-path-config.js b/development/scripts/ensure-route-path-config.js new file mode 100644 index 000000000000..d755923a8bf5 --- /dev/null +++ b/development/scripts/ensure-route-path-config.js @@ -0,0 +1,122 @@ +const fs = require('node:fs'); +const path = require('node:path'); + +const { generateRoutePathConfig } = require('./compile-route-path-config'); + +const repoRoot = path.resolve(__dirname, '../..'); +const routeWatchers = new Map(); + +const ensureRoutePathConfig = (targetNames) => { + const result = generateRoutePathConfig({ + targetNames, + silent: true, + }); + if (result.durationMs > 0) { + console.log( + `[routes] Generated ${result.targets.join(', ')} cold-start config in ${result.durationMs.toFixed(0)} ms.`, + ); + } + return result; +}; + +const routeSourceExtensions = ['.ts', '.tsx', '.mjs', '.js', '.jsx']; +const isRouteSourceFile = (filePath) => + routeSourceExtensions.some((extension) => filePath.endsWith(extension)); +const normalizeWatchPath = (filePath) => + filePath?.toString().replaceAll('\\', '/'); + +const shouldWatchRoutePathConfig = (args = process.argv.slice(2)) => + args.some( + (arg) => + arg === '--watch' || + arg === '--watch=true' || + arg === '--watchAll' || + arg === '--watchAll=true', + ); + +const isViewRouterSourceFile = (filePath) => { + if (!isRouteSourceFile(filePath)) { + return false; + } + const segments = filePath.split('/'); + if (segments.includes('router')) { + return true; + } + const fileName = segments.at(-1) || ''; + const extension = routeSourceExtensions.find((item) => + fileName.endsWith(item), + ); + const baseName = extension ? fileName.slice(0, -extension.length) : fileName; + return baseName.split('.')[0] === 'router'; +}; + +const watchRoutePathConfig = (targetNames) => { + if (process.env.NODE_ENV === 'production') { + return; + } + const watcherKey = targetNames.toSorted().join(','); + if (routeWatchers.has(watcherKey)) { + return; + } + + let refreshTimer; + const scheduleRefresh = () => { + clearTimeout(refreshTimer); + refreshTimer = setTimeout(() => { + try { + const result = generateRoutePathConfig({ + targetNames, + silent: true, + force: true, + }); + console.log( + `[routes] Refreshed ${result.targets.join(', ')} cold-start config in ${result.durationMs.toFixed(0)} ms.`, + ); + } catch (error) { + console.error('[routes] Failed to refresh cold-start config.', error); + } + }, 50); + refreshTimer.unref(); + }; + + const watchRoots = [ + { + directory: path.join(repoRoot, 'packages/shared/src/routes'), + matches: isRouteSourceFile, + }, + { + directory: path.join(repoRoot, 'packages/kit/src/routes'), + matches: (filePath) => + isRouteSourceFile(filePath) && !filePath.startsWith('generated/'), + }, + { + directory: path.join(repoRoot, 'packages/kit/src/views'), + matches: isViewRouterSourceFile, + }, + ]; + const watchers = watchRoots.map(({ directory, matches }) => { + const watcher = fs.watch( + directory, + { recursive: true }, + (_eventType, fileName) => { + const filePath = normalizeWatchPath(fileName); + if (filePath && matches(filePath)) { + scheduleRefresh(); + } + }, + ); + watcher.on('error', (error) => { + console.error(`[routes] Failed to watch ${directory}.`, error); + }); + watcher.unref(); + return watcher; + }); + routeWatchers.set(watcherKey, watchers); +}; + +module.exports = { + ensureRoutePathConfig, + isViewRouterSourceFile, + shouldWatchRoutePathConfig, + watchRoutePathConfig, +}; diff --git a/development/scripts/ensure-route-path-config.test.js b/development/scripts/ensure-route-path-config.test.js new file mode 100644 index 000000000000..3a4595baab3c --- /dev/null +++ b/development/scripts/ensure-route-path-config.test.js @@ -0,0 +1,45 @@ +const { + isViewRouterSourceFile, + shouldWatchRoutePathConfig, +} = require('./ensure-route-path-config'); + +describe('route config source watcher', () => { + it.each([ + 'Onboardingv2/router/index.tsx', + 'Onboardingv2/router/index.web-only.tsx', + 'Onboardingv2/router.ts', + 'Onboardingv2/router.native.mjs', + 'Onboardingv2/router.web-only.jsx', + ])('watches supported router source %s', (filePath) => { + expect(isViewRouterSourceFile(filePath)).toBe(true); + }); + + it.each([ + 'Onboardingv2/pages/router-notes.ts', + 'Onboardingv2/router/index.json', + ])('ignores unrelated source %s', (filePath) => { + expect(isViewRouterSourceFile(filePath)).toBe(false); + }); + + it('handles a long router-like file name without backtracking', () => { + expect( + isViewRouterSourceFile(`Feature/${'.'.repeat(20_000)}router.tsx`), + ).toBe(false); + }); + + it.each(['--watch', '--watch=true', '--watchAll', '--watchAll=true'])( + 'starts the route watcher for explicit Jest flag %s', + (flag) => { + expect(shouldWatchRoutePathConfig([flag])).toBe(true); + }, + ); + + it.each([ + [[]], + [['--runInBand']], + [['--watch=false']], + [['--watchAll=false']], + ])('does not start the route watcher for a one-shot Jest run: %j', (args) => { + expect(shouldWatchRoutePathConfig(args)).toBe(false); + }); +}); diff --git a/development/webpack/webpack.base.config.js b/development/webpack/webpack.base.config.js index 171406bf6d92..71c68e52e53e 100644 --- a/development/webpack/webpack.base.config.js +++ b/development/webpack/webpack.base.config.js @@ -9,6 +9,10 @@ const webpack = require('webpack'); const webpackManifestPlugin = require('webpack-manifest-plugin'); const { readOneKeyBootstrapDataCode } = require('../htmlBootstrapData'); +const { + ensureRoutePathConfig, + watchRoutePathConfig, +} = require('../scripts/ensure-route-path-config'); const { resolveCommitSha } = require('../utils/resolveCommitSha'); const { isDev, PUBLIC_URL, NODE_ENV, ONEKEY_PROXY } = require('./constant'); @@ -145,6 +149,8 @@ const basePerformance = { }; module.exports = ({ platform, basePath, configName }) => { + ensureRoutePathConfig([platform]); + watchRoutePathConfig([platform]); const babelLoaderOption = { babelrc: false, configFile: true, diff --git a/jest.config.js b/jest.config.js index 5c1f3bbfc301..b5c4150f1e2d 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,10 +1,23 @@ // https://jestjs.io/docs/configuration const util = require('node:util'); +const exec = util.promisify(require('node:child_process').exec); const { defaults } = require('jest-config'); -const exec = util.promisify(require('node:child_process').exec); + +const { + targets: routePathConfigTargets, +} = require('./development/scripts/compile-route-path-config'); +const { + ensureRoutePathConfig, + shouldWatchRoutePathConfig, + watchRoutePathConfig, +} = require('./development/scripts/ensure-route-path-config'); module.exports = async () => { + ensureRoutePathConfig(routePathConfigTargets); + if (shouldWatchRoutePathConfig()) { + watchRoutePathConfig(routePathConfigTargets); + } const { stdout } = await exec('yarn config get cacheFolder'); const cacheDirectory = stdout.trim().replace('\n', ''); return { @@ -71,7 +84,7 @@ module.exports = async () => { }, // TODO unify with transpile modules transformIgnorePatterns: [ - 'node_modules/(?!(react-native-reanimated|react-native-aes-crypto|@keystonehq/bc-ur-registry-eth|@mysten/(sui|bcs|utils)|@noble/hashes|@scure/(base|bip32|bip39))/)', + 'node_modules/(?!(react-native-reanimated|react-native-aes-crypto|@keystonehq/bc-ur-registry-eth|@mysten/(sui|bcs|utils)|@noble/hashes|@scure/(base|bip32|bip39)|@react-navigation/(core|routers))/)', ], transform: { 'node_modules/(@mysten/(sui|bcs|utils)|@noble/hashes|@scure/(base|bip32|bip39))/.+\\.m?js$': diff --git a/package.json b/package.json index 3a8386b6c91b..9806cf63f9b3 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "scripts": { "setup:env": "node -e \"const fs=require('fs');if(!fs.existsSync('.env')){if(fs.existsSync('.env.example')){fs.copyFileSync('.env.example','.env')}else{fs.writeFileSync('.env','')}}\"", "skills:sync": "node development/scripts/skillshare/install.js", - "postinstall": "node development/scripts/postinstall.js", + "postinstall": "node development/scripts/postinstall.js && node development/scripts/compile-route-path-config.js --write", "copy:inject": "node development/scripts/copy-injected.js", "app:desktop": "yarn workspace @onekeyhq/desktop start", "app:desktop:rspack": "yarn workspace @onekeyhq/desktop start:rspack", @@ -83,6 +83,8 @@ "reinstall": "yarn clean && yarn install", "agent:check": "node development/scripts/agent-check.js", "agent:review-thread": "node development/scripts/agent-review-thread.js", + "routes:generate": "node development/scripts/compile-route-path-config.js --write", + "routes:check": "node development/scripts/compile-route-path-config.js --check", "_tsc": "node ./development/lint/ts", "_lint": "node ./development/lint/eslint", "_electron": "node ./development/lint/electron", @@ -96,8 +98,9 @@ "oxlint": "npx oxlint --tsconfig ./tsconfig.json --type-aware . --fix --deny-warnings", "lint:only": "npx oxlint --tsconfig ./tsconfig.json --type-aware . --fix --deny-warnings", "lint:staged": "git diff --cached --name-only --diff-filter=ACMR | grep -E '\\.(ts|tsx)$' | grep -v '^packages/shared/src/locale/enum/translations.ts$' | grep -v '^packages/shared/src/locale/localeJsonMap.ts$' | xargs -r npx oxlint --tsconfig ./tsconfig.json --type-aware --fix --deny-warnings", - "tsc:only": "npx tsgo -p ./tsconfig.json --noEmit --tsBuildInfoFile \"$(yarn config get cacheFolder)\"/.app-mono-ts-cache --pretty", - "tsc:staged": "npx tsgo -p ./tsconfig.json --noEmit --tsBuildInfoFile \"$(yarn config get cacheFolder)\"/.app-mono-ts-cache --pretty", + "tsc:only": "yarn routes:generate --target native && npx tsgo -p ./tsconfig.json --noEmit --tsBuildInfoFile \"$(yarn config get cacheFolder)\"/.app-mono-ts-cache --pretty", + "tsc:staged:no-routes": "npx tsgo -p ./tsconfig.json --noEmit --tsBuildInfoFile \"$(yarn config get cacheFolder)\"/.app-mono-ts-cache --pretty", + "tsc:staged": "yarn routes:generate --target native && yarn tsc:staged:no-routes", "test": "yarn jest", "test:e2e:desktop:browser": "node apps/desktop/e2e/open-url.e2e.js", "test:e2e:desktop:lse": "cross-env DESKTOP_E2E_FLOW=local-secret-envelope node apps/desktop/e2e/open-url.e2e.js", @@ -108,7 +111,7 @@ "test:e2e:web:lse": "node apps/web/e2e/local-secret-envelope.e2e.js", "test:unit": "yarn workspace @onekeyfe/cli test:unit", "test:integration:cli": "yarn workspace @onekeyfe/cli test:integration:cli", - "ci:gate": "yarn lint:staged && yarn tsc:staged && yarn test:unit && bash apps/cli/scripts/audit-persistence-fields.sh && yarn test:integration:cli", + "ci:gate": "yarn routes:generate && yarn lint:staged && yarn tsc:staged:no-routes && yarn test:unit && bash apps/cli/scripts/audit-persistence-fields.sh && yarn test:integration:cli", "op": "./development/scripts/op-run.sh", "keychain": "./development/scripts/keychain-run.sh", "oenv": "oenv onekey-app-monorepo-env", diff --git a/packages/components/src/layouts/Navigation/Navigator/RootModalNavigator.tsx b/packages/components/src/layouts/Navigation/Navigator/RootModalNavigator.tsx index e953aa8894a5..6b582aa05182 100644 --- a/packages/components/src/layouts/Navigation/Navigator/RootModalNavigator.tsx +++ b/packages/components/src/layouts/Navigation/Navigator/RootModalNavigator.tsx @@ -15,6 +15,8 @@ import type { EPageType } from '../../../hocs'; export interface IModalRootNavigatorConfig { name: RouteName; children: IModalFlowNavigatorConfig[]; + /** Explicit inbound URL opt-in. Every route in a nested path must opt in. */ + allowColdStart?: boolean; onMounted?: () => void; onUnmounted?: () => void; rewrite?: string; diff --git a/packages/components/src/layouts/Navigation/Navigator/types.ts b/packages/components/src/layouts/Navigation/Navigator/types.ts index 3f956ae9af63..da665f44eaae 100644 --- a/packages/components/src/layouts/Navigation/Navigator/types.ts +++ b/packages/components/src/layouts/Navigation/Navigator/types.ts @@ -40,6 +40,8 @@ export interface ITabSubNavigatorConfig< export interface ITabNavigatorConfig { name: RouteName; + /** Explicit inbound URL opt-in. Every route in a nested path must opt in. */ + allowColdStart?: boolean; tabBarIcon: (focused?: boolean) => IKeyOfIcons; /** Native tab bar icon for react-native-bottom-tabs (iOS/Android only) */ nativeTabBarIcon?: (props: { focused: boolean }) => INativeTabBarIcon; @@ -70,6 +72,8 @@ export interface ICommonNavigatorConfig< > { name: RouteName; component: (() => JSX.Element) | ComponentType; + /** Explicit inbound URL opt-in. Every route in a nested path must opt in. */ + allowColdStart?: boolean; rewrite?: string; /** with exact property set to true, current screen will ignore the parent screen's path config */ exact?: boolean; diff --git a/packages/kit/package.json b/packages/kit/package.json index 78e3c1983769..431d6353082b 100644 --- a/packages/kit/package.json +++ b/packages/kit/package.json @@ -20,8 +20,8 @@ "zbar.wasm": "^2.1.1" }, "scripts": { - "lint": "tsc --noEmit && eslint . --ext .ts,.tsx", - "lint:fix": "tsc --noEmit && eslint . --ext .ts,.tsx --fix" + "lint": "yarn routes:generate --target native && tsc --noEmit && eslint . --ext .ts,.tsx", + "lint:fix": "yarn routes:generate --target native && tsc --noEmit && eslint . --ext .ts,.tsx --fix" }, "devDependencies": { "@types/d3-scale": "^4.0.3", diff --git a/packages/kit/src/routes/Modal/router.tsx b/packages/kit/src/routes/Modal/router.tsx index 5cd95497c79f..80e0ac90de82 100644 --- a/packages/kit/src/routes/Modal/router.tsx +++ b/packages/kit/src/routes/Modal/router.tsx @@ -66,12 +66,14 @@ const onboardingRouterConfig = { await backgroundApiProxy.serviceV4Migration.clearV4MigrationPayload(); }, name: EModalRoutes.OnboardingModal, + allowColdStart: true, children: OnboardingRouter, }; const router: IModalRootNavigatorConfig[] = [ { name: EModalRoutes.MainModal, + allowColdStart: true, children: ModalMainStack, }, { @@ -81,6 +83,7 @@ const router: IModalRootNavigatorConfig[] = [ { name: EModalRoutes.SettingModal, children: ModalSettingStack, + allowColdStart: true, rewrite: '/settings', exact: true, }, @@ -121,6 +124,7 @@ const router: IModalRootNavigatorConfig[] = [ }, { name: EModalRoutes.FirmwareUpdateModal, + allowColdStart: true, children: ModalFirmwareUpdateStack, }, { @@ -137,6 +141,7 @@ const router: IModalRootNavigatorConfig[] = [ }, { name: EModalRoutes.SignatureConfirmModal, + allowColdStart: true, children: ModalSignatureConfirmStack, }, { @@ -145,6 +150,7 @@ const router: IModalRootNavigatorConfig[] = [ }, { name: EModalRoutes.DAppConnectionModal, + allowColdStart: true, children: DAppConnectionRouter, }, { @@ -173,6 +179,7 @@ const router: IModalRootNavigatorConfig[] = [ }, { name: EModalRoutes.AppUpdateModal, + allowColdStart: true, rewrite: '/update', children: AppUpdateRouter, }, @@ -190,6 +197,7 @@ const router: IModalRootNavigatorConfig[] = [ }, { name: EModalRoutes.StakingModal, + allowColdStart: true, children: StakingModalRouter, }, { @@ -198,6 +206,7 @@ const router: IModalRootNavigatorConfig[] = [ }, { name: EModalRoutes.NotificationsModal, + allowColdStart: true, children: ModalNotificationsRouter, }, { @@ -210,6 +219,7 @@ const router: IModalRootNavigatorConfig[] = [ }, { name: EModalRoutes.ReferFriendsModal, + allowColdStart: true, children: ReferFriendsRouter, }, { @@ -242,6 +252,7 @@ const router: IModalRootNavigatorConfig[] = [ if (platformEnv.isDev) { router.push({ name: EModalRoutes.TestModal, + allowColdStart: true, children: TestModalRouter, }); } @@ -255,6 +266,7 @@ export const fullModalRouter = [ }, { name: EModalRoutes.DAppConnectionModal, + allowColdStart: true, children: DAppConnectionRouter, }, { @@ -267,6 +279,7 @@ export const fullModalRouter = [ }, { name: EModalRoutes.SignatureConfirmModal, + allowColdStart: true, children: ModalSignatureConfirmStack, }, ]; @@ -303,6 +316,7 @@ export const onboardingRouterV2Config: IModalRootNavigatorConfig = new Set(Object.values(ETabRoutes)); - -const resolveScreens = (routes: IScreenRouterConfig[]) => - routes - ? routes.reduce((prev, route) => { - prev[route.name] = { - path: route.rewrite ? route.rewrite : route.name, - exact: !!route.exact, - }; - const config = Array.isArray(route.children) - ? route.children - : undefined; - if (config) { - prev[route.name].screens = resolveScreens(config); - if (config.length > 0 && tabRouteNames.has(route.name)) { - prev[route.name].initialRouteName = config[0].name; - } - } - - return prev; - }, {} as IScreenPathConfig) - : undefined; const extHtmlFileUrl = `/${getExtensionIndexHtml()}`; const ROOT_PATH = platformEnv.isExtension ? extHtmlFileUrl : '/'; diff --git a/packages/kit/src/routes/config/resolveScreens.ts b/packages/kit/src/routes/config/resolveScreens.ts new file mode 100644 index 000000000000..9f33595443eb --- /dev/null +++ b/packages/kit/src/routes/config/resolveScreens.ts @@ -0,0 +1,34 @@ +import { ETabRoutes } from '@onekeyhq/shared/src/routes'; +import type { IScreenPathConfig } from '@onekeyhq/shared/src/utils/routeUtils'; + +export interface IScreenRouterConfig { + name: string; + rewrite?: string; + exact?: boolean; + children?: readonly IScreenRouterConfig[] | null; +} + +const tabRouteNames: ReadonlySet = new Set(Object.values(ETabRoutes)); + +export const resolveScreens = ( + routes: readonly IScreenRouterConfig[], +): IScreenPathConfig | undefined => + routes + ? routes.reduce((prev, route) => { + prev[route.name] = { + path: route.rewrite ? route.rewrite : route.name, + exact: !!route.exact, + }; + const config = Array.isArray(route.children) + ? route.children + : undefined; + if (config) { + prev[route.name].screens = resolveScreens(config); + if (config.length > 0 && tabRouteNames.has(route.name)) { + prev[route.name].initialRouteName = config[0].name; + } + } + + return prev; + }, {} as IScreenPathConfig) + : undefined; diff --git a/packages/kit/src/routes/legacyDisplayedDeepLinks.v640.test.tsx b/packages/kit/src/routes/legacyDisplayedDeepLinks.v640.test.tsx new file mode 100644 index 000000000000..a550bdfa576d --- /dev/null +++ b/packages/kit/src/routes/legacyDisplayedDeepLinks.v640.test.tsx @@ -0,0 +1,392 @@ +/** @jest-environment jsdom */ + +import { renderHook } from '@testing-library/react'; + +import { + EAppUpdateRoutes, + EModalReferFriendsRoutes, + EModalRewardCenterRoutes, + EModalRoutes, + EModalSignatureConfirmRoutes, + EModalStakingRoutes, + EOnboardingPagesV2, + EOnboardingV2Routes, + ERootRoutes, + ETabHomeRoutes, + ETabMarketRoutes, + ETabReferFriendsRoutes, + ETabRoutes, + ETabSwapRoutes, +} from '@onekeyhq/shared/src/routes'; +import { buildAllowList } from '@onekeyhq/shared/src/utils/routeUtils'; + +import { getStateFromPath } from './config/getStateFromPath'; +import { resolveScreens } from './config/resolveScreens'; +import { useRootRouter } from './router'; + +import type { NavigationState, PartialState } from '@react-navigation/routers'; + +const mockUsePerpTabConfig = jest.fn(() => ({ + perpDisabled: false, + perpTabShowWeb: false, +})); + +jest.mock('@onekeyhq/shared/src/platformEnv', () => { + const actual = jest.requireActual< + typeof import('@onekeyhq/shared/src/platformEnv') + >('@onekeyhq/shared/src/platformEnv'); + return { + __esModule: true, + default: { + ...actual.default, + isDev: false, + isProduction: true, + isWeb: true, + isWebDappMode: false, + isNative: false, + isDesktop: false, + isExtension: false, + isExtensionUiPopup: false, + isExtensionUiSidePanel: false, + }, + }; +}); + +jest.mock('@onekeyhq/components', () => { + const actual = jest.requireActual( + '@onekeyhq/components', + ); + return { + ...actual, + useMedia: () => ({ gtMd: true, md: false }), + }; +}); + +jest.mock('@react-navigation/native', () => ({ + CommonActions: { navigate: (payload: unknown) => payload }, +})); + +jest.mock('../hooks/usePerpTabConfig', () => ({ + usePerpTabConfig: () => mockUsePerpTabConfig(), +})); + +jest.mock('../views/DeviceManagement/hooks/useDeviceManagerModalStyle', () => ({ + useDeviceManagerModalStyle: () => ({ isModalStack: false }), +})); + +jest.mock('@onekeyhq/kit/src/routes/Tab/Navigator', () => ({ + TabNavigator: () => null, +})); + +jest.mock('./Tab/RootTabLoadingFallback', () => ({ + __esModule: true, + default: () => null, + RootTabLoadingFallback: () => null, +})); + +jest.mock('./Tab/Marktet/MarketDetailV2LoadingFallback', () => ({ + MarketDetailV2LoadingFallback: () => null, +})); + +jest.mock('../views/Perp/router', () => ({ + perpRouters: [{ name: 'Perp' }], +})); + +jest.mock('../views/PerpTrade/router', () => ({ + perpTradeRouters: [{ name: 'WebviewPerpTrade' }], +})); + +jest.mock('../views/Home/router', () => + jest.requireActual( + '../views/Home/router/index.web-only', + ), +); + +jest.mock('../views/Home/pages/urlAccount/urlAccountUtils', () => ({ + urlAccountLandingRewrite: '/not-part-of-v6.4-display-contract', +})); + +type IPartialNavigationState = PartialState; + +interface ILegacyDisplayedDeepLink { + path: string; + routeChain: string[]; + showParams: boolean; +} + +// Frozen compatibility contract extracted from v6.4.0 routeUtils.ts. Do not +// update these paths to match a new Router: changing one is a public URL +// compatibility decision and requires an explicit migration review. +const legacyDisplayedDeepLinksV640: ILegacyDisplayedDeepLink[] = [ + { + path: '/market/tokens/BTC', + routeChain: [ + ERootRoutes.Main, + ETabRoutes.Market, + ETabMarketRoutes.MarketDetail, + ], + showParams: true, + }, + { + path: '/market/token/evm--1/0x123', + routeChain: [ + ERootRoutes.Main, + ETabRoutes.Market, + ETabMarketRoutes.MarketDetailV2, + ], + showParams: true, + }, + { + path: '/market/token/evm--1', + routeChain: [ + ERootRoutes.Main, + ETabRoutes.Market, + ETabMarketRoutes.MarketNativeDetail, + ], + showParams: true, + }, + { + path: '/refer-friends', + routeChain: [ + ERootRoutes.Main, + ETabRoutes.ReferFriends, + ETabReferFriendsRoutes.TabReferAFriend, + ], + showParams: false, + }, + { + path: '/refer-friends/invite-reward', + routeChain: [ + ERootRoutes.Main, + ETabRoutes.ReferFriends, + ETabReferFriendsRoutes.TabInviteReward, + ], + showParams: false, + }, + { + path: '/defi', + routeChain: [ERootRoutes.Main, ETabRoutes.Earn], + showParams: true, + }, + { + path: '/market', + routeChain: [ERootRoutes.Main, ETabRoutes.Market], + showParams: true, + }, + { + path: '/defi/staking/ETH/lido', + routeChain: [ + ERootRoutes.Modal, + EModalRoutes.StakingModal, + EModalStakingRoutes.ProtocolDetails, + ], + showParams: true, + }, + { + path: '/defi/staking/v2/ETH/lido', + routeChain: [ + ERootRoutes.Modal, + EModalRoutes.StakingModal, + EModalStakingRoutes.ProtocolDetailsV2, + ], + showParams: true, + }, + { + path: '/ManagePosition?accountId=legacy-account', + routeChain: [ + ERootRoutes.Modal, + EModalRoutes.StakingModal, + EModalStakingRoutes.ManagePosition, + ], + showParams: true, + }, + { + path: '/swap', + routeChain: [ERootRoutes.Main, ETabRoutes.Swap, ETabSwapRoutes.TabSwap], + showParams: true, + }, + { + path: '/onboarding/get-started', + routeChain: [ + ERootRoutes.Onboarding, + EOnboardingV2Routes.OnboardingV2, + EOnboardingPagesV2.GetStarted, + ], + showParams: true, + }, + { + path: '/modal/ReferFriendsModal/ReferAFriend', + routeChain: [ + ERootRoutes.Modal, + EModalRoutes.ReferFriendsModal, + EModalReferFriendsRoutes.ReferAFriend, + ], + showParams: false, + }, + { + path: '/modal/SignatureConfirmModal/TxConfirmFromDApp?query=transaction', + routeChain: [ + ERootRoutes.Modal, + EModalRoutes.SignatureConfirmModal, + EModalSignatureConfirmRoutes.TxConfirmFromDApp, + ], + showParams: true, + }, + { + path: '/modal/SignatureConfirmModal/MessageConfirmFromDApp?query=message', + routeChain: [ + ERootRoutes.Modal, + EModalRoutes.SignatureConfirmModal, + EModalSignatureConfirmRoutes.MessageConfirmFromDApp, + ], + showParams: true, + }, + { + path: '/modal/update/preview', + routeChain: [ + ERootRoutes.Modal, + EModalRoutes.AppUpdateModal, + EAppUpdateRoutes.UpdatePreview, + ], + showParams: true, + }, + { + path: '/bulk-send-addresses', + routeChain: [ + ERootRoutes.Main, + ETabRoutes.Home, + ETabHomeRoutes.TabHomeBulkSendAddressesInput, + ], + showParams: false, + }, + { + path: '/bulk-send-amounts', + routeChain: [ + ERootRoutes.Main, + ETabRoutes.Home, + ETabHomeRoutes.TabHomeBulkSendAmountsInput, + ], + showParams: false, + }, + { + path: '/redeem-bitcoin-voucher', + routeChain: [ + ERootRoutes.Main, + ETabRoutes.Home, + ETabHomeRoutes.TabHomeRedeemBitcoinVoucher, + ], + showParams: true, + }, + { + path: '/perps', + routeChain: [ERootRoutes.Main, ETabRoutes.Perp], + showParams: true, + }, +]; + +const getFocusedRouteNames = ( + state: IPartialNavigationState | undefined, +): string[] => { + const routeNames: string[] = []; + let currentState = state; + while (currentState) { + const route = + currentState.routes[currentState.index ?? currentState.routes.length - 1]; + if (!route) { + break; + } + routeNames.push(route.name); + currentState = route.state as IPartialNavigationState | undefined; + } + return routeNames; +}; + +const findDisplayRule = ( + rules: ReturnType, + path: string, +) => { + const pathWithoutQuery = path.split('?')[0] || '/'; + return ( + rules[pathWithoutQuery] || + Object.entries(rules).find(([pattern]) => + new RegExp(pattern).test(path), + )?.[1] + ); +}; + +describe('v6.4.0 displayed deep-link compatibility', () => { + beforeEach(() => { + mockUsePerpTabConfig.mockReturnValue({ + perpDisabled: false, + perpTabShowWeb: false, + }); + }); + + it.each(legacyDisplayedDeepLinksV640)( + 'keeps $path parseable with the same route chain and display policy', + ({ path, routeChain, showParams }) => { + const { result } = renderHook(() => useRootRouter()); + const screens = resolveScreens(result.current); + expect(screens).toBeDefined(); + if (!screens) { + return; + } + + const state = getStateFromPath(path, { screens }); + const focusedRouteNames = getFocusedRouteNames(state); + expect(focusedRouteNames.slice(0, routeChain.length)).toEqual(routeChain); + + const rules = buildAllowList(screens, false, false); + expect(findDisplayRule(rules, path)).toMatchObject({ + showUrl: true, + showParams, + }); + }, + ); + + it('keeps the v6.4.0 WebviewPerpTrade display URL variant', () => { + mockUsePerpTabConfig.mockReturnValue({ + perpDisabled: false, + perpTabShowWeb: true, + }); + const { result } = renderHook(() => useRootRouter()); + const screens = resolveScreens(result.current); + expect(screens).toBeDefined(); + if (!screens) { + return; + } + + const path = '/perps'; + const state = getStateFromPath(path, { screens }); + expect(getFocusedRouteNames(state).slice(0, 2)).toEqual([ + ERootRoutes.Main, + ETabRoutes.WebviewPerpTrade, + ]); + expect( + findDisplayRule(buildAllowList(screens, false, true), path), + ).toMatchObject({ + showUrl: true, + showParams: true, + }); + }); + + it('keeps Reward Center inbound-only as in v6.4.0', () => { + const { result } = renderHook(() => useRootRouter()); + const screens = resolveScreens(result.current); + expect(screens).toBeDefined(); + if (!screens) { + return; + } + + const path = '/reward-center'; + const state = getStateFromPath(path, { screens }); + expect(getFocusedRouteNames(state).slice(0, 3)).toEqual([ + ERootRoutes.Modal, + EModalRoutes.MainModal, + EModalRewardCenterRoutes.RewardCenter, + ]); + expect( + findDisplayRule(buildAllowList(screens, false, false), path), + ).toBeUndefined(); + }); +}); diff --git a/packages/kit/src/routes/router.ts b/packages/kit/src/routes/router.ts index 99c032f9354b..b5c72f11d653 100644 --- a/packages/kit/src/routes/router.ts +++ b/packages/kit/src/routes/router.ts @@ -5,13 +5,7 @@ import LazyLoad from '@onekeyhq/shared/src/lazyLoad'; import platformEnv from '@onekeyhq/shared/src/platformEnv'; import { ERootRoutes } from '@onekeyhq/shared/src/routes'; -import { - fullModalRouterPathConfig, - fullScreenPushRouterPathConfig, - modalRouterPathConfig, - onboardingRouterV2PathConfig, - webViewRouterPathConfig, -} from './routerPathConfig'; +import { rootRouterPathConfig } from './routerPathConfig'; import { TabNavigator } from './Tab/Navigator'; import { useTabRouterConfig } from './Tab/router'; @@ -48,36 +42,39 @@ const buildPermissionRouter = () => { import('@onekeyhq/kit/src/views/Permission/PromptWebDeviceAccessPage'), ); return [ - platformEnv.isExtension - ? { - name: ERootRoutes.PermissionWebDevice, - component: PromptWebDeviceAccessPage, - rewrite: '/permission/web-device', - exact: true, - } - : undefined, - ].filter(Boolean); + { + name: ERootRoutes.PermissionWebDevice, + component: PromptWebDeviceAccessPage, + allowColdStart: true, + rewrite: '/permission/web-device', + exact: true, + }, + ]; }; export const rootRouter: IRootStackNavigatorConfig[] = [ { name: ERootRoutes.Main, component: TabNavigator, + allowColdStart: true, initialRoute: true, }, { name: ERootRoutes.Onboarding, component: OnboardingNavigator, + allowColdStart: true, type: 'onboarding', }, { name: ERootRoutes.Modal, component: ModalNavigator, + allowColdStart: true, type: 'modal', }, { name: ERootRoutes.iOSFullScreen, component: IOSFullScreenNavigator, + allowColdStart: true, type: 'iOSFullScreen', }, { @@ -93,45 +90,55 @@ export const rootRouter: IRootStackNavigatorConfig[] = [ ...buildPermissionRouter(), ]; +export const rootRouterPathConfigSources = [ + { + name: ERootRoutes.Onboarding, + pathConfigFile: './Modal/router', + pathConfigExport: 'onboardingRouterV2Config', + }, + { + name: ERootRoutes.Modal, + pathConfigFile: './Modal/router', + pathConfigExport: 'modalRouter', + }, + { + name: ERootRoutes.iOSFullScreen, + pathConfigFile: './Modal/router', + pathConfigExport: 'fullModalRouter', + }, + { + name: ERootRoutes.FullScreenPush, + pathConfigFile: './Modal/router', + pathConfigExport: 'fullScreenPushRouterConfig', + }, + { + name: ERootRoutes.WebView, + pathConfigFile: './WebView/router', + pathConfigExport: 'webViewRouter', + }, +] as const; + if (platformEnv.isDev) { const NotFound = LazyLoad(() => import('../components/NotFound')); rootRouter.push({ name: ERootRoutes.NotFound, component: NotFound, + allowColdStart: true, }); } export const useRootRouter = () => { const tabRouter = useTabRouterConfig(); return useMemo( - () => [ - { - name: ERootRoutes.Main, - children: tabRouter, - }, - { - name: ERootRoutes.Onboarding, - children: onboardingRouterV2PathConfig, - }, - { - name: ERootRoutes.Modal, - children: modalRouterPathConfig, - }, - { - name: ERootRoutes.iOSFullScreen, - children: fullModalRouterPathConfig, - }, - { - name: ERootRoutes.FullScreenPush, - children: fullScreenPushRouterPathConfig, - }, - { - name: ERootRoutes.WebView, - children: webViewRouterPathConfig, - }, - - ...buildPermissionRouter(), - ], + () => + rootRouterPathConfig.map((route) => + route.name === ERootRoutes.Main + ? { + ...route, + children: tabRouter, + } + : route, + ), [tabRouter], ); }; diff --git a/packages/kit/src/routes/routerPathConfig.parity.test.ts b/packages/kit/src/routes/routerPathConfig.parity.test.ts new file mode 100644 index 000000000000..557354e733f5 --- /dev/null +++ b/packages/kit/src/routes/routerPathConfig.parity.test.ts @@ -0,0 +1,368 @@ +import fs from 'node:fs'; +import nodePath from 'node:path'; + +import { OneKeyLocalError } from '@onekeyhq/shared/src/errors'; +import platformEnv from '@onekeyhq/shared/src/platformEnv'; +import type { IScreenPathConfig } from '@onekeyhq/shared/src/utils/routeUtils'; + +import { getStateFromPath } from './config/getStateFromPath'; +import { resolveScreens } from './config/resolveScreens'; + +import type { IRoutePathConfig } from './routerPathConfig'; + +jest.mock('./routerPathConfig', () => ({ rootRouterPathConfig: [] })); +jest.mock('./Tab/Navigator', () => ({ TabNavigator: () => null })); +jest.mock('./Tab/router', () => ({ useTabRouterConfig: () => [] })); +jest.mock('@onekeyhq/shared/src/lazyLoad', () => ({ + __esModule: true, + default: () => () => null, +})); +jest.mock('@onekeyhq/kit/src/background/instance/backgroundApiProxy', () => ({ + __esModule: true, + default: {}, +})); +jest.mock('@onekeyhq/kit-bg/src/states/jotai/atoms', () => ({ + isOnBoardingOpenAtom: { set: jest.fn() }, + v4migrationAtom: { set: jest.fn() }, +})); +jest.mock( + '@onekeyhq/kit/src/components/KeylessWallet/useKeylessWallet', + () => ({ keylessOnboardingCache: { clear: jest.fn() } }), +); +jest.mock('@onekeyhq/kit/src/components/LazyLoadPage', () => ({ + LazyLoadPage: () => () => null, + LazyLoadRootTabPage: () => () => null, +})); +jest.mock('@onekeyhq/kit/src/routes/Tab/RootTabLoadingFallback', () => ({ + default: () => null, + RootTabLoadingFallback: () => null, +})); +jest.mock('@onekeyhq/kit/src/views/Onboardingv2/components/Layout', () => ({ + OnboardingPageFallback: () => null, +})); +jest.mock( + '@onekeyhq/kit/src/views/Onboardingv2/components/OnboardingLayout', + () => ({ OnboardingLayoutFallback: () => null }), +); +jest.mock('@onekeyhq/kit/src/views/Home/pages/HomePageContainer', () => ({ + __esModule: true, + default: () => null, +})); +jest.mock( + '@onekeyhq/kit/src/views/Home/pages/urlAccount/urlAccountUtils', + () => ({ urlAccountLandingRewrite: '/not-part-of-static-root' }), +); +interface IGeneratedRouteConfig { + schemaVersion: 2; + target: IRouteTarget; + mode: IRouteMode; + routes: IRoutePathConfig[]; +} + +type IRouteMode = 'production' | 'development'; +type IRouteTarget = + | 'web' + | 'web-embed' + | 'ext' + | 'desktop' + | 'ios' + | 'android' + | 'native'; + +const routeTargets: IRouteTarget[] = [ + 'web', + 'web-embed', + 'ext', + 'desktop', + 'ios', + 'android', + 'native', +]; + +const readGeneratedRouteConfig = ( + target: IRouteTarget, + mode: IRouteMode, +): IGeneratedRouteConfig => + JSON.parse( + fs.readFileSync( + nodePath.join( + __dirname, + 'generated', + `routePathConfig.generated.${target}.${mode}.json`, + ), + 'utf8', + ), + ) as IGeneratedRouteConfig; + +interface IParameterizedRoute { + names: string[]; + pathPattern: string; +} + +interface IParsedRoute { + name: string; + params?: Record; + state?: IParsedState; +} + +interface IParsedState { + index?: number; + routes: IParsedRoute[]; +} + +const normalizeRoutePath = (routePath: string): string => + `/${routePath.split('/').filter(Boolean).join('/')}`; + +const joinRoutePath = (parentPath: string, routePath: string): string => + normalizeRoutePath(`${parentPath}/${routePath}`); + +const collectParameterizedRoutes = ( + routes: readonly IRoutePathConfig[], + parentPath = '', + parentNames: readonly string[] = [], +): IParameterizedRoute[] => + routes.flatMap((route) => { + const routePath = route.rewrite ?? route.name; + const fullPath = route.exact + ? normalizeRoutePath(routePath) + : joinRoutePath(parentPath, routePath); + const names = [...parentNames, route.name]; + const current = fullPath.includes(':') + ? [{ names, pathPattern: fullPath }] + : []; + return [ + ...current, + ...collectParameterizedRoutes(route.children ?? [], fullPath, names), + ]; + }); + +const materializePathPattern = ( + pathPattern: string, +): { params: Record; path: string } => { + const params: Record = {}; + const path = pathPattern.replace( + /:([A-Za-z_][A-Za-z0-9_]*)(?:\([^)]*\))?[?*+]?/gu, + (_match, name: string) => { + const value = `parity-${name}`; + params[name] = value; + return encodeURIComponent(value); + }, + ); + return { params, path }; +}; + +const getFocusedRouteChain = ( + inputState: unknown, +): { names: string[]; route: IParsedRoute | undefined } => { + const names: string[] = []; + let route: IParsedRoute | undefined; + let state = inputState as IParsedState | undefined; + while (state) { + route = state.routes[state.index ?? state.routes.length - 1]; + if (!route) { + break; + } + names.push(route.name); + state = route.state; + } + return { names, route }; +}; + +const projectRouteMetadata = ( + routes: readonly unknown[], + parentNames: readonly string[] = [], +): IRoutePathConfig[] => + routes.flatMap((route, index) => { + if (!route || typeof route !== 'object') { + throw new OneKeyLocalError( + `Runtime route ${[...parentNames, String(index)].join(' > ')} is not an object`, + ); + } + const input = route as Record; + if (typeof input.name !== 'string') { + throw new OneKeyLocalError( + `Runtime route ${[...parentNames, String(index)].join(' > ')} has no static name`, + ); + } + + if (input.allowColdStart !== true) { + return []; + } + + const output: IRoutePathConfig = { name: input.name }; + if (Object.hasOwn(input, 'rewrite') && input.rewrite !== undefined) { + if (typeof input.rewrite !== 'string') { + throw new OneKeyLocalError( + `Runtime route ${input.name} has a non-string rewrite`, + ); + } + output.rewrite = input.rewrite; + } + if (Object.hasOwn(input, 'exact') && input.exact !== undefined) { + if (typeof input.exact !== 'boolean') { + throw new OneKeyLocalError( + `Runtime route ${input.name} has a non-boolean exact`, + ); + } + output.exact = input.exact; + } + const children = Array.isArray(input.children) + ? projectRouteMetadata(input.children, [...parentNames, input.name]) + : []; + if (children.length > 0) { + output.children = children; + } + return [output]; + }); + +const loadRuntimeProjection = ( + target: IRouteTarget, + isDev: boolean, +): IRoutePathConfig[] => { + let projection: IRoutePathConfig[] | undefined; + const isNative = ['ios', 'android', 'native'].includes(target); + const mockedPlatformEnv = { + ...platformEnv, + isJest: false, + isDev, + isProduction: !isDev, + isWeb: target === 'web', + isWebEmbed: target === 'web-embed', + isExtension: target === 'ext', + isDesktop: target === 'desktop', + isNative, + isNativeIOS: target === 'ios', + isNativeAndroid: target === 'android', + isNativeIOS26Plus: false, + }; + + jest.resetModules(); + jest.doMock('@onekeyhq/shared/src/platformEnv', () => ({ + __esModule: true, + default: mockedPlatformEnv, + })); + const usesWebOnlyRoutes = target === 'web' || target === 'web-embed'; + jest.doMock('@onekeyhq/kit/src/views/Home/router', () => + usesWebOnlyRoutes + ? jest.requireActual< + typeof import('@onekeyhq/kit/src/views/Home/router/index.web-only') + >('@onekeyhq/kit/src/views/Home/router/index.web-only') + : jest.requireActual< + typeof import('@onekeyhq/kit/src/views/Home/router/index') + >('@onekeyhq/kit/src/views/Home/router/index'), + ); + jest.doMock('@onekeyhq/kit/src/views/Onboardingv2/router', () => + usesWebOnlyRoutes + ? jest.requireActual< + typeof import('@onekeyhq/kit/src/views/Onboardingv2/router/index.web-only') + >('@onekeyhq/kit/src/views/Onboardingv2/router/index.web-only') + : jest.requireActual< + typeof import('@onekeyhq/kit/src/views/Onboardingv2/router/index') + >('@onekeyhq/kit/src/views/Onboardingv2/router/index'), + ); + jest.isolateModules(() => { + const { rootRouter, rootRouterPathConfigSources } = + jest.requireActual('./router'); + const childrenByRootRoute = new Map(); + for (const source of rootRouterPathConfigSources) { + const sourceModule = jest.requireActual>( + source.pathConfigFile, + ); + const children = sourceModule[source.pathConfigExport]; + if (!Array.isArray(children)) { + throw new OneKeyLocalError( + `${source.pathConfigExport} is not a route array`, + ); + } + childrenByRootRoute.set(source.name, children); + } + const runtimeRoot = rootRouter.map((route) => { + const children = childrenByRootRoute.get(route.name); + return children ? { ...route, children } : route; + }); + projection = projectRouteMetadata(runtimeRoot); + }); + jest.dontMock('@onekeyhq/shared/src/platformEnv'); + jest.dontMock('@onekeyhq/kit/src/views/Home/router'); + jest.dontMock('@onekeyhq/kit/src/views/Onboardingv2/router'); + + if (!projection) { + throw new OneKeyLocalError( + `Runtime ${target} route projection was not created`, + ); + } + return projection; +}; + +describe.each(routeTargets)('generated %s route config parity', (target) => { + it.each([ + ['production', false], + ['development', true], + ] as const)( + 'matches the independently executed %s Router projection', + (mode, isDev) => { + const generated = readGeneratedRouteConfig(target, mode); + expect(generated).toMatchObject({ schemaVersion: 2, target, mode }); + expect(loadRuntimeProjection(target, isDev)).toEqual(generated.routes); + }, + ); + + it.each([['production'], ['development']] as const)( + 'parses every generated %s parameterized path and query parameter', + (mode) => { + const routeConfig = readGeneratedRouteConfig(target, mode).routes; + const parameterizedRoutes = collectParameterizedRoutes(routeConfig); + const screens = resolveScreens(routeConfig) as IScreenPathConfig; + + expect(parameterizedRoutes.length).toBeGreaterThan(0); + for (const parameterizedRoute of parameterizedRoutes) { + const { params, path } = materializePathPattern( + parameterizedRoute.pathPattern, + ); + const state = getStateFromPath(`${path}?parityQuery=route-config`, { + screens, + }); + const focused = getFocusedRouteChain(state); + expect(focused.names).toEqual(parameterizedRoute.names); + expect(focused.route?.params).toEqual({ + ...params, + parityQuery: 'route-config', + }); + } + }, + ); +}); + +describe.each([['production'], ['development']] as const)( + 'generated %s route topology', + (mode) => { + it('is shared by every platform target', () => { + const expected = readGeneratedRouteConfig('web', mode).routes; + for (const target of routeTargets) { + expect(readGeneratedRouteConfig(target, mode).routes).toEqual(expected); + } + }); + }, +); + +describe('generated mode isolation', () => { + it('keeps development-only routes out of the production asset', () => { + const production = fs.readFileSync( + nodePath.join( + __dirname, + 'generated/routePathConfig.generated.web.production.json', + ), + 'utf8', + ); + const development = fs.readFileSync( + nodePath.join( + __dirname, + 'generated/routePathConfig.generated.web.development.json', + ), + 'utf8', + ); + + expect(production).not.toContain('NotFound'); + expect(development).toContain('NotFound'); + }); +}); diff --git a/packages/kit/src/routes/routerPathConfig.test.ts b/packages/kit/src/routes/routerPathConfig.test.ts new file mode 100644 index 000000000000..d55554fc16e2 --- /dev/null +++ b/packages/kit/src/routes/routerPathConfig.test.ts @@ -0,0 +1,483 @@ +/** @jest-environment jsdom */ + +import fs from 'node:fs'; +import nodePath from 'node:path'; + +import platformEnv from '@onekeyhq/shared/src/platformEnv'; +import { + EAppUpdateRoutes, + EDAppConnectionModal, + EModalFirmwareUpdateRoutes, + EModalReferFriendsRoutes, + EModalRewardCenterRoutes, + EModalRoutes, + EModalSettingRoutes, + EModalSignatureConfirmRoutes, + EModalStakingRoutes, + EOnboardingPages, + EOnboardingPagesV2, + EOnboardingV2Routes, + ERootRoutes, + ETestModalPages, +} from '@onekeyhq/shared/src/routes'; +import { EModalAssetDetailRoutes } from '@onekeyhq/shared/src/routes/assetDetails'; +import { EModalNotificationsRoutes } from '@onekeyhq/shared/src/routes/notifications'; +import type { IScreenPathConfig } from '@onekeyhq/shared/src/utils/routeUtils'; + +import { getStateFromPath as getWebStateFromPath } from './config/getStateFromPath'; +import { getStateFromPath as getExtensionStateFromPath } from './config/getStateFromPath.ext'; +import { resolveScreens } from './config/resolveScreens'; +import { + type IRoutePathConfig, + modalRouterPathConfig, + onboardingRouterV2PathConfig, + rootRouterPathConfig, +} from './routerPathConfig'; + +import type { NavigationState, PartialState } from '@react-navigation/routers'; + +type IPartialNavigationState = PartialState; + +const screens = resolveScreens(rootRouterPathConfig) as IScreenPathConfig; +const extensionRootRouterPathConfig = ( + JSON.parse( + fs.readFileSync( + nodePath.join( + __dirname, + 'generated/routePathConfig.generated.ext.production.json', + ), + 'utf8', + ), + ) as { routes: IRoutePathConfig[] } +).routes; +const extensionScreens = resolveScreens( + extensionRootRouterPathConfig, +) as IScreenPathConfig; + +const getFocusedRouteNames = ( + state: IPartialNavigationState | undefined, +): string[] => { + const routeNames: string[] = []; + let currentState = state; + + while (currentState) { + const route = + currentState.routes[currentState.index ?? currentState.routes.length - 1]; + if (!route) { + break; + } + routeNames.push(route.name); + currentState = route.state as IPartialNavigationState | undefined; + } + + return routeNames; +}; + +const parseExtensionHash = (hash: string) => { + globalThis.location.hash = hash; + return getExtensionStateFromPath(hash, { screens: extensionScreens }); +}; + +const findRoute = ( + routes: IRoutePathConfig[], + ...names: string[] +): IRoutePathConfig | undefined => { + let currentRoutes = routes; + let current: IRoutePathConfig | undefined; + for (const name of names) { + current = currentRoutes.find((route) => route.name === name); + if (!current) { + return undefined; + } + currentRoutes = current.children ?? []; + } + return current; +}; + +const countRoutes = (routes: IRoutePathConfig[]): number => + routes.reduce( + (total, route) => total + 1 + countRoutes(route.children ?? []), + 0, + ); + +describe('generated cold-start route config', () => { + afterEach(() => { + globalThis.location.hash = ''; + }); + + it('contains only explicit inbound routes without UI metadata', () => { + expect(rootRouterPathConfig.map((route) => route.name)).toEqual([ + ERootRoutes.Main, + ERootRoutes.Onboarding, + ERootRoutes.Modal, + ERootRoutes.iOSFullScreen, + ERootRoutes.PermissionWebDevice, + ...(platformEnv.isDev ? [ERootRoutes.NotFound] : []), + ]); + expect(countRoutes(rootRouterPathConfig)).toBe(platformEnv.isDev ? 98 : 95); + + const visit = (routes: IRoutePathConfig[]) => { + for (const route of routes) { + expect(Object.keys(route)).toEqual(expect.arrayContaining(['name'])); + expect( + Object.keys(route).every((key) => + ['name', 'rewrite', 'exact', 'children'].includes(key), + ), + ).toBe(true); + visit(route.children ?? []); + } + }; + visit(rootRouterPathConfig); + }); + + it('keeps only explicitly allowed onboarding pages', () => { + const onboardingRoute = onboardingRouterV2PathConfig[0]; + const pageNames = + onboardingRoute?.children?.map((route) => route.name) ?? []; + + expect(onboardingRoute).toMatchObject({ + name: EOnboardingV2Routes.OnboardingV2, + rewrite: '/onboarding', + exact: true, + }); + expect(pageNames).toEqual([ + EOnboardingPagesV2.GetStarted, + EOnboardingPagesV2.CreateNewWallet, + EOnboardingPagesV2.CreateOrImportWallet, + EOnboardingPagesV2.PickYourDevice, + ]); + expect(onboardingRoute?.children?.[0]).toMatchObject({ + name: EOnboardingPagesV2.GetStarted, + rewrite: '/get-started', + }); + }); + + it('contains only explicitly allowed Web modal stacks', () => { + const modalNames = modalRouterPathConfig.map((route) => route.name); + expect(modalNames).toEqual([ + EModalRoutes.MainModal, + EModalRoutes.SettingModal, + EModalRoutes.OnboardingModal, + EModalRoutes.FirmwareUpdateModal, + EModalRoutes.SignatureConfirmModal, + EModalRoutes.DAppConnectionModal, + EModalRoutes.AppUpdateModal, + EModalRoutes.StakingModal, + EModalRoutes.NotificationsModal, + EModalRoutes.ReferFriendsModal, + ...(platformEnv.isDev ? [EModalRoutes.TestModal] : []), + ]); + }); + + it.each([ + [EOnboardingPagesV2.GetStarted, '/onboarding/get-started'], + [EOnboardingPagesV2.CreateNewWallet, '/onboarding/create-new-wallet'], + [ + EOnboardingPagesV2.CreateOrImportWallet, + '/onboarding/create-or-import-wallet', + ], + [EOnboardingPagesV2.PickYourDevice, '/onboarding/PickYourDevice'], + ])( + 'parses registered onboarding page %s as a Web URL and extension hash', + (page, path) => { + const expected = [ + ERootRoutes.Onboarding, + EOnboardingV2Routes.OnboardingV2, + page, + ]; + + expect( + getFocusedRouteNames(getWebStateFromPath(path, { screens })), + ).toEqual(expected); + expect(getFocusedRouteNames(parseExtensionHash(`#${path}`))).toEqual( + expected, + ); + }, + ); + + it.each([ + EOnboardingPagesV2.ConnectYourDevice, + EOnboardingPagesV2.CheckAndUpdate, + EOnboardingPagesV2.ShowRecoveryPhrase, + EOnboardingPagesV2.VerifyRecoveryPhrase, + 'UnknownPage', + ])('rejects non-opted-in onboarding page %s', (page) => { + const path = `/onboarding/${page}`; + expect(getWebStateFromPath(path, { screens })).toBeUndefined(); + expect(parseExtensionHash(`#${path}`)).toBeUndefined(); + }); + + it.each([ + [ + '/reward-center', + [ + ERootRoutes.Modal, + EModalRoutes.MainModal, + EModalRewardCenterRoutes.RewardCenter, + ], + ], + [ + '/settings/protection', + [ + ERootRoutes.Modal, + EModalRoutes.SettingModal, + EModalSettingRoutes.SettingProtectModal, + ], + ], + [ + '/modal/update/preview', + [ + ERootRoutes.Modal, + EModalRoutes.AppUpdateModal, + EAppUpdateRoutes.UpdatePreview, + ], + ], + [ + '/defi/staking/ETH/lido', + [ + ERootRoutes.Modal, + EModalRoutes.StakingModal, + EModalStakingRoutes.ProtocolDetails, + ], + ], + [ + '/defi/staking/v2/ETH/lido', + [ + ERootRoutes.Modal, + EModalRoutes.StakingModal, + EModalStakingRoutes.ProtocolDetailsV2, + ], + ], + [ + '/defi/ethereum/ETH/lido', + [ + ERootRoutes.Modal, + EModalRoutes.StakingModal, + EModalStakingRoutes.ProtocolDetailsV2Share, + ], + ], + [ + '/ManagePosition?accountId=legacy-account', + [ + ERootRoutes.Modal, + EModalRoutes.StakingModal, + EModalStakingRoutes.ManagePosition, + ], + ], + [ + '/modal/SignatureConfirmModal/TxConfirmFromDApp?query=transaction', + [ + ERootRoutes.Modal, + EModalRoutes.SignatureConfirmModal, + EModalSignatureConfirmRoutes.TxConfirmFromDApp, + ], + ], + [ + '/modal/SignatureConfirmModal/MessageConfirmFromDApp?query=message', + [ + ERootRoutes.Modal, + EModalRoutes.SignatureConfirmModal, + EModalSignatureConfirmRoutes.MessageConfirmFromDApp, + ], + ], + [ + '/modal/ReferFriendsModal/ReferAFriend', + [ + ERootRoutes.Modal, + EModalRoutes.ReferFriendsModal, + EModalReferFriendsRoutes.ReferAFriend, + ], + ], + ])('parses cold-start path %s across root domains', (path, names) => { + expect( + getFocusedRouteNames(getWebStateFromPath(path, { screens })), + ).toEqual(names); + }); + + it.each([ + '/modal/ApprovalManagementModal/BulkRevoke', + '/fullScreenPush/ActionCenter/ActionCenter', + '/RootWebView/WebView/WebView', + ])('rejects internal runtime path %s', (path) => { + expect(getWebStateFromPath(path, { screens })).toBeUndefined(); + expect(parseExtensionHash(`#${path}`)).toBeUndefined(); + }); + + it('shares a route discovered from an Extension entry with Web', () => { + const path = '/modal/DAppConnectionModal/ConnectionModal'; + expect( + getFocusedRouteNames(getWebStateFromPath(path, { screens })), + ).toEqual([ + ERootRoutes.Modal, + EModalRoutes.DAppConnectionModal, + EDAppConnectionModal.ConnectionModal, + ]); + }); + + it.each([ + [ + '#/modal/DAppConnectionModal/ConnectionModal?query=approval', + [ + ERootRoutes.Modal, + EModalRoutes.DAppConnectionModal, + EDAppConnectionModal.ConnectionModal, + ], + ], + [ + '#/iOSFullScreen/DAppConnectionModal/WalletConnectSessionProposalModal?query=proposal', + [ + ERootRoutes.iOSFullScreen, + EModalRoutes.DAppConnectionModal, + EDAppConnectionModal.WalletConnectSessionProposalModal, + ], + ], + [ + '#/modal/SignatureConfirmModal/MessageConfirmFromDApp?query=message', + [ + ERootRoutes.Modal, + EModalRoutes.SignatureConfirmModal, + EModalSignatureConfirmRoutes.MessageConfirmFromDApp, + ], + ], + [ + '#/iOSFullScreen/SignatureConfirmModal/TxConfirmFromDApp?query=transaction', + [ + ERootRoutes.iOSFullScreen, + EModalRoutes.SignatureConfirmModal, + EModalSignatureConfirmRoutes.TxConfirmFromDApp, + ], + ], + ])('parses Extension standalone approval hash %s', (hash, names) => { + expect(getFocusedRouteNames(parseExtensionHash(hash))).toEqual(names); + }); + + it.each([ + [ + '#/modal/FirmwareUpdateModal/ChangeLog?connectId=device-1&firmwareType=bitcoinonly', + [ + ERootRoutes.Modal, + EModalRoutes.FirmwareUpdateModal, + EModalFirmwareUpdateRoutes.ChangeLog, + ], + ], + [ + '#/modal/FirmwareUpdateModal/Install', + [ + ERootRoutes.Modal, + EModalRoutes.FirmwareUpdateModal, + EModalFirmwareUpdateRoutes.Install, + ], + ], + [ + '#/modal/FirmwareUpdateModal/InstallV2', + [ + ERootRoutes.Modal, + EModalRoutes.FirmwareUpdateModal, + EModalFirmwareUpdateRoutes.InstallV2, + ], + ], + [ + '#/modal/OnboardingModal/V4MigrationGetStarted', + [ + ERootRoutes.Modal, + EModalRoutes.OnboardingModal, + EOnboardingPages.V4MigrationGetStarted, + ], + ], + [ + '#/modal/NotificationsModal/NotificationList', + [ + ERootRoutes.Modal, + EModalRoutes.NotificationsModal, + EModalNotificationsRoutes.NotificationList, + ], + ], + [ + '#/modal/MainModal/AssetDetail_HistoryDetails?transactionHash=0x01', + [ + ERootRoutes.Modal, + EModalRoutes.MainModal, + EModalAssetDetailRoutes.HistoryDetails, + ], + ], + ])('parses shared product cold-start entry %s', (hash, names) => { + expect(getFocusedRouteNames(parseExtensionHash(hash))).toEqual(names); + expect( + getFocusedRouteNames( + getWebStateFromPath(hash.slice(1), { + screens, + }), + ), + ).toEqual(names); + }); + + it('preserves Firmware Update cold-start query parameters', () => { + const state = parseExtensionHash( + '#/modal/FirmwareUpdateModal/ChangeLog?connectId=device-1&firmwareType=bitcoinonly', + ); + let currentState = state; + let focusedRoute: + | { + params?: Record; + state?: IPartialNavigationState; + } + | undefined; + while (currentState) { + focusedRoute = currentState.routes[ + currentState.index ?? currentState.routes.length - 1 + ] as + | { + params?: Record; + state?: IPartialNavigationState; + } + | undefined; + currentState = focusedRoute?.state; + } + expect(focusedRoute?.params).toMatchObject({ + connectId: 'device-1', + firmwareType: 'bitcoinonly', + }); + }); + + it('preserves the v6.4.0 Extension permission display URL', () => { + expect( + getFocusedRouteNames( + parseExtensionHash('#/permission/web-device?requestId=legacy'), + ), + ).toEqual([ERootRoutes.PermissionWebDevice]); + }); + + it('preserves the v6.4.0 development-only displayed modal URL', () => { + if (!platformEnv.isDev) { + return; + } + expect( + getFocusedRouteNames( + getWebStateFromPath('/modal/TestModal/TestSimpleModal', { screens }), + ), + ).toEqual([ + ERootRoutes.Modal, + EModalRoutes.TestModal, + ETestModalPages.TestSimpleModal, + ]); + }); + + it('keeps allowed deep routes and excludes internal runtime routes', () => { + expect( + findRoute( + rootRouterPathConfig, + ERootRoutes.Modal, + EModalRoutes.SettingModal, + EModalSettingRoutes.SettingProtectModal, + ), + ).toBeDefined(); + expect( + findRoute( + rootRouterPathConfig, + ERootRoutes.Modal, + EModalRoutes.ApprovalManagementModal, + 'BulkRevoke', + ), + ).toBeUndefined(); + }); +}); diff --git a/packages/kit/src/routes/routerPathConfig.ts b/packages/kit/src/routes/routerPathConfig.ts index 673d88bb47a0..3b639536841e 100644 --- a/packages/kit/src/routes/routerPathConfig.ts +++ b/packages/kit/src/routes/routerPathConfig.ts @@ -1,22 +1,6 @@ -import platformEnv from '@onekeyhq/shared/src/platformEnv'; -import { - EAppUpdateRoutes, - EDAppConnectionModal, - EModalReferFriendsRoutes, - EModalRewardCenterRoutes, - EModalRoutes, - EModalSettingRoutes, - EModalSignatureConfirmRoutes, - EModalStakingRoutes, - EOnboardingPagesV2, - EOnboardingV2Routes, - ETestModalPages, - EWebViewRoutes, -} from '@onekeyhq/shared/src/routes'; -import { - EActionCenterPages, - EFullScreenPushRoutes, -} from '@onekeyhq/shared/src/routes/fullScreenPush'; +import { ERootRoutes } from '@onekeyhq/shared/src/routes'; + +import generatedRoutePathConfig from './generated/routePathConfig.generated'; export interface IRoutePathConfig { name: string; @@ -25,182 +9,29 @@ export interface IRoutePathConfig { children?: IRoutePathConfig[]; } -const route = ({ - name, - rewrite, - exact, - children, -}: IRoutePathConfig): IRoutePathConfig => ({ - name, - ...(rewrite ? { rewrite } : {}), - ...(exact ? { exact } : {}), - ...(children ? { children } : {}), -}); - -const mainModalPathConfig = [ - route({ - name: EModalRewardCenterRoutes.RewardCenter, - rewrite: '/reward-center', - exact: true, - }), -]; - -const settingPathConfig = [ - route({ name: EModalSettingRoutes.SettingListModal, rewrite: '/' }), - route({ - name: EModalSettingRoutes.SettingProtectModal, - rewrite: '/protection', - }), -]; - -const appUpdatePathConfig = [ - route({ - name: EAppUpdateRoutes.UpdatePreview, - rewrite: '/preview', - }), -]; - -const stakingPathConfig = [ - route({ - name: EModalStakingRoutes.ProtocolDetails, - rewrite: '/defi/staking/:symbol/:provider', - exact: true, - }), - route({ - name: EModalStakingRoutes.ProtocolDetailsV2, - rewrite: '/defi/staking/v2/:symbol/:provider', - exact: true, - }), - route({ - name: EModalStakingRoutes.ProtocolDetailsV2Share, - rewrite: '/defi/:network/:symbol/:provider', - exact: true, - }), - route({ name: EModalStakingRoutes.ManagePosition, exact: true }), -]; +interface IGeneratedRoutePathConfig { + schemaVersion: 2; + target: string; + mode: 'production' | 'development'; + routes: IRoutePathConfig[]; +} -// Ext standalone windows cold-start from the URL hash (see -// getStateFromPath.ext.ts), so every screen opened via ServiceDApp.openModal -// must be listed here, or the window resolves to NotFound. -// VerifyMessage is excluded: DAppConnectionRouter registers no screen for it, -// and a parsed route pointing at an unregistered screen creates navigation -// state the navigator cannot render. -const dAppConnectionPathConfig = Object.values(EDAppConnectionModal) - .filter((name) => name !== EDAppConnectionModal.VerifyMessage) - .map((name) => route({ name })); +const generated = + generatedRoutePathConfig as unknown as IGeneratedRoutePathConfig; -const signatureConfirmPathConfig = [ - route({ name: EModalSignatureConfirmRoutes.TxConfirmFromDApp }), - route({ name: EModalSignatureConfirmRoutes.MessageConfirmFromDApp }), - route({ name: EModalSignatureConfirmRoutes.LnurlPayRequest }), - route({ name: EModalSignatureConfirmRoutes.LnurlWithdraw }), - route({ name: EModalSignatureConfirmRoutes.LnurlAuth }), - route({ name: EModalSignatureConfirmRoutes.WeblnSendPayment }), -]; +export const rootRouterPathConfig: IRoutePathConfig[] = generated.routes; -const onboardingV2PagePathConfig = [ - route({ - name: EOnboardingPagesV2.GetStarted, - rewrite: '/get-started', - }), - route({ - name: EOnboardingPagesV2.CreateNewWallet, - rewrite: '/create-new-wallet', - }), - route({ - name: EOnboardingPagesV2.CreateOrImportWallet, - rewrite: '/create-or-import-wallet', - }), -]; +const getRootChildren = (name: ERootRoutes): IRoutePathConfig[] => + rootRouterPathConfig.find((route) => route.name === name)?.children ?? []; -const modalRouteNames = Object.values(EModalRoutes).filter( - (name) => name !== EModalRoutes.TestModal, +export const modalRouterPathConfig = getRootChildren(ERootRoutes.Modal); +export const fullModalRouterPathConfig = getRootChildren( + ERootRoutes.iOSFullScreen, ); - -const modalRouteOverrides: Partial> = { - [EModalRoutes.MainModal]: route({ - name: EModalRoutes.MainModal, - children: mainModalPathConfig, - }), - [EModalRoutes.SettingModal]: route({ - name: EModalRoutes.SettingModal, - rewrite: '/settings', - exact: true, - children: settingPathConfig, - }), - [EModalRoutes.AppUpdateModal]: route({ - name: EModalRoutes.AppUpdateModal, - rewrite: '/update', - children: appUpdatePathConfig, - }), - [EModalRoutes.StakingModal]: route({ - name: EModalRoutes.StakingModal, - children: stakingPathConfig, - }), - [EModalRoutes.ReferFriendsModal]: route({ - name: EModalRoutes.ReferFriendsModal, - children: [route({ name: EModalReferFriendsRoutes.ReferAFriend })], - }), - [EModalRoutes.DAppConnectionModal]: route({ - name: EModalRoutes.DAppConnectionModal, - children: dAppConnectionPathConfig, - }), - [EModalRoutes.SignatureConfirmModal]: route({ - name: EModalRoutes.SignatureConfirmModal, - children: signatureConfirmPathConfig, - }), -}; - -export const modalRouterPathConfig: IRoutePathConfig[] = [ - ...modalRouteNames.map( - (name) => modalRouteOverrides[name] ?? route({ name }), - ), - ...(platformEnv.isDev - ? [ - route({ - name: EModalRoutes.TestModal, - children: [route({ name: ETestModalPages.TestSimpleModal })], - }), - ] - : []), -]; - -export const fullModalRouterPathConfig: IRoutePathConfig[] = [ - route({ - name: EModalRoutes.AppUpdateModal, - children: appUpdatePathConfig, - }), - route({ - name: EModalRoutes.DAppConnectionModal, - children: dAppConnectionPathConfig, - }), - route({ name: EModalRoutes.ReceiveModal }), - route({ name: EModalRoutes.SendModal }), - route({ - name: EModalRoutes.SignatureConfirmModal, - children: signatureConfirmPathConfig, - }), -]; - -export const fullScreenPushRouterPathConfig: IRoutePathConfig[] = [ - route({ - name: EFullScreenPushRoutes.ActionCenter, - children: [route({ name: EActionCenterPages.ActionCenter })], - }), -]; - -export const onboardingRouterV2PathConfig: IRoutePathConfig[] = [ - route({ - name: EOnboardingV2Routes.OnboardingV2, - rewrite: '/onboarding', - exact: true, - children: onboardingV2PagePathConfig, - }), -]; - -export const webViewRouterPathConfig: IRoutePathConfig[] = [ - route({ - name: EWebViewRoutes.WebView, - children: [route({ name: EWebViewRoutes.WebView })], - }), -]; +export const fullScreenPushRouterPathConfig = getRootChildren( + ERootRoutes.FullScreenPush, +); +export const onboardingRouterV2PathConfig = getRootChildren( + ERootRoutes.Onboarding, +); +export const webViewRouterPathConfig = getRootChildren(ERootRoutes.WebView); diff --git a/packages/kit/src/views/AppUpdate/router/index.ts b/packages/kit/src/views/AppUpdate/router/index.ts index db86c0c8bdbc..167db11e7fd7 100644 --- a/packages/kit/src/views/AppUpdate/router/index.ts +++ b/packages/kit/src/views/AppUpdate/router/index.ts @@ -26,6 +26,7 @@ export const AppUpdateRouter: IModalFlowNavigatorConfig< { name: EAppUpdateRoutes.UpdatePreview, component: UpdatePreview, + allowColdStart: true, rewrite: '/preview', }, { diff --git a/packages/kit/src/views/AssetDetails/router/index.ts b/packages/kit/src/views/AssetDetails/router/index.ts index 12d5c7ecba18..1c5eed8babe4 100644 --- a/packages/kit/src/views/AssetDetails/router/index.ts +++ b/packages/kit/src/views/AssetDetails/router/index.ts @@ -46,6 +46,7 @@ export const ModalAssetDetailsStack: IModalFlowNavigatorConfig< { name: EModalAssetDetailRoutes.HistoryDetails, component: HistoryDetails, + allowColdStart: true, }, { name: EModalAssetDetailRoutes.UTXODetails, diff --git a/packages/kit/src/views/DAppConnection/router/index.ts b/packages/kit/src/views/DAppConnection/router/index.ts index dcb65b1dd486..75f08905047d 100644 --- a/packages/kit/src/views/DAppConnection/router/index.ts +++ b/packages/kit/src/views/DAppConnection/router/index.ts @@ -64,61 +64,78 @@ export const DAppConnectionRouter: IModalFlowNavigatorConfig< EDAppConnectionModal, IDAppConnectionModalParamList >[] = [ + // Extension standalone windows cold-start from a hash URL built by + // ServiceDApp.openModal. Cold-start route declarations are shared by every + // platform even when only one platform currently opens a specific entry. { name: EDAppConnectionModal.ConnectionModal, component: ConnectionModal, + allowColdStart: true, dismissOnOverlayPress: false, }, { name: EDAppConnectionModal.ConnectionList, component: ConnectionList, + allowColdStart: true, }, { name: EDAppConnectionModal.WalletConnectSessionProposalModal, component: WalletConnectSessionProposalModal, + allowColdStart: true, }, { name: EDAppConnectionModal.SignMessageModal, component: SignMessageModal, + allowColdStart: true, }, { name: EDAppConnectionModal.AddCustomNetworkModal, component: SettingCustomNetworkModal, + allowColdStart: true, }, { name: EDAppConnectionModal.AddCustomTokenModal, component: AddCustomTokenModal, + allowColdStart: true, }, { name: EDAppConnectionModal.CurrentConnectionModal, component: CurrentConnectionModal, + allowColdStart: true, }, { name: EDAppConnectionModal.DefaultWalletSettingsModal, component: DefaultWalletSettingsModal, + allowColdStart: true, }, { name: EDAppConnectionModal.MakeInvoice, component: MakeInvoiceModal, + allowColdStart: true, }, { name: EDAppConnectionModal.NostrSignEventModal, component: NostrSignEventModal, + allowColdStart: true, }, { name: EDAppConnectionModal.CosmosEnigmaUnlockModal, component: CosmosEnigmaUnlockModal, + allowColdStart: true, }, { name: EDAppConnectionModal.RiskWhiteListModal, component: RiskWhiteListModal, + allowColdStart: true, }, { name: EDAppConnectionModal.ClipboardPermissionModal, component: ClipboardPermissionModal, + allowColdStart: true, }, { name: EDAppConnectionModal.DeriveContextHashModal, component: DeriveContextHashModal, + allowColdStart: true, }, ]; diff --git a/packages/kit/src/views/FirmwareUpdate/hooks/restartOnboardingUtils.test.ts b/packages/kit/src/views/FirmwareUpdate/hooks/restartOnboardingUtils.test.ts new file mode 100644 index 000000000000..885ca4f71225 --- /dev/null +++ b/packages/kit/src/views/FirmwareUpdate/hooks/restartOnboardingUtils.test.ts @@ -0,0 +1,31 @@ +import { EDeviceType } from '@onekeyfe/hd-shared'; + +import { + EOnboardingPagesV2, + EOnboardingV2Routes, +} from '@onekeyhq/shared/src/routes'; + +import { buildFirmwareUpdateOnboardingParams } from './restartOnboardingUtils'; + +describe('buildFirmwareUpdateOnboardingParams', () => { + it('returns to device selection when firmware metadata has no device type', () => { + expect(buildFirmwareUpdateOnboardingParams(undefined)).toEqual({ + screen: EOnboardingV2Routes.OnboardingV2, + params: { + screen: EOnboardingPagesV2.PickYourDevice, + }, + }); + }); + + it('continues to device connection when firmware metadata has a device type', () => { + expect(buildFirmwareUpdateOnboardingParams(EDeviceType.Pro)).toEqual({ + screen: EOnboardingV2Routes.OnboardingV2, + params: { + screen: EOnboardingPagesV2.ConnectYourDevice, + params: { + deviceType: [EDeviceType.Pro], + }, + }, + }); + }); +}); diff --git a/packages/kit/src/views/FirmwareUpdate/hooks/restartOnboardingUtils.ts b/packages/kit/src/views/FirmwareUpdate/hooks/restartOnboardingUtils.ts new file mode 100644 index 000000000000..238830ecef3d --- /dev/null +++ b/packages/kit/src/views/FirmwareUpdate/hooks/restartOnboardingUtils.ts @@ -0,0 +1,22 @@ +import { + EOnboardingPagesV2, + EOnboardingV2Routes, +} from '@onekeyhq/shared/src/routes'; + +import type { EDeviceType } from '@onekeyfe/hd-shared'; + +export const buildFirmwareUpdateOnboardingParams = ( + deviceType: EDeviceType | undefined, +) => ({ + screen: EOnboardingV2Routes.OnboardingV2, + params: deviceType + ? { + screen: EOnboardingPagesV2.ConnectYourDevice, + params: { + deviceType: [deviceType], + }, + } + : { + screen: EOnboardingPagesV2.PickYourDevice, + }, +}); diff --git a/packages/kit/src/views/FirmwareUpdate/hooks/useFirmwareUpdateActions.tsx b/packages/kit/src/views/FirmwareUpdate/hooks/useFirmwareUpdateActions.tsx index 99aba43800a5..d6a03334d70e 100644 --- a/packages/kit/src/views/FirmwareUpdate/hooks/useFirmwareUpdateActions.tsx +++ b/packages/kit/src/views/FirmwareUpdate/hooks/useFirmwareUpdateActions.tsx @@ -10,8 +10,6 @@ import platformEnv from '@onekeyhq/shared/src/platformEnv'; import { EModalFirmwareUpdateRoutes, EModalRoutes, - EOnboardingPagesV2, - EOnboardingV2Routes, ERootRoutes, } from '@onekeyhq/shared/src/routes'; import type { ICheckAllFirmwareReleaseResult } from '@onekeyhq/shared/types/device'; @@ -21,6 +19,8 @@ import useAppNavigation from '../../../hooks/useAppNavigation'; import { FirmwareUpdateCheckList } from '../components/FirmwareUpdateCheckList'; import { getTargetFirmwareTypeLabel } from '../utils'; +import { buildFirmwareUpdateOnboardingParams } from './restartOnboardingUtils'; + import type { AllFirmwareRelease } from '@onekeyfe/hd-core'; import type { EDeviceType, EFirmwareType } from '@onekeyfe/hd-shared'; @@ -145,15 +145,10 @@ export function useFirmwareUpdateActions() { const restartOnboarding = useCallback( async ({ deviceType }: { deviceType: EDeviceType | undefined }) => { - resetToRoute(ERootRoutes.Onboarding, { - screen: EOnboardingV2Routes.OnboardingV2, - params: { - screen: EOnboardingPagesV2.ConnectYourDevice, - params: { - deviceType: [deviceType], - }, - }, - }); + resetToRoute( + ERootRoutes.Onboarding, + buildFirmwareUpdateOnboardingParams(deviceType), + ); }, [], ); diff --git a/packages/kit/src/views/FirmwareUpdate/router/index.ts b/packages/kit/src/views/FirmwareUpdate/router/index.ts index b73700efc7bd..a1021719fc72 100644 --- a/packages/kit/src/views/FirmwareUpdate/router/index.ts +++ b/packages/kit/src/views/FirmwareUpdate/router/index.ts @@ -25,13 +25,16 @@ export const ModalFirmwareUpdateStack: IModalFlowNavigatorConfig< { name: EModalFirmwareUpdateRoutes.ChangeLog, component: PageFirmwareUpdateChangeLog, + allowColdStart: true, }, { name: EModalFirmwareUpdateRoutes.Install, component: PageFirmwareUpdateInstall, + allowColdStart: true, }, { name: EModalFirmwareUpdateRoutes.InstallV2, component: PageFirmwareUpdateInstallV2, + allowColdStart: true, }, ]; diff --git a/packages/kit/src/views/Notifications/router/index.ts b/packages/kit/src/views/Notifications/router/index.ts index 9925e79b61ad..5b10ca16ec34 100644 --- a/packages/kit/src/views/Notifications/router/index.ts +++ b/packages/kit/src/views/Notifications/router/index.ts @@ -19,6 +19,7 @@ export const ModalNotificationsRouter: IModalFlowNavigatorConfig< { name: EModalNotificationsRoutes.NotificationList, component: NotificationList, + allowColdStart: true, }, { diff --git a/packages/kit/src/views/Onboarding/router/index.ts b/packages/kit/src/views/Onboarding/router/index.ts index 40130de3fd45..aaf6fea9eefa 100644 --- a/packages/kit/src/views/Onboarding/router/index.ts +++ b/packages/kit/src/views/Onboarding/router/index.ts @@ -96,6 +96,7 @@ export const OnboardingRouter: IModalFlowNavigatorConfig< { name: EOnboardingPages.V4MigrationGetStarted, component: V4MigrationGetStarted, + allowColdStart: true, }, { name: EOnboardingPages.V4MigrationPreview, diff --git a/packages/kit/src/views/Onboardingv2/pages/CheckAndUpdate.tsx b/packages/kit/src/views/Onboardingv2/pages/CheckAndUpdate.tsx index 0fce11bf7d9e..234a6c1aa7b1 100644 --- a/packages/kit/src/views/Onboardingv2/pages/CheckAndUpdate.tsx +++ b/packages/kit/src/views/Onboardingv2/pages/CheckAndUpdate.tsx @@ -51,6 +51,8 @@ import { usePrepareUSBConnectForFirmwareUpdate } from '../hooks/usePrepareUSBCon import { OnboardingTestIDs } from '../testIDs'; import { getForceTransportType } from '../utils'; +import { isValidCheckAndUpdateRouteParams } from './routeParamGuards'; + import type { Features, KnownDevice, SearchDevice } from '@onekeyfe/hd-core'; enum ECheckAndUpdateStepState { @@ -1231,6 +1233,19 @@ export default function CheckAndUpdate({ IOnboardingParamListV2, EOnboardingPagesV2.CheckAndUpdate >) { + const appNavigation = useAppNavigation(); + const hasValidRouteParams = isValidCheckAndUpdateRouteParams(route?.params); + + useEffect(() => { + if (!hasValidRouteParams) { + appNavigation.popStack(); + } + }, [appNavigation, hasValidRouteParams]); + + if (!hasValidRouteParams) { + return null; + } + return ( ) { + const appNavigation = useAppNavigation(); + const hasValidRouteParams = isValidConnectYourDeviceRouteParams( + route?.params, + ); + + useEffect(() => { + if (!hasValidRouteParams) { + appNavigation.popStack(); + } + }, [appNavigation, hasValidRouteParams]); + + if (!hasValidRouteParams) { + return null; + } + return ( >(); + const routeParams = route.params; + const hasValidRouteParams = isValidRecoveryPhraseRouteParams(routeParams); + + useEffect(() => { + if (!hasValidRouteParams) { + navigation.popStack(); + } + }, [hasValidRouteParams, navigation]); const { result: mnemonic = '' } = usePromiseResult(async () => { - const routeMnemonic = route.params?.mnemonic; - if (routeMnemonic) { - ensureSensitiveTextEncoded(routeMnemonic); - return backgroundApiProxy.servicePassword.decodeSensitiveText({ - encodedText: routeMnemonic, - }); + if (!hasValidRouteParams) { + return ''; } - return backgroundApiProxy.serviceAccount.generateMnemonic(); - }, [route.params.mnemonic]); + ensureSensitiveTextEncoded(routeParams.mnemonic); + return backgroundApiProxy.servicePassword.decodeSensitiveText({ + encodedText: routeParams.mnemonic, + }); + }, [hasValidRouteParams, routeParams]); const { result: displayName } = usePromiseResult(async () => { - if (!route.params.walletId) { + if (!hasValidRouteParams) { return ''; } const wallet = await backgroundApiProxy.serviceAccount.getWallet({ - walletId: route.params.walletId, + walletId: routeParams.walletId, }); if ( - route.params.accountName && + routeParams.accountName && accountUtils.isOthersWallet({ walletId: wallet.id }) ) { - return route.params.accountName; + return routeParams.accountName; } return wallet.name; - }, [route.params.accountName, route.params.walletId]); + }, [hasValidRouteParams, routeParams]); const recoveryPhrase = useMemo( () => mnemonic.split(' ').filter(Boolean), [mnemonic], ); const handleContinue = useCallback(async () => { - let isNotBackedUp = true; - if (route.params.walletId) { - const wallet = await backgroundApiProxy.serviceAccount.getWallet({ - walletId: route.params.walletId, - }); - isNotBackedUp = !wallet?.backuped; + if (!hasValidRouteParams) { + return; } + let isNotBackedUp = true; + const wallet = await backgroundApiProxy.serviceAccount.getWallet({ + walletId: routeParams.walletId, + }); + isNotBackedUp = !wallet?.backuped; if (isNotBackedUp) { - navigation.push(EOnboardingPagesV2.VerifyRecoveryPhrase, route.params); + navigation.push(EOnboardingPagesV2.VerifyRecoveryPhrase, routeParams); } else { navigation.popStack(); } - }, [navigation, route.params]); + }, [hasValidRouteParams, navigation, routeParams]); useRecoveryPhraseProtected({ enabled: Boolean(mnemonic) }); @@ -130,6 +140,10 @@ export default function ShowRecoveryPhrase() { ); }, [handleCopyMnemonic]); + if (!hasValidRouteParams || !mnemonic) { + return null; + } + return ( diff --git a/packages/kit/src/views/Onboardingv2/pages/VerifyRecoveryPhrase.tsx b/packages/kit/src/views/Onboardingv2/pages/VerifyRecoveryPhrase.tsx index 5cbd91160278..045aa25e249c 100644 --- a/packages/kit/src/views/Onboardingv2/pages/VerifyRecoveryPhrase.tsx +++ b/packages/kit/src/views/Onboardingv2/pages/VerifyRecoveryPhrase.tsx @@ -1,4 +1,4 @@ -import { useCallback, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { useRoute } from '@react-navigation/core'; import bip39Wordlists from 'bip39/src/wordlists/english.json'; @@ -27,6 +27,8 @@ import { OnboardingLayout } from '../components/OnboardingLayout'; import { OnboardingTestIDs } from '../testIDs'; import { shuffleWordsIndices } from '../utils'; +import { isValidRecoveryPhraseRouteParams } from './routeParamGuards'; + import type { RouteProp } from '@react-navigation/core'; export default function VerifyRecoveryPhrase() { @@ -36,17 +38,25 @@ export default function VerifyRecoveryPhrase() { useRoute< RouteProp >(); + const routeMnemonic = route.params?.mnemonic; + const walletId = route.params?.walletId; + const hasValidRouteParams = isValidRecoveryPhraseRouteParams(route.params); + + useEffect(() => { + if (!hasValidRouteParams) { + navigation.popStack(); + } + }, [hasValidRouteParams, navigation]); const { result: mnemonic = '' } = usePromiseResult(async () => { - const routeMnemonic = route.params?.mnemonic; - if (routeMnemonic) { - ensureSensitiveTextEncoded(routeMnemonic); - return backgroundApiProxy.servicePassword.decodeSensitiveText({ - encodedText: routeMnemonic, - }); + if (!hasValidRouteParams) { + return ''; } - return backgroundApiProxy.serviceAccount.generateMnemonic(); - }, [route.params?.mnemonic]); + ensureSensitiveTextEncoded(routeMnemonic); + return backgroundApiProxy.servicePassword.decodeSensitiveText({ + encodedText: routeMnemonic, + }); + }, [hasValidRouteParams, routeMnemonic]); const recoveryPhrase = useMemo( () => mnemonic.split(' ').filter(Boolean), [mnemonic], @@ -110,9 +120,9 @@ export default function VerifyRecoveryPhrase() { ); if (verifyResult) { - if (route.params?.walletId) { + if (walletId) { await backgroundApiProxy.serviceAccount.updateWalletBackupStatus({ - walletId: route.params?.walletId, + walletId, isBackedUp: true, }); } @@ -137,16 +147,13 @@ export default function VerifyRecoveryPhrase() { } }); }, - [ - answerIndices, - intl, - navigation, - recoveryPhrase, - route.params?.walletId, - selectedWords, - ], + [answerIndices, intl, navigation, recoveryPhrase, selectedWords, walletId], ); + if (!hasValidRouteParams || !mnemonic) { + return null; + } + return ( diff --git a/packages/kit/src/views/Onboardingv2/pages/routeParamGuards.test.ts b/packages/kit/src/views/Onboardingv2/pages/routeParamGuards.test.ts new file mode 100644 index 000000000000..cacb077e89ad --- /dev/null +++ b/packages/kit/src/views/Onboardingv2/pages/routeParamGuards.test.ts @@ -0,0 +1,69 @@ +import { EDeviceType } from '@onekeyfe/hd-shared'; + +import { ENCODE_TEXT_PREFIX } from '@onekeyhq/shared/src/utils/sensitiveTextUtils'; +import { EConnectDeviceChannel } from '@onekeyhq/shared/types/connectDevice'; +import { EHardwareVendor } from '@onekeyhq/shared/types/device'; + +import { + isValidCheckAndUpdateRouteParams, + isValidConnectYourDeviceRouteParams, + isValidRecoveryPhraseRouteParams, +} from './routeParamGuards'; + +describe('onboarding route parameter guards', () => { + it('requires encoded recovery phrase state and a wallet id', () => { + expect( + isValidRecoveryPhraseRouteParams({ + mnemonic: `${ENCODE_TEXT_PREFIX.aes}ciphertext`, + walletId: 'wallet-id', + }), + ).toBe(true); + expect( + isValidRecoveryPhraseRouteParams({ + mnemonic: 'unencoded words', + walletId: 'wallet-id', + }), + ).toBe(false); + expect(isValidRecoveryPhraseRouteParams({ walletId: 'wallet-id' })).toBe( + false, + ); + }); + + it('rejects URL-shaped device type strings', () => { + expect( + isValidConnectYourDeviceRouteParams({ + deviceType: [EDeviceType.Pro], + }), + ).toBe(true); + expect( + isValidConnectYourDeviceRouteParams({ deviceType: EDeviceType.Pro }), + ).toBe(false); + expect(isValidConnectYourDeviceRouteParams({ deviceType: [] })).toBe(true); + expect( + isValidConnectYourDeviceRouteParams({ + deviceType: [], + vendor: EHardwareVendor.ledger, + }), + ).toBe(true); + expect( + isValidConnectYourDeviceRouteParams({ + deviceType: [undefined], + }), + ).toBe(false); + }); + + it('requires a connected device object and channel', () => { + expect( + isValidCheckAndUpdateRouteParams({ + deviceData: { device: { connectId: 'device-id' } }, + tabValue: EConnectDeviceChannel.usbOrBle, + }), + ).toBe(true); + expect( + isValidCheckAndUpdateRouteParams({ + deviceData: {}, + tabValue: EConnectDeviceChannel.usbOrBle, + }), + ).toBe(false); + }); +}); diff --git a/packages/kit/src/views/Onboardingv2/pages/routeParamGuards.ts b/packages/kit/src/views/Onboardingv2/pages/routeParamGuards.ts new file mode 100644 index 000000000000..3efc79e22c0c --- /dev/null +++ b/packages/kit/src/views/Onboardingv2/pages/routeParamGuards.ts @@ -0,0 +1,64 @@ +import { EDeviceType } from '@onekeyfe/hd-shared'; + +import type { IOnboardingParamListV2 } from '@onekeyhq/shared/src/routes/onboardingv2'; +import { isEncodedSensitiveText } from '@onekeyhq/shared/src/utils/sensitiveTextUtils'; +import { EConnectDeviceChannel } from '@onekeyhq/shared/types/connectDevice'; +import { EHardwareVendor } from '@onekeyhq/shared/types/device'; + +type IRecoveryPhraseRouteParams = IOnboardingParamListV2['ShowRecoveryPhrase']; +type IConnectYourDeviceRouteParams = + IOnboardingParamListV2['ConnectYourDevice']; +type ICheckAndUpdateRouteParams = IOnboardingParamListV2['CheckAndUpdate']; + +const deviceTypes = new Set(Object.values(EDeviceType)); +const hardwareVendors = new Set(Object.values(EHardwareVendor)); +const connectDeviceChannels = new Set( + Object.values(EConnectDeviceChannel), +); + +const isRecord = (value: unknown): value is Record => + Boolean(value) && typeof value === 'object' && !Array.isArray(value); + +export const isValidRecoveryPhraseRouteParams = ( + params: unknown, +): params is IRecoveryPhraseRouteParams => { + if (!isRecord(params)) { + return false; + } + return ( + typeof params.mnemonic === 'string' && + isEncodedSensitiveText(params.mnemonic) && + typeof params.walletId === 'string' && + params.walletId.length > 0 && + (params.accountName === undefined || typeof params.accountName === 'string') + ); +}; + +export const isValidConnectYourDeviceRouteParams = ( + params: unknown, +): params is IConnectYourDeviceRouteParams => { + if (!isRecord(params) || !Array.isArray(params.deviceType)) { + return false; + } + return ( + params.deviceType.every( + (deviceType) => + typeof deviceType === 'string' && deviceTypes.has(deviceType), + ) && + (params.vendor === undefined || + (typeof params.vendor === 'string' && hardwareVendors.has(params.vendor))) + ); +}; + +export const isValidCheckAndUpdateRouteParams = ( + params: unknown, +): params is ICheckAndUpdateRouteParams => { + if (!isRecord(params) || !isRecord(params.deviceData)) { + return false; + } + return ( + isRecord(params.deviceData.device) && + typeof params.tabValue === 'string' && + connectDeviceChannels.has(params.tabValue) + ); +}; diff --git a/packages/kit/src/views/Onboardingv2/router/index.tsx b/packages/kit/src/views/Onboardingv2/router/index.tsx index 68beaaea9bc2..44b5aaa2a445 100644 --- a/packages/kit/src/views/Onboardingv2/router/index.tsx +++ b/packages/kit/src/views/Onboardingv2/router/index.tsx @@ -185,18 +185,21 @@ export const OnboardingRouterV2: IModalFlowNavigatorConfig< { name: EOnboardingPagesV2.GetStarted, component: GetStarted, + allowColdStart: true, options: hiddenHeaderOptions, rewrite: '/get-started', }, { name: EOnboardingPagesV2.CreateNewWallet, component: CreateNewWallet, + allowColdStart: true, options: hiddenHeaderOptions, rewrite: '/create-new-wallet', }, { name: EOnboardingPagesV2.CreateOrImportWallet, component: CreateOrImportWallet, + allowColdStart: true, options: hiddenHeaderOptions, rewrite: '/create-or-import-wallet', }, @@ -208,6 +211,7 @@ export const OnboardingRouterV2: IModalFlowNavigatorConfig< { name: EOnboardingPagesV2.PickYourDevice, component: PickYourDevice, + allowColdStart: true, options: hiddenHeaderOptions, }, { diff --git a/packages/kit/src/views/Onboardingv2/router/index.web-only.tsx b/packages/kit/src/views/Onboardingv2/router/index.web-only.tsx index 5c1e06da199c..368b3db66814 100644 --- a/packages/kit/src/views/Onboardingv2/router/index.web-only.tsx +++ b/packages/kit/src/views/Onboardingv2/router/index.web-only.tsx @@ -177,18 +177,21 @@ export const OnboardingRouterV2: IModalFlowNavigatorConfig< { name: EOnboardingPagesV2.GetStarted, component: GetStarted, + allowColdStart: true, options: hiddenHeaderOptions, rewrite: '/get-started', }, { name: EOnboardingPagesV2.CreateNewWallet, component: CreateNewWallet, + allowColdStart: true, options: hiddenHeaderOptions, rewrite: '/create-new-wallet', }, { name: EOnboardingPagesV2.CreateOrImportWallet, component: CreateOrImportWallet, + allowColdStart: true, options: hiddenHeaderOptions, rewrite: '/create-or-import-wallet', }, @@ -200,6 +203,7 @@ export const OnboardingRouterV2: IModalFlowNavigatorConfig< { name: EOnboardingPagesV2.PickYourDevice, component: PickYourDevice, + allowColdStart: true, options: hiddenHeaderOptions, }, { diff --git a/packages/kit/src/views/ReferFriends/router/index.ts b/packages/kit/src/views/ReferFriends/router/index.ts index 3468b6e4360f..492213800794 100644 --- a/packages/kit/src/views/ReferFriends/router/index.ts +++ b/packages/kit/src/views/ReferFriends/router/index.ts @@ -53,6 +53,7 @@ export const ReferFriendsRouter: IModalFlowNavigatorConfig< { name: EModalReferFriendsRoutes.ReferAFriend, component: ReferFriends, + allowColdStart: true, }, { name: EModalReferFriendsRoutes.InvitedByFriend, diff --git a/packages/kit/src/views/RewardCenter/router/index.ts b/packages/kit/src/views/RewardCenter/router/index.ts index ba833e67b50f..d6cea9c080e3 100644 --- a/packages/kit/src/views/RewardCenter/router/index.ts +++ b/packages/kit/src/views/RewardCenter/router/index.ts @@ -13,6 +13,7 @@ export const RewardCenterStack: IModalFlowNavigatorConfig< { name: EModalRewardCenterRoutes.RewardCenter, component: RewardCenterModal, + allowColdStart: true, rewrite: '/reward-center', exact: true, }, diff --git a/packages/kit/src/views/Setting/router/basicModalSettingRouter.ts b/packages/kit/src/views/Setting/router/basicModalSettingRouter.ts index 7533460e6f4f..9059ed064082 100644 --- a/packages/kit/src/views/Setting/router/basicModalSettingRouter.ts +++ b/packages/kit/src/views/Setting/router/basicModalSettingRouter.ts @@ -220,6 +220,7 @@ export const BasicModalSettingStack: IModalFlowNavigatorConfig< { name: EModalSettingRoutes.SettingProtectModal, component: SettingProtectionModal, + allowColdStart: true, rewrite: '/protection', }, { diff --git a/packages/kit/src/views/SignatureConfirm/router/index.tsx b/packages/kit/src/views/SignatureConfirm/router/index.tsx index d6a287ca9a6b..af3aed4cd217 100644 --- a/packages/kit/src/views/SignatureConfirm/router/index.tsx +++ b/packages/kit/src/views/SignatureConfirm/router/index.tsx @@ -85,73 +85,91 @@ export const ModalSignatureConfirmStack: IModalFlowNavigatorConfig< EModalSignatureConfirmRoutes, IModalSignatureConfirmParamList >[] = [ + // Extension standalone windows cold-start from a hash URL built by + // ServiceDApp.openModal. Cold-start route declarations are shared by every + // platform even when only one platform currently opens a specific entry. { name: EModalSignatureConfirmRoutes.TxConfirm, component: TxConfirm, + allowColdStart: true, }, { name: EModalSignatureConfirmRoutes.MessageConfirm, component: MessageConfirm, + allowColdStart: true, }, { name: EModalSignatureConfirmRoutes.TxConfirmFromDApp, component: TxConfirmFromDApp, + allowColdStart: true, }, { name: EModalSignatureConfirmRoutes.MessageConfirmFromDApp, component: MessageConfirmFromDApp, + allowColdStart: true, }, { name: EModalSignatureConfirmRoutes.TxConfirmFromSwap, component: TxConfirmFromSwap, + allowColdStart: true, }, { name: EModalSignatureConfirmRoutes.TxDataInput, component: TxDataInput, + allowColdStart: true, }, { name: EModalSignatureConfirmRoutes.TxAmountInput, component: TxAmountInput, + allowColdStart: true, }, { name: EModalSignatureConfirmRoutes.TxReplace, component: TxReplace, + allowColdStart: true, }, { name: EModalSignatureConfirmRoutes.TxSelectToken, component: TxTokenSelector, + allowColdStart: true, }, { name: EModalSignatureConfirmRoutes.TxSelectAggregateToken, component: TxAggregateTokenSelector, + allowColdStart: true, }, { name: EModalSignatureConfirmRoutes.TxSelectDeriveAddress, component: TxDeriveTypesAddress, + allowColdStart: true, }, { name: EModalSignatureConfirmRoutes.LnurlPayRequest, component: LnurlPayRequestModal, + allowColdStart: true, }, { name: EModalSignatureConfirmRoutes.LnurlWithdraw, component: LnurlWithdrawModal, + allowColdStart: true, }, { name: EModalSignatureConfirmRoutes.WeblnSendPayment, component: WeblnSendPaymentModal, + allowColdStart: true, }, { name: EModalSignatureConfirmRoutes.LnurlAuth, component: LnurlAuthModal, + allowColdStart: true, }, ]; diff --git a/packages/kit/src/views/Staking/router/index.tsx b/packages/kit/src/views/Staking/router/index.tsx index efa229312a45..8614fe03195b 100644 --- a/packages/kit/src/views/Staking/router/index.tsx +++ b/packages/kit/src/views/Staking/router/index.tsx @@ -89,24 +89,28 @@ export const StakingModalRouter: IModalFlowNavigatorConfig< { name: EModalStakingRoutes.ProtocolDetails, component: ProtocolDetails, + allowColdStart: true, exact: true, rewrite: '/defi/staking/:symbol/:provider', }, { name: EModalStakingRoutes.ProtocolDetailsV2, component: ProtocolDetailsV2, + allowColdStart: true, exact: true, rewrite: '/defi/staking/v2/:symbol/:provider', }, { name: EModalStakingRoutes.ProtocolDetailsV2Share, component: ProtocolDetailsV2, + allowColdStart: true, exact: true, rewrite: '/defi/:network/:symbol/:provider', }, { name: EModalStakingRoutes.ManagePosition, component: ManagePosition, + allowColdStart: true, exact: true, }, { diff --git a/packages/kit/src/views/TestModal/router/index.ts b/packages/kit/src/views/TestModal/router/index.ts index af6317a1a2ed..89bcde745801 100644 --- a/packages/kit/src/views/TestModal/router/index.ts +++ b/packages/kit/src/views/TestModal/router/index.ts @@ -17,5 +17,6 @@ export const TestModalRouter: IModalFlowNavigatorConfig< { name: ETestModalPages.TestSimpleModal, component: TestSimpleModal, + allowColdStart: true, }, ]; diff --git a/packages/shared/src/utils/routeUtils.ts b/packages/shared/src/utils/routeUtils.ts index f369a78f4005..78584fbcf013 100644 --- a/packages/shared/src/utils/routeUtils.ts +++ b/packages/shared/src/utils/routeUtils.ts @@ -54,6 +54,10 @@ const allowListMap = new Map(); const buildAllowListMapKey = (screenNames: string[]) => screenNames.join(','); +// This allow list controls state-to-URL serialization only: it decides whether +// navigation state is reflected in the address bar. It is not an inbound URL or +// cold-start allow list; URL-to-state parsing uses linking screens generated +// from the route declarations whose complete path opts in via allowColdStart. export const getAllowPathFromScreenNames = (screenNames: string[]) => allowListMap.get(buildAllowListMapKey(screenNames)) || '/';