diff --git a/.github/actions/e2e-android-setup/action.yml b/.github/actions/e2e-android-setup/action.yml new file mode 100644 index 00000000..5163ad68 --- /dev/null +++ b/.github/actions/e2e-android-setup/action.yml @@ -0,0 +1,117 @@ +name: E2E Android setup +description: >- + Shared setup for the Android Detox jobs: toolchains, caches, KVM, the e2e + project's dependencies and the local react-native-update link. Runs after + the react-native-update checkout (this action lives in it). +inputs: + project-dir: + description: E2E app directory, relative to the workspace. + default: Example/e2etest + checkout-cli: + description: Check out and build react-native-update-cli (needed to prepare update artifacts). + default: 'true' + bun-cache-scope: + description: Extra bun cache key segment for jobs whose dependency set differs. + default: '' + bun-cache-key-suffix: + description: Extra hash appended to the bun cache key. + default: '' + gradle-cache-dependency-path: + description: Files whose hash keys the Gradle cache; they must exist before the e2e project is created. + default: | + Example/e2etest/bun.lock + Example/e2etest/android/*.gradle + Example/e2etest/android/**/*.gradle + Example/e2etest/android/gradle/wrapper/gradle-wrapper.properties + create-project: + description: Command that generates the e2e project before its dependencies are installed. + default: '' + frozen-lockfile: + description: Install with --frozen-lockfile (false only for generated projects that have no lockfile). + default: 'true' +runs: + using: composite + steps: + - name: Checkout react-native-update-cli + if: inputs.checkout-cli == 'true' + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: reactnativecn/react-native-update-cli + path: react-native-update-cli + + - name: Setup Node.js + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: 24 + + - name: Setup Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version: 1.4.0 + + - name: Cache Bun dependencies + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ~/.bun/install/cache + key: ${{ runner.os }}-bun-e2e${{ inputs.bun-cache-scope }}-${{ hashFiles('Example/e2etest/bun.lock', 'react-native-update-cli/bun.lock') }}${{ inputs.bun-cache-key-suffix }} + restore-keys: | + ${{ runner.os }}-bun-e2e${{ inputs.bun-cache-scope }}- + ${{ runner.os }}-bun-e2e- + + - name: Setup Java + uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 + with: + distribution: temurin + java-version: '17' + cache: gradle + cache-dependency-path: ${{ inputs.gradle-cache-dependency-path }} + + - name: Enable KVM + shell: bash + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + + - name: Create e2e project + if: inputs.create-project != '' + shell: bash + env: + CREATE_PROJECT: ${{ inputs.create-project }} + run: eval "$CREATE_PROJECT" + + - name: Install e2e project dependencies + shell: bash + working-directory: ${{ inputs.project-dir }} + env: + FROZEN: ${{ inputs.frozen-lockfile }} + run: | + if [ "$FROZEN" = 'true' ]; then + bun install --frozen-lockfile + else + bun install + fi + + - name: Link local react-native-update + # Mirrors what an npm install ships (package.json `files`), plus cpp/ + # for the iOS/Android native builds; scripts/ is dev-only and unused + # by any build step. + shell: bash + env: + E2E_DIR: ${{ inputs.project-dir }} + run: | + set -euo pipefail + export LOCAL_RNU_VERSION="$(node -p "require('./${E2E_DIR}/node_modules/react-native-update/package.json').version")" + export LOCAL_RNU_DIR="$PWD/$E2E_DIR/.e2e-artifacts/local-react-native-update" + rm -rf "$LOCAL_RNU_DIR" "$E2E_DIR/node_modules/react-native-update" + mkdir -p "$LOCAL_RNU_DIR" + cp package.json react-native-update.podspec react-native.config.js expo-module.config.json "$LOCAL_RNU_DIR"/ + rsync -a --exclude='.git' src ios android cpp "$LOCAL_RNU_DIR"/ + node -e "const fs = require('fs'); const file = process.env.LOCAL_RNU_DIR + '/package.json'; const pkg = JSON.parse(fs.readFileSync(file, 'utf8')); pkg.version = process.env.LOCAL_RNU_VERSION; fs.writeFileSync(file, JSON.stringify(pkg, null, 2) + '\n');" + ln -s ../.e2e-artifacts/local-react-native-update "$E2E_DIR/node_modules/react-native-update" + + - name: Install react-native-update-cli dependencies + if: inputs.checkout-cli == 'true' + shell: bash + working-directory: react-native-update-cli + run: bun install --frozen-lockfile && bun run build diff --git a/.github/actions/e2e-cache/action.yml b/.github/actions/e2e-cache/action.yml index d598441a..ad8ebaa0 100644 --- a/.github/actions/e2e-cache/action.yml +++ b/.github/actions/e2e-cache/action.yml @@ -1,5 +1,5 @@ name: Bounded content-addressed E2E cache -description: Restore shared content caches; only mainline core saves a daily snapshot. +description: Restore shared content caches; only the mainline iOS build job saves a daily snapshot. inputs: path: description: Content-addressed cache directory, never a built app. @@ -8,7 +8,7 @@ inputs: description: Cache schema/toolchain identity. required: true write: - description: The core matrix leg may write on a trusted master push. + description: The e2e-ios-build job may write on a trusted master push. default: 'false' runs: using: composite @@ -17,16 +17,16 @@ runs: shell: bash run: echo "day=$(date -u +%Y%m%d)" >> "$GITHUB_OUTPUT" - name: Restore read-only snapshot - if: "!(inputs.write == 'true' && github.event_name == 'push' && github.ref == 'refs/heads/master' && github.job == 'e2e-ios')" - uses: actions/cache/restore@v6 + if: "!(inputs.write == 'true' && github.event_name == 'push' && github.ref == 'refs/heads/master' && github.job == 'e2e-ios-build')" + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ${{ inputs.path }} key: e2e-content-v2-${{ runner.os }}-${{ runner.arch }}-${{ inputs.scope }}-${{ steps.epoch.outputs.day }} restore-keys: | e2e-content-v2-${{ runner.os }}-${{ runner.arch }}-${{ inputs.scope }}- - name: Restore and save trusted daily snapshot - if: inputs.write == 'true' && github.event_name == 'push' && github.ref == 'refs/heads/master' && github.job == 'e2e-ios' - uses: actions/cache@v6 + if: inputs.write == 'true' && github.event_name == 'push' && github.ref == 'refs/heads/master' && github.job == 'e2e-ios-build' + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ${{ inputs.path }} key: e2e-content-v2-${{ runner.os }}-${{ runner.arch }}-${{ inputs.scope }}-${{ steps.epoch.outputs.day }} diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 7058cefb..4c6e1f66 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -2,8 +2,11 @@ version: 2 updates: # Actions are pinned to commit SHAs in the workflows; Dependabot keeps the # SHA + version comment pairs current instead of a human copying hashes. + # The local composite actions pin SHAs too and are not scanned from "/". - package-ecosystem: github-actions - directory: / + directories: + - / + - /.github/actions/* schedule: interval: weekly groups: diff --git a/.github/workflows/ci-failure-email.yml b/.github/workflows/ci-failure-email.yml index 09d50c2a..9a0f245d 100644 --- a/.github/workflows/ci-failure-email.yml +++ b/.github/workflows/ci-failure-email.yml @@ -2,9 +2,12 @@ name: CI Failure Issue on: workflow_run: + # native-gate is left out on purpose: it only posts a commit status and + # its own run stays green whatever that status is. workflows: - e2e-android - e2e-ios + - harmony-build - lint - Publish Package to npmjs - test diff --git a/.github/workflows/e2e_android.yml b/.github/workflows/e2e_android.yml index 1a1796b3..0a220f4b 100644 --- a/.github/workflows/e2e_android.yml +++ b/.github/workflows/e2e_android.yml @@ -4,36 +4,23 @@ on: pull_request: branches: - '**' - paths: + paths: &e2e-android-paths - '.github/workflows/e2e_android.yml' + - '.github/actions/e2e-android-setup/**' - '.gitmodules' + # codegenConfig is copied into the linked package and drives codegen - 'package.json' - - 'bun.lock' - 'react-native-update.podspec' - 'react-native.config.js' - 'expo-module.config.json' - 'src/**' - - 'scripts/**' - 'android/**' - 'cpp/**' - 'Example/e2etest/**' push: branches: - master - - main - paths: - - '.github/workflows/e2e_android.yml' - - '.gitmodules' - - 'package.json' - - 'bun.lock' - - 'react-native-update.podspec' - - 'react-native.config.js' - - 'expo-module.config.json' - - 'src/**' - - 'scripts/**' - - 'android/**' - - 'cpp/**' - - 'Example/e2etest/**' + paths: *e2e-android-paths concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -45,69 +32,12 @@ jobs: timeout-minutes: 30 steps: - name: Checkout react-native-update - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: submodules: recursive - - name: Checkout react-native-update-cli - uses: actions/checkout@v7 - with: - repository: reactnativecn/react-native-update-cli - path: react-native-update-cli - - - name: Setup Node.js - uses: actions/setup-node@v7 - with: - node-version: 24 - - - name: Setup Bun - uses: oven-sh/setup-bun@v2 - with: - bun-version: 1.4.0 - - - name: Cache Bun dependencies - uses: actions/cache@v6 - with: - path: ~/.bun/install/cache - key: ${{ runner.os }}-bun-e2e-${{ hashFiles('Example/e2etest/bun.lock', 'react-native-update-cli/bun.lock') }} - restore-keys: | - ${{ runner.os }}-bun-e2e- - - - name: Setup Java - uses: actions/setup-java@v6 - with: - distribution: temurin - java-version: '17' - cache: gradle - cache-dependency-path: | - Example/e2etest/bun.lock - Example/e2etest/android/*.gradle - Example/e2etest/android/**/*.gradle - Example/e2etest/android/gradle/wrapper/gradle-wrapper.properties - - - name: Enable KVM - run: | - echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules - sudo udevadm control --reload-rules - sudo udevadm trigger --name-match=kvm - - - name: Install e2etest dependencies - run: cd Example/e2etest && bun install --frozen-lockfile - - - name: Link local react-native-update - run: | - set -euo pipefail - export LOCAL_RNU_VERSION="$(node -p "require('./Example/e2etest/node_modules/react-native-update/package.json').version")" - LOCAL_RNU_DIR="$PWD/Example/e2etest/.e2e-artifacts/local-react-native-update" - rm -rf "$LOCAL_RNU_DIR" Example/e2etest/node_modules/react-native-update - mkdir -p "$LOCAL_RNU_DIR" - cp package.json react-native-update.podspec react-native.config.js expo-module.config.json "$LOCAL_RNU_DIR"/ - rsync -a --exclude='.git' src ios android cpp scripts "$LOCAL_RNU_DIR"/ - node -e "const fs = require('fs'); const file = 'Example/e2etest/.e2e-artifacts/local-react-native-update/package.json'; const pkg = JSON.parse(fs.readFileSync(file, 'utf8')); pkg.version = process.env.LOCAL_RNU_VERSION; fs.writeFileSync(file, JSON.stringify(pkg, null, 2) + '\n');" - ln -s ../.e2e-artifacts/local-react-native-update Example/e2etest/node_modules/react-native-update - - - name: Install react-native-update-cli dependencies - run: cd react-native-update-cli && bun install --frozen-lockfile && bun run build + - name: Set up e2e environment + uses: ./.github/actions/e2e-android-setup - name: Detox build (android.emu.release) env: @@ -122,7 +52,7 @@ jobs: run: cd Example/e2etest && bun run prepare:e2e - name: Detox test (android.emu.release) - uses: reactivecircus/android-emulator-runner@v2 + uses: reactivecircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d # v2.38.0 env: DETOX_ANDROID_ARCHS: x86_64 RNU_CLI_ROOT: ${{ github.workspace }}/react-native-update-cli @@ -135,24 +65,24 @@ jobs: avd-name: api34 # Pinned while stabilizing e2e. The 2026-07-30 breakage was NOT the # emulator version (red herring): bundle-hash.test.ts landed 07-29 as - # a second suite, and .detoxrc.js forces maxWorkers 2 on CI — the - # second Detox worker tries to boot the same AVD twice, which Android - # cannot do, and retries until the job times out (fixed with - # --maxWorkers 1 below). Unpin once a run proves 37.x stable. + # a second suite, and a second Detox worker tried to boot the same + # AVD twice, which Android cannot do, retrying until the job timed + # out. .detoxrc.js now pins the Android configurations to one + # worker. Unpin once a run proves 37.x stable. emulator-build: 15507667 emulator-boot-timeout: 900 # 每条命令自带 cd:android-emulator-runner 逐行起独立 shell, # 单独一行的 cd 不会保持到下一条(实测 detox 会在仓库根找不到 jest) script: | - cd Example/e2etest && E2E_PLATFORM=android bunx detox test --configuration android.emu.release --headless --record-logs all --retries 1 --maxWorkers 1 + cd Example/e2etest && E2E_PLATFORM=android bunx detox test --configuration android.emu.release --headless --record-logs all --retries 1 # 原生冷启动检测有自己的 runner 配置(见 e2e/native/jest.config.js); # Android 这个 job 有预算,不必像 iOS 那样拆成独立 job - cd Example/e2etest && E2E_PLATFORM=android bunx detox test --configuration android.emu.release --headless --record-logs all --retries 1 --maxWorkers 1 --config e2e/native/jest.config.js + cd Example/e2etest && E2E_PLATFORM=android bunx detox test --configuration android.emu.release --headless --record-logs all --retries 1 --config e2e/native/jest.config.js - name: Upload Detox artifacts # cancelled() 覆盖 job 超时被杀的场景,否则超时时拿不到现场 if: failure() || cancelled() - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: e2e-android-detox-artifacts path: Example/e2etest/artifacts @@ -168,60 +98,14 @@ jobs: timeout-minutes: 30 steps: - name: Checkout react-native-update - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: submodules: recursive - - name: Setup Node.js - uses: actions/setup-node@v7 - with: - node-version: 24 - - - name: Setup Bun - uses: oven-sh/setup-bun@v2 - with: - bun-version: 1.4.0 - - - name: Cache Bun dependencies - uses: actions/cache@v6 + - name: Set up e2e environment + uses: ./.github/actions/e2e-android-setup with: - path: ~/.bun/install/cache - key: ${{ runner.os }}-bun-e2e-${{ hashFiles('Example/e2etest/bun.lock', 'react-native-update-cli/bun.lock') }} - restore-keys: | - ${{ runner.os }}-bun-e2e- - - - name: Setup Java - uses: actions/setup-java@v6 - with: - distribution: temurin - java-version: '17' - cache: gradle - cache-dependency-path: | - Example/e2etest/bun.lock - Example/e2etest/android/*.gradle - Example/e2etest/android/**/*.gradle - Example/e2etest/android/gradle/wrapper/gradle-wrapper.properties - - - name: Enable KVM - run: | - echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules - sudo udevadm control --reload-rules - sudo udevadm trigger --name-match=kvm - - - name: Install e2etest dependencies - run: cd Example/e2etest && bun install --frozen-lockfile - - - name: Link local react-native-update - run: | - set -euo pipefail - export LOCAL_RNU_VERSION="$(node -p "require('./Example/e2etest/node_modules/react-native-update/package.json').version")" - LOCAL_RNU_DIR="$PWD/Example/e2etest/.e2e-artifacts/local-react-native-update" - rm -rf "$LOCAL_RNU_DIR" Example/e2etest/node_modules/react-native-update - mkdir -p "$LOCAL_RNU_DIR" - cp package.json react-native-update.podspec react-native.config.js expo-module.config.json "$LOCAL_RNU_DIR"/ - rsync -a --exclude='.git' src ios android cpp scripts "$LOCAL_RNU_DIR"/ - node -e "const fs = require('fs'); const file = 'Example/e2etest/.e2e-artifacts/local-react-native-update/package.json'; const pkg = JSON.parse(fs.readFileSync(file, 'utf8')); pkg.version = process.env.LOCAL_RNU_VERSION; fs.writeFileSync(file, JSON.stringify(pkg, null, 2) + '\n');" - ln -s ../.e2e-artifacts/local-react-native-update Example/e2etest/node_modules/react-native-update + checkout-cli: 'false' - name: Detox build (android.emu.debug) env: @@ -239,7 +123,7 @@ jobs: nohup bash -c 'for i in $(seq 1 90); do curl -sf http://localhost:8081/status >/dev/null && break; sleep 2; done; curl -sf --max-time 420 -o /dev/null "http://localhost:8081/index.bundle?platform=android&dev=true&minify=false"' > .e2e-artifacts/metro-prewarm.log 2>&1 & - name: Detox test (android.emu.debug boot smoke) - uses: reactivecircus/android-emulator-runner@v2 + uses: reactivecircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d # v2.38.0 env: DETOX_ANDROID_ARCHS: x86_64 DETOX_AVD_NAME: api34 @@ -248,12 +132,7 @@ jobs: target: google_apis arch: x86_64 avd-name: api34 - # Pinned while stabilizing e2e. The 2026-07-30 breakage was NOT the - # emulator version (red herring): bundle-hash.test.ts landed 07-29 as - # a second suite, and .detoxrc.js forces maxWorkers 2 on CI — the - # second Detox worker tries to boot the same AVD twice, which Android - # cannot do, and retries until the job times out (fixed with - # --maxWorkers 1 below). Unpin once a run proves 37.x stable. + # Pinned while stabilizing e2e; see the e2e-android job. emulator-build: 15507667 emulator-boot-timeout: 900 # 注意:emulator-runner 把 script 的每一行单独用 sh -c 执行, @@ -266,7 +145,7 @@ jobs: - name: Upload Detox artifacts # cancelled() 覆盖 job 超时被杀的场景,否则超时时拿不到现场 if: failure() || cancelled() - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: e2e-android-debug-smoke-detox-artifacts path: | @@ -281,71 +160,27 @@ jobs: timeout-minutes: 35 steps: - name: Checkout react-native-update - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: submodules: recursive - - name: Checkout react-native-update-cli - uses: actions/checkout@v7 - with: - repository: reactnativecn/react-native-update-cli - path: react-native-update-cli - - - name: Setup Node.js - uses: actions/setup-node@v7 - with: - node-version: 24 - - - name: Setup Bun - uses: oven-sh/setup-bun@v2 - with: - bun-version: 1.4.0 - - - name: Cache Bun dependencies - uses: actions/cache@v6 - with: - path: ~/.bun/install/cache - key: ${{ runner.os }}-bun-e2e-rn077-oldarch-${{ hashFiles('Example/e2etest/bun.lock', 'Example/e2etest/scripts/create-rn077-oldarch-project.js', 'react-native-update-cli/bun.lock') }} - restore-keys: | - ${{ runner.os }}-bun-e2e-rn077-oldarch- - ${{ runner.os }}-bun-e2e- - - - name: Setup Java - uses: actions/setup-java@v6 - with: - distribution: temurin - java-version: '17' - cache: gradle - - - name: Enable KVM - run: | - echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules - sudo udevadm control --reload-rules - sudo udevadm trigger --name-match=kvm - - - name: Create RN 0.77.3 old architecture e2e project - run: node Example/e2etest/scripts/create-rn077-oldarch-project.js - - - name: Install RN 0.77.3 old architecture dependencies - run: cd .e2e-rn077-oldarch/AwesomeProject && bun install - - - name: Link local react-native-update - run: | - set -euo pipefail - E2E_DIR=".e2e-rn077-oldarch/AwesomeProject" - export LOCAL_RNU_VERSION="$(node -p "require('./${E2E_DIR}/node_modules/react-native-update/package.json').version")" - LOCAL_RNU_DIR="$PWD/$E2E_DIR/.e2e-artifacts/local-react-native-update" - rm -rf "$LOCAL_RNU_DIR" "$E2E_DIR/node_modules/react-native-update" - mkdir -p "$LOCAL_RNU_DIR" - cp package.json react-native-update.podspec react-native.config.js expo-module.config.json "$LOCAL_RNU_DIR"/ - rsync -a --exclude='.git' src ios android cpp scripts "$LOCAL_RNU_DIR"/ - node -e "const fs = require('fs'); const file = process.env.LOCAL_RNU_DIR + '/package.json'; const pkg = JSON.parse(fs.readFileSync(file, 'utf8')); pkg.version = process.env.LOCAL_RNU_VERSION; fs.writeFileSync(file, JSON.stringify(pkg, null, 2) + '\n');" - ln -s ../.e2e-artifacts/local-react-native-update "$E2E_DIR/node_modules/react-native-update" - env: - LOCAL_RNU_DIR: ${{ github.workspace }}/.e2e-rn077-oldarch/AwesomeProject/.e2e-artifacts/local-react-native-update - - - name: Install react-native-update-cli dependencies - run: cd react-native-update-cli && bun install --frozen-lockfile && bun run build + - name: Set up e2e environment + uses: ./.github/actions/e2e-android-setup + with: + project-dir: .e2e-rn077-oldarch/AwesomeProject + bun-cache-scope: -rn077-oldarch + bun-cache-key-suffix: -${{ hashFiles('Example/e2etest/scripts/create-rn077-oldarch-project.js') }} + # The project is generated by the script below (RN/template/Detox + # versions live there) after the cache is restored, so key the + # Gradle cache on its inputs rather than on files that do not exist + # yet; the library module's own build.gradle participates too. + gradle-cache-dependency-path: | + Example/e2etest/scripts/create-rn077-oldarch-project.js + android/build.gradle + create-project: node Example/e2etest/scripts/create-rn077-oldarch-project.js + # `react-native init --skip-install` writes no lockfile, so there is + # nothing to freeze against. + frozen-lockfile: 'false' - name: Detox build (RN 0.77.3 old arch android.emu.release) env: @@ -360,7 +195,7 @@ jobs: run: cd .e2e-rn077-oldarch/AwesomeProject && bun run prepare:e2e - name: Detox test (RN 0.77.3 old arch android.emu.release) - uses: reactivecircus/android-emulator-runner@v2 + uses: reactivecircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d # v2.38.0 env: DETOX_ANDROID_ARCHS: x86_64 RNU_CLI_ROOT: ${{ github.workspace }}/react-native-update-cli @@ -371,23 +206,18 @@ jobs: target: google_apis arch: x86_64 avd-name: api34 - # Pinned while stabilizing e2e. The 2026-07-30 breakage was NOT the - # emulator version (red herring): bundle-hash.test.ts landed 07-29 as - # a second suite, and .detoxrc.js forces maxWorkers 2 on CI — the - # second Detox worker tries to boot the same AVD twice, which Android - # cannot do, and retries until the job times out (fixed with - # --maxWorkers 1 below). Unpin once a run proves 37.x stable. + # Pinned while stabilizing e2e; see the e2e-android job. emulator-build: 15507667 emulator-boot-timeout: 900 # 每条命令自带 cd,理由同上 script: | - cd .e2e-rn077-oldarch/AwesomeProject && E2E_PLATFORM=android bunx detox test --configuration android.emu.release --headless --record-logs all --retries 1 --maxWorkers 1 - cd .e2e-rn077-oldarch/AwesomeProject && E2E_PLATFORM=android bunx detox test --configuration android.emu.release --headless --record-logs all --retries 1 --maxWorkers 1 --config e2e/native/jest.config.js + cd .e2e-rn077-oldarch/AwesomeProject && E2E_PLATFORM=android bunx detox test --configuration android.emu.release --headless --record-logs all --retries 1 + cd .e2e-rn077-oldarch/AwesomeProject && E2E_PLATFORM=android bunx detox test --configuration android.emu.release --headless --record-logs all --retries 1 --config e2e/native/jest.config.js - name: Upload Detox artifacts # cancelled() 覆盖 job 超时被杀的场景,否则超时时拿不到现场 if: failure() || cancelled() - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: e2e-android-rn077-oldarch-detox-artifacts path: .e2e-rn077-oldarch/AwesomeProject/artifacts diff --git a/.github/workflows/e2e_harmony.yml b/.github/workflows/e2e_harmony.yml index 61620624..47dc11a3 100644 --- a/.github/workflows/e2e_harmony.yml +++ b/.github/workflows/e2e_harmony.yml @@ -26,12 +26,12 @@ jobs: HVD_NAME: api20 steps: - name: Checkout react-native-update - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: submodules: recursive - name: Checkout react-native-update-cli - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: reactnativecn/react-native-update-cli path: react-native-update-cli @@ -94,7 +94,7 @@ jobs: - name: Upload failure diagnostics # cancelled() 覆盖 job 超时被杀的场景,否则超时时拿不到现场 if: failure() || cancelled() - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: harmony-e2e-diagnostics path: harmony-diagnostics/ diff --git a/.github/workflows/e2e_ios.yml b/.github/workflows/e2e_ios.yml index cc031572..433d937d 100644 --- a/.github/workflows/e2e_ios.yml +++ b/.github/workflows/e2e_ios.yml @@ -4,17 +4,16 @@ on: pull_request: branches: - '**' - paths: + paths: &e2e-ios-paths - '.github/workflows/e2e_ios.yml' - '.github/actions/e2e-cache/**' - '.gitmodules' + # codegenConfig is copied into the linked package and drives codegen - 'package.json' - - 'bun.lock' - 'react-native-update.podspec' - 'react-native.config.js' - 'expo-module.config.json' - 'src/**' - - 'scripts/**' - 'ios/**' - 'cpp/**' - 'android/jni/**' @@ -22,71 +21,44 @@ on: push: branches: - master - paths: - - '.github/workflows/e2e_ios.yml' - - '.github/actions/e2e-cache/**' - - '.gitmodules' - - 'package.json' - - 'bun.lock' - - 'react-native-update.podspec' - - 'react-native.config.js' - - 'expo-module.config.json' - - 'src/**' - - 'scripts/**' - - 'ios/**' - - 'cpp/**' - - 'android/jni/**' - - 'Example/e2etest/**' + paths: *e2e-ios-paths concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true jobs: - e2e-ios: - # 不带 matrix 值全量拼名,否则 check 叫 "e2e-ios (native, --config e2e/…)" - name: e2e-ios (${{ matrix.suite }}) + # Builds the release .app once (pod install + xcodebuild, or the cached app + # with a refreshed JS bundle) and prepares the local update artifacts; both + # test legs below install that same build instead of compiling it twice. + e2e-ios-build: + name: e2e-ios (build) runs-on: macos-26 - # 绿色基线 ~16 分钟。60 分钟覆盖最坏情况:一个用例等满 300s testTimeout, - # 再叠一次 --retries 1 全量重试。40 分钟不够——实测重试跑到一半被腰斩, - # 把"慢但能恢复"的运行变成 cancelled,等于白开了 retries。 - timeout-minutes: 60 - strategy: - # 两条 leg 各自独立成败:一条超时不该掩盖另一条的结论 - fail-fast: false - matrix: - include: - # 既有 suite(local-merge / bundle-hash),沿用默认 runner 配置 - - suite: core - jest-config: '' - # 原生冷启动检测:每条断言都要真实重启 app,单独跑才不会把 core - # 顶过 40 分钟(拆分前实测 core 已占 36 分) - - suite: native - jest-config: '--config e2e/native/jest.config.js' + timeout-minutes: 45 steps: - name: Checkout react-native-update - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: submodules: recursive - name: Checkout react-native-update-cli - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: reactnativecn/react-native-update-cli path: react-native-update-cli - name: Setup Node.js - uses: actions/setup-node@v7 + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: node-version: 22 - name: Setup Bun - uses: oven-sh/setup-bun@v2 + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: bun-version: 1.4.0 - name: Cache Bun dependencies - uses: actions/cache@v6 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/.bun/install/cache key: ${{ runner.os }}-bun-e2e-${{ hashFiles('Example/e2etest/bun.lock', 'react-native-update-cli/bun.lock') }} @@ -94,49 +66,40 @@ jobs: ${{ runner.os }}-bun-e2e- - name: Setup Xcode - uses: maxim-lobanov/setup-xcode@v1 + uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1.7.0 with: xcode-version: '26.4' - - name: Boot iOS simulator in background - # 模拟器冷启动要 2-5 分钟,提前异步 boot,让它和依赖安装/xcodebuild - # 并行。型号必须用与 .detoxrc.ts 完全同一份探测代码——此前 shell 里 - # 另写一份选中了 detox 不用的设备,预热全部白费。 - # 失败无害:detox 测试阶段会自己 boot。 - run: | - udid=$(node Example/e2etest/scripts/detect-ios-simulator-type.js --udid) - if [ -n "$udid" ]; then - echo "pre-booting $(node Example/e2etest/scripts/detect-ios-simulator-type.js) ($udid)" - xcrun simctl boot "$udid" || true - else - echo "no matching simulator found, skipping pre-boot" - fi - - name: Cache Homebrew downloads - # Bottle content addressing still controls reuse; only snapshot writes - # are reduced to the successful master/core run's daily identity. + # Bottle content addressing still controls reuse; only this job's + # master runs write the daily snapshot. applesimutils is installed + # here too (unused by the build) so the snapshot the test legs + # restore holds every bottle they need. uses: ./.github/actions/e2e-cache with: path: ~/Library/Caches/Homebrew scope: brew-xcode-26.4 - write: ${{ matrix.suite == 'core' }} + write: 'true' - name: Install applesimutils and ccache run: brew trust wix/brew && HOMEBREW_NO_AUTO_UPDATE=1 brew install wix/brew/applesimutils ccache - name: Cache ccache # Old content-addressed entries affect hit rate, not build correctness. - # PRs and the native matrix leg only restore; no per-commit duplicates. + # PRs only restore; no per-commit duplicates. uses: ./.github/actions/e2e-cache with: path: ~/Library/Caches/ccache scope: ccache-xcode-26.4 - write: ${{ matrix.suite == 'core' }} + write: 'true' - name: Install e2etest dependencies run: cd Example/e2etest && bun install --frozen-lockfile - name: Link local react-native-update + # Mirrors what an npm install ships (package.json `files`), plus cpp/ + # for the native build; scripts/ is dev-only and unused by any build + # step. run: | set -euo pipefail export LOCAL_RNU_VERSION="$(node -p "require('./Example/e2etest/node_modules/react-native-update/package.json').version")" @@ -144,7 +107,7 @@ jobs: rm -rf "$LOCAL_RNU_DIR" Example/e2etest/node_modules/react-native-update mkdir -p "$LOCAL_RNU_DIR" cp package.json react-native-update.podspec react-native.config.js expo-module.config.json "$LOCAL_RNU_DIR"/ - rsync -a --exclude='.git' src ios android cpp scripts "$LOCAL_RNU_DIR"/ + rsync -a --exclude='.git' src ios android cpp "$LOCAL_RNU_DIR"/ node -e "const fs = require('fs'); const file = 'Example/e2etest/.e2e-artifacts/local-react-native-update/package.json'; const pkg = JSON.parse(fs.readFileSync(file, 'utf8')); pkg.version = process.env.LOCAL_RNU_VERSION; fs.writeFileSync(file, JSON.stringify(pkg, null, 2) + '\n');" ln -s ../.e2e-artifacts/local-react-native-update Example/e2etest/node_modules/react-native-update @@ -157,7 +120,7 @@ jobs: with: path: Example/e2etest/.metro-cache scope: metro-xcode-26.4 - write: ${{ matrix.suite == 'core' }} + write: 'true' - name: Prepare local update artifacts env: @@ -166,7 +129,7 @@ jobs: run: cd Example/e2etest && bun run prepare:e2e - name: Cache CocoaPods downloads - uses: actions/cache@v6 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/Library/Caches/CocoaPods key: ${{ runner.os }}-cocoapods-e2e-${{ hashFiles('Example/e2etest/ios/Podfile.lock', 'Example/e2etest/ios/Podfile', 'Example/e2etest/bun.lock') }} @@ -181,7 +144,7 @@ jobs: # 会在改 spec 后把新 JS 注入到旧胶水的二进制上。 # 故意不设 restore-keys:部分命中就是错的二进制,宁可全量重建。 id: cache-ios-app - uses: actions/cache@v6 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: | Example/e2etest/ios/build/Build/Products/Release-iphonesimulator/AwesomeProject.app @@ -194,19 +157,6 @@ jobs: # RN 自带的 ccache 编译器包装脚本 run: cd Example/e2etest/ios && USE_CCACHE=1 pod install - - name: Cache Detox iOS framework - id: cache-detox-ios-framework - uses: actions/cache@v6 - with: - path: ~/Library/Detox - key: ${{ runner.os }}-detox-ios-framework-xcode-26.4-${{ hashFiles('Example/e2etest/bun.lock') }} - restore-keys: | - ${{ runner.os }}-detox-ios-framework-xcode-26.4- - - - name: Build Detox iOS framework cache - if: steps.cache-detox-ios-framework.outputs.cache-hit != 'true' - run: cd Example/e2etest && bunx detox build-framework-cache - - name: Detox build (ios.sim.release) if: steps.cache-ios-app.outputs.cache-hit != 'true' env: @@ -220,16 +170,127 @@ jobs: if: steps.cache-ios-app.outputs.cache-hit == 'true' run: cd Example/e2etest && ./scripts/refresh-ios-app-bundle.sh + - name: Pack app and update artifacts + # tar keeps the executable bits and symlinks that upload-artifact + # would drop. hermesc stays behind: only the bundle refresh above + # needs it. + run: | + tar -cf e2e-ios-build.tar \ + Example/e2etest/ios/build/Build/Products/Release-iphonesimulator/AwesomeProject.app \ + Example/e2etest/.e2e-artifacts/ios + + - name: Upload app and update artifacts + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: e2e-ios-build + path: e2e-ios-build.tar + if-no-files-found: error + retention-days: 1 + + e2e-ios: + # 不带 matrix 值全量拼名,否则 check 叫 "e2e-ios (native, --config e2e/…)" + name: e2e-ios (${{ matrix.suite }}) + needs: e2e-ios-build + runs-on: macos-26 + # 绿色基线(不含构建)约 10 分钟。60 分钟覆盖最坏情况:一个用例等满 300s + # testTimeout,再叠一次 --retries 1 全量重试。40 分钟不够——实测重试跑到 + # 一半被腰斩,把"慢但能恢复"的运行变成 cancelled,等于白开了 retries。 + timeout-minutes: 60 + strategy: + # 两条 leg 各自独立成败:一条超时不该掩盖另一条的结论 + fail-fast: false + matrix: + include: + # 既有 suite(local-merge / bundle-hash),沿用默认 runner 配置 + - suite: core + jest-config: '' + # 原生冷启动检测:每条断言都要真实重启 app,单独跑才不会把 core + # 顶过 40 分钟(拆分前实测 core 已占 36 分) + - suite: native + jest-config: '--config e2e/native/jest.config.js' + steps: + - name: Checkout react-native-update + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Setup Node.js + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: 22 + + - name: Setup Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version: 1.4.0 + + - name: Cache Bun dependencies + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ~/.bun/install/cache + key: ${{ runner.os }}-bun-e2e-${{ hashFiles('Example/e2etest/bun.lock', 'react-native-update-cli/bun.lock') }} + restore-keys: | + ${{ runner.os }}-bun-e2e- + + - name: Setup Xcode + uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1.7.0 + with: + xcode-version: '26.4' + + - name: Boot iOS simulator in background + # 模拟器冷启动要 2-5 分钟,提前异步 boot,让它和依赖安装/下载并行。 + # 型号必须用与 .detoxrc.js 完全同一份探测代码——此前 shell 里 + # 另写一份选中了 detox 不用的设备,预热全部白费。 + # 失败无害:detox 测试阶段会自己 boot。 + run: | + udid=$(node Example/e2etest/scripts/detect-ios-simulator-type.js --udid) + if [ -n "$udid" ]; then + echo "pre-booting $(node Example/e2etest/scripts/detect-ios-simulator-type.js) ($udid)" + xcrun simctl boot "$udid" || true + else + echo "no matching simulator found, skipping pre-boot" + fi + + - name: Cache Homebrew downloads + uses: ./.github/actions/e2e-cache + with: + path: ~/Library/Caches/Homebrew + scope: brew-xcode-26.4 + + - name: Install applesimutils + run: brew trust wix/brew && HOMEBREW_NO_AUTO_UPDATE=1 brew install wix/brew/applesimutils + + - name: Install e2etest dependencies + run: cd Example/e2etest && bun install --frozen-lockfile + + - name: Download app and update artifacts + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: e2e-ios-build + + - name: Unpack app and update artifacts + run: tar -xf e2e-ios-build.tar && rm e2e-ios-build.tar + + - name: Cache Detox iOS framework + id: cache-detox-ios-framework + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ~/Library/Detox + key: ${{ runner.os }}-detox-ios-framework-xcode-26.4-${{ hashFiles('Example/e2etest/bun.lock') }} + restore-keys: | + ${{ runner.os }}-detox-ios-framework-xcode-26.4- + + - name: Build Detox iOS framework cache + if: steps.cache-detox-ios-framework.outputs.cache-hit != 'true' + run: cd Example/e2etest && bunx detox build-framework-cache + - name: Detox test (ios.sim.release) env: - RNU_CLI_ROOT: ${{ github.workspace }}/react-native-update-cli RNU_E2E_SKIP_PREPARE: 'true' run: cd Example/e2etest && E2E_PLATFORM=ios bunx detox test --configuration ios.sim.release --retries 1 ${{ matrix.jest-config }} - name: Upload Detox artifacts # cancelled() 覆盖 job 超时被杀的场景,否则超时时拿不到现场 if: failure() || cancelled() - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: e2e-ios-detox-artifacts-${{ matrix.suite }} path: Example/e2etest/artifacts diff --git a/.github/workflows/harmony-build.yml b/.github/workflows/harmony-build.yml index a04850b3..bc166c13 100644 --- a/.github/workflows/harmony-build.yml +++ b/.github/workflows/harmony-build.yml @@ -3,7 +3,7 @@ name: harmony-build # Compiles the HarmonyOS HAR on pull requests that touch its inputs, in the # same pinned container publish.yml uses. Until this job existed the HAR was # only compiled at release time, so an ArkTS type error, a broken NAPI export -# or a stale C++ method table (NATIVE_CHECK_FOLLOWUPS.md, 2026-08-13) was +# or a stale C++ method table (docs/NATIVE_CHECK_FOLLOWUPS.md, 2026-08-13) was # first seen when publishing. assembleHar runs the ArkTS compiler over the # .ets/.ts sources, which is the real type check; check-harmony-types.js # adds a strict tsc pass over the .ts sources and must not skip here. @@ -50,19 +50,19 @@ jobs: container: ghcr.io/sanchuanhehe/harmony-next-pipeline-docker/harmonyos-ci-image:latest@sha256:2836142b7b6ae8837ad0e36cacc650f00e8c88f55b4e9f34be66cc98a731c5da timeout-minutes: 20 steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: submodules: recursive - name: Configure git safe.directory run: | git config --global --add safe.directory "$GITHUB_WORKSPACE" git submodule foreach --recursive 'git config --global --add safe.directory "$toplevel/$sm_path"' - - uses: oven-sh/setup-bun@v2 + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: bun-version: 1.4.0 # The container ships no node; the check scripts and build-harmony-har # (npm run) need one, exactly as publish.yml sets it up. - - uses: actions/setup-node@v7 + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: node-version: 24 - run: bun install --frozen-lockfile diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index c2bdab1c..1b4ad709 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -20,21 +20,19 @@ jobs: timeout-minutes: 5 steps: - - uses: actions/checkout@v7 - - uses: oven-sh/setup-bun@v2 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: bun-version: 1.4.0 - name: Install Dependency run: bun install --frozen-lockfile + # `bun lint` ends with check-native-spec-parity.js: every + # src/NativePushy.ts method must be exported by all four native + # bindings, with the right sync/async kind on Harmony. - name: Run lint run: bun lint - name: Verify publish file list run: node scripts/check-packlist.js - - # Every src/NativePushy.ts method must be exported by all four native - # bindings, with the right sync/async kind on Harmony. - - name: Verify JS <-> native method parity - run: node scripts/check-native-spec-parity.js diff --git a/.github/workflows/native-gate.yml b/.github/workflows/native-gate.yml index 051cc968..e08bc7bf 100644 --- a/.github/workflows/native-gate.yml +++ b/.github/workflows/native-gate.yml @@ -22,7 +22,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 5 steps: - - uses: actions/github-script@v9 + - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: script: | const sha = context.payload.workflow_run.head_sha; diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index c90fe80a..04da5855 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -9,17 +9,46 @@ env: permissions: id-token: write # Required for OIDC contents: read + statuses: read # native-required gate check jobs: check_changes: runs-on: ubuntu-latest # npm --provenance requires a GitHub-hosted runner + # Room for the native-required wait below. + timeout-minutes: 60 outputs: harmony_changed: ${{ steps.check.outputs.changed }} prev_tag: ${{ steps.check.outputs.prev_tag }} steps: - - uses: actions/checkout@v7 + # Both publish jobs need this job, so a release can only ship a commit + # whose lint/test/native E2E gate (native-gate.yml posts it as the + # "native-required" commit status) is green. Pending or not yet posted + # (a release cut right after the push) is waited out; failure, error + # or a status that never shows up fails the release. + - name: Require native-required to be green for the release commit + shell: bash + env: + GH_TOKEN: ${{ github.token }} + SHA: ${{ github.sha }} + run: | + for attempt in $(seq 1 45); do + state="$(gh api "repos/$GITHUB_REPOSITORY/commits/$SHA/status" \ + --jq '[.statuses[] | select(.context == "native-required")][0].state // "missing"')" + echo "native-required for $SHA: $state" + case "$state" in + success) exit 0 ;; + pending|missing) sleep 60 ;; + *) echo "::error::native-required is $state for $SHA; fix CI before publishing"; exit 1 ;; + esac + done + echo "::error::native-required for $SHA did not turn green within 45 minutes" + exit 1 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 + # Only tags, commits and trees are read (git tag --merged, git diff + # --name-only); skip the blobs of the whole history. + filter: blob:none - name: Check Harmony changes id: check shell: bash @@ -69,7 +98,7 @@ jobs: # Keep in sync with PINNED_NDK_VERSION in scripts/build-android-so.sh. NDK_VERSION: 28.2.13676358 steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: submodules: recursive - name: Install pinned NDK @@ -80,7 +109,7 @@ jobs: # resolution order — force the pinned one so published .so files don't # drift with runner image upgrades. run: ANDROID_NDK_HOME="$ANDROID_HOME/ndk/$NDK_VERSION" bash scripts/build-android-so.sh - - uses: actions/upload-artifact@v7 + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: android-so path: android/lib/ @@ -94,18 +123,18 @@ jobs: container: ghcr.io/sanchuanhehe/harmony-next-pipeline-docker/harmonyos-ci-image:latest@sha256:2836142b7b6ae8837ad0e36cacc650f00e8c88f55b4e9f34be66cc98a731c5da timeout-minutes: 15 steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: submodules: recursive - name: Configure git safe.directory run: | git config --global --add safe.directory "$GITHUB_WORKSPACE" git submodule foreach --recursive 'git config --global --add safe.directory "$toplevel/$sm_path"' - - uses: oven-sh/setup-bun@v2 + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: bun-version: 1.4.0 # Setup .npmrc file to publish to npm - - uses: actions/setup-node@v7 + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: node-version: 24 registry-url: 'https://registry.npmjs.org' @@ -116,7 +145,7 @@ jobs: - name: Remove committed Android native libraries run: rm -rf android/lib - name: Use CI-built Android native libraries - uses: actions/download-artifact@v8 + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: name: android-so path: android/lib @@ -145,18 +174,18 @@ jobs: runs-on: ubuntu-latest # npm --provenance requires a GitHub-hosted runner timeout-minutes: 10 steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: submodules: recursive - name: Configure git safe.directory run: | git config --global --add safe.directory "$GITHUB_WORKSPACE" git submodule foreach --recursive 'git config --global --add safe.directory "$toplevel/$sm_path"' - - uses: oven-sh/setup-bun@v2 + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: bun-version: 1.4.0 # Setup .npmrc file to publish to npm - - uses: actions/setup-node@v7 + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: node-version: 24 registry-url: 'https://registry.npmjs.org' @@ -167,14 +196,15 @@ jobs: - name: Remove committed Android native libraries run: rm -rf android/lib - name: Use CI-built Android native libraries - uses: actions/download-artifact@v8 + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: name: android-so path: android/lib - name: Verify Android native libraries run: node scripts/verify-android-so.js # Fail closed: the package must always ship harmony/pushy.har - # (.npmignore explicitly whitelists it). If the previous release can't + # (package.json `files` lists it and check-packlist.js --release + # requires it). If the previous release can't # be fetched, fix the cause or re-run — never publish a HAR-less package. - name: Fetch previous Harmony HAR shell: bash diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 511a0cf9..72618738 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -15,14 +15,49 @@ concurrency: cancel-in-progress: true jobs: + # The native jobs below only run when their inputs change. A job skipped by + # its `if:` still reports success, so required checks and native-gate (which + # looks at the workflow conclusion) are satisfied on unrelated changes. + changes: + name: Detect changed native inputs + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + pull-requests: read + outputs: + cpp: ${{ steps.filter.outputs.cpp }} + ios: ${{ steps.filter.outputs.ios }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - id: filter + uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4.0.3 + with: + filters: | + cpp: + - '.github/workflows/test.yml' + - '.gitmodules' + - 'cpp/**' + - 'android/jni/**' + - 'scripts/test-*' + # TS reference the C++ golden vectors are generated from. + - 'src/updateFlowCore.ts' + - 'scripts/generate-flow-vectors.ts' + ios: + - '.github/workflows/test.yml' + - 'ios/**' + - 'cpp/**' + - 'scripts/test-ios-purge-restore.sh' + - 'scripts/tests/ios-purge-restore/**' + js-test: name: JS Unit Tests runs-on: ubuntu-latest timeout-minutes: 5 steps: - - uses: actions/checkout@v7 - - uses: oven-sh/setup-bun@v2 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: bun-version: 1.4.0 @@ -34,11 +69,13 @@ jobs: cpp-test: name: C++ Unit Tests + needs: changes + if: needs.changes.outputs.cpp == 'true' runs-on: ubuntu-latest timeout-minutes: 10 steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: submodules: recursive @@ -76,17 +113,20 @@ jobs: # libFuzzer is unavailable on the runner. - name: Fuzz parsers (bounded) run: | + sudo apt-get update sudo apt-get install -y clang FUZZ=1 ./scripts/test-update-flow-core.sh FUZZ=1 ./scripts/test-patch-core.sh ios-purge-restore-test: name: Native purge-restore ordering + needs: changes + if: needs.changes.outputs.ios == 'true' runs-on: macos-latest timeout-minutes: 5 steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 # Foundation-only executable: exercises the production tvOS Release # methods without RN, CocoaPods, a simulator, or real network delays. - name: Run deterministic native ordering tests and negative controls diff --git a/.npmignore b/.npmignore deleted file mode 100644 index 69b72780..00000000 --- a/.npmignore +++ /dev/null @@ -1,95 +0,0 @@ -.babelrc -.npmignore -.eslintrc -.nvmrc -.travis.yml -Example -android/build -.vscode -.github/ - -# OSX -# -.DS_Store - -# Xcode -# -build/ -*.pbxuser -!default.pbxuser -*.mode1v3 -!default.mode1v3 -*.mode2v3 -!default.mode2v3 -*.perspectivev3 -!default.perspectivev3 -xcuserdata -*.xccheckout -*.moved-aside -DerivedData -*.hmap -*.ipa -*.xcuserstate -project.xcworkspace - -# Android/IJ -# -.idea -.gradle -local.properties -android/build -android/obj -.project - -# node.js -# -node_modules/ -npm-debug.log -Example -yarn.lock -bun.lock -*.test.ts -*.test.js -src/__tests__/ - -# Native build caches / intermediates (must not ship to consumers) -android/.cxx/ -android/.gradle/ -**/*.o -CODE_AUDIT.md - -domains.json -endpoints.json -endpoints_cresc.json - -tea.yaml - -e2e/ - -harmony/** -!harmony/pushy.har -!harmony/pushy/src/** -!harmony/hvigor-plugin.ts - -.tmp -.cursor -.e2e-rn077-oldarch -.claude -.bun-cache -.ohpm-cache - -android/jni/lzma/bin -android/jni/lzma/DOC - -# Ignore unnecessary development files -scripts/** -harmony/pushy/src/test/ -babel.config.js -tsconfig.json -bunfig.toml - -# Ignore temporary files created by npm pack during CI builds -*.tgz -package - - diff --git a/CODE_AUDIT.md b/CODE_AUDIT.md index 58c1ead0..b2823a8b 100644 --- a/CODE_AUDIT.md +++ b/CODE_AUDIT.md @@ -290,7 +290,7 @@ NATIVE_CHECK_FOLLOWUPS 中两项已在代码里修复、可关闭:"iOS 合流 - **6.3(P3)根 package.json 里的死依赖与死脚本**:`firebase-tools`、`jest`、`ts-jest`、`@types/jest`、`detox`、`pod-install` 在根目录无任何使用(detox/pod-install 由 `Example/e2etest` 自己的 lock 提供;测试跑的是 `bun test`);`tests:emulator:prepare / tests:emulator:start-ci / tests:packager:jet-ci / tests:ios:pod:install` 脚本和整个 `.github/workflows/scripts/`(firebase emulator、functions、rules)是模板残留,仓库里没有任何 workflow 引用;根 `e2e/starter.test.js` 测的是不存在的 welcome/hello 元素。清掉后 `bun install` 少装 ~30MB,lockfile 与 dependabot 面都小。 - **6.4(P3)仓库里的大二进制**:`Example/harmony_use_pushy/debug.png`(761KB)、`demo.png`(345KB)在 git 历史里;`android/lib/*/librnupdate.so` 每次重建都是 4×~290KB 的新 blob(历史里已有 3 代)。发布已改为 CI 重建(publish.yml),可以考虑不再提交 `.so`(本地开发走 `bun build:so`,或 git LFS)。 - **6.5(P3)README 与公开 API**:`testChannel / healthCheck / autoMarkSuccessDelayMs / resetToPackagedBundle / getUpdateMetadata / attachToSentry / maxRetries / beforeReload / onPackageExpired / overridePackageVersion / dismissErrorAfter / useUpdateProgress / onError` 在两份 README 里均无提及(只有外站文档链接)。至少在 README 里放一张 options 索引表指向文档页。 -- **6.6(P4)设计文档**:`NATIVE_CHECKUPDATE_DESIGN.md`(36KB)、`NATIVE_CHECK_FOLLOWUPS.md`(31KB)、`BUNDLEHASH_MIGRATION.md`(25KB)都在仓库根;FOLLOWUPS 里大量已关闭项与原始描述并存。建议移到 `docs/`,FOLLOWUPS 只保留开放项表。 +- **6.6(P4)设计文档**:`NATIVE_CHECKUPDATE_DESIGN.md`(36KB)、`NATIVE_CHECK_FOLLOWUPS.md`(31KB)、`BUNDLEHASH_MIGRATION.md`(25KB)都在仓库根;FOLLOWUPS 里大量已关闭项与原始描述并存。建议移到 `docs/`,FOLLOWUPS 只保留开放项表。(2026-10-12:四份设计文档已移到 `docs/`;FOLLOWUPS 精简未做。) - **6.7(P4)方法表对等性**:本次核对 16 个 Spec 方法在 oldarch/newarch/iOS/鸿蒙 C++/鸿蒙 ArkTS 五处全部注册(好)。但没有自动化守护,鸿蒙已经断过一次;见 5.2 的脚本建议。另 iOS 未导出 Spec 声明的 `isUsingBundleUrl` 常量(Android/鸿蒙有;JS 侧无人使用)——要么 iOS 补上,要么从 Spec 删掉。 - **6.8(P3)安全模型说明**:三端均无 bundle 签名校验(grep 无 signature/ed25519/publicKey),完整性完全依赖 TLS + 服务端下发的 hash;端点列表又可由 gitee/jsdelivr 上的 `endpoints.json` 远程注入(`getRemoteEndpoints` 只要求是字符串,不要求 https)。这是既定信任模型,但两处低成本加固值得做:远程端点列表只接受 `https://`;长期看可加可选的离线签名(app 内置公钥、服务端签 manifest),对自托管/http 部署尤其有价值。 diff --git a/Example/e2etest/.detoxrc.js b/Example/e2etest/.detoxrc.js index c028b31c..ee23e3a1 100644 --- a/Example/e2etest/.detoxrc.js +++ b/Example/e2etest/.detoxrc.js @@ -49,6 +49,12 @@ const androidArchitectures = process.env.DETOX_ANDROID_ARCHS const iosBuildBase = 'xcodebuild -workspace ios/AwesomeProject.xcworkspace -UseNewBuildSystem=NO -scheme AwesomeProject -sdk iphonesimulator -destination "generic/platform=iOS Simulator" -derivedDataPath ios/build'; +// One AVD can only be booted once: a second Detox worker on Android retries +// booting the same emulator until the job times out (the 2026-07-30 e2e +// breakage). Pin Android to a single worker here instead of on every +// `detox test` command line. +const androidRunner = { args: { maxWorkers: 1 } }; + /** @type {import('detox').DetoxConfig} */ const config = { logger: { @@ -57,6 +63,8 @@ const config = { testRunner: { args: { config: 'e2e/jest.config.js', + // iOS on CI: two workers, each on its own cloned simulator. Android + // overrides this to 1 (androidRunner below). maxWorkers: process.env.CI ? 2 : undefined, _: ['e2e'], }, @@ -128,18 +136,22 @@ const config = { 'android.att.debug': { device: 'attached', app: 'android.debug', + testRunner: androidRunner, }, 'android.att.release': { device: 'attached', app: 'android.release', + testRunner: androidRunner, }, 'android.emu.debug': { device: 'emulator', app: 'android.debug', + testRunner: androidRunner, }, 'android.emu.release': { device: 'emulator', app: 'android.release', + testRunner: androidRunner, }, }, }; diff --git a/Example/e2etest/e2e/harmony/README.md b/Example/e2etest/e2e/harmony/README.md index 008687ee..cd5a582e 100644 --- a/Example/e2etest/e2e/harmony/README.md +++ b/Example/e2etest/e2e/harmony/README.md @@ -19,7 +19,12 @@ RNU_E2E_SKIP_PREPARE=true npm run test:e2e:harmony ``` 套件:`local-update.test.ts`(完整更新流:BINARY_BASE → v1 全量 → v2 ppk 差量 → -重启持久化 → upToDate);`smoke.test.ts`(驱动自检)。 +bundleHash 与 hap 内 `rawfile/bundle.harmony.js` 的 sha256 一致 → v3 包差量(以 hap +内置 bundle 为源)→ v4 v2-track 差量 → 重启持久化 → upToDate);`smoke.test.ts`(驱动自检)。 + +v3 包差量以已安装 hap 为源,只能在 assembleHap 之后生成:`build-harmony-e2e.sh` +会在出包后以 `E2E_HARMONY_PACKAGE_DIFF_ONLY=true` 补跑这一步。换了 hap 却没重新生成 +差量(例如只手动 assembleHap),v3 会因源校验失败而卡住。 ## 前置条件 diff --git a/Example/e2etest/e2e/harmony/local-update.test.ts b/Example/e2etest/e2e/harmony/local-update.test.ts index e9a6d6f6..06f959c1 100644 --- a/Example/e2etest/e2e/harmony/local-update.test.ts +++ b/Example/e2etest/e2e/harmony/local-update.test.ts @@ -1,7 +1,8 @@ /** * Harmony update-flow e2e, mirroring the Detox local-merge suite: - * binary base -> full package (v1) -> ppk diff (v2) -> v2-track diff (v4) - * -> persistence. + * binary base -> full package (v1) -> ppk diff (v2) -> package diff against + * the hap's embedded bundle (v3) -> v2-track diff (v4) -> persistence, plus + * the bundleHash identity check. * * Prerequisites: * - Emulator/device in `hdc list targets` @@ -9,6 +10,9 @@ * the e2e entry (e2e/entry.base.ts) installed on the device * - Artifacts + local server are handled by globalSetup (E2E_PLATFORM=harmony) */ +import { execFileSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import * as path from 'node:path'; import { HarmonyDriver } from '../harness/harmony-driver.ts'; const BUNDLE_NAME = @@ -17,6 +21,7 @@ const LABELS = { base: 'BINARY_BASE', full: 'E2E_FULL_V1', ppkPatch: 'E2E_PPK_PATCH_V2', + packagePatch: 'E2E_PACKAGE_PATCH_V3', v2Track: 'E2E_V2TRACK_V4', } as const; const LOCAL_UPDATE_PORT = 31337; @@ -32,8 +37,32 @@ const MAX_CHECK_UPDATE_ATTEMPTS = 2; // no such hook, so wait out the default delay with margin instead. const MARK_SUCCESS_SETTLE_MS = 3000; +const HAP_PATH = path.resolve( + __dirname, + '../../../harmony_use_pushy/harmony/entry/build/default/outputs/default/entry-default-signed.hap' +); +const HAP_BUNDLE_ENTRY = 'resources/rawfile/bundle.harmony.js'; + const driver = new HarmonyDriver(BUNDLE_NAME); +function embeddedBundleSha256(): string { + const bundle = execFileSync('unzip', ['-p', HAP_PATH, HAP_BUNDLE_ENTRY], { + maxBuffer: 256 * 1024 * 1024, + }); + if (bundle.length === 0) { + throw new Error(`${HAP_BUNDLE_ENTRY} not found in ${HAP_PATH}`); + } + return createHash('sha256').update(bundle).digest('hex'); +} + +async function lastReportedBundleHash(): Promise { + const response = await fetch( + `http://127.0.0.1:${LOCAL_UPDATE_PORT}/control/last-bundle-hash?platform=harmony` + ); + const body = (await response.json()) as { bundleHash?: string }; + return body.bundleHash ?? ''; +} + async function waitForBundleLabel(expectedLabel: string, timeout: number) { const expectedText = `bundleLabel: ${expectedLabel}`; await driver.waitFor( @@ -95,6 +124,17 @@ describe('harmony local update flow', () => { await tapCheckUpdateAndWaitForBundleLabel(LABELS.ppkPatch); }); + it('reports the embedded bundle sha256 as bundleHash', async () => { + // Every checkUpdate so far carried the hash the native side computed from + // the rawfile fd. A mismatch would make the server trust a pdiff whose + // origin check then fails on device. + expect(await lastReportedBundleHash()).toBe(embeddedBundleSha256()); + }); + + it('applies the package diff against the embedded bundle (v3)', async () => { + await tapCheckUpdateAndWaitForBundleLabel(LABELS.packagePatch); + }); + it('applies the v2-track diff update (v4)', async () => { await tapCheckUpdateAndWaitForBundleLabel(LABELS.v2Track); }); diff --git a/Example/e2etest/scripts/build-harmony-e2e.sh b/Example/e2etest/scripts/build-harmony-e2e.sh index 621c918b..a4f74b30 100755 --- a/Example/e2etest/scripts/build-harmony-e2e.sh +++ b/Example/e2etest/scripts/build-harmony-e2e.sh @@ -1,7 +1,8 @@ #!/usr/bin/env bash # Build & install the HarmonyOS e2e base app, encoding the required order: # local har -> swap local react-native-update -> refresh oh_modules -# -> prepare update artifacts -> base bundle -> assembleHap -> install. +# -> prepare update artifacts -> base bundle -> assembleHap +# -> package diff against that hap -> install. # # The order matters: `pushy bundle --platform harmony` (run by the prepare # step) overwrites the project's rawfile/bundle.harmony.js, so the base bundle @@ -77,6 +78,10 @@ echo "==> Assembling signed hap" exit 1 } +echo "==> Generating package diff against the hap's embedded bundle" +(cd "$E2ETEST_ROOT" && E2E_PLATFORM=harmony E2E_HARMONY_PACKAGE_DIFF_ONLY=true \ + node scripts/run-prepare-local-update-artifacts.js) + if [ "${SKIP_INSTALL:-false}" != "true" ]; then echo "==> Installing $BUNDLE_NAME" hdc list targets | grep -qv '^\[Empty\]$' || { diff --git a/Example/e2etest/scripts/local-e2e-server.ts b/Example/e2etest/scripts/local-e2e-server.ts index 7cbdfab9..a7119769 100644 --- a/Example/e2etest/scripts/local-e2e-server.ts +++ b/Example/e2etest/scripts/local-e2e-server.ts @@ -70,6 +70,10 @@ function safeResolve(urlPath: string) { return target; } +// bundleHash each platform sent with its most recent checkUpdate, so a +// test can compare it against the sha256 of the bundle embedded in the binary. +const lastBundleHash: Record = {}; + function buildUpdateResponse( platform: string, currentHash: string, @@ -104,12 +108,15 @@ function buildUpdateResponse( }; } - if (platform === 'android' && currentHash === LOCAL_UPDATE_HASHES.ppkPatch) { + // Package diffs (patched against the bundle embedded in the installed + // binary) run on Android and Harmony; iOS has no package-diff artifact. + const servesPackageDiff = platform === 'android' || platform === 'harmony'; + if (servesPackageDiff && currentHash === LOCAL_UPDATE_HASHES.ppkPatch) { return { update: true, name: 'local-pdiff-v3', hash: LOCAL_UPDATE_HASHES.packagePatch, - description: 'Serve an Android package diff from local e2e server.', + description: 'Serve a package diff from local e2e server.', metaInfo: JSON.stringify({ stage: 'pdiff', platform }), paths: [assetBasePath], pdiff: LOCAL_UPDATE_FILES.packageDiff, @@ -122,10 +129,9 @@ function buildUpdateResponse( platform, LOCAL_UPDATE_FILES.v2TrackDiff ); - const v2TrackPrevHash = - platform === 'android' - ? LOCAL_UPDATE_HASHES.packagePatch - : LOCAL_UPDATE_HASHES.ppkPatch; + const v2TrackPrevHash = servesPackageDiff + ? LOCAL_UPDATE_HASHES.packagePatch + : LOCAL_UPDATE_HASHES.ppkPatch; if (currentHash === v2TrackPrevHash && diffV >= 2) { if (fullFallback) { return { @@ -177,6 +183,11 @@ const server = Bun.serve({ }); } + if (url.pathname === '/control/last-bundle-hash') { + const target = url.searchParams.get('platform') || ''; + return json({ bundleHash: lastBundleHash[target] ?? '' }); + } + if (url.pathname === '/control/force-boot') { if (request.method !== 'POST') { return new Response('method not allowed', { status: 405 }); @@ -202,7 +213,12 @@ const server = Bun.serve({ const payload = (await request.json().catch(() => ({}))) as { hash?: unknown; diffV?: unknown; + bundleHash?: unknown; }; + // Every request overwrites: a later check that drops the hash must + // fail the identity test rather than hide behind an earlier one. + lastBundleHash[platform] = + typeof payload.bundleHash === 'string' ? payload.bundleHash : ''; const currentHash = typeof payload.hash === 'string' ? payload.hash : ''; const diffV = typeof payload.diffV === 'number' ? payload.diffV : 0; diff --git a/Example/e2etest/scripts/prepare-local-update-artifacts.ts b/Example/e2etest/scripts/prepare-local-update-artifacts.ts index e1c3051e..78f2bec5 100644 --- a/Example/e2etest/scripts/prepare-local-update-artifacts.ts +++ b/Example/e2etest/scripts/prepare-local-update-artifacts.ts @@ -24,16 +24,19 @@ type DiffCommandRunner = { 'no-interactive': true; }; }) => Promise; - hdiffFromApk: (options: { - args: [string, string]; - options: { - output: string; - customDiff: (oldSource?: Buffer, newSource?: Buffer) => Buffer; - 'no-interactive': true; - }; - }) => Promise; + hdiffFromApk: PackageDiffCommand; + hdiffFromApp: PackageDiffCommand; }; +type PackageDiffCommand = (options: { + args: [string, string]; + options: { + output: string; + customDiff: (oldSource?: Buffer, newSource?: Buffer) => Buffer; + 'no-interactive': true; + }; +}) => Promise; + type HdiffModule = { diff?: (oldSource?: Buffer, newSource?: Buffer) => Buffer; patch?: (oldSource?: Buffer, patchSource?: Buffer) => Buffer; @@ -109,6 +112,14 @@ if (!['ios', 'android', 'harmony'].includes(platform)) { const harmonyProjectRoot = path.resolve(projectRoot, '../harmony_use_pushy'); const bundleProjectRoot = platform === 'harmony' ? harmonyProjectRoot : projectRoot; +// The signed release hap the device runs. Its rawfile bundle is the pdiff +// origin, so the package diff can only be made once this hap exists — which is +// after artifact prep (build-harmony-e2e.sh reruns this script with +// E2E_HARMONY_PACKAGE_DIFF_ONLY=true right after assembleHap). +const harmonyHapPath = path.join( + harmonyProjectRoot, + 'harmony/entry/build/default/outputs/default/entry-default-signed.hap' +); if (!fs.existsSync(cliEntry)) { throw new Error(`react-native-update-cli entry not found: ${cliEntry}`); @@ -431,7 +442,51 @@ async function generateAndroidPackageDiff( verifyGeneratedFile('package diff', output); } +async function generateHarmonyPackageDiff(v3: string) { + const output = path.join(artifactsDir, LOCAL_UPDATE_FILES.packageDiff); + if (!fs.existsSync(harmonyHapPath)) { + // Fresh checkout: build-harmony-e2e.sh produces the hap and comes back + // for this step. The server would serve a broken pdiff otherwise. + console.warn( + `Harmony hap not found yet (${harmonyHapPath}); package diff deferred.` + ); + writeFallbackPatch('package diff', output); + return; + } + if (useFullFallbackArtifacts) { + writeFallbackPatch('package diff', output); + return; + } + const { customDiff } = ensureHdiffModule(); + console.log( + `Running hdiffFromApp: ${harmonyHapPath} -> ${v3} (${ + fs.statSync(harmonyHapPath).size + } -> ${fs.statSync(v3).size} bytes)` + ); + await keepProcessAlive( + 'harmony package diff', + diffCommands.hdiffFromApp({ + args: [harmonyHapPath, v3], + options: { output, customDiff, 'no-interactive': true }, + }) + ); + verifyGeneratedFile('harmony package diff', output); +} + async function main() { + if ( + platform === 'harmony' && + process.env.E2E_HARMONY_PACKAGE_DIFF_ONLY === 'true' + ) { + const v3 = path.join(artifactsDir, LOCAL_UPDATE_FILES.packageFull); + if (!fs.existsSync(v3)) { + throw new Error( + `v3 artifact missing: ${v3}; run the full prepare first.` + ); + } + await generateHarmonyPackageDiff(v3); + return; + } prepareDir(); const v1 = path.join(artifactsDir, LOCAL_UPDATE_FILES.full); @@ -445,7 +500,7 @@ async function main() { // hermesc -base-bytecode=v1, so every patch below carries delta-mode bytecode. bundleTo('e2e/entry.v1.ts', v1); bundleTo('e2e/entry.v2.ts', v2, v1); - if (platform === 'android') { + if (platform === 'android' || platform === 'harmony') { bundleTo('e2e/entry.v3.ts', v3, v1); } bundleTo('e2e/entry.v4.ts', v4, v1); @@ -519,8 +574,19 @@ async function main() { console.log('Generating ppk diff...'); await generatePpkDiff(v1, v2, ppkDiff, hdiff.customDiff); + // Harmony runs the same chain as Android (v2 -> pdiff v3 -> v4), so + // its v2-track diff starts from v3. console.log('Generating v2-track ppk diff...'); - await generateV2TrackPpkDiff(v2, v4, v2TrackDiff, hdiff); + await generateV2TrackPpkDiff( + platform === 'harmony' ? v3 : v2, + v4, + v2TrackDiff, + hdiff + ); + } + if (platform === 'harmony') { + console.log('Generating harmony package diff...'); + await generateHarmonyPackageDiff(v3); } } diff --git a/Example/harmony_use_pushy/e2e/entry.v3.ts b/Example/harmony_use_pushy/e2e/entry.v3.ts new file mode 100644 index 00000000..217a8fc4 --- /dev/null +++ b/Example/harmony_use_pushy/e2e/entry.v3.ts @@ -0,0 +1,8 @@ +import { LOCAL_UPDATE_LABELS } from './localUpdateConfig'; + +const bundleLabelGlobal = globalThis as typeof globalThis & { + __RNU_E2E_BUNDLE_LABEL?: string; +}; + +bundleLabelGlobal.__RNU_E2E_BUNDLE_LABEL = LOCAL_UPDATE_LABELS.packagePatch; +require('./entry.base'); diff --git a/Example/harmony_use_pushy/e2e/localUpdateConfig.ts b/Example/harmony_use_pushy/e2e/localUpdateConfig.ts index 2756ed6c..3ca31e1a 100644 --- a/Example/harmony_use_pushy/e2e/localUpdateConfig.ts +++ b/Example/harmony_use_pushy/e2e/localUpdateConfig.ts @@ -9,6 +9,7 @@ export const LOCAL_UPDATE_LABELS = { base: 'BINARY_BASE', full: 'E2E_FULL_V1', ppkPatch: 'E2E_PPK_PATCH_V2', + packagePatch: 'E2E_PACKAGE_PATCH_V3', v2Track: 'E2E_V2TRACK_V4', } as const; diff --git a/README-CN.md b/README-CN.md index 3cf212cb..a6d93eff 100644 --- a/README-CN.md +++ b/README-CN.md @@ -178,4 +178,4 @@ Hermes 字节码对通用二进制 diff 极不友好,我们在两个环节同 原生宿主入口统一为 `prepareBundle`:Android 使用 `PushyRuntime`, Apple 平台使用 `RCTPushy`,Harmony 使用已有的 `PushyFileJSBundleProvider`。 请在正常启动 bundle 解析后调用。改名不改变 JS 桥接契约,但原生接入代码需要迁移并重新构建。 -详见[原生接口迁移说明](NATIVE_HOST_API_MIGRATION.md)。 +详见[原生接口迁移说明](docs/NATIVE_HOST_API_MIGRATION.md)。 diff --git a/README.md b/README.md index 219cda21..5f408404 100644 --- a/README.md +++ b/README.md @@ -160,5 +160,5 @@ Since 10.52.1 every cold start runs one background update check that **does not Bridge-free hosts use `PushyRuntime.prepareBundle` (Android), `RCTPushy.prepareBundle` (Swift), or `PushyFileJSBundleProvider.prepareBundle` (HarmonyOS), after normal launch bundle resolution. See the -[native host API migration guide](NATIVE_HOST_API_MIGRATION.md) for the +[native host API migration guide](docs/NATIVE_HOST_API_MIGRATION.md) for the source-breaking rename, unchanged JS bridge contract, and native rebuild steps. diff --git a/android/jni/Android.mk b/android/jni/Android.mk index a3576c4c..5de674f7 100644 --- a/android/jni/Android.mk +++ b/android/jni/Android.mk @@ -5,6 +5,7 @@ include $(CLEAR_VARS) LOCAL_MODULE := rnpushy LOCAL_CPPFLAGS += -std=c++17 LOCAL_LDFLAGS += -Wl,--exclude-libs,ALL +LOCAL_LDLIBS := -llog LOCAL_C_INCLUDES := \ $(LOCAL_PATH) \ $(LOCAL_PATH)/HDiffPatch \ diff --git a/android/lib/arm64-v8a/librnpushy.so b/android/lib/arm64-v8a/librnpushy.so index 4a1a5789..59a3ea9f 100755 Binary files a/android/lib/arm64-v8a/librnpushy.so and b/android/lib/arm64-v8a/librnpushy.so differ diff --git a/android/lib/armeabi-v7a/librnpushy.so b/android/lib/armeabi-v7a/librnpushy.so index ae2d3516..e7e4a628 100755 Binary files a/android/lib/armeabi-v7a/librnpushy.so and b/android/lib/armeabi-v7a/librnpushy.so differ diff --git a/android/lib/x86/librnpushy.so b/android/lib/x86/librnpushy.so index 33733942..e6b28b84 100755 Binary files a/android/lib/x86/librnpushy.so and b/android/lib/x86/librnpushy.so differ diff --git a/android/lib/x86_64/librnpushy.so b/android/lib/x86_64/librnpushy.so index 197c935a..5a7e2da0 100755 Binary files a/android/lib/x86_64/librnpushy.so and b/android/lib/x86_64/librnpushy.so differ diff --git a/android/proguard.pro b/android/proguard.pro index 01f089c2..33955d08 100644 --- a/android/proguard.pro +++ b/android/proguard.pro @@ -6,7 +6,9 @@ -keep class cn.reactnative.modules.update.ArchivePatchPlanResult { (); ; } -keep class cn.reactnative.modules.update.CopyGroupResult { (); ; } -# JNI entry points are bound by their mangled Java names. +# Native methods are bound from JNI_OnLoad by class/method name (the encoded +# table in cpp/patch_core/jni_registration.cpp), not by exported symbols, so +# their classes and names must still survive shrinking unchanged. -keepclasseswithmembernames class cn.reactnative.modules.update.** { native ; } diff --git a/android/src/main/java/cn/reactnative/modules/update/CleanupKeepNames.java b/android/src/main/java/cn/reactnative/modules/update/CleanupKeepNames.java index a24388d2..50d37a35 100644 --- a/android/src/main/java/cn/reactnative/modules/update/CleanupKeepNames.java +++ b/android/src/main/java/cn/reactnative/modules/update/CleanupKeepNames.java @@ -1,25 +1,24 @@ package cn.reactnative.modules.update; -/** Selects the at-most-two distinct version names supported by cleanup JNI. */ +import java.util.LinkedHashSet; + +/** + * The distinct version names cleanup must never touch: the persisted current + * and last versions AND the version this process booted from (two switches + * without a restart otherwise evict the running bundle while its on-demand + * assets are still being served). + */ final class CleanupKeepNames { private CleanupKeepNames() { } static String[] select(String current, String previous, String running) { - String[] keep = new String[2]; + LinkedHashSet keep = new LinkedHashSet<>(); for (String candidate : new String[] {current, previous, running}) { - if (candidate == null || candidate.isEmpty() - || candidate.equals(keep[0]) || candidate.equals(keep[1])) { - continue; - } - if (keep[0] == null) { - keep[0] = candidate; - } else if (keep[1] == null) { - keep[1] = candidate; - } else { - return null; + if (candidate != null && !candidate.isEmpty()) { + keep.add(candidate); } } - return keep; + return keep.toArray(new String[0]); } } diff --git a/android/src/main/java/cn/reactnative/modules/update/ConfigEquivalence.java b/android/src/main/java/cn/reactnative/modules/update/ConfigEquivalence.java new file mode 100644 index 00000000..a91e97db --- /dev/null +++ b/android/src/main/java/cn/reactnative/modules/update/ConfigEquivalence.java @@ -0,0 +1,134 @@ +package cn.reactnative.modules.update; + +import androidx.annotation.Nullable; +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.Iterator; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; +import org.json.JSONTokener; + +/** + * Cross-platform canonical equality of native-check config JSON strings + * (shared contract with the JS / iOS / Harmony sides). Two configs are + * equivalent iff their canonical forms deep-equal: + *
    + *
  • absent keys take the defaults {disabled: false, queryUrls: [], rnu: "", + * rn: "", afterDownload: "none"};
  • + *
  • each string in endpoints / queryUrls loses its trailing '/', then + * duplicates are dropped keeping the first occurrence;
  • + *
  • key order is irrelevant.
  • + *
+ * Anything that is not a JSON object string is never equivalent to anything. + */ +final class ConfigEquivalence { + private static final Object NULL_VALUE = new Object(); + + private ConfigEquivalence() { + } + + static boolean equivalent(@Nullable String a, @Nullable String b) { + Map left = canonical(a); + if (left == null) { + return false; + } + Map right = canonical(b); + return right != null && left.equals(right); + } + + @Nullable + static Map canonical(@Nullable String json) { + if (json == null) { + return null; + } + Object parsed; + try { + JSONTokener tokener = new JSONTokener(json); + parsed = tokener.nextValue(); + // nextValue() stops after the first value; anything but trailing + // whitespace after it is not JSON (as JSON.parse/NSJSONSerialization). + if (tokener.nextClean() != 0) { + return null; + } + } catch (JSONException e) { + return null; + } + if (!(parsed instanceof JSONObject)) { + return null; + } + @SuppressWarnings("unchecked") + Map result = (Map) toCanonical(parsed); + putDefault(result, "disabled", Boolean.FALSE); + putDefault(result, "queryUrls", new ArrayList()); + putDefault(result, "rnu", ""); + putDefault(result, "rn", ""); + putDefault(result, "afterDownload", "none"); + normalizeUrls(result, "endpoints"); + normalizeUrls(result, "queryUrls"); + return result; + } + + private static void putDefault(Map map, String key, Object value) { + if (!map.containsKey(key)) { + map.put(key, value); + } + } + + private static void normalizeUrls(Map map, String key) { + Object value = map.get(key); + if (!(value instanceof List)) { + return; + } + LinkedHashSet unique = new LinkedHashSet<>(); + for (Object item : (List) value) { + unique.add(item instanceof String ? stripTrailingSlashes((String) item) : item); + } + map.put(key, new ArrayList(unique)); + } + + private static String stripTrailingSlashes(String url) { + int end = url.length(); + while (end > 0 && url.charAt(end - 1) == '/') { + end--; + } + return url.substring(0, end); + } + + // JSONObject/JSONArray have identity equals; convert to Map/List with + // value semantics. Numbers compare by value (1 == 1.0). + private static Object toCanonical(Object value) { + if (value instanceof JSONObject) { + JSONObject object = (JSONObject) value; + Map map = new HashMap<>(); + for (Iterator keys = object.keys(); keys.hasNext();) { + String key = keys.next(); + map.put(key, toCanonical(object.opt(key))); + } + return map; + } + if (value instanceof JSONArray) { + JSONArray array = (JSONArray) value; + List list = new ArrayList<>(array.length()); + for (int i = 0; i < array.length(); i++) { + list.add(toCanonical(array.opt(i))); + } + return list; + } + if (value instanceof Number) { + try { + return new BigDecimal(value.toString()).stripTrailingZeros(); + } catch (NumberFormatException e) { + return value.toString(); + } + } + if (value == null || value == JSONObject.NULL) { + return NULL_VALUE; + } + return value; + } +} diff --git a/android/src/main/java/cn/reactnative/modules/update/DownloadTask.java b/android/src/main/java/cn/reactnative/modules/update/DownloadTask.java index 6d796cf3..432536df 100644 --- a/android/src/main/java/cn/reactnative/modules/update/DownloadTask.java +++ b/android/src/main/java/cn/reactnative/modules/update/DownloadTask.java @@ -8,9 +8,12 @@ import com.facebook.react.bridge.WritableMap; import java.io.ByteArrayOutputStream; import java.io.File; +import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStream; import java.nio.charset.StandardCharsets; +import java.security.DigestOutputStream; +import java.security.MessageDigest; import java.util.ArrayList; import java.util.Enumeration; import java.util.HashMap; @@ -33,7 +36,9 @@ class DownloadTask implements Runnable { private static final String BUNDLE_DELTA_ENTRY = Texts.reveal("3319f0d4b6c56a502c3b10fcdca0de7d4b3307e9"); - private static final int DOWNLOAD_CHUNK_SIZE = 4096; + // Read size per loop iteration (and the buffer for small in-memory + // reads); progress events are throttled separately. + private static final int DOWNLOAD_CHUNK_SIZE = 64 * 1024; // When the server does not report Content-Length we cannot key progress // events on percentage change, so throttle by bytes to avoid flooding the // bridge (e.g. a 20MB chunked download would otherwise emit ~5000 events). @@ -42,7 +47,7 @@ class DownloadTask implements Runnable { // slow-dripping connection could occupy the single-threaded download // executor indefinitely and starve queued tasks. The call timeout is a // generous upper bound sized for large full-package downloads. - private static final OkHttpClient HTTP_CLIENT = new OkHttpClient.Builder() + private static final OkHttpClient HTTP_CLIENT = HttpClients.base().newBuilder() .connectTimeout(15, java.util.concurrent.TimeUnit.SECONDS) .readTimeout(30, java.util.concurrent.TimeUnit.SECONDS) .writeTimeout(30, java.util.concurrent.TimeUnit.SECONDS) @@ -62,8 +67,15 @@ private File stagingDirectory() { return InstallRecord.stagingDirectoryFor(params.unzipDirectory); } - // SHA-256 of the downloaded archive, computed right before extraction. + // SHA-256 of the downloaded archive, recorded in the install record. private String artifactSha256 = ""; + // The archive digest computed while its bytes streamed to disk (a resumed + // transfer first hashes the prefix already on disk); null when this task + // did not stream the whole file itself and it must be hashed from disk. + private String streamedArchiveSha256; + // Digest of index.bundlejs computed while a full package was extracted; + // null when the bundle was produced some other way (patch tasks). + private String stagedBundleSha256; private static final class PatchArchiveContents { final ArrayList entryNames = new ArrayList(); @@ -187,6 +199,7 @@ private void writeResumeMeta( private void downloadFile() throws IOException { this.hash = params.hash; + streamedArchiveSha256 = null; String url = params.url; File writePath = params.targetFile; File sidecar = resumeSidecarFile(writePath); @@ -364,10 +377,16 @@ private boolean transferArchive( // than the margin). A throw keeps the partial for a later attempt. long freeSpaceReservedUntil = 0; + // The archive digest is computed as the bytes reach the file, so + // the install does not re-read the whole archive afterwards. + MessageDigest digest = UpdateFileUtils.newSha256(); + if (append) { + UpdateFileUtils.updateDigest(digest, writePath); + } try ( BufferedSource source = body.source(); - BufferedSink sink = Okio.buffer( - append ? Okio.appendingSink(writePath) : Okio.sink(writePath)) + BufferedSink sink = Okio.buffer(Okio.sink(new DigestOutputStream( + new FileOutputStream(writePath, append), digest))) ) { while ((bytesRead = source.read(sink.buffer(), DOWNLOAD_CHUNK_SIZE)) != -1) { received += bytesRead; @@ -385,7 +404,9 @@ private boolean transferArchive( throw e; } } - sink.emit(); + // Write whole segments only: a 64KB read no longer costs + // a write() syscall per partially filled segment. + sink.emitCompleteSegments(); long overall = baseOffset + received; if (overall > ArchiveLimits.MAX_ARCHIVE_BYTES) { @@ -415,6 +436,7 @@ private boolean transferArchive( throw new IOException("Download incomplete: expected " + totalAll + " bytes, got " + writePath.length()); } + streamedArchiveSha256 = UpdateFileUtils.toHex(digest.digest()); // Final progress event, skipped when the loop already posted this // exact value (known length reaching 100% posts it in-loop). if (baseOffset + received != lastPostedBytes) { @@ -564,19 +586,38 @@ private void doFullPatch() throws IOException { File work = stagingDirectory(); UpdateFileUtils.removeDirectory(work); UpdateFileUtils.ensureDirectory(work); - artifactSha256 = UpdateFileUtils.sha256Hex(params.targetFile); - + artifactSha256 = archiveSha256(); + + // The bundle's digest (for the install record) is taken from the + // bytes as they are extracted, instead of re-reading the bundle right + // after. Any entry resolving to the bundle path counts; the last write + // wins on disk and in the digest alike. + File bundleTarget = new File(work, "index.bundlejs").getCanonicalFile(); + stagedBundleSha256 = null; try (SafeZipFile zipFile = new SafeZipFile(params.targetFile)) { ensureArchiveWithinLimits(params.targetFile, zipFile, work); Enumeration entries = zipFile.entries(); while (entries.hasMoreElements()) { - zipFile.unzipToPath(entries.nextElement(), work); + ZipEntry entry = entries.nextElement(); + if (!entry.isDirectory() + && new File(work, entry.getName()).getCanonicalFile().equals(bundleTarget)) { + MessageDigest digest = UpdateFileUtils.newSha256(); + zipFile.unzipToPath(entry, work, digest); + stagedBundleSha256 = UpdateFileUtils.toHex(digest.digest()); + } else { + zipFile.unzipToPath(entry, work); + } } } deleteConsumedArchive(); } + private String archiveSha256() throws IOException { + return streamedArchiveSha256 != null + ? streamedArchiveSha256 : UpdateFileUtils.sha256Hex(params.targetFile); + } + /** * Last step of a successful patch task: the completion record (with the * final bundle's digest) goes into the staging directory, which is then @@ -596,7 +637,8 @@ private void promoteStaging() throws IOException, JSONException { if (!bundle.isFile()) { throw new IOException("bundle missing after install: " + bundle); } - String bundleSha256 = UpdateFileUtils.sha256Hex(bundle); + String bundleSha256 = stagedBundleSha256 != null + ? stagedBundleSha256 : UpdateFileUtils.sha256Hex(bundle); InstallRecord.write( work, InstallRecord.build(params.hash, bundleSha256, artifactSha256)); if (params.unzipDirectory.exists()) { @@ -643,7 +685,7 @@ private void deleteConsumedArchive() { private void doPatchFromApk() throws IOException, JSONException { downloadFile(); File work = stagingDirectory(); - artifactSha256 = UpdateFileUtils.sha256Hex(params.targetFile); + artifactSha256 = archiveSha256(); PatchArchiveContents contents = extractPatchArchive(params.targetFile, work); buildArchivePlan( @@ -689,7 +731,7 @@ private void doPatchFromApk() throws IOException, JSONException { private void doPatchFromPpk() throws IOException, JSONException { downloadFile(); File work = stagingDirectory(); - artifactSha256 = UpdateFileUtils.sha256Hex(params.targetFile); + artifactSha256 = archiveSha256(); PatchArchiveContents contents = extractPatchArchive(params.targetFile, work); ArchivePatchPlanResult plan = buildArchivePlan( @@ -716,20 +758,38 @@ private void doPatchFromPpk() throws IOException, JSONException { deleteConsumedArchive(); } + // Dot-named, so the selection under the versions root never picks it. + static final String TRASH_DIR_NAME = ".trash"; + private void doCleanUp() { + final File trash = new File(params.unzipDirectory, TRASH_DIR_NAME); + // Phase 1, under the version-files lock: only renames doomed entries + // into the trash, so activation is never blocked behind a recursive + // delete of several version directories. UpdateContext.getInstance(context).runCleanupWithLatestState( new UpdateContext.CleanupAction() { @Override - public void run(String keepCurrent, String keepPrevious) { + public void run(String[] keepNames) { cleanupOldEntries( params.unzipDirectory.getAbsolutePath(), - keepCurrent, - keepPrevious, - params.maxAgeDays + keepNames, + params.maxAgeDays, + trash.getAbsolutePath() ); } } ); + // Phase 2, lock released: nothing in the trash is reachable as a + // version any more. Also purges leftovers of an interrupted run. + // Best effort: phase 1 already succeeded, and a leftover the native + // delete cannot remove must not fail every later cleanup task. + if (trash.isDirectory()) { + try { + cleanupOldEntries(trash.getAbsolutePath(), new String[0], 0, null); + } catch (RuntimeException e) { + Log.w(Texts.LOG_TAG, "Failed to purge cleanup trash", e); + } + } } private void cleanUpAfterFailure(int taskType) { @@ -931,11 +991,13 @@ private static native void applyDeltaFromSource( String hbcTransformMeta ); + // trashDir non-empty: move the selected entries there instead of + // deleting them (see doCleanUp). private static native void cleanupOldEntries( String rootDir, - String keepCurrent, - String keepPrevious, - int maxAgeDays + String[] keepNames, + int maxAgeDays, + String trashDir ); private static native ArchivePatchPlanResult buildArchivePlan( diff --git a/android/src/main/java/cn/reactnative/modules/update/HttpClients.java b/android/src/main/java/cn/reactnative/modules/update/HttpClients.java new file mode 100644 index 00000000..0c935bcf --- /dev/null +++ b/android/src/main/java/cn/reactnative/modules/update/HttpClients.java @@ -0,0 +1,21 @@ +package cn.reactnative.modules.update; + +import okhttp3.OkHttpClient; + +/** + * The one OkHttpClient the SDK owns. Callers derive their timeout variants + * with newBuilder(), which shares the connection pool and dispatcher threads + * instead of each client growing its own. + */ +final class HttpClients { + private HttpClients() { + } + + private static final class Holder { + static final OkHttpClient BASE = new OkHttpClient(); + } + + static OkHttpClient base() { + return Holder.BASE; + } +} diff --git a/android/src/main/java/cn/reactnative/modules/update/SafeZipFile.java b/android/src/main/java/cn/reactnative/modules/update/SafeZipFile.java index a47a32cc..a6ff9906 100644 --- a/android/src/main/java/cn/reactnative/modules/update/SafeZipFile.java +++ b/android/src/main/java/cn/reactnative/modules/update/SafeZipFile.java @@ -1,11 +1,12 @@ package cn.reactnative.modules.update; -import java.io.BufferedInputStream; -import java.io.BufferedOutputStream; import java.io.File; import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStream; +import java.io.OutputStream; +import java.security.DigestOutputStream; +import java.security.MessageDigest; import java.util.Enumeration; import java.util.zip.ZipEntry; import java.util.zip.ZipFile; @@ -17,7 +18,7 @@ public SafeZipFile(File file) throws IOException { super(file); } - private static final int BUFFER_SIZE = 8192; + private static final int BUFFER_SIZE = 64 * 1024; // Bytes written by unzipToFile across the archive: the sum of the central // directory's declared sizes is checked up front (inspect), the actual @@ -124,6 +125,11 @@ public ZipEntry nextElement() { } public void unzipToPath(ZipEntry ze, File targetPath) throws IOException { + unzipToPath(ze, targetPath, null); + } + + /** As above; a non-null digest is fed every byte written for the entry. */ + void unzipToPath(ZipEntry ze, File targetPath, MessageDigest digest) throws IOException { String name = ze.getName(); File target = new File(targetPath, name); @@ -141,10 +147,14 @@ public void unzipToPath(ZipEntry ze, File targetPath) throws IOException { target.mkdirs(); return; } - unzipToFile(ze, target); + unzipToFile(ze, target, digest); } public void unzipToFile(ZipEntry ze, File target) throws IOException { + unzipToFile(ze, target, null); + } + + private void unzipToFile(ZipEntry ze, File target, MessageDigest digest) throws IOException { File parent = target.getParentFile(); if (parent != null && !parent.exists() && !parent.mkdirs() && !parent.exists()) { throw new IOException("Failed to create parent dir for " + target); @@ -157,9 +167,12 @@ public void unzipToFile(ZipEntry ze, File target) throws IOException { ? Math.min(declared, ArchiveLimits.MAX_ENTRY_BYTES) : ArchiveLimits.MAX_ENTRY_BYTES; long written = 0; - try (InputStream inputStream = getInputStream(ze)) { - try (BufferedOutputStream output = new BufferedOutputStream(new FileOutputStream(target)); - BufferedInputStream input = new BufferedInputStream(inputStream)) { + // One 64KB buffer, no extra buffered wrappers: every read/write is + // already a large block. + try (InputStream input = getInputStream(ze)) { + try (OutputStream output = digest == null + ? new FileOutputStream(target) + : new DigestOutputStream(new FileOutputStream(target), digest)) { byte[] buffer = new byte[BUFFER_SIZE]; int n; while ((n = input.read(buffer, 0, BUFFER_SIZE)) >= 0) { diff --git a/android/src/main/java/cn/reactnative/modules/update/SyncCoordinator.java b/android/src/main/java/cn/reactnative/modules/update/SyncCoordinator.java index 29cbfd10..e6ef1d99 100644 --- a/android/src/main/java/cn/reactnative/modules/update/SyncCoordinator.java +++ b/android/src/main/java/cn/reactnative/modules/update/SyncCoordinator.java @@ -37,7 +37,8 @@ final class SyncCoordinator { static final String KEY_CONFIG = "nativeConfig"; // Raw response cache for the JS side to reuse (§10.3), scoped to the - // exact logical request and native config that produced it. + // exact logical request and native config that produced it. Stored in + // its own SharedPreferences file (UpdateContext.getNativeCheckCache). static final String KEY_RESP_CACHE = "nativeCheckResp"; // Set when a round starts, cleared when it ends (§11.4). Residue on the // next launch means the previous process died mid-round (a crash rescue @@ -91,28 +92,19 @@ static BundlePreparationResult prepareBundle(UpdateContext context) throws Inter if (!nativeReady || sContext != context || !context.getIsUsingBundleUrl()) { return BundlePreparationResult.of(BundlePreparationResult.SKIPPED, "not_initialized"); } - String configJson = context.getKv(KEY_CONFIG); - if (configJson == null || configJson.isEmpty()) { - return BundlePreparationResult.of(BundlePreparationResult.SKIPPED, "not_configured"); - } - try { - JSONObject config = new JSONObject(configJson); - if (config.optBoolean("disabled", false)) { - return BundlePreparationResult.of(BundlePreparationResult.SKIPPED, "disabled"); - } - if (config.optString("appKey", "").isEmpty()) { - return BundlePreparationResult.of(BundlePreparationResult.FAILED, "invalid_config"); - } - } catch (JSONException e) { - return BundlePreparationResult.of(BundlePreparationResult.FAILED, "invalid_config"); + RunnableConfig parsed = parseRunnableConfig(context.getKv(KEY_CONFIG)); + if (parsed.rejection != null) { + return parsed.rejection; } + String configJson = parsed.json; startRound(0); if (!roundStarted.get()) { return BundlePreparationResult.of(BundlePreparationResult.SKIPPED, "config_changed"); } roundDone.await(); if (roundConfigGeneration != UpdateContext.getNativeConfigGeneration() - || !configJson.equals(roundConfigJson) || !configJson.equals(context.getKv(KEY_CONFIG))) { + || !ConfigEquivalence.equivalent(configJson, roundConfigJson) + || !ConfigEquivalence.equivalent(configJson, context.getKv(KEY_CONFIG))) { return BundlePreparationResult.of(BundlePreparationResult.CANCELLED, "config_changed"); } if (roundGeneration != UpdateContext.getResetGeneration()) { @@ -127,14 +119,66 @@ static void recordJsRound(String config) { } /** - * True when JS already obtained a valid response in this process for the - * exact config the native round would use: the delayed round is then a - * duplicate request. Only the scheduled round consults this — the - * crash-rescue path still runs, JS is dead by then. + * True when JS already obtained a valid response in this process for a + * config equivalent (ConfigEquivalence, the cross-platform contract) to + * the one the native round would use: the delayed round is then a + * duplicate request. JS and a host may serialize the same config + * differently, so exact string equality would almost never match. Only + * the scheduled round consults this — the crash-rescue path still runs, + * JS is dead by then. */ private static boolean hasJsRound(UpdateContext context) { String jsConfig = sJsCompletedConfig; - return jsConfig != null && jsConfig.equals(context.getKv(KEY_CONFIG)); + return jsConfig != null + && ConfigEquivalence.equivalent(jsConfig, context.getKv(KEY_CONFIG)); + } + + /** + * The persisted config, parsed and checked once for every entry point + * (prepareBundle, the automatic round's gate, the round itself), so they + * cannot disagree on what is runnable. Exactly one of {config, rejection} + * is set. + */ + static final class RunnableConfig { + @Nullable final String json; + @Nullable final JSONObject config; + // Trimmed, non-empty. + @Nullable final String appKey; + @Nullable final BundlePreparationResult rejection; + + private RunnableConfig(String json, JSONObject config, String appKey, + BundlePreparationResult rejection) { + this.json = json; + this.config = config; + this.appKey = appKey; + this.rejection = rejection; + } + + static RunnableConfig rejected(String status, String reason) { + return new RunnableConfig( + null, null, null, BundlePreparationResult.of(status, reason)); + } + } + + static RunnableConfig parseRunnableConfig(@Nullable String json) { + if (json == null || json.isEmpty()) { + return RunnableConfig.rejected(BundlePreparationResult.SKIPPED, "not_configured"); + } + JSONObject config; + try { + config = new JSONObject(json); + } catch (JSONException e) { + return RunnableConfig.rejected(BundlePreparationResult.FAILED, "invalid_config"); + } + if (config.optBoolean("disabled", false)) { + return RunnableConfig.rejected(BundlePreparationResult.SKIPPED, "disabled"); + } + Object appKey = config.opt("appKey"); + String trimmed = appKey instanceof String ? ((String) appKey).trim() : ""; + if (trimmed.isEmpty()) { + return RunnableConfig.rejected(BundlePreparationResult.FAILED, "invalid_config"); + } + return new RunnableConfig(json, config, trimmed, null); } private SyncCoordinator() { @@ -194,21 +238,8 @@ static boolean isRoundInFlight() { * and download phase to the remaining budget. */ private static boolean hasRunnableConfig(UpdateContext context) { - if (context == null) { - return false; - } - try { - String json = context.getKv(KEY_CONFIG); - if (json == null) { - return false; - } - JSONObject config = new JSONObject(json); - return !config.optBoolean("disabled", false) - && config.opt("appKey") instanceof String - && !config.getString("appKey").trim().isEmpty(); - } catch (JSONException e) { - return false; - } + return context != null + && parseRunnableConfig(context.getKv(KEY_CONFIG)).rejection == null; } static void onConfigured(UpdateContext context) { @@ -310,26 +341,13 @@ private static void runOnce( roundResult = BundlePreparationResult.of(BundlePreparationResult.FAILED, "check_failed"); String configJson = context.getKv(KEY_CONFIG); roundConfigJson = configJson; - if (configJson == null || configJson.isEmpty()) { - roundResult = BundlePreparationResult.of(BundlePreparationResult.SKIPPED, "not_configured"); - return; - } - JSONObject config; - try { - config = new JSONObject(configJson); - } catch (JSONException e) { - roundResult = BundlePreparationResult.of(BundlePreparationResult.FAILED, "invalid_config"); - return; - } - if (config.optBoolean("disabled", false)) { - roundResult = BundlePreparationResult.of(BundlePreparationResult.SKIPPED, "disabled"); - return; - } - String appKey = config.optString("appKey", ""); - if (appKey.isEmpty()) { - roundResult = BundlePreparationResult.of(BundlePreparationResult.FAILED, "invalid_config"); + RunnableConfig parsed = parseRunnableConfig(configJson); + if (parsed.rejection != null) { + roundResult = parsed.rejection; return; } + JSONObject config = parsed.config; + String appKey = parsed.appKey; // Keep the existing interrupted-round breadcrumb and reset generation. try { context.setKv(KEY_ROUND_INCOMPLETE, "1"); @@ -340,8 +358,13 @@ private static void runOnce( context, launchRolledBackVersion, deadlineNanos, resetGeneration, configJson, config, appKey); } finally { + // A committed round already cleared the breadcrumb in its own + // commit (UpdateContext.commitSyncResult); only rounds that ended + // without committing need the extra write. try { - context.removeKv(KEY_ROUND_INCOMPLETE); + if (context.getKv(KEY_ROUND_INCOMPLETE) != null) { + context.removeKv(KEY_ROUND_INCOMPLETE); + } } catch (IllegalStateException ignored) { } } @@ -539,7 +562,7 @@ private static String buildResponseCacheJson( return cacheEntry.toString(); } - private static final OkHttpClient httpClient = new OkHttpClient.Builder() + private static final OkHttpClient httpClient = HttpClients.base().newBuilder() .connectTimeout(10, TimeUnit.SECONDS) .readTimeout(10, TimeUnit.SECONDS) .callTimeout(15, TimeUnit.SECONDS) diff --git a/android/src/main/java/cn/reactnative/modules/update/UpdateContext.java b/android/src/main/java/cn/reactnative/modules/update/UpdateContext.java index 7c7118ac..96979d26 100644 --- a/android/src/main/java/cn/reactnative/modules/update/UpdateContext.java +++ b/android/src/main/java/cn/reactnative/modules/update/UpdateContext.java @@ -12,7 +12,6 @@ import java.io.IOException; import java.io.InputStream; import java.security.MessageDigest; -import java.security.NoSuchAlgorithmException; import java.util.concurrent.Executor; import java.util.concurrent.Executors; import java.util.concurrent.ThreadFactory; @@ -29,6 +28,10 @@ public class UpdateContext { private final File rootDir; private final Executor executor; private final SharedPreferences sp; + // The native check's raw response cache (SyncCoordinator.KEY_RESP_CACHE) + // lives in its own file: it is a whole server response, and keeping it in + // sp made every small state commit rewrite it. + private final SharedPreferences checkCacheSp; // Our own PackageInfo, looked up once: every getPackageInfo call is a // Binder IPC, and the constructor, the bundle-hash cache and the native // check all need the version name / install time. @@ -48,6 +51,16 @@ public class UpdateContext { // running app has not loaded yet. Volatile: written during launch, read // from the state serial and download executors. private volatile String launchVersion; + // bundleSha256 of launchVersion's install record, read once at launch + // resolution so getConstants does not hit the file system on the JS + // thread. {hash, sha256}; null until a version was resolved. + private volatile String[] launchBundleSha256; + // bundleHash of the embedded bundle, computed once per process on its own + // thread (see startBundleHashComputation); null until known. + private final Object bundleHashLock = new Object(); + private String bundleHash; + private java.util.ArrayList pendingBundleHashListeners = + new java.util.ArrayList<>(); private static final int STATE_OP_SWITCH_VERSION = 1; private static final int STATE_OP_MARK_SUCCESS = 2; private static final int STATE_OP_ROLLBACK = 3; @@ -114,6 +127,8 @@ public Thread newThread(Runnable r) { } this.sp = this.context.getSharedPreferences(Texts.reveal("2f07f0d0ba8e"), Context.MODE_PRIVATE); + this.checkCacheSp = this.context.getSharedPreferences( + Texts.reveal("2f07f0d0ba8e") + "_check", Context.MODE_PRIVATE); this.packageInfo = lookupPackageInfo(this.context); this.reactInstanceManager = pendingReactInstanceManager; @@ -132,6 +147,23 @@ public Thread newThread(Runnable r) { editor.clear(); applyState(editor, nextState); persistEditor(editor, "sync state with binary version"); + clearNativeCheckCache(); + } else { + migrateNativeCheckCache(); + } + startBundleHashComputation(); + } + + // One-time move of the response cache out of the main state file. + private void migrateNativeCheckCache() { + String legacy = sp.getString(SyncCoordinator.KEY_RESP_CACHE, null); + if (legacy == null) { + return; + } + if (checkCacheSp.edit().putString(SyncCoordinator.KEY_RESP_CACHE, legacy).commit()) { + SharedPreferences.Editor editor = sp.edit(); + editor.remove(SyncCoordinator.KEY_RESP_CACHE); + persistEditor(editor, "migrate check cache"); } } @@ -177,32 +209,92 @@ public interface BundleHashListener { * identity of the binary itself, not of whatever hot update is currently * running. Hashes exactly the bytes pdiff patches from: the hardcoded * "index.android.bundle" asset read via AssetManager, same as - * DownloadTask.copyBundledAssetToFile. Runs on the download executor; - * never fails — an empty string means "unknown" and the server falls back - * to the buildTime heuristic. + * DownloadTask.copyBundledAssetToFile. Never fails — an empty string + * means "unknown" and the server falls back to the buildTime heuristic. + * + * Computed once per process on a dedicated thread started by the + * constructor (not on the download executor, where it used to queue + * behind cleanup and downloads and make the first JS checkUpdate send an + * empty hash). The listener runs on that thread, or on the caller's when + * the value is already known. * - * Deliberately java.security.MessageDigest instead of the C++ - * pushy::digest: librnpushy.so is a prebuilt artifact and this must not - * force a rebuild. The NIST vectors in the patch_core tests anchor both - * implementations to the same standard. + * java.security.MessageDigest rather than the C++ pushy::digest: the + * bytes come from an AssetManager stream on the Java side, and the + * platform digest is native-backed already. The NIST vectors in the + * patch_core tests anchor both implementations to the same standard. */ public void getBundleHash(final BundleHashListener listener) { + String known; + synchronized (bundleHashLock) { + known = bundleHash; + if (known == null) { + pendingBundleHashListeners.add(listener); + return; + } + } + listener.onBundleHash(known); + } + + // Package-private: also the native cold-start check's request input + // (SyncCoordinator). Blocks until the shared computation finished — call + // off the main thread. + String computeBundleHash() { + synchronized (bundleHashLock) { + while (bundleHash == null) { + try { + bundleHashLock.wait(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return ""; + } + } + return bundleHash; + } + } + + private void startBundleHashComputation() { if (DEBUG) { // Metro serves the bundle in debug; mirror the dev buildTime behaviour. - listener.onBundleHash(""); + publishBundleHash(""); return; } - executor.execute(new Runnable() { + Thread thread = new Thread(new Runnable() { @Override public void run() { - listener.onBundleHash(computeBundleHash()); + String hash = ""; + try { + hash = loadOrHashBundledAsset(); + } catch (Throwable e) { + Log.w(TAG, "bundle hash failed: " + e); + } finally { + publishBundleHash(hash); + } } - }); + }, "pushy-bundle-hash"); + thread.setDaemon(true); + thread.start(); } - // Package-private: also the native cold-start check's request input - // (SyncCoordinator). Blocking — call off the main thread. - String computeBundleHash() { + private void publishBundleHash(String hash) { + java.util.ArrayList listeners; + synchronized (bundleHashLock) { + bundleHash = hash; + listeners = pendingBundleHashListeners; + pendingBundleHashListeners = null; + bundleHashLock.notifyAll(); + } + if (listeners != null) { + for (BundleHashListener listener : listeners) { + try { + listener.onBundleHash(hash); + } catch (Throwable e) { + Log.w(TAG, "bundle hash listener failed: " + e); + } + } + } + } + + private String loadOrHashBundledAsset() { String cachePrefix = getPackageVersion() + "|" + getPackageLastUpdateTime() + "|"; String cached = sp.getString(KEY_BUNDLE_HASH_CACHE, null); if (cached != null && cached.startsWith(cachePrefix)) { @@ -223,20 +315,14 @@ private long getPackageLastUpdateTime() { private String sha256OfBundledAsset(String assetName) { try (InputStream in = context.getAssets().open(assetName)) { - MessageDigest md = MessageDigest.getInstance("SHA-256"); + MessageDigest md = UpdateFileUtils.newSha256(); byte[] buffer = new byte[64 * 1024]; int read; while ((read = in.read(buffer)) != -1) { md.update(buffer, 0, read); } - byte[] digest = md.digest(); - StringBuilder hex = new StringBuilder(digest.length * 2); - for (byte b : digest) { - hex.append(Character.forDigit((b >> 4) & 0xf, 16)); - hex.append(Character.forDigit(b & 0xf, 16)); - } - return hex.toString(); - } catch (IOException | NoSuchAlgorithmException e) { + return UpdateFileUtils.toHex(md.digest()); + } catch (IOException e) { // Expected when there is no embedded bundle (custom name, no // release bundle); "unknown" is a valid answer, not an error. Log.i(TAG, "Cannot hash bundled asset " + assetName + ": " + e); @@ -406,6 +492,16 @@ private boolean persistEditor(SharedPreferences.Editor editor, String reason) { return true; } + /** + * Asynchronous variant for the getConstants path (JS thread): the + * in-memory state changes immediately, the disk write happens on the + * SharedPreferences writer. Losing it to a crash only re-reports the + * one-shot markers on the next launch. + */ + private static void applyEditor(SharedPreferences.Editor editor) { + editor.apply(); + } + /** * Persistence for operations whose result is promised to a caller * (switchVersion / markSuccess / setLocalHashInfo / the cold-start round): @@ -518,9 +614,9 @@ static final class LaunchMarkers { /** * Reads and clears the first-load marker and the rollback mark in one - * commit. getConstants runs this synchronously on the JS thread (a - * TurboModule constant read), so the two writes are folded into a single - * editor; persistence is best-effort on this launch path. + * editor. getConstants runs this synchronously on the JS thread (a + * TurboModule constant read), so the write is applied asynchronously; + * persistence is best-effort on this launch path. */ LaunchMarkers consumeLaunchMarkers() { LaunchMarkers markers; @@ -543,7 +639,7 @@ LaunchMarkers consumeLaunchMarkers() { ); applyState(editor, nextState); } - persistEditor(editor, "consume launch markers"); + applyEditor(editor); } } if (markers.rolledBackVersion != null) { @@ -606,6 +702,11 @@ private void resetToPackagedBundleLocked() { resetState.firstTime = false; resetState.firstTimeOk = true; String uuid = sp.getString("uuid", null); + // The cached check response still advertises the version this reset + // removes; drop it first so a failure aborts before the state wipe. + if (!clearNativeCheckCache()) { + throw new IllegalStateException("Failed to persist state for reset check cache"); + } SharedPreferences.Editor editor = sp.edit(); editor.clear(); applyState(editor, resetState); @@ -614,8 +715,6 @@ private void resetToPackagedBundleLocked() { } persistEditorOrThrow(editor, "reset to packaged bundle"); ignoreRollback = false; - // editor.clear() above already dropped the cached check response; it - // still advertised the version this reset removed. Log.i(TAG, "Reset to packaged bundle"); DownloadTaskParams params = new DownloadTaskParams(); @@ -721,6 +820,8 @@ public String getBundleUrl(String defaultAssetsUrl) { continue; } launchVersion = currentVersion; + launchBundleSha256 = new String[] { + currentVersion, readBundleSha256(currentVersion)}; syncRolledBackVersion = rolledBackVersion(); return bundleFile.toString(); } @@ -747,15 +848,22 @@ static long getNativeConfigGeneration() { void setNativeConfig(String config) { synchronized (commitLock) { - boolean changed = !config.equals(sp.getString(SyncCoordinator.KEY_CONFIG, null)); + // Canonical comparison (ConfigEquivalence): re-sending the same + // config with another key order or trailing slashes must not + // cancel an in-flight round or drop its cache. + boolean changed = !ConfigEquivalence.equivalent( + config, sp.getString(SyncCoordinator.KEY_CONFIG, null)); SharedPreferences.Editor editor = sp.edit(); if (changed) { // Also invalidate on a failed persistence attempt: an older // round must not commit over uncertain configuration state. resetGeneration.incrementAndGet(); nativeConfigGeneration.incrementAndGet(); - editor.remove(SyncCoordinator.KEY_RESP_CACHE); SyncCoordinator.recordJsRound(null); + if (!clearNativeCheckCache()) { + throw new IllegalStateException( + "Failed to persist state for configuration check cache"); + } } // A native-only first launch needs a stable gray-release identity // before JS initializes. Never replace an existing installation ID. @@ -820,13 +928,20 @@ private boolean commitSyncResultState( if (switching) { applyState(editor, computeSwitchState(hash)); } - if (responseCacheJson != null) { - editor.putString(SyncCoordinator.KEY_RESP_CACHE, responseCacheJson); - } + // The round is over once its result lands: clear the + // interrupted-round breadcrumb in the same commit. + editor.remove(SyncCoordinator.KEY_ROUND_INCOMPLETE); persistEditorOrThrow(editor, "commit sync result"); if (switching) { ignoreRollback = false; } + // Advisory cache, separate file: written after the state it + // describes, still under commitLock so a reset cannot interleave. + if (responseCacheJson != null + && !checkCacheSp.edit() + .putString(SyncCoordinator.KEY_RESP_CACHE, responseCacheJson).commit()) { + Log.w(TAG, "Failed to persist native sync response cache"); + } return true; } } @@ -837,6 +952,14 @@ private boolean commitSyncResultState( * unreadable record. Exposed to JS for crash-report attribution. */ String currentBundleSha256(String hash) { + String[] cached = launchBundleSha256; + if (cached != null && cached[0].equals(hash)) { + return cached[1]; + } + return readBundleSha256(hash); + } + + private String readBundleSha256(String hash) { if (hash == null || hash.isEmpty() || !UpdateFileUtils.isSafePathComponent(hash)) { return ""; } @@ -844,6 +967,23 @@ String currentBundleSha256(String hash) { return record == null ? "" : record.optString("bundleSha256", ""); } + /** Raw native check response for JS (§10.3); null when absent. */ + @Nullable + String getNativeCheckCache() { + return checkCacheSp.getString(SyncCoordinator.KEY_RESP_CACHE, null); + } + + private boolean clearNativeCheckCache() { + if (!checkCacheSp.contains(SyncCoordinator.KEY_RESP_CACHE)) { + return true; + } + if (!checkCacheSp.edit().remove(SyncCoordinator.KEY_RESP_CACHE).commit()) { + Log.e(TAG, "Failed to clear native sync response cache"); + return false; + } + return true; + } + boolean hasCompletedVersion(String hash) { if (!UpdateFileUtils.isSafePathComponent(hash)) { return false; @@ -884,10 +1024,15 @@ static String runningVersion() { } interface CleanupAction { - void run(@Nullable String keepCurrent, @Nullable String keepPrevious); + void run(String[] keepNames); } - /** Resolve keep names immediately before deletion, never when enqueued. */ + /** + * Resolve keep names immediately before the cleanup acts, never when + * enqueued. The action runs under versionFilesLock, so it must stay cheap + * (DownloadTask only renames doomed entries into a trash directory and + * deletes them after this returns). + */ void runCleanupWithLatestState(CleanupAction action) { synchronized (versionFilesLock) { final String[] keepNames; @@ -898,13 +1043,7 @@ void runCleanupWithLatestState(CleanupAction action) { launchVersion ); } - if (keepNames == null) { - // JNI has only two keep slots; three distinct live versions are - // safer to defer until the next process than to delete one. - Log.i(TAG, "Skipping cleanup: three live versions need protection"); - return; - } - action.run(keepNames[0], keepNames[1]); + action.run(keepNames); } } diff --git a/android/src/main/java/cn/reactnative/modules/update/UpdateFileUtils.java b/android/src/main/java/cn/reactnative/modules/update/UpdateFileUtils.java index 936f28cc..f285b1a4 100644 --- a/android/src/main/java/cn/reactnative/modules/update/UpdateFileUtils.java +++ b/android/src/main/java/cn/reactnative/modules/update/UpdateFileUtils.java @@ -7,7 +7,7 @@ import java.io.InputStream; final class UpdateFileUtils { - private static final int BUFFER_SIZE = 8192; + private static final int BUFFER_SIZE = 64 * 1024; private UpdateFileUtils() { } @@ -57,27 +57,41 @@ static void removeDirectory(File file) throws IOException { } } - /** Streaming SHA-256 of a file, lowercase hex. */ - static String sha256Hex(File file) throws IOException { + static java.security.MessageDigest newSha256() throws IOException { + try { + return java.security.MessageDigest.getInstance("SHA-256"); + } catch (java.security.NoSuchAlgorithmException e) { + throw new IOException("SHA-256 unavailable", e); + } + } + + /** Feeds the whole file into digest (streaming, bounded memory). */ + static void updateDigest(java.security.MessageDigest digest, File file) throws IOException { try (InputStream in = new FileInputStream(file)) { - java.security.MessageDigest md = java.security.MessageDigest.getInstance("SHA-256"); - byte[] buffer = new byte[64 * 1024]; + byte[] buffer = new byte[BUFFER_SIZE]; int read; while ((read = in.read(buffer)) != -1) { - md.update(buffer, 0, read); + digest.update(buffer, 0, read); } - byte[] digest = md.digest(); - StringBuilder hex = new StringBuilder(digest.length * 2); - for (byte b : digest) { - hex.append(Character.forDigit((b >> 4) & 0xf, 16)); - hex.append(Character.forDigit(b & 0xf, 16)); - } - return hex.toString(); - } catch (java.security.NoSuchAlgorithmException e) { - throw new IOException("SHA-256 unavailable", e); } } + static String toHex(byte[] digest) { + StringBuilder hex = new StringBuilder(digest.length * 2); + for (byte b : digest) { + hex.append(Character.forDigit((b >> 4) & 0xf, 16)); + hex.append(Character.forDigit(b & 0xf, 16)); + } + return hex.toString(); + } + + /** Streaming SHA-256 of a file, lowercase hex. */ + static String sha256Hex(File file) throws IOException { + java.security.MessageDigest md = newSha256(); + updateDigest(md, file); + return toHex(md.digest()); + } + static void copyFile(File from, File to) throws IOException { ensureParentDirectory(to); try ( diff --git a/android/src/main/java/cn/reactnative/modules/update/UpdateModuleImpl.java b/android/src/main/java/cn/reactnative/modules/update/UpdateModuleImpl.java index 0c38f882..f57c6615 100644 --- a/android/src/main/java/cn/reactnative/modules/update/UpdateModuleImpl.java +++ b/android/src/main/java/cn/reactnative/modules/update/UpdateModuleImpl.java @@ -45,6 +45,8 @@ public Map getConstants() { String currentVersion = updateContext.getCurrentVersion(); constants.put("currentVersion", currentVersion); constants.put("currentVersionInfo", updateContext.getKv("hash_" + currentVersion)); + // Served from the value cached at launch resolution (no file read on + // the JS thread) when currentVersion is the launched version. constants.put("currentBundleSha256", updateContext.currentBundleSha256(currentVersion)); constants.put("buildTime", updateContext.getBuildTime()); constants.put("isUsingBundleUrl", updateContext.getIsUsingBundleUrl()); @@ -284,7 +286,7 @@ public void run() { } public void getBundleHash(final Promise promise) { - // Threading lives in UpdateContext (download executor); resolve is + // Threading lives in UpdateContext (its bundle-hash thread); resolve is // thread-safe. Never rejects — empty string means "unknown". updateContext.getBundleHash(new UpdateContext.BundleHashListener() { @Override @@ -310,7 +312,7 @@ public void run() { * rejects. */ public void getNativeCheckCache(final Promise promise) { - String cached = updateContext.getKv(SyncCoordinator.KEY_RESP_CACHE); + String cached = updateContext.getNativeCheckCache(); promise.resolve(cached == null ? "" : cached); } diff --git a/android/src/test/java/cn/reactnative/modules/update/CleanupKeepNamesTest.java b/android/src/test/java/cn/reactnative/modules/update/CleanupKeepNamesTest.java index deb7504e..0da81464 100644 --- a/android/src/test/java/cn/reactnative/modules/update/CleanupKeepNamesTest.java +++ b/android/src/test/java/cn/reactnative/modules/update/CleanupKeepNamesTest.java @@ -1,22 +1,23 @@ package cn.reactnative.modules.update; import static org.junit.Assert.assertArrayEquals; -import static org.junit.Assert.assertNull; import org.junit.Test; public class CleanupKeepNamesTest { @Test - public void fillsAvailableSlotsAndDeduplicates() { + public void keepsDistinctNonEmptyNamesInOrder() { assertArrayEquals(new String[] {"current", "running"}, CleanupKeepNames.select("current", null, "running")); assertArrayEquals(new String[] {"same", "running"}, CleanupKeepNames.select("same", "same", "running")); - assertArrayEquals(new String[] {"running", null}, - CleanupKeepNames.select(null, null, "running")); + assertArrayEquals(new String[] {"running"}, + CleanupKeepNames.select(null, "", "running")); + assertArrayEquals(new String[0], CleanupKeepNames.select(null, null, null)); } @Test - public void defersWhenThreeDistinctVersionsNeedProtection() { - assertNull(CleanupKeepNames.select("current", "previous", "running")); + public void protectsThreeDistinctLiveVersions() { + assertArrayEquals(new String[] {"current", "previous", "running"}, + CleanupKeepNames.select("current", "previous", "running")); } } diff --git a/android/src/test/java/cn/reactnative/modules/update/ConfigEquivalenceTest.java b/android/src/test/java/cn/reactnative/modules/update/ConfigEquivalenceTest.java new file mode 100644 index 00000000..6508947b --- /dev/null +++ b/android/src/test/java/cn/reactnative/modules/update/ConfigEquivalenceTest.java @@ -0,0 +1,63 @@ +package cn.reactnative.modules.update; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import org.json.JSONArray; +import org.json.JSONObject; +import org.junit.Test; + +public class ConfigEquivalenceTest { + private static final String VECTORS = + "cpp/update_flow_core/tests/config-equivalence-vectors.json"; + + // The shared C1 vectors live at the package root (next to android/). Gradle + // runs unit tests from the module directory, which is android/ in this repo + // and node_modules/react-native-update/android in a host app; walk up from + // there so both layouts (and symlinked copies) resolve. + private static File vectorsFile() { + String override = System.getProperty("pushy.configVectors"); + if (override != null) { + return new File(override); + } + for (File dir = new File(System.getProperty("user.dir")).getAbsoluteFile(); + dir != null; dir = dir.getParentFile()) { + File candidate = new File(dir, VECTORS); + if (candidate.isFile()) { + return candidate; + } + } + return null; + } + + @Test + public void sharedVectors() throws Exception { + File file = vectorsFile(); + assertNotNull("cannot locate " + VECTORS + " above " + System.getProperty("user.dir"), file); + JSONArray cases = new JSONObject( + new String(Files.readAllBytes(file.toPath()), StandardCharsets.UTF_8)) + .getJSONArray("cases"); + assertTrue("vector file has cases", cases.length() >= 30); + for (int i = 0; i < cases.length(); i++) { + JSONObject vector = cases.getJSONObject(i); + String name = vector.getString("name"); + String a = vector.getString("a"); + String b = vector.getString("b"); + boolean equivalent = vector.getBoolean("equivalent"); + assertEquals(name, equivalent, ConfigEquivalence.equivalent(a, b)); + assertEquals(name + " (reversed)", equivalent, ConfigEquivalence.equivalent(b, a)); + } + } + + @Test + public void missingConfigIsNeverEquivalent() { + assertFalse(ConfigEquivalence.equivalent(null, null)); + assertFalse(ConfigEquivalence.equivalent("{\"appKey\":\"k\"}", null)); + assertFalse(ConfigEquivalence.equivalent(null, "{\"appKey\":\"k\"}")); + } +} diff --git a/babel.config.js b/babel.config.js deleted file mode 100644 index f7b3da3b..00000000 --- a/babel.config.js +++ /dev/null @@ -1,3 +0,0 @@ -module.exports = { - presets: ['module:@react-native/babel-preset'], -}; diff --git a/bun.lock b/bun.lock index 83f37990..5076326b 100644 --- a/bun.lock +++ b/bun.lock @@ -4,26 +4,21 @@ "workspaces": { "": { "name": "react-native-update", - "dependencies": { - "nanoid": "^3.3.3", - }, "devDependencies": { - "@babel/core": "^7.25.8", "@biomejs/biome": "^2.5.5", - "@react-native/babel-preset": "^0.73.21", - "@react-native/typescript-config": "0.84.1", + "@react-native/typescript-config": "0.85.2", "@types/bun": "^1.3.11", "@types/node": "^25.5.0", "@types/react": "^19.2.14", - "@types/react-test-renderer": "18.0.7", - "react": "18.2.0", - "react-native": "0.73", - "react-test-renderer": "18.2.0", + "@types/react-test-renderer": "19.1.0", + "react": "19.2.3", + "react-native": "0.85.2", + "react-test-renderer": "19.2.3", "typescript": "^7.0.2", }, "peerDependencies": { "react": ">=16.8.0", - "react-native": ">=0.59.0", + "react-native": "*", }, }, }, @@ -38,233 +33,31 @@ "@babel/generator": ["@babel/generator@7.27.0", "", { "dependencies": { "@babel/parser": "^7.27.0", "@babel/types": "^7.27.0", "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.25", "jsesc": "^3.0.2" } }, "sha512-VybsKvpiN1gU1sdMZIp7FcqphVVKEwcuj02x73uvcHE0PTihx1nlBcowYWhDwjpoAXRv43+gDzyggGnn1XZhVw=="], - "@babel/helper-annotate-as-pure": ["@babel/helper-annotate-as-pure@7.25.9", "", { "dependencies": { "@babel/types": "^7.25.9" } }, "sha512-gv7320KBUFJz1RnylIg5WWYPRXKZ884AGkYpgpWW02TH66Dl+HaC1t1CKd0z3R4b6hdYEcmrNZHUmfCP+1u3/g=="], - "@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.27.0", "", { "dependencies": { "@babel/compat-data": "^7.26.8", "@babel/helper-validator-option": "^7.25.9", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-LVk7fbXml0H2xH34dFzKQ7TDZ2G4/rVTOrq9V+icbbadjbVxxeFeDsNHv2SrZeWoA+6ZiTyWYWtScEIW07EAcA=="], - "@babel/helper-create-class-features-plugin": ["@babel/helper-create-class-features-plugin@7.27.0", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.25.9", "@babel/helper-member-expression-to-functions": "^7.25.9", "@babel/helper-optimise-call-expression": "^7.25.9", "@babel/helper-replace-supers": "^7.26.5", "@babel/helper-skip-transparent-expression-wrappers": "^7.25.9", "@babel/traverse": "^7.27.0", "semver": "^6.3.1" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-vSGCvMecvFCd/BdpGlhpXYNhhC4ccxyvQWpbGL4CWbvfEoLFWUZuSuf7s9Aw70flgQF+6vptvgK2IfOnKlRmBg=="], - - "@babel/helper-create-regexp-features-plugin": ["@babel/helper-create-regexp-features-plugin@7.27.0", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.25.9", "regexpu-core": "^6.2.0", "semver": "^6.3.1" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-fO8l08T76v48BhpNRW/nQ0MxfnSdoSKUJBMjubOAYffsVuGG5qOfMq7N6Es7UJvi7Y8goXXo07EfcHZXDPuELQ=="], - - "@babel/helper-define-polyfill-provider": ["@babel/helper-define-polyfill-provider@0.6.4", "", { "dependencies": { "@babel/helper-compilation-targets": "^7.22.6", "@babel/helper-plugin-utils": "^7.22.5", "debug": "^4.1.1", "lodash.debounce": "^4.0.8", "resolve": "^1.14.2" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, "sha512-jljfR1rGnXXNWnmQg2K3+bvhkxB51Rl32QRaOTuwwjviGrHzIbSc8+x9CpraDtbT7mfyjXObULP4w/adunNwAw=="], - - "@babel/helper-environment-visitor": ["@babel/helper-environment-visitor@7.24.7", "", { "dependencies": { "@babel/types": "^7.24.7" } }, "sha512-DoiN84+4Gnd0ncbBOM9AZENV4a5ZiL39HYMyZJGZ/AZEykHYdJw0wW3kdcsh9/Kn+BRXHLkkklZ51ecPKmI1CQ=="], - - "@babel/helper-member-expression-to-functions": ["@babel/helper-member-expression-to-functions@7.25.9", "", { "dependencies": { "@babel/traverse": "^7.25.9", "@babel/types": "^7.25.9" } }, "sha512-wbfdZ9w5vk0C0oyHqAJbc62+vet5prjj01jjJ8sKn3j9h3MQQlflEdXYvuqRWjHnM12coDEqiC1IRCi0U/EKwQ=="], + "@babel/helper-globals": ["@babel/helper-globals@7.29.7", "", {}, "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA=="], "@babel/helper-module-imports": ["@babel/helper-module-imports@7.25.9", "", { "dependencies": { "@babel/traverse": "^7.25.9", "@babel/types": "^7.25.9" } }, "sha512-tnUA4RsrmflIM6W6RFTLFSXITtl0wKjgpnLgXyowocVPrbYrLUXSBXDgTs8BlbmIzIdlBySRQjINYs2BAkiLtw=="], "@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.26.0", "", { "dependencies": { "@babel/helper-module-imports": "^7.25.9", "@babel/helper-validator-identifier": "^7.25.9", "@babel/traverse": "^7.25.9" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-xO+xu6B5K2czEnQye6BHA7DolFFmS3LB7stHZFaOLb1pAwO1HWLS8fXA+eh0A2yIvltPVmx3eNNDBJA2SLHXFw=="], - "@babel/helper-optimise-call-expression": ["@babel/helper-optimise-call-expression@7.25.9", "", { "dependencies": { "@babel/types": "^7.25.9" } }, "sha512-FIpuNaz5ow8VyrYcnXQTDRGvV6tTjkNtCK/RYNDXGSLlUD6cBuQTSw43CShGxjvfBTfcUA/r6UhUCbtYqkhcuQ=="], - - "@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.26.5", "", {}, "sha512-RS+jZcRdZdRFzMyr+wcsaqOmld1/EqTghfaBGQQd/WnRdzdlvSZ//kF7U8VQTxf1ynZ4cjUcYgjVGx13ewNPMg=="], - - "@babel/helper-remap-async-to-generator": ["@babel/helper-remap-async-to-generator@7.25.9", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.25.9", "@babel/helper-wrap-function": "^7.25.9", "@babel/traverse": "^7.25.9" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-IZtukuUeBbhgOcaW2s06OXTzVNJR0ybm4W5xC1opWFFJMZbwRj5LCk+ByYH7WdZPZTt8KnFwA8pvjN2yqcPlgw=="], - - "@babel/helper-replace-supers": ["@babel/helper-replace-supers@7.26.5", "", { "dependencies": { "@babel/helper-member-expression-to-functions": "^7.25.9", "@babel/helper-optimise-call-expression": "^7.25.9", "@babel/traverse": "^7.26.5" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-bJ6iIVdYX1YooY2X7w1q6VITt+LnUILtNk7zT78ykuwStx8BauCzxvFqFaHjOpW1bVnSUM1PN1f0p5P21wHxvg=="], - - "@babel/helper-skip-transparent-expression-wrappers": ["@babel/helper-skip-transparent-expression-wrappers@7.25.9", "", { "dependencies": { "@babel/traverse": "^7.25.9", "@babel/types": "^7.25.9" } }, "sha512-K4Du3BFa3gvyhzgPcntrkDgZzQaq6uozzcpGbOO1OEJaI+EJdqWIMTLgFgQf6lrfiDFo5FU+BxKepI9RmZqahA=="], - - "@babel/helper-string-parser": ["@babel/helper-string-parser@7.25.9", "", {}, "sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA=="], + "@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="], - "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.25.9", "", {}, "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ=="], + "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], "@babel/helper-validator-option": ["@babel/helper-validator-option@7.25.9", "", {}, "sha512-e/zv1co8pp55dNdEcCynfj9X7nyUKUXoUEwfXqaZt0omVOmDe9oOTdKStH4GmAw6zxMFs50ZayuMfHDKlO7Tfw=="], - "@babel/helper-wrap-function": ["@babel/helper-wrap-function@7.25.9", "", { "dependencies": { "@babel/template": "^7.25.9", "@babel/traverse": "^7.25.9", "@babel/types": "^7.25.9" } }, "sha512-ETzz9UTjQSTmw39GboatdymDq4XIQbR8ySgVrylRhPOFpsd+JrKHIuF0de7GCWmem+T4uC5z7EZguod7Wj4A4g=="], - "@babel/helpers": ["@babel/helpers@7.27.0", "", { "dependencies": { "@babel/template": "^7.27.0", "@babel/types": "^7.27.0" } }, "sha512-U5eyP/CTFPuNE3qk+WZMxFkp/4zUzdceQlfzf7DdGdhp+Fezd7HD+i8Y24ZuTMKX3wQBld449jijbGq6OdGNQg=="], - "@babel/parser": ["@babel/parser@7.27.0", "", { "dependencies": { "@babel/types": "^7.27.0" }, "bin": "./bin/babel-parser.js" }, "sha512-iaepho73/2Pz7w2eMS0Q5f83+0RKI7i4xmiYeBmDzfRVbQtTOG7Ts0S4HzJVsTMGI9keU8rNfuZr8DKfSt7Yyg=="], - - "@babel/plugin-bugfix-firefox-class-in-computed-class-key": ["@babel/plugin-bugfix-firefox-class-in-computed-class-key@7.25.9", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.25.9", "@babel/traverse": "^7.25.9" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-ZkRyVkThtxQ/J6nv3JFYv1RYY+JT5BvU0y3k5bWrmuG4woXypRa4PXmm9RhOwodRkYFWqC0C0cqcJ4OqR7kW+g=="], - - "@babel/plugin-bugfix-safari-class-field-initializer-scope": ["@babel/plugin-bugfix-safari-class-field-initializer-scope@7.25.9", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-MrGRLZxLD/Zjj0gdU15dfs+HH/OXvnw/U4jJD8vpcP2CJQapPEv1IWwjc/qMg7ItBlPwSv1hRBbb7LeuANdcnw=="], - - "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": ["@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@7.25.9", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-2qUwwfAFpJLZqxd02YW9btUCZHl+RFvdDkNfZwaIJrvB8Tesjsk8pEQkTvGwZXLqXUx/2oyY3ySRhm6HOXuCug=="], - - "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": ["@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining@7.25.9", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.25.9", "@babel/helper-skip-transparent-expression-wrappers": "^7.25.9", "@babel/plugin-transform-optional-chaining": "^7.25.9" }, "peerDependencies": { "@babel/core": "^7.13.0" } }, "sha512-6xWgLZTJXwilVjlnV7ospI3xi+sl8lN8rXXbBD6vYn3UYDlGsag8wrZkKcSI8G6KgqKP7vNFaDgeDnfAABq61g=="], - - "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": ["@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly@7.25.9", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.25.9", "@babel/traverse": "^7.25.9" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-aLnMXYPnzwwqhYSCyXfKkIkYgJ8zv9RK+roo9DkTXz38ynIhd9XCbN08s3MGvqL2MYGVUGdRQLL/JqBIeJhJBg=="], - - "@babel/plugin-proposal-async-generator-functions": ["@babel/plugin-proposal-async-generator-functions@7.20.7", "", { "dependencies": { "@babel/helper-environment-visitor": "^7.18.9", "@babel/helper-plugin-utils": "^7.20.2", "@babel/helper-remap-async-to-generator": "^7.18.9", "@babel/plugin-syntax-async-generators": "^7.8.4" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-xMbiLsn/8RK7Wq7VeVytytS2L6qE69bXPB10YCmMdDZbKF4okCqY74pI/jJQ/8U0b/F6NrT2+14b8/P9/3AMGA=="], - - "@babel/plugin-proposal-class-properties": ["@babel/plugin-proposal-class-properties@7.18.6", "", { "dependencies": { "@babel/helper-create-class-features-plugin": "^7.18.6", "@babel/helper-plugin-utils": "^7.18.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-cumfXOF0+nzZrrN8Rf0t7M+tF6sZc7vhQwYQck9q1/5w2OExlD+b4v4RpMJFaV1Z7WcDRgO6FqvxqxGlwo+RHQ=="], - - "@babel/plugin-proposal-export-default-from": ["@babel/plugin-proposal-export-default-from@7.25.9", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-ykqgwNfSnNOB+C8fV5X4mG3AVmvu+WVxcaU9xHHtBb7PCrPeweMmPjGsn8eMaeJg6SJuoUuZENeeSWaarWqonQ=="], - - "@babel/plugin-proposal-nullish-coalescing-operator": ["@babel/plugin-proposal-nullish-coalescing-operator@7.18.6", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.18.6", "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-wQxQzxYeJqHcfppzBDnm1yAY0jSRkUXR2z8RePZYrKwMKgMlE8+Z6LUno+bd6LvbGh8Gltvy74+9pIYkr+XkKA=="], - - "@babel/plugin-proposal-numeric-separator": ["@babel/plugin-proposal-numeric-separator@7.18.6", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.18.6", "@babel/plugin-syntax-numeric-separator": "^7.10.4" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-ozlZFogPqoLm8WBr5Z8UckIoE4YQ5KESVcNudyXOR8uqIkliTEgJ3RoketfG6pmzLdeZF0H/wjE9/cCEitBl7Q=="], - - "@babel/plugin-proposal-object-rest-spread": ["@babel/plugin-proposal-object-rest-spread@7.20.7", "", { "dependencies": { "@babel/compat-data": "^7.20.5", "@babel/helper-compilation-targets": "^7.20.7", "@babel/helper-plugin-utils": "^7.20.2", "@babel/plugin-syntax-object-rest-spread": "^7.8.3", "@babel/plugin-transform-parameters": "^7.20.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-d2S98yCiLxDVmBmE8UjGcfPvNEUbA1U5q5WxaWFUGRzJSVAZqm5W6MbPct0jxnegUZ0niLeNX+IOzEs7wYg9Dg=="], - - "@babel/plugin-proposal-optional-catch-binding": ["@babel/plugin-proposal-optional-catch-binding@7.18.6", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.18.6", "@babel/plugin-syntax-optional-catch-binding": "^7.8.3" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-Q40HEhs9DJQyaZfUjjn6vE8Cv4GmMHCYuMGIWUnlxH6400VGxOuwWsPt4FxXxJkC/5eOzgn0z21M9gMT4MOhbw=="], - - "@babel/plugin-proposal-optional-chaining": ["@babel/plugin-proposal-optional-chaining@7.21.0", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.20.2", "@babel/helper-skip-transparent-expression-wrappers": "^7.20.0", "@babel/plugin-syntax-optional-chaining": "^7.8.3" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-p4zeefM72gpmEe2fkUr/OnOXpWEf8nAgk7ZYVqqfFiyIG7oFfVZcCrU64hWn5xp4tQ9LkV4bTIa5rD0KANpKNA=="], - - "@babel/plugin-proposal-private-property-in-object": ["@babel/plugin-proposal-private-property-in-object@7.21.0-placeholder-for-preset-env.2", "", { "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-SOSkfJDddaM7mak6cPEpswyTRnuRltl429hMraQEglW+OkovnCzsiszTmsrlY//qLFjCpQDFRvjdm2wA5pPm9w=="], - - "@babel/plugin-syntax-async-generators": ["@babel/plugin-syntax-async-generators@7.8.4", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.8.0" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw=="], - - "@babel/plugin-syntax-dynamic-import": ["@babel/plugin-syntax-dynamic-import@7.8.3", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.8.0" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-5gdGbFon+PszYzqs83S3E5mpi7/y/8M9eC90MRTZfduQOYW76ig6SOSPNe41IG5LoP3FGBn2N0RjVDSQiS94kQ=="], - - "@babel/plugin-syntax-export-default-from": ["@babel/plugin-syntax-export-default-from@7.25.9", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-9MhJ/SMTsVqsd69GyQg89lYR4o9T+oDGv5F6IsigxxqFVOyR/IflDLYP8WDI1l8fkhNGGktqkvL5qwNCtGEpgQ=="], - - "@babel/plugin-syntax-flow": ["@babel/plugin-syntax-flow@7.26.0", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-B+O2DnPc0iG+YXFqOxv2WNuNU97ToWjOomUQ78DouOENWUaM5sVrmet9mcomUGQFwpJd//gvUagXBSdzO1fRKg=="], - - "@babel/plugin-syntax-import-assertions": ["@babel/plugin-syntax-import-assertions@7.26.0", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-QCWT5Hh830hK5EQa7XzuqIkQU9tT/whqbDz7kuaZMHFl1inRRg7JnuAEOQ0Ur0QUl0NufCk1msK2BeY79Aj/eg=="], - - "@babel/plugin-syntax-import-attributes": ["@babel/plugin-syntax-import-attributes@7.26.0", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-e2dttdsJ1ZTpi3B9UYGLw41hifAubg19AtCu/2I/F1QNVclOBr1dYpTdmdyZ84Xiz43BS/tCUkMAZNLv12Pi+A=="], - - "@babel/plugin-syntax-jsx": ["@babel/plugin-syntax-jsx@7.25.9", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-ld6oezHQMZsZfp6pWtbjaNDF2tiiCYYDqQszHt5VV437lewP9aSi2Of99CK0D0XB21k7FLgnLcmQKyKzynfeAA=="], - - "@babel/plugin-syntax-nullish-coalescing-operator": ["@babel/plugin-syntax-nullish-coalescing-operator@7.8.3", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.8.0" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ=="], - - "@babel/plugin-syntax-numeric-separator": ["@babel/plugin-syntax-numeric-separator@7.10.4", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.10.4" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug=="], - - "@babel/plugin-syntax-object-rest-spread": ["@babel/plugin-syntax-object-rest-spread@7.8.3", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.8.0" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA=="], - - "@babel/plugin-syntax-optional-catch-binding": ["@babel/plugin-syntax-optional-catch-binding@7.8.3", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.8.0" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q=="], - - "@babel/plugin-syntax-optional-chaining": ["@babel/plugin-syntax-optional-chaining@7.8.3", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.8.0" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg=="], - - "@babel/plugin-syntax-typescript": ["@babel/plugin-syntax-typescript@7.25.9", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-hjMgRy5hb8uJJjUcdWunWVcoi9bGpJp8p5Ol1229PoN6aytsLwNMgmdftO23wnCLMfVmTwZDWMPNq/D1SY60JQ=="], - - "@babel/plugin-syntax-unicode-sets-regex": ["@babel/plugin-syntax-unicode-sets-regex@7.18.6", "", { "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.18.6", "@babel/helper-plugin-utils": "^7.18.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-727YkEAPwSIQTv5im8QHz3upqp92JTWhidIC81Tdx4VJYIte/VndKf1qKrfnnhPLiPghStWfvC/iFaMCQu7Nqg=="], - - "@babel/plugin-transform-arrow-functions": ["@babel/plugin-transform-arrow-functions@7.25.9", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-6jmooXYIwn9ca5/RylZADJ+EnSxVUS5sjeJ9UPk6RWRzXCmOJCy6dqItPJFpw2cuCangPK4OYr5uhGKcmrm5Qg=="], - - "@babel/plugin-transform-async-generator-functions": ["@babel/plugin-transform-async-generator-functions@7.26.8", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.26.5", "@babel/helper-remap-async-to-generator": "^7.25.9", "@babel/traverse": "^7.26.8" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-He9Ej2X7tNf2zdKMAGOsmg2MrFc+hfoAhd3po4cWfo/NWjzEAKa0oQruj1ROVUdl0e6fb6/kE/G3SSxE0lRJOg=="], - - "@babel/plugin-transform-async-to-generator": ["@babel/plugin-transform-async-to-generator@7.25.9", "", { "dependencies": { "@babel/helper-module-imports": "^7.25.9", "@babel/helper-plugin-utils": "^7.25.9", "@babel/helper-remap-async-to-generator": "^7.25.9" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-NT7Ejn7Z/LjUH0Gv5KsBCxh7BH3fbLTV0ptHvpeMvrt3cPThHfJfst9Wrb7S8EvJ7vRTFI7z+VAvFVEQn/m5zQ=="], - - "@babel/plugin-transform-block-scoped-functions": ["@babel/plugin-transform-block-scoped-functions@7.26.5", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.26.5" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-chuTSY+hq09+/f5lMj8ZSYgCFpppV2CbYrhNFJ1BFoXpiWPnnAb7R0MqrafCpN8E1+YRrtM1MXZHJdIx8B6rMQ=="], - - "@babel/plugin-transform-block-scoping": ["@babel/plugin-transform-block-scoping@7.27.0", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.26.5" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-u1jGphZ8uDI2Pj/HJj6YQ6XQLZCNjOlprjxB5SVz6rq2T6SwAR+CdrWK0CP7F+9rDVMXdB0+r6Am5G5aobOjAQ=="], - - "@babel/plugin-transform-class-properties": ["@babel/plugin-transform-class-properties@7.25.9", "", { "dependencies": { "@babel/helper-create-class-features-plugin": "^7.25.9", "@babel/helper-plugin-utils": "^7.25.9" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-bbMAII8GRSkcd0h0b4X+36GksxuheLFjP65ul9w6C3KgAamI3JqErNgSrosX6ZPj+Mpim5VvEbawXxJCyEUV3Q=="], - - "@babel/plugin-transform-class-static-block": ["@babel/plugin-transform-class-static-block@7.26.0", "", { "dependencies": { "@babel/helper-create-class-features-plugin": "^7.25.9", "@babel/helper-plugin-utils": "^7.25.9" }, "peerDependencies": { "@babel/core": "^7.12.0" } }, "sha512-6J2APTs7BDDm+UMqP1useWqhcRAXo0WIoVj26N7kPFB6S73Lgvyka4KTZYIxtgYXiN5HTyRObA72N2iu628iTQ=="], - - "@babel/plugin-transform-classes": ["@babel/plugin-transform-classes@7.25.9", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.25.9", "@babel/helper-compilation-targets": "^7.25.9", "@babel/helper-plugin-utils": "^7.25.9", "@babel/helper-replace-supers": "^7.25.9", "@babel/traverse": "^7.25.9", "globals": "^11.1.0" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-mD8APIXmseE7oZvZgGABDyM34GUmK45Um2TXiBUt7PnuAxrgoSVf123qUzPxEr/+/BHrRn5NMZCdE2m/1F8DGg=="], - - "@babel/plugin-transform-computed-properties": ["@babel/plugin-transform-computed-properties@7.25.9", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.25.9", "@babel/template": "^7.25.9" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-HnBegGqXZR12xbcTHlJ9HGxw1OniltT26J5YpfruGqtUHlz/xKf/G2ak9e+t0rVqrjXa9WOhvYPz1ERfMj23AA=="], - - "@babel/plugin-transform-destructuring": ["@babel/plugin-transform-destructuring@7.25.9", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-WkCGb/3ZxXepmMiX101nnGiU+1CAdut8oHyEOHxkKuS1qKpU2SMXE2uSvfz8PBuLd49V6LEsbtyPhWC7fnkgvQ=="], - - "@babel/plugin-transform-dotall-regex": ["@babel/plugin-transform-dotall-regex@7.25.9", "", { "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.25.9", "@babel/helper-plugin-utils": "^7.25.9" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-t7ZQ7g5trIgSRYhI9pIJtRl64KHotutUJsh4Eze5l7olJv+mRSg4/MmbZ0tv1eeqRbdvo/+trvJD/Oc5DmW2cA=="], - - "@babel/plugin-transform-duplicate-keys": ["@babel/plugin-transform-duplicate-keys@7.25.9", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-LZxhJ6dvBb/f3x8xwWIuyiAHy56nrRG3PeYTpBkkzkYRRQ6tJLu68lEF5VIqMUZiAV7a8+Tb78nEoMCMcqjXBw=="], - - "@babel/plugin-transform-duplicate-named-capturing-groups-regex": ["@babel/plugin-transform-duplicate-named-capturing-groups-regex@7.25.9", "", { "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.25.9", "@babel/helper-plugin-utils": "^7.25.9" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-0UfuJS0EsXbRvKnwcLjFtJy/Sxc5J5jhLHnFhy7u4zih97Hz6tJkLU+O+FMMrNZrosUPxDi6sYxJ/EA8jDiAog=="], - - "@babel/plugin-transform-dynamic-import": ["@babel/plugin-transform-dynamic-import@7.25.9", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-GCggjexbmSLaFhqsojeugBpeaRIgWNTcgKVq/0qIteFEqY2A+b9QidYadrWlnbWQUrW5fn+mCvf3tr7OeBFTyg=="], - - "@babel/plugin-transform-exponentiation-operator": ["@babel/plugin-transform-exponentiation-operator@7.26.3", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-7CAHcQ58z2chuXPWblnn1K6rLDnDWieghSOEmqQsrBenH0P9InCUtOJYD89pvngljmZlJcz3fcmgYsXFNGa1ZQ=="], - - "@babel/plugin-transform-export-namespace-from": ["@babel/plugin-transform-export-namespace-from@7.25.9", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-2NsEz+CxzJIVOPx2o9UsW1rXLqtChtLoVnwYHHiB04wS5sgn7mrV45fWMBX0Kk+ub9uXytVYfNP2HjbVbCB3Ww=="], - - "@babel/plugin-transform-flow-strip-types": ["@babel/plugin-transform-flow-strip-types@7.26.5", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.26.5", "@babel/plugin-syntax-flow": "^7.26.0" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-eGK26RsbIkYUns3Y8qKl362juDDYK+wEdPGHGrhzUl6CewZFo55VZ7hg+CyMFU4dd5QQakBN86nBMpRsFpRvbQ=="], - - "@babel/plugin-transform-for-of": ["@babel/plugin-transform-for-of@7.26.9", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.26.5", "@babel/helper-skip-transparent-expression-wrappers": "^7.25.9" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-Hry8AusVm8LW5BVFgiyUReuoGzPUpdHQQqJY5bZnbbf+ngOHWuCuYFKw/BqaaWlvEUrF91HMhDtEaI1hZzNbLg=="], - - "@babel/plugin-transform-function-name": ["@babel/plugin-transform-function-name@7.25.9", "", { "dependencies": { "@babel/helper-compilation-targets": "^7.25.9", "@babel/helper-plugin-utils": "^7.25.9", "@babel/traverse": "^7.25.9" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-8lP+Yxjv14Vc5MuWBpJsoUCd3hD6V9DgBon2FVYL4jJgbnVQ9fTgYmonchzZJOVNgzEgbxp4OwAf6xz6M/14XA=="], - - "@babel/plugin-transform-json-strings": ["@babel/plugin-transform-json-strings@7.25.9", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-xoTMk0WXceiiIvsaquQQUaLLXSW1KJ159KP87VilruQm0LNNGxWzahxSS6T6i4Zg3ezp4vA4zuwiNUR53qmQAw=="], - - "@babel/plugin-transform-literals": ["@babel/plugin-transform-literals@7.25.9", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-9N7+2lFziW8W9pBl2TzaNht3+pgMIRP74zizeCSrtnSKVdUl8mAjjOP2OOVQAfZ881P2cNjDj1uAMEdeD50nuQ=="], - - "@babel/plugin-transform-logical-assignment-operators": ["@babel/plugin-transform-logical-assignment-operators@7.25.9", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-wI4wRAzGko551Y8eVf6iOY9EouIDTtPb0ByZx+ktDGHwv6bHFimrgJM/2T021txPZ2s4c7bqvHbd+vXG6K948Q=="], - - "@babel/plugin-transform-member-expression-literals": ["@babel/plugin-transform-member-expression-literals@7.25.9", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-PYazBVfofCQkkMzh2P6IdIUaCEWni3iYEerAsRWuVd8+jlM1S9S9cz1dF9hIzyoZ8IA3+OwVYIp9v9e+GbgZhA=="], - - "@babel/plugin-transform-modules-amd": ["@babel/plugin-transform-modules-amd@7.25.9", "", { "dependencies": { "@babel/helper-module-transforms": "^7.25.9", "@babel/helper-plugin-utils": "^7.25.9" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-g5T11tnI36jVClQlMlt4qKDLlWnG5pP9CSM4GhdRciTNMRgkfpo5cR6b4rGIOYPgRRuFAvwjPQ/Yk+ql4dyhbw=="], - - "@babel/plugin-transform-modules-commonjs": ["@babel/plugin-transform-modules-commonjs@7.26.3", "", { "dependencies": { "@babel/helper-module-transforms": "^7.26.0", "@babel/helper-plugin-utils": "^7.25.9" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-MgR55l4q9KddUDITEzEFYn5ZsGDXMSsU9E+kh7fjRXTIC3RHqfCo8RPRbyReYJh44HQ/yomFkqbOFohXvDCiIQ=="], - - "@babel/plugin-transform-modules-systemjs": ["@babel/plugin-transform-modules-systemjs@7.25.9", "", { "dependencies": { "@babel/helper-module-transforms": "^7.25.9", "@babel/helper-plugin-utils": "^7.25.9", "@babel/helper-validator-identifier": "^7.25.9", "@babel/traverse": "^7.25.9" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-hyss7iIlH/zLHaehT+xwiymtPOpsiwIIRlCAOwBB04ta5Tt+lNItADdlXw3jAWZ96VJ2jlhl/c+PNIQPKNfvcA=="], - - "@babel/plugin-transform-modules-umd": ["@babel/plugin-transform-modules-umd@7.25.9", "", { "dependencies": { "@babel/helper-module-transforms": "^7.25.9", "@babel/helper-plugin-utils": "^7.25.9" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-bS9MVObUgE7ww36HEfwe6g9WakQ0KF07mQF74uuXdkoziUPfKyu/nIm663kz//e5O1nPInPFx36z7WJmJ4yNEw=="], - - "@babel/plugin-transform-named-capturing-groups-regex": ["@babel/plugin-transform-named-capturing-groups-regex@7.25.9", "", { "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.25.9", "@babel/helper-plugin-utils": "^7.25.9" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-oqB6WHdKTGl3q/ItQhpLSnWWOpjUJLsOCLVyeFgeTktkBSCiurvPOsyt93gibI9CmuKvTUEtWmG5VhZD+5T/KA=="], - - "@babel/plugin-transform-new-target": ["@babel/plugin-transform-new-target@7.25.9", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-U/3p8X1yCSoKyUj2eOBIx3FOn6pElFOKvAAGf8HTtItuPyB+ZeOqfn+mvTtg9ZlOAjsPdK3ayQEjqHjU/yLeVQ=="], - - "@babel/plugin-transform-nullish-coalescing-operator": ["@babel/plugin-transform-nullish-coalescing-operator@7.26.6", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.26.5" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-CKW8Vu+uUZneQCPtXmSBUC6NCAUdya26hWCElAWh5mVSlSRsmiCPUUDKb3Z0szng1hiAJa098Hkhg9o4SE35Qw=="], - - "@babel/plugin-transform-numeric-separator": ["@babel/plugin-transform-numeric-separator@7.25.9", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-TlprrJ1GBZ3r6s96Yq8gEQv82s8/5HnCVHtEJScUj90thHQbwe+E5MLhi2bbNHBEJuzrvltXSru+BUxHDoog7Q=="], - - "@babel/plugin-transform-object-rest-spread": ["@babel/plugin-transform-object-rest-spread@7.25.9", "", { "dependencies": { "@babel/helper-compilation-targets": "^7.25.9", "@babel/helper-plugin-utils": "^7.25.9", "@babel/plugin-transform-parameters": "^7.25.9" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-fSaXafEE9CVHPweLYw4J0emp1t8zYTXyzN3UuG+lylqkvYd7RMrsOQ8TYx5RF231be0vqtFC6jnx3UmpJmKBYg=="], - - "@babel/plugin-transform-object-super": ["@babel/plugin-transform-object-super@7.25.9", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.25.9", "@babel/helper-replace-supers": "^7.25.9" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-Kj/Gh+Rw2RNLbCK1VAWj2U48yxxqL2x0k10nPtSdRa0O2xnHXalD0s+o1A6a0W43gJ00ANo38jxkQreckOzv5A=="], - - "@babel/plugin-transform-optional-catch-binding": ["@babel/plugin-transform-optional-catch-binding@7.25.9", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-qM/6m6hQZzDcZF3onzIhZeDHDO43bkNNlOX0i8n3lR6zLbu0GN2d8qfM/IERJZYauhAHSLHy39NF0Ctdvcid7g=="], - - "@babel/plugin-transform-optional-chaining": ["@babel/plugin-transform-optional-chaining@7.25.9", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.25.9", "@babel/helper-skip-transparent-expression-wrappers": "^7.25.9" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-6AvV0FsLULbpnXeBjrY4dmWF8F7gf8QnvTEoO/wX/5xm/xE1Xo8oPuD3MPS+KS9f9XBEAWN7X1aWr4z9HdOr7A=="], - - "@babel/plugin-transform-parameters": ["@babel/plugin-transform-parameters@7.25.9", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-wzz6MKwpnshBAiRmn4jR8LYz/g8Ksg0o80XmwZDlordjwEk9SxBzTWC7F5ef1jhbrbOW2DJ5J6ayRukrJmnr0g=="], - - "@babel/plugin-transform-private-methods": ["@babel/plugin-transform-private-methods@7.25.9", "", { "dependencies": { "@babel/helper-create-class-features-plugin": "^7.25.9", "@babel/helper-plugin-utils": "^7.25.9" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-D/JUozNpQLAPUVusvqMxyvjzllRaF8/nSrP1s2YGQT/W4LHK4xxsMcHjhOGTS01mp9Hda8nswb+FblLdJornQw=="], - - "@babel/plugin-transform-private-property-in-object": ["@babel/plugin-transform-private-property-in-object@7.25.9", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.25.9", "@babel/helper-create-class-features-plugin": "^7.25.9", "@babel/helper-plugin-utils": "^7.25.9" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-Evf3kcMqzXA3xfYJmZ9Pg1OvKdtqsDMSWBDzZOPLvHiTt36E75jLDQo5w1gtRU95Q4E5PDttrTf25Fw8d/uWLw=="], - - "@babel/plugin-transform-property-literals": ["@babel/plugin-transform-property-literals@7.25.9", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-IvIUeV5KrS/VPavfSM/Iu+RE6llrHrYIKY1yfCzyO/lMXHQ+p7uGhonmGVisv6tSBSVgWzMBohTcvkC9vQcQFA=="], - - "@babel/plugin-transform-react-display-name": ["@babel/plugin-transform-react-display-name@7.25.9", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-KJfMlYIUxQB1CJfO3e0+h0ZHWOTLCPP115Awhaz8U0Zpq36Gl/cXlpoyMRnUWlhNUBAzldnCiAZNvCDj7CrKxQ=="], - - "@babel/plugin-transform-react-jsx": ["@babel/plugin-transform-react-jsx@7.25.9", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.25.9", "@babel/helper-module-imports": "^7.25.9", "@babel/helper-plugin-utils": "^7.25.9", "@babel/plugin-syntax-jsx": "^7.25.9", "@babel/types": "^7.25.9" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-s5XwpQYCqGerXl+Pu6VDL3x0j2d82eiV77UJ8a2mDHAW7j9SWRqQ2y1fNo1Z74CdcYipl5Z41zvjj4Nfzq36rw=="], - - "@babel/plugin-transform-react-jsx-self": ["@babel/plugin-transform-react-jsx-self@7.25.9", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-y8quW6p0WHkEhmErnfe58r7x0A70uKphQm8Sp8cV7tjNQwK56sNVK0M73LK3WuYmsuyrftut4xAkjjgU0twaMg=="], - - "@babel/plugin-transform-react-jsx-source": ["@babel/plugin-transform-react-jsx-source@7.25.9", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-+iqjT8xmXhhYv4/uiYd8FNQsraMFZIfxVSqxxVSZP0WbbSAWvBXAul0m/zu+7Vv4O/3WtApy9pmaTMiumEZgfg=="], - - "@babel/plugin-transform-regenerator": ["@babel/plugin-transform-regenerator@7.27.0", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.26.5", "regenerator-transform": "^0.15.2" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-LX/vCajUJQDqE7Aum/ELUMZAY19+cDpghxrnyt5I1tV6X5PyC86AOoWXWFYFeIvauyeSA6/ktn4tQVn/3ZifsA=="], - - "@babel/plugin-transform-regexp-modifiers": ["@babel/plugin-transform-regexp-modifiers@7.26.0", "", { "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.25.9", "@babel/helper-plugin-utils": "^7.25.9" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-vN6saax7lrA2yA/Pak3sCxuD6F5InBjn9IcrIKQPjpsLvuHYLVroTxjdlVRHjjBWxKOqIwpTXDkOssYT4BFdRw=="], - - "@babel/plugin-transform-reserved-words": ["@babel/plugin-transform-reserved-words@7.25.9", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-7DL7DKYjn5Su++4RXu8puKZm2XBPHyjWLUidaPEkCUBbE7IPcsrkRHggAOOKydH1dASWdcUBxrkOGNxUv5P3Jg=="], - - "@babel/plugin-transform-runtime": ["@babel/plugin-transform-runtime@7.26.10", "", { "dependencies": { "@babel/helper-module-imports": "^7.25.9", "@babel/helper-plugin-utils": "^7.26.5", "babel-plugin-polyfill-corejs2": "^0.4.10", "babel-plugin-polyfill-corejs3": "^0.11.0", "babel-plugin-polyfill-regenerator": "^0.6.1", "semver": "^6.3.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-NWaL2qG6HRpONTnj4JvDU6th4jYeZOJgu3QhmFTCihib0ermtOJqktA5BduGm3suhhVe9EMP9c9+mfJ/I9slqw=="], - - "@babel/plugin-transform-shorthand-properties": ["@babel/plugin-transform-shorthand-properties@7.25.9", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-MUv6t0FhO5qHnS/W8XCbHmiRWOphNufpE1IVxhK5kuN3Td9FT1x4rx4K42s3RYdMXCXpfWkGSbCSd0Z64xA7Ng=="], - - "@babel/plugin-transform-spread": ["@babel/plugin-transform-spread@7.25.9", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.25.9", "@babel/helper-skip-transparent-expression-wrappers": "^7.25.9" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-oNknIB0TbURU5pqJFVbOOFspVlrpVwo2H1+HUIsVDvp5VauGGDP1ZEvO8Nn5xyMEs3dakajOxlmkNW7kNgSm6A=="], - - "@babel/plugin-transform-sticky-regex": ["@babel/plugin-transform-sticky-regex@7.25.9", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-WqBUSgeVwucYDP9U/xNRQam7xV8W5Zf+6Eo7T2SRVUFlhRiMNFdFz58u0KZmCVVqs2i7SHgpRnAhzRNmKfi2uA=="], - - "@babel/plugin-transform-template-literals": ["@babel/plugin-transform-template-literals@7.26.8", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.26.5" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-OmGDL5/J0CJPJZTHZbi2XpO0tyT2Ia7fzpW5GURwdtp2X3fMmN8au/ej6peC/T33/+CRiIpA8Krse8hFGVmT5Q=="], - - "@babel/plugin-transform-typeof-symbol": ["@babel/plugin-transform-typeof-symbol@7.27.0", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.26.5" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-+LLkxA9rKJpNoGsbLnAgOCdESl73vwYn+V6b+5wHbrE7OGKVDPHIQvbFSzqE6rwqaCw2RE+zdJrlLkcf8YOA0w=="], - - "@babel/plugin-transform-typescript": ["@babel/plugin-transform-typescript@7.27.0", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.25.9", "@babel/helper-create-class-features-plugin": "^7.27.0", "@babel/helper-plugin-utils": "^7.26.5", "@babel/helper-skip-transparent-expression-wrappers": "^7.25.9", "@babel/plugin-syntax-typescript": "^7.25.9" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-fRGGjO2UEGPjvEcyAZXRXAS8AfdaQoq7HnxAbJoAoW10B9xOKesmmndJv+Sym2a+9FHWZ9KbyyLCe9s0Sn5jtg=="], - - "@babel/plugin-transform-unicode-escapes": ["@babel/plugin-transform-unicode-escapes@7.25.9", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-s5EDrE6bW97LtxOcGj1Khcx5AaXwiMmi4toFWRDP9/y0Woo6pXC+iyPu/KuhKtfSrNFd7jJB+/fkOtZy6aIC6Q=="], - - "@babel/plugin-transform-unicode-property-regex": ["@babel/plugin-transform-unicode-property-regex@7.25.9", "", { "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.25.9", "@babel/helper-plugin-utils": "^7.25.9" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-Jt2d8Ga+QwRluxRQ307Vlxa6dMrYEMZCgGxoPR8V52rxPyldHu3hdlHspxaqYmE7oID5+kB+UKUB/eWS+DkkWg=="], - - "@babel/plugin-transform-unicode-regex": ["@babel/plugin-transform-unicode-regex@7.25.9", "", { "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.25.9", "@babel/helper-plugin-utils": "^7.25.9" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-yoxstj7Rg9dlNn9UQxzk4fcNivwv4nUYz7fYXBaKxvw/lnmPuOm/ikoELygbYq68Bls3D/D+NBPHiLwZdZZ4HA=="], - - "@babel/plugin-transform-unicode-sets-regex": ["@babel/plugin-transform-unicode-sets-regex@7.25.9", "", { "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.25.9", "@babel/helper-plugin-utils": "^7.25.9" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-8BYqO3GeVNHtx69fdPshN3fnzUNLrWdHhk/icSwigksJGczKSizZ+Z6SBCxTs723Fr5VSNorTIK7a+R2tISvwQ=="], - - "@babel/preset-env": ["@babel/preset-env@7.26.9", "", { "dependencies": { "@babel/compat-data": "^7.26.8", "@babel/helper-compilation-targets": "^7.26.5", "@babel/helper-plugin-utils": "^7.26.5", "@babel/helper-validator-option": "^7.25.9", "@babel/plugin-bugfix-firefox-class-in-computed-class-key": "^7.25.9", "@babel/plugin-bugfix-safari-class-field-initializer-scope": "^7.25.9", "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": "^7.25.9", "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": "^7.25.9", "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": "^7.25.9", "@babel/plugin-proposal-private-property-in-object": "7.21.0-placeholder-for-preset-env.2", "@babel/plugin-syntax-import-assertions": "^7.26.0", "@babel/plugin-syntax-import-attributes": "^7.26.0", "@babel/plugin-syntax-unicode-sets-regex": "^7.18.6", "@babel/plugin-transform-arrow-functions": "^7.25.9", "@babel/plugin-transform-async-generator-functions": "^7.26.8", "@babel/plugin-transform-async-to-generator": "^7.25.9", "@babel/plugin-transform-block-scoped-functions": "^7.26.5", "@babel/plugin-transform-block-scoping": "^7.25.9", "@babel/plugin-transform-class-properties": "^7.25.9", "@babel/plugin-transform-class-static-block": "^7.26.0", "@babel/plugin-transform-classes": "^7.25.9", "@babel/plugin-transform-computed-properties": "^7.25.9", "@babel/plugin-transform-destructuring": "^7.25.9", "@babel/plugin-transform-dotall-regex": "^7.25.9", "@babel/plugin-transform-duplicate-keys": "^7.25.9", "@babel/plugin-transform-duplicate-named-capturing-groups-regex": "^7.25.9", "@babel/plugin-transform-dynamic-import": "^7.25.9", "@babel/plugin-transform-exponentiation-operator": "^7.26.3", "@babel/plugin-transform-export-namespace-from": "^7.25.9", "@babel/plugin-transform-for-of": "^7.26.9", "@babel/plugin-transform-function-name": "^7.25.9", "@babel/plugin-transform-json-strings": "^7.25.9", "@babel/plugin-transform-literals": "^7.25.9", "@babel/plugin-transform-logical-assignment-operators": "^7.25.9", "@babel/plugin-transform-member-expression-literals": "^7.25.9", "@babel/plugin-transform-modules-amd": "^7.25.9", "@babel/plugin-transform-modules-commonjs": "^7.26.3", "@babel/plugin-transform-modules-systemjs": "^7.25.9", "@babel/plugin-transform-modules-umd": "^7.25.9", "@babel/plugin-transform-named-capturing-groups-regex": "^7.25.9", "@babel/plugin-transform-new-target": "^7.25.9", "@babel/plugin-transform-nullish-coalescing-operator": "^7.26.6", "@babel/plugin-transform-numeric-separator": "^7.25.9", "@babel/plugin-transform-object-rest-spread": "^7.25.9", "@babel/plugin-transform-object-super": "^7.25.9", "@babel/plugin-transform-optional-catch-binding": "^7.25.9", "@babel/plugin-transform-optional-chaining": "^7.25.9", "@babel/plugin-transform-parameters": "^7.25.9", "@babel/plugin-transform-private-methods": "^7.25.9", "@babel/plugin-transform-private-property-in-object": "^7.25.9", "@babel/plugin-transform-property-literals": "^7.25.9", "@babel/plugin-transform-regenerator": "^7.25.9", "@babel/plugin-transform-regexp-modifiers": "^7.26.0", "@babel/plugin-transform-reserved-words": "^7.25.9", "@babel/plugin-transform-shorthand-properties": "^7.25.9", "@babel/plugin-transform-spread": "^7.25.9", "@babel/plugin-transform-sticky-regex": "^7.25.9", "@babel/plugin-transform-template-literals": "^7.26.8", "@babel/plugin-transform-typeof-symbol": "^7.26.7", "@babel/plugin-transform-unicode-escapes": "^7.25.9", "@babel/plugin-transform-unicode-property-regex": "^7.25.9", "@babel/plugin-transform-unicode-regex": "^7.25.9", "@babel/plugin-transform-unicode-sets-regex": "^7.25.9", "@babel/preset-modules": "0.1.6-no-external-plugins", "babel-plugin-polyfill-corejs2": "^0.4.10", "babel-plugin-polyfill-corejs3": "^0.11.0", "babel-plugin-polyfill-regenerator": "^0.6.1", "core-js-compat": "^3.40.0", "semver": "^6.3.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-vX3qPGE8sEKEAZCWk05k3cpTAE3/nOYca++JA+Rd0z2NCNzabmYvEiSShKzm10zdquOIAVXsy2Ei/DTW34KlKQ=="], - - "@babel/preset-flow": ["@babel/preset-flow@7.25.9", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.25.9", "@babel/helper-validator-option": "^7.25.9", "@babel/plugin-transform-flow-strip-types": "^7.25.9" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-EASHsAhE+SSlEzJ4bzfusnXSHiU+JfAYzj+jbw2vgQKgq5HrUr8qs+vgtiEL5dOH6sEweI+PNt2D7AqrDSHyqQ=="], - - "@babel/preset-modules": ["@babel/preset-modules@0.1.6-no-external-plugins", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.0.0", "@babel/types": "^7.4.4", "esutils": "^2.0.2" }, "peerDependencies": { "@babel/core": "^7.0.0-0 || ^8.0.0-0 <8.0.0" } }, "sha512-HrcgcIESLm9aIR842yhJ5RWan/gebQUJ6E/E5+rf0y9o6oj7w0Br+sWuL6kEQ/o/AdfvR1Je9jG18/gnpwjEyA=="], - - "@babel/preset-typescript": ["@babel/preset-typescript@7.27.0", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.26.5", "@babel/helper-validator-option": "^7.25.9", "@babel/plugin-syntax-jsx": "^7.25.9", "@babel/plugin-transform-modules-commonjs": "^7.26.3", "@babel/plugin-transform-typescript": "^7.27.0" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-vxaPFfJtHhgeOVXRKuHpHPAOgymmy8V8I65T1q53R7GCZlefKeCaTyDs3zOPHTTbmquvNlQYC5klEvWsBAtrBQ=="], - - "@babel/register": ["@babel/register@7.25.9", "", { "dependencies": { "clone-deep": "^4.0.1", "find-cache-dir": "^2.0.0", "make-dir": "^2.1.0", "pirates": "^4.0.6", "source-map-support": "^0.5.16" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-8D43jXtGsYmEeDvm4MWHYUpWf8iiXgWYx3fW7E7Wb7Oe6FWqJPl5K6TuFW0dOwNZzEE5rjlaSJYH9JjrUKJszA=="], + "@babel/parser": ["@babel/parser@7.29.9", "", { "dependencies": { "@babel/types": "^7.29.8" }, "bin": "./bin/babel-parser.js" }, "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA=="], "@babel/runtime": ["@babel/runtime@7.27.0", "", { "dependencies": { "regenerator-runtime": "^0.14.0" } }, "sha512-VtPOkrdPHZsKc/clNqyi9WUA8TINkZ4cGk63UUE3u4pmB2k+ZMQRDuIOagv8UVd6j7k0T3+RRIb7beKTebNbcw=="], "@babel/template": ["@babel/template@7.27.0", "", { "dependencies": { "@babel/code-frame": "^7.26.2", "@babel/parser": "^7.27.0", "@babel/types": "^7.27.0" } }, "sha512-2ncevenBqXI6qRMukPlXwHKHchC7RyMuu4xv5JBXRfOGVcTy1mXCD12qrp7Jsoxll1EV3+9sE4GugBVRjT2jFA=="], - "@babel/traverse": ["@babel/traverse@7.27.0", "", { "dependencies": { "@babel/code-frame": "^7.26.2", "@babel/generator": "^7.27.0", "@babel/parser": "^7.27.0", "@babel/template": "^7.27.0", "@babel/types": "^7.27.0", "debug": "^4.3.1", "globals": "^11.1.0" } }, "sha512-19lYZFzYVQkkHkl4Cy4WrAVcqBkgvV2YM2TU3xG6DIwO7O3ecbDPfW3yM3bjAGcqcQHi+CCtjMR3dIEHxsd6bA=="], + "@babel/traverse": ["@babel/traverse@7.29.10", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.8", "@babel/helper-globals": "^7.29.7", "@babel/parser": "^7.29.9", "@babel/template": "^7.29.7", "@babel/types": "^7.29.8", "debug": "^4.3.1" } }, "sha512-B3lYAs2nkKskbgQ1nfy8Ma9ao9Q1rntTAQHrzzyDuj+TSO+X3le6PfWwqDvodsb+OumE10eMFfuMCTQSQ5iuIQ=="], - "@babel/types": ["@babel/types@7.27.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.25.9", "@babel/helper-validator-identifier": "^7.25.9" } }, "sha512-H45s8fVLYjbhFH62dIJ3WtmJ6RSPt/3DRO0ZcT2SUiYiQyz3BLVb9ADEnLl91m74aQPS3AzzeajZHYOalWe3bg=="], + "@babel/types": ["@babel/types@7.29.8", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg=="], "@biomejs/biome": ["@biomejs/biome@2.5.5", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.5.5", "@biomejs/cli-darwin-x64": "2.5.5", "@biomejs/cli-linux-arm64": "2.5.5", "@biomejs/cli-linux-arm64-musl": "2.5.5", "@biomejs/cli-linux-x64": "2.5.5", "@biomejs/cli-linux-x64-musl": "2.5.5", "@biomejs/cli-win32-arm64": "2.5.5", "@biomejs/cli-win32-x64": "2.5.5" }, "bin": { "biome": "bin/biome" } }, "sha512-r1S8nFsAG1MY+vJFZALzIvwXAJv6ejDQ0mxP21Tgr9YK3ZFtjrvbBwDdNhx1rUqvccEIeNg20cYCNzl6Cr69pQ=="], @@ -284,18 +77,8 @@ "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.5.5", "", { "os": "win32", "cpu": "x64" }, "sha512-bIBFo+n6MIxdNcVFy5CrurbKiZQiUciK3bt8+O9I4wjFZNTfXLpi+giq47522eXqW5NBc9ulx7dR1SlZKi2J5g=="], - "@hapi/hoek": ["@hapi/hoek@9.3.0", "", {}, "sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ=="], - - "@hapi/topo": ["@hapi/topo@5.1.0", "", { "dependencies": { "@hapi/hoek": "^9.0.0" } }, "sha512-foQZKJig7Ob0BMAYBfcJk8d77QtOe7Wo4ox7ff1lQYoNNAb6jwcY1ncdoy2e9wQZzvNy7ODZCYJkK8kzmcAnAg=="], - "@isaacs/ttlcache": ["@isaacs/ttlcache@1.4.1", "", {}, "sha512-RQgQ4uQ+pLbqXfOmieB91ejmLwvSgv9nLx6sT6sD83s7umBypgg+OIBOBbEUiJXrfpnp9j0mRhYYdzp9uqq3lA=="], - "@jest/create-cache-key-function": ["@jest/create-cache-key-function@29.7.0", "", { "dependencies": { "@jest/types": "^29.6.3" } }, "sha512-4QqS3LY5PBmTRHj9sAg1HLoPzqAI0uOX6wI/TRqHIcOxlFidy6YEmCQJk6FSZjNLGCeubDMfmkWL+qaLKhSGQA=="], - - "@jest/environment": ["@jest/environment@29.7.0", "", { "dependencies": { "@jest/fake-timers": "^29.7.0", "@jest/types": "^29.6.3", "@types/node": "*", "jest-mock": "^29.7.0" } }, "sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw=="], - - "@jest/fake-timers": ["@jest/fake-timers@29.7.0", "", { "dependencies": { "@jest/types": "^29.6.3", "@sinonjs/fake-timers": "^10.0.2", "@types/node": "*", "jest-message-util": "^29.7.0", "jest-mock": "^29.7.0", "jest-util": "^29.7.0" } }, "sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ=="], - "@jest/schemas": ["@jest/schemas@29.6.3", "", { "dependencies": { "@sinclair/typebox": "^0.27.8" } }, "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA=="], "@jest/types": ["@jest/types@29.6.3", "", { "dependencies": { "@jest/schemas": "^29.6.3", "@types/istanbul-lib-coverage": "^2.0.0", "@types/istanbul-reports": "^3.0.0", "@types/node": "*", "@types/yargs": "^17.0.8", "chalk": "^4.0.0" } }, "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw=="], @@ -312,68 +95,30 @@ "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.25", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ=="], - "@react-native-community/cli": ["@react-native-community/cli@12.3.7", "", { "dependencies": { "@react-native-community/cli-clean": "12.3.7", "@react-native-community/cli-config": "12.3.7", "@react-native-community/cli-debugger-ui": "12.3.7", "@react-native-community/cli-doctor": "12.3.7", "@react-native-community/cli-hermes": "12.3.7", "@react-native-community/cli-plugin-metro": "12.3.7", "@react-native-community/cli-server-api": "12.3.7", "@react-native-community/cli-tools": "12.3.7", "@react-native-community/cli-types": "12.3.7", "chalk": "^4.1.2", "commander": "^9.4.1", "deepmerge": "^4.3.0", "execa": "^5.0.0", "find-up": "^4.1.0", "fs-extra": "^8.1.0", "graceful-fs": "^4.1.3", "prompts": "^2.4.2", "semver": "^7.5.2" }, "bin": { "react-native": "build/bin.js" } }, "sha512-7+mOhk+3+X3BjSJZZvYrDJynA00gPYTlvT28ZjiLlbuVGfqfNiBKaxuF7rty+gjjpch4iKGvLhIhSN5cuOsdHQ=="], - - "@react-native-community/cli-clean": ["@react-native-community/cli-clean@12.3.7", "", { "dependencies": { "@react-native-community/cli-tools": "12.3.7", "chalk": "^4.1.2", "execa": "^5.0.0" } }, "sha512-BCYW77QqyxfhiMEBOoHyciJRNV6Rhz1RvclReIKnCA9wAwmoJBeu4Mu+AwiECA2bUITX16fvPt3NwDsSd1jwfQ=="], - - "@react-native-community/cli-config": ["@react-native-community/cli-config@12.3.7", "", { "dependencies": { "@react-native-community/cli-tools": "12.3.7", "chalk": "^4.1.2", "cosmiconfig": "^5.1.0", "deepmerge": "^4.3.0", "glob": "^7.1.3", "joi": "^17.2.1" } }, "sha512-IU2UhO9yj1rEBNhHWGzIXpPDzha4hizLP/PUOrhR4BUf6RVPUWEp+e1PXNGR0qjIf6esu7OC7t6mLOhH0NUJEw=="], - - "@react-native-community/cli-debugger-ui": ["@react-native-community/cli-debugger-ui@12.3.7", "", { "dependencies": { "serve-static": "^1.13.1" } }, "sha512-UHUFrRdcjWSCdWG9KIp2QjuRIahBQnb9epnQI7JCq6NFbFHYfEI4rI7msjMn+gG8/tSwKTV2PTPuPmZ5wWlE7Q=="], - - "@react-native-community/cli-doctor": ["@react-native-community/cli-doctor@12.3.7", "", { "dependencies": { "@react-native-community/cli-config": "12.3.7", "@react-native-community/cli-platform-android": "12.3.7", "@react-native-community/cli-platform-ios": "12.3.7", "@react-native-community/cli-tools": "12.3.7", "chalk": "^4.1.2", "command-exists": "^1.2.8", "deepmerge": "^4.3.0", "envinfo": "^7.10.0", "execa": "^5.0.0", "hermes-profile-transformer": "^0.0.6", "node-stream-zip": "^1.9.1", "ora": "^5.4.1", "semver": "^7.5.2", "strip-ansi": "^5.2.0", "wcwidth": "^1.0.1", "yaml": "^2.2.1" } }, "sha512-gCamZztRoAyhciuQPqdz4Xe4t3gOdNsaADNd+rva+Rx8W2PoPeNv60i7/et06wlsn6B6Sh0/hMiAftJbiHDFkg=="], - - "@react-native-community/cli-hermes": ["@react-native-community/cli-hermes@12.3.7", "", { "dependencies": { "@react-native-community/cli-platform-android": "12.3.7", "@react-native-community/cli-tools": "12.3.7", "chalk": "^4.1.2", "hermes-profile-transformer": "^0.0.6" } }, "sha512-ezzeiSKjRXK2+i1AAe7NhhN9CEHrgtRmTn2MAdBpE++N8fH5EQZgxFcGgGdwGvns2fm9ivyyeVnI5eAYwvM+jg=="], - - "@react-native-community/cli-platform-android": ["@react-native-community/cli-platform-android@12.3.7", "", { "dependencies": { "@react-native-community/cli-tools": "12.3.7", "chalk": "^4.1.2", "execa": "^5.0.0", "fast-xml-parser": "^4.2.4", "glob": "^7.1.3", "logkitty": "^0.7.1" } }, "sha512-mOltF3cpjNdJb3WSFwEHc1GH4ibCcnOvQ34OdWyblKy9ijuvG5SjNTlYR/UW/CURaDi3OUKAhxQMTY5d27bzGQ=="], - - "@react-native-community/cli-platform-ios": ["@react-native-community/cli-platform-ios@12.3.7", "", { "dependencies": { "@react-native-community/cli-tools": "12.3.7", "chalk": "^4.1.2", "execa": "^5.0.0", "fast-xml-parser": "^4.0.12", "glob": "^7.1.3", "ora": "^5.4.1" } }, "sha512-2WnVsMH4ORZIhBm/5nCms1NeeKG4KarNC7PMLmrXWXB/bibDcaNsjrJiqnmCUcpTEvTQTokRfoO7Aj6NM0Cqow=="], - - "@react-native-community/cli-plugin-metro": ["@react-native-community/cli-plugin-metro@12.3.7", "", {}, "sha512-ahEw0Vfnv2Nv/jdZ2QDuGjQ9l2SczO4lXjb3ubu5vEYNLyTw3jYsLMK6iES7YQ/ApQmKdG476HU1O9uZdpaYPg=="], - - "@react-native-community/cli-server-api": ["@react-native-community/cli-server-api@12.3.7", "", { "dependencies": { "@react-native-community/cli-debugger-ui": "12.3.7", "@react-native-community/cli-tools": "12.3.7", "compression": "^1.7.1", "connect": "^3.6.5", "errorhandler": "^1.5.1", "nocache": "^3.0.1", "pretty-format": "^26.6.2", "serve-static": "^1.13.1", "ws": "^7.5.1" } }, "sha512-LYETs3CCjrLn1ZU0kYv44TywiIl5IPFHZGeXhAh2TtgOk4mo3kvXxECDil9CdO3bmDra6qyiG61KHvzr8IrHdg=="], - - "@react-native-community/cli-tools": ["@react-native-community/cli-tools@12.3.7", "", { "dependencies": { "appdirsjs": "^1.2.4", "chalk": "^4.1.2", "find-up": "^5.0.0", "mime": "^2.4.1", "node-fetch": "^2.6.0", "open": "^6.2.0", "ora": "^5.4.1", "semver": "^7.5.2", "shell-quote": "^1.7.3", "sudo-prompt": "^9.0.0" } }, "sha512-7NL/1/i+wzd4fBr/FSr3ypR05tiU/Kv9l/M1sL1c6jfcDtWXAL90R161gQkQFK7shIQ8Idp0dQX1rq49tSyfQw=="], + "@react-native/assets-registry": ["@react-native/assets-registry@0.85.2", "", {}, "sha512-kauC/oPaxklU4Y+u9gBfCBJm51qX6WBZq4xx0USCdimtp+G8+554kpygfSWIjoqCJa2o06bWxBEjesiuCv+LzA=="], - "@react-native-community/cli-types": ["@react-native-community/cli-types@12.3.7", "", { "dependencies": { "joi": "^17.2.1" } }, "sha512-NFtUMyIrNfi3A5C1cjVKDVvYHvvOF7MnOMwdD8jm2NQKewQJrehKBh1eMuykKdqhWyZmuemD4KKhL8f4FxgG0w=="], + "@react-native/codegen": ["@react-native/codegen@0.85.2", "", { "dependencies": { "@babel/core": "^7.25.2", "@babel/parser": "^7.29.0", "hermes-parser": "0.33.3", "invariant": "^2.2.4", "nullthrows": "^1.1.1", "tinyglobby": "^0.2.15", "yargs": "^17.6.2" } }, "sha512-XCginmxh0//++EXVOEJHBVZxHla294FzLCFF6jXwAUjvXVhqyIKyxhABfz+r4OOmaiuWk4Rtd4arqdAzeHeprg=="], - "@react-native/assets-registry": ["@react-native/assets-registry@0.73.1", "", {}, "sha512-2FgAbU7uKM5SbbW9QptPPZx8N9Ke2L7bsHb+EhAanZjFZunA9PaYtyjUQ1s7HD+zDVqOQIvjkpXSv7Kejd2tqg=="], + "@react-native/community-cli-plugin": ["@react-native/community-cli-plugin@0.85.2", "", { "dependencies": { "@react-native/dev-middleware": "0.85.2", "debug": "^4.4.0", "invariant": "^2.2.4", "metro": "^0.84.0", "metro-config": "^0.84.0", "metro-core": "^0.84.0", "semver": "^7.1.3" }, "peerDependencies": { "@react-native-community/cli": "*", "@react-native/metro-config": "0.85.2" }, "optionalPeers": ["@react-native-community/cli", "@react-native/metro-config"] }, "sha512-3KLgSg1kHvBpr93zMaQhvfYTgnCw7yZRED+3J4dMcYjfSjtD0Wf8SofU6uBmAw9JaVYvP43lpdwUpI4p0+ABsg=="], - "@react-native/babel-plugin-codegen": ["@react-native/babel-plugin-codegen@0.73.4", "", { "dependencies": { "@react-native/codegen": "0.73.3" } }, "sha512-XzRd8MJGo4Zc5KsphDHBYJzS1ryOHg8I2gOZDAUCGcwLFhdyGu1zBNDJYH2GFyDrInn9TzAbRIf3d4O+eltXQQ=="], + "@react-native/debugger-frontend": ["@react-native/debugger-frontend@0.85.2", "", {}, "sha512-j+0b9H5f5hGTLQxHIhJU/b/W6ijuxJF+ZTLHB0se2kzUBNxFKd7DkIc6753qk3CJdiv55vxG3XDgmlpbHxOpmA=="], - "@react-native/babel-preset": ["@react-native/babel-preset@0.73.21", "", { "dependencies": { "@babel/core": "^7.20.0", "@babel/plugin-proposal-async-generator-functions": "^7.0.0", "@babel/plugin-proposal-class-properties": "^7.18.0", "@babel/plugin-proposal-export-default-from": "^7.0.0", "@babel/plugin-proposal-nullish-coalescing-operator": "^7.18.0", "@babel/plugin-proposal-numeric-separator": "^7.0.0", "@babel/plugin-proposal-object-rest-spread": "^7.20.0", "@babel/plugin-proposal-optional-catch-binding": "^7.0.0", "@babel/plugin-proposal-optional-chaining": "^7.20.0", "@babel/plugin-syntax-dynamic-import": "^7.8.0", "@babel/plugin-syntax-export-default-from": "^7.0.0", "@babel/plugin-syntax-flow": "^7.18.0", "@babel/plugin-syntax-nullish-coalescing-operator": "^7.0.0", "@babel/plugin-syntax-optional-chaining": "^7.0.0", "@babel/plugin-transform-arrow-functions": "^7.0.0", "@babel/plugin-transform-async-to-generator": "^7.20.0", "@babel/plugin-transform-block-scoping": "^7.0.0", "@babel/plugin-transform-classes": "^7.0.0", "@babel/plugin-transform-computed-properties": "^7.0.0", "@babel/plugin-transform-destructuring": "^7.20.0", "@babel/plugin-transform-flow-strip-types": "^7.20.0", "@babel/plugin-transform-function-name": "^7.0.0", "@babel/plugin-transform-literals": "^7.0.0", "@babel/plugin-transform-modules-commonjs": "^7.0.0", "@babel/plugin-transform-named-capturing-groups-regex": "^7.0.0", "@babel/plugin-transform-parameters": "^7.0.0", "@babel/plugin-transform-private-methods": "^7.22.5", "@babel/plugin-transform-private-property-in-object": "^7.22.11", "@babel/plugin-transform-react-display-name": "^7.0.0", "@babel/plugin-transform-react-jsx": "^7.0.0", "@babel/plugin-transform-react-jsx-self": "^7.0.0", "@babel/plugin-transform-react-jsx-source": "^7.0.0", "@babel/plugin-transform-runtime": "^7.0.0", "@babel/plugin-transform-shorthand-properties": "^7.0.0", "@babel/plugin-transform-spread": "^7.0.0", "@babel/plugin-transform-sticky-regex": "^7.0.0", "@babel/plugin-transform-typescript": "^7.5.0", "@babel/plugin-transform-unicode-regex": "^7.0.0", "@babel/template": "^7.0.0", "@react-native/babel-plugin-codegen": "0.73.4", "babel-plugin-transform-flow-enums": "^0.0.2", "react-refresh": "^0.14.0" } }, "sha512-WlFttNnySKQMeujN09fRmrdWqh46QyJluM5jdtDNrkl/2Hx6N4XeDUGhABvConeK95OidVO7sFFf7sNebVXogA=="], + "@react-native/debugger-shell": ["@react-native/debugger-shell@0.85.2", "", { "dependencies": { "cross-spawn": "^7.0.6", "debug": "^4.4.0", "fb-dotslash": "0.5.8" } }, "sha512-r5BkhqPMfg3LmaZS5zadHmBNVH5h4bhSpv4BEPGfK4gat9HABAMzUzybi+2wpgU3SoHxnyKGdExEJvoqVcjeRg=="], - "@react-native/codegen": ["@react-native/codegen@0.73.3", "", { "dependencies": { "@babel/parser": "^7.20.0", "flow-parser": "^0.206.0", "glob": "^7.1.1", "invariant": "^2.2.4", "jscodeshift": "^0.14.0", "mkdirp": "^0.5.1", "nullthrows": "^1.1.1" }, "peerDependencies": { "@babel/preset-env": "^7.1.6" } }, "sha512-sxslCAAb8kM06vGy9Jyh4TtvjhcP36k/rvj2QE2Jdhdm61KvfafCATSIsOfc0QvnduWFcpXUPvAVyYwuv7PYDg=="], + "@react-native/dev-middleware": ["@react-native/dev-middleware@0.85.2", "", { "dependencies": { "@isaacs/ttlcache": "^1.4.1", "@react-native/debugger-frontend": "0.85.2", "@react-native/debugger-shell": "0.85.2", "chrome-launcher": "^0.15.2", "chromium-edge-launcher": "^0.3.0", "connect": "^3.6.5", "debug": "^4.4.0", "invariant": "^2.2.4", "nullthrows": "^1.1.1", "open": "^7.0.3", "serve-static": "^1.16.2", "ws": "^7.5.10" } }, "sha512-3J+NaDUg+QEfDeLAUzgaWhpaxEg78g+KwbydlDCewh2G6WnHpsty8XooruxNHzyAsqVWywZMrzmbn78Ctc1O9Q=="], - "@react-native/community-cli-plugin": ["@react-native/community-cli-plugin@0.73.18", "", { "dependencies": { "@react-native-community/cli-server-api": "12.3.7", "@react-native-community/cli-tools": "12.3.7", "@react-native/dev-middleware": "0.73.8", "@react-native/metro-babel-transformer": "0.73.15", "chalk": "^4.0.0", "execa": "^5.1.1", "metro": "^0.80.3", "metro-config": "^0.80.3", "metro-core": "^0.80.3", "node-fetch": "^2.2.0", "readline": "^1.3.0" } }, "sha512-RN8piDh/eF+QT6YYmrj3Zd9uiaDsRY/kMT0FYR42j8/M/boE4hs4Xn0u91XzT8CAkU9q/ilyo3wJsXIJo2teww=="], + "@react-native/gradle-plugin": ["@react-native/gradle-plugin@0.85.2", "", {}, "sha512-YXBOLeAqFrv7XwUeBPTKZeOV1FIxn4AW7UAEitScf3ibC8bu8+6NpJu4HWgbNQHg7vDbbTZVbcOl8EwGxsSq2w=="], - "@react-native/debugger-frontend": ["@react-native/debugger-frontend@0.73.3", "", {}, "sha512-RgEKnWuoo54dh7gQhV7kvzKhXZEhpF9LlMdZolyhGxHsBqZ2gXdibfDlfcARFFifPIiaZ3lXuOVVa4ei+uPgTw=="], + "@react-native/js-polyfills": ["@react-native/js-polyfills@0.85.2", "", {}, "sha512-esGEAmKVM40DV/yVmNljCKZTIeUo7qXqc+Hwffkv3TG+b3E24xyFovHrbP98gGxZr2ZsEyx+2sKLdXF5asY5nw=="], - "@react-native/dev-middleware": ["@react-native/dev-middleware@0.73.8", "", { "dependencies": { "@isaacs/ttlcache": "^1.4.1", "@react-native/debugger-frontend": "0.73.3", "chrome-launcher": "^0.15.2", "chromium-edge-launcher": "^1.0.0", "connect": "^3.6.5", "debug": "^2.2.0", "node-fetch": "^2.2.0", "open": "^7.0.3", "serve-static": "^1.13.1", "temp-dir": "^2.0.0", "ws": "^6.2.2" } }, "sha512-oph4NamCIxkMfUL/fYtSsE+JbGOnrlawfQ0kKtDQ5xbOjPKotKoXqrs1eGwozNKv7FfQ393stk1by9a6DyASSg=="], + "@react-native/normalize-colors": ["@react-native/normalize-colors@0.85.2", "", {}, "sha512-svuOLtjbFGXDdHsriHXuND5FgHg7XlkOXCbH/8+X4t76YLH6qSTffSIQQrKLDL5mn4EFU+Oh/PNO0/FfpnTOTg=="], - "@react-native/gradle-plugin": ["@react-native/gradle-plugin@0.73.5", "", {}, "sha512-Orrn8J/kqzEuXudl96XcZk84ZcdIpn1ojjwGSuaSQSXNcCYbOXyt0RwtW5kjCqjgSzGnOMsJNZc5FDXHVq/WzA=="], + "@react-native/typescript-config": ["@react-native/typescript-config@0.85.2", "", {}, "sha512-kJAIGIQdYSf2llBq28EuEjgkK0ELaOLs9hzZunhwQUWICqUuad4yJtqL1JioFeFrkzN3NvdfgM7eBN4Se4Tq9w=="], - "@react-native/js-polyfills": ["@react-native/js-polyfills@0.73.1", "", {}, "sha512-ewMwGcumrilnF87H4jjrnvGZEaPFCAC4ebraEK+CurDDmwST/bIicI4hrOAv+0Z0F7DEK4O4H7r8q9vH7IbN4g=="], - - "@react-native/metro-babel-transformer": ["@react-native/metro-babel-transformer@0.73.15", "", { "dependencies": { "@babel/core": "^7.20.0", "@react-native/babel-preset": "0.73.21", "hermes-parser": "0.15.0", "nullthrows": "^1.1.1" } }, "sha512-LlkSGaXCz+xdxc9819plmpsl4P4gZndoFtpjN3GMBIu6f7TBV0GVbyJAU4GE8fuAWPVSVL5ArOcdkWKSbI1klw=="], - - "@react-native/normalize-colors": ["@react-native/normalize-colors@0.73.2", "", {}, "sha512-bRBcb2T+I88aG74LMVHaKms2p/T8aQd8+BZ7LuuzXlRfog1bMWWn/C5i0HVuvW4RPtXQYgIlGiXVDy9Ir1So/w=="], - - "@react-native/typescript-config": ["@react-native/typescript-config@0.84.1", "", {}, "sha512-ar7Gn6ma3b+Ricdxn2sTZL2DT1NMlrfsWmOkFZegpfQJzheqX/8gzIB1aIbfZyvhEDsoz07RG7wmsyQAWqXjsw=="], - - "@react-native/virtualized-lists": ["@react-native/virtualized-lists@0.73.4", "", { "dependencies": { "invariant": "^2.2.4", "nullthrows": "^1.1.1" }, "peerDependencies": { "react-native": "*" } }, "sha512-HpmLg1FrEiDtrtAbXiwCgXFYyloK/dOIPIuWW3fsqukwJEWAiTzm1nXGJ7xPU5XTHiWZ4sKup5Ebaj8z7iyWog=="], - - "@sideway/address": ["@sideway/address@4.1.5", "", { "dependencies": { "@hapi/hoek": "^9.0.0" } }, "sha512-IqO/DUQHUkPeixNQ8n0JA6102hT9CmaljNTPmQ1u8MEhBo/R4Q8eKLN/vGZxuebwOroDB4cbpjheD4+/sKFK4Q=="], - - "@sideway/formula": ["@sideway/formula@3.0.1", "", {}, "sha512-/poHZJJVjx3L+zVD6g9KgHfYnb443oi7wLu/XKojDviHy6HOEOA6z1Trk5aR1dGcmPenJEgb2sK2I80LeS3MIg=="], - - "@sideway/pinpoint": ["@sideway/pinpoint@2.0.0", "", {}, "sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ=="], + "@react-native/virtualized-lists": ["@react-native/virtualized-lists@0.85.2", "", { "dependencies": { "invariant": "^2.2.4", "nullthrows": "^1.1.1" }, "peerDependencies": { "@types/react": "^19.2.0", "react": "*", "react-native": "0.85.2" }, "optionalPeers": ["@types/react"] }, "sha512-wmVKpAlcr+UB0L5SpbrV865EdleUP7I5+X+48e1aRsQK8q+wsTRBXeUwWVip/1l+HZwlZFeO8iOILJ16VRu0Cw=="], "@sinclair/typebox": ["@sinclair/typebox@0.27.8", "", {}, "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA=="], - "@sinonjs/commons": ["@sinonjs/commons@3.0.1", "", { "dependencies": { "type-detect": "4.0.8" } }, "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ=="], - - "@sinonjs/fake-timers": ["@sinonjs/fake-timers@10.3.0", "", { "dependencies": { "@sinonjs/commons": "^3.0.0" } }, "sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA=="], - "@types/bun": ["@types/bun@1.3.11", "", { "dependencies": { "bun-types": "1.3.11" } }, "sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg=="], "@types/istanbul-lib-coverage": ["@types/istanbul-lib-coverage@2.0.6", "", {}, "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w=="], @@ -386,9 +131,7 @@ "@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="], - "@types/react-test-renderer": ["@types/react-test-renderer@18.0.7", "", { "dependencies": { "@types/react": "*" } }, "sha512-1+ANPOWc6rB3IkSnElhjv6VLlKg2dSv/OWClUyZimbLsQyBn8Js9Vtdsi3UICJ2rIQ3k2la06dkB+C92QfhKmg=="], - - "@types/stack-utils": ["@types/stack-utils@2.0.3", "", {}, "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw=="], + "@types/react-test-renderer": ["@types/react-test-renderer@19.1.0", "", { "dependencies": { "@types/react": "*" } }, "sha512-XD0WZrHqjNrxA/MaR9O22w/RNidWR9YZmBdRGI7wcnWGrv/3dA8wKCJ8m63Sn+tLJhcjmuhOi629N66W6kgWzQ=="], "@types/yargs": ["@types/yargs@17.0.33", "", { "dependencies": { "@types/yargs-parser": "*" } }, "sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA=="], @@ -436,70 +179,34 @@ "abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="], - "accepts": ["accepts@1.3.8", "", { "dependencies": { "mime-types": "~2.1.34", "negotiator": "0.6.3" } }, "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw=="], + "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], "acorn": ["acorn@8.14.1", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg=="], - "anser": ["anser@1.4.10", "", {}, "sha512-hCv9AqTQ8ycjpSd3upOJd7vFwW1JaoYQ7tpham03GJ1ca8/65rqn0RpaWpItOAd6ylW9wAw6luXYPJIyPFVOww=="], + "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], - "ansi-fragments": ["ansi-fragments@0.2.1", "", { "dependencies": { "colorette": "^1.0.7", "slice-ansi": "^2.0.0", "strip-ansi": "^5.0.0" } }, "sha512-DykbNHxuXQwUDRv5ibc2b0x7uw7wmwOGLBUd5RmaQ5z8Lhx19vwvKV+FAsM5rEA6dEcHxX+/Ad5s9eF2k2bB+w=="], + "anser": ["anser@1.4.10", "", {}, "sha512-hCv9AqTQ8ycjpSd3upOJd7vFwW1JaoYQ7tpham03GJ1ca8/65rqn0RpaWpItOAd6ylW9wAw6luXYPJIyPFVOww=="], "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], - "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], - - "anymatch": ["anymatch@3.1.3", "", { "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" } }, "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw=="], - - "appdirsjs": ["appdirsjs@1.2.7", "", {}, "sha512-Quji6+8kLBC3NnBeo14nPDq0+2jUs5s3/xEye+udFHumHhRk4M7aAMXp/PBJqkKYGuuyR9M/6Dq7d2AViiGmhw=="], - - "argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="], + "ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="], "asap": ["asap@2.0.6", "", {}, "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA=="], - "ast-types": ["ast-types@0.15.2", "", { "dependencies": { "tslib": "^2.0.1" } }, "sha512-c27loCv9QkZinsa5ProX751khO9DJl/AcB5c2KNtA6NRvHKS0PgLfcftz72KVq504vB0Gku5s2kUZzDBvQWvHg=="], - - "astral-regex": ["astral-regex@1.0.0", "", {}, "sha512-+Ryf6g3BKoRc7jfp7ad8tM4TtMiaWvbF/1/sQcZPkkS7ag3D5nMBCe2UfOTONtAkaG0tO0ij3C5Lwmf1EiyjHg=="], - - "async-limiter": ["async-limiter@1.0.1", "", {}, "sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ=="], - - "babel-core": ["babel-core@7.0.0-bridge.0", "", { "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-poPX9mZH/5CSanm50Q+1toVci6pv5KSRv/5TWCwtzQS5XEwn40BcCrgIeMFWP9CKKIniKXNxoIOnOq4VVlGXhg=="], - - "babel-plugin-polyfill-corejs2": ["babel-plugin-polyfill-corejs2@0.4.13", "", { "dependencies": { "@babel/compat-data": "^7.22.6", "@babel/helper-define-polyfill-provider": "^0.6.4", "semver": "^6.3.1" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, "sha512-3sX/eOms8kd3q2KZ6DAhKPc0dgm525Gqq5NtWKZ7QYYZEv57OQ54KtblzJzH1lQF/eQxO8KjWGIK9IPUJNus5g=="], - - "babel-plugin-polyfill-corejs3": ["babel-plugin-polyfill-corejs3@0.11.1", "", { "dependencies": { "@babel/helper-define-polyfill-provider": "^0.6.3", "core-js-compat": "^3.40.0" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, "sha512-yGCqvBT4rwMczo28xkH/noxJ6MZ4nJfkVYdoDaC/utLtWrXxv27HVrzAeSbqR8SxDsp46n0YF47EbHoixy6rXQ=="], - - "babel-plugin-polyfill-regenerator": ["babel-plugin-polyfill-regenerator@0.6.4", "", { "dependencies": { "@babel/helper-define-polyfill-provider": "^0.6.4" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, "sha512-7gD3pRadPrbjhjLyxebmx/WrFYcuSjZ0XbdUujQMZ/fcE9oeewk2U/7PCvez84UeuK3oSjmPZ0Ch0dlupQvGzw=="], - - "babel-plugin-transform-flow-enums": ["babel-plugin-transform-flow-enums@0.0.2", "", { "dependencies": { "@babel/plugin-syntax-flow": "^7.12.1" } }, "sha512-g4aaCrDDOsWjbm0PUUeVnkcVd6AKJsVc/MbnPhEotEpkeJQP6b8nzewohQi7+QS8UyPehOhGWn0nOwjvWpmMvQ=="], - - "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + "babel-plugin-syntax-hermes-parser": ["babel-plugin-syntax-hermes-parser@0.33.3", "", { "dependencies": { "hermes-parser": "0.33.3" } }, "sha512-/Z9xYdaJ1lC0pT9do6TqCqhOSLfZ5Ot8D5za1p+feEfWYupCOfGbhhEXN9r2ZgJtDNUNRw/Z+T2CvAGKBqtqWA=="], "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], - "bl": ["bl@4.1.0", "", { "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w=="], - - "brace-expansion": ["brace-expansion@1.1.11", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA=="], - "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], "browserslist": ["browserslist@4.24.4", "", { "dependencies": { "caniuse-lite": "^1.0.30001688", "electron-to-chromium": "^1.5.73", "node-releases": "^2.0.19", "update-browserslist-db": "^1.1.1" }, "bin": { "browserslist": "cli.js" } }, "sha512-KDi1Ny1gSePi1vm0q4oxSF8b4DR44GF4BbmS2YdhPLOEqd8pDviZOGH/GsmRwoWJ2+5Lr085X7naowMwKHDG1A=="], "bser": ["bser@2.1.1", "", { "dependencies": { "node-int64": "^0.4.0" } }, "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ=="], - "buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="], - "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], "bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="], - "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], - - "caller-callsite": ["caller-callsite@2.0.0", "", { "dependencies": { "callsites": "^2.0.0" } }, "sha512-JuG3qI4QOftFsZyOn1qq87fq5grLIyk1JYd5lJmdA+fG7aQ9pA/i3JIJGcO3q0MrRcHlOt1U+ZeHW8Dq9axALQ=="], - - "caller-path": ["caller-path@2.0.0", "", { "dependencies": { "caller-callsite": "^2.0.0" } }, "sha512-MCL3sf6nCSXOwCTzvPKhN18TU7AHTvdtam8DAogxcrJ8Rjfbbg7Lgng64H9Iy+vUV6VGFClN/TyxBkAebLRR4A=="], - - "callsites": ["callsites@2.0.0", "", {}, "sha512-ksWePWBloaWPxJYQ8TL0JHvtci6G5QTKwQ95RcWAa/lzoAKuAOflGdAK92hpHXjkwb8zLxoLNUoNYZgVsaJzvQ=="], - "camelcase": ["camelcase@6.3.0", "", {}, "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA=="], "caniuse-lite": ["caniuse-lite@1.0.30001715", "", {}, "sha512-7ptkFGMm2OAOgvZpwgA4yjQ5SQbrNVGdRjzH0pBdy1Fasvcr+KAeECmbCAECzTuDuoX0FCY8KzUxjf9+9kfZEw=="], @@ -508,68 +215,32 @@ "chrome-launcher": ["chrome-launcher@0.15.2", "", { "dependencies": { "@types/node": "*", "escape-string-regexp": "^4.0.0", "is-wsl": "^2.2.0", "lighthouse-logger": "^1.0.0" }, "bin": { "print-chrome-path": "bin/print-chrome-path.js" } }, "sha512-zdLEwNo3aUVzIhKhTtXfxhdvZhUghrnmkvcAq2NoDd+LeOHKf03H5jwZ8T/STsAlzyALkBVK552iaG1fGf1xVQ=="], - "chromium-edge-launcher": ["chromium-edge-launcher@1.0.0", "", { "dependencies": { "@types/node": "*", "escape-string-regexp": "^4.0.0", "is-wsl": "^2.2.0", "lighthouse-logger": "^1.0.0", "mkdirp": "^1.0.4", "rimraf": "^3.0.2" } }, "sha512-pgtgjNKZ7i5U++1g1PWv75umkHvhVTDOQIZ+sjeUX9483S7Y6MUvO0lrd7ShGlQlFHMN4SwKTCq/X8hWrbv2KA=="], + "chromium-edge-launcher": ["chromium-edge-launcher@0.3.0", "", { "dependencies": { "@types/node": "*", "escape-string-regexp": "^4.0.0", "is-wsl": "^2.2.0", "lighthouse-logger": "^1.0.0", "mkdirp": "^1.0.4" } }, "sha512-p03azHlGjtyRvFEee3cyvtsRYdniSkwjkzmM/KmVnqT5d7QkkwpJBhis/zCLMYdQMVJ5tt140TBNqqrZPaWeFA=="], "ci-info": ["ci-info@2.0.0", "", {}, "sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ=="], - "cli-cursor": ["cli-cursor@3.1.0", "", { "dependencies": { "restore-cursor": "^3.1.0" } }, "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw=="], - - "cli-spinners": ["cli-spinners@2.9.2", "", {}, "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg=="], - "cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="], - "clone": ["clone@1.0.4", "", {}, "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg=="], - - "clone-deep": ["clone-deep@4.0.1", "", { "dependencies": { "is-plain-object": "^2.0.4", "kind-of": "^6.0.2", "shallow-clone": "^3.0.0" } }, "sha512-neHB9xuzh/wk0dIHweyAXv2aPGZIVk3pLMe+/RNzINf17fe0OG96QroktYAUm7SM1PBnzTabaLboqqxDyMU+SQ=="], - "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], - "colorette": ["colorette@1.4.0", "", {}, "sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g=="], - - "command-exists": ["command-exists@1.2.9", "", {}, "sha512-LTQ/SGc+s0Xc0Fu5WaKnR0YiygZkm9eKFvyS+fRsU7/ZWFF8ykFM6Pc9aCVf1+xasOOZpO3BAVgVrKvsqKHV7w=="], - - "commander": ["commander@9.5.0", "", {}, "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ=="], - - "commondir": ["commondir@1.0.1", "", {}, "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg=="], - - "compressible": ["compressible@2.0.18", "", { "dependencies": { "mime-db": ">= 1.43.0 < 2" } }, "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg=="], - - "compression": ["compression@1.8.0", "", { "dependencies": { "bytes": "3.1.2", "compressible": "~2.0.18", "debug": "2.6.9", "negotiator": "~0.6.4", "on-headers": "~1.0.2", "safe-buffer": "5.2.1", "vary": "~1.1.2" } }, "sha512-k6WLKfunuqCYD3t6AsuPGvQWaKwuLLh2/xHNcX4qE+vIfDNXpSqnrhwA7O53R7WVQUnt8dVAIW+YHr7xTgOgGA=="], - - "concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="], + "commander": ["commander@12.1.0", "", {}, "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA=="], "connect": ["connect@3.7.0", "", { "dependencies": { "debug": "2.6.9", "finalhandler": "1.1.2", "parseurl": "~1.3.3", "utils-merge": "1.0.1" } }, "sha512-ZqRXc+tZukToSNmh5C2iWMSoV3X1YUcPbqEM4DkEG5tNQXrQUZCNVGGv3IuicnkMtPfGf3Xtp8WCXs295iQ1pQ=="], - "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], - - "core-js-compat": ["core-js-compat@3.41.0", "", { "dependencies": { "browserslist": "^4.24.4" } }, "sha512-RFsU9LySVue9RTwdDVX/T0e2Y6jRYWXERKElIjpuEOEnxaXffI0X7RUwVzfYLfzuLXSNJDYoRYUAmRUcyln20A=="], + "content-type": ["content-type@2.1.0", "", {}, "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag=="], - "core-util-is": ["core-util-is@1.0.3", "", {}, "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="], - - "cosmiconfig": ["cosmiconfig@5.2.1", "", { "dependencies": { "import-fresh": "^2.0.0", "is-directory": "^0.3.1", "js-yaml": "^3.13.1", "parse-json": "^4.0.0" } }, "sha512-H65gsXo1SKjf8zmrJ67eJk8aIRKV5ff2D4uKZIBZShbhGSpEmsQOPW/SKMKYhSTrqR7ufy6RP69rPogdaPh/kA=="], + "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], - "dayjs": ["dayjs@1.11.13", "", {}, "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg=="], - "debug": ["debug@4.4.0", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA=="], - "decamelize": ["decamelize@1.2.0", "", {}, "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA=="], - - "deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="], - - "defaults": ["defaults@1.0.4", "", { "dependencies": { "clone": "^1.0.2" } }, "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A=="], - - "denodeify": ["denodeify@1.2.1", "", {}, "sha512-KNTihKNmQENUZeKu5fzfpzRqR5S2VMp4gl9RFHiWzj9DfvYQPMJ6XHKNaQxaGCXwPk6y9yme3aUoaiAe+KX+vg=="], - "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], - "deprecated-react-native-prop-types": ["deprecated-react-native-prop-types@5.0.0", "", { "dependencies": { "@react-native/normalize-colors": "^0.73.0", "invariant": "^2.2.4", "prop-types": "^15.8.1" } }, "sha512-cIK8KYiiGVOFsKdPMmm1L3tA/Gl+JopXL6F5+C7x39MyPsQYnP57Im/D6bNUzcborD7fcMwiwZqcBdBXXZucYQ=="], - "destroy": ["destroy@1.2.0", "", {}, "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg=="], "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], @@ -580,188 +251,94 @@ "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], - "encoding": ["encoding@0.1.13", "", { "dependencies": { "iconv-lite": "^0.6.2" } }, "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A=="], - - "envinfo": ["envinfo@7.14.0", "", { "bin": { "envinfo": "dist/cli.js" } }, "sha512-CO40UI41xDQzhLB1hWyqUKgFhs250pNcGbyGKe1l/e4FSaI/+YE4IMG76GDt0In67WLPACIITC+sOi08x4wIvg=="], - - "error-ex": ["error-ex@1.3.2", "", { "dependencies": { "is-arrayish": "^0.2.1" } }, "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g=="], - "error-stack-parser": ["error-stack-parser@2.1.4", "", { "dependencies": { "stackframe": "^1.3.4" } }, "sha512-Sk5V6wVazPhq5MhpO+AUxJn5x7XSXGl1R93Vn7i+zS15KDVxQijejNCrz8340/2bgLBjR9GtEG8ZVKONDjcqGQ=="], - "errorhandler": ["errorhandler@1.5.1", "", { "dependencies": { "accepts": "~1.3.7", "escape-html": "~1.0.3" } }, "sha512-rcOwbfvP1WTViVoUjcfZicVzjhjTuhSMntHh6mW3IrEiyE6mJyXvsToJUJGlGlw/2xU9P5whlWNGlIDVeCiT4A=="], - "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="], "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], - "esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="], - - "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], - "etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="], "event-target-shim": ["event-target-shim@5.0.1", "", {}, "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="], - "execa": ["execa@5.1.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^6.0.0", "human-signals": "^2.1.0", "is-stream": "^2.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^4.0.1", "onetime": "^5.1.2", "signal-exit": "^3.0.3", "strip-final-newline": "^2.0.0" } }, "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg=="], - "exponential-backoff": ["exponential-backoff@3.1.2", "", {}, "sha512-8QxYTVXUkuy7fIIoitQkPwGonB8F3Zj8eEO8Sqg9Zv/bkI7RJAzowee4gr81Hak/dUTpA2Z7VfQgoijjPNlUZA=="], - "fast-xml-parser": ["fast-xml-parser@4.5.3", "", { "dependencies": { "strnum": "^1.1.1" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-RKihhV+SHsIUGXObeVy9AXiBbFwkVk7Syp8XgwN5U3JV416+Gwp/GO9i0JYKmikykgz/UHRrrV4ROuZEo/T0ig=="], + "fb-dotslash": ["fb-dotslash@0.5.8", "", { "bin": { "dotslash": "bin/dotslash" } }, "sha512-XHYLKk9J4BupDxi9bSEhkfss0m+Vr9ChTrjhf9l2iw3jB5C7BnY4GVPoMcqbrTutsKJso6yj2nAB6BI/F2oZaA=="], "fb-watchman": ["fb-watchman@2.0.2", "", { "dependencies": { "bser": "2.1.1" } }, "sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA=="], + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], "finalhandler": ["finalhandler@1.1.2", "", { "dependencies": { "debug": "2.6.9", "encodeurl": "~1.0.2", "escape-html": "~1.0.3", "on-finished": "~2.3.0", "parseurl": "~1.3.3", "statuses": "~1.5.0", "unpipe": "~1.0.0" } }, "sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA=="], - "find-cache-dir": ["find-cache-dir@2.1.0", "", { "dependencies": { "commondir": "^1.0.1", "make-dir": "^2.0.0", "pkg-dir": "^3.0.0" } }, "sha512-Tq6PixE0w/VMFfCgbONnkiQIVol/JJL7nRMi20fqzA4NRs9AfeqMGeRdPi3wIhYkxjeBaWh2rxwapn5Tu3IqOQ=="], - - "find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="], - "flow-enums-runtime": ["flow-enums-runtime@0.0.6", "", {}, "sha512-3PYnM29RFXwvAN6Pc/scUfkI7RwhQ/xqyLUyPNlXUp9S40zI8nup9tUSrTLSVnWGBN38FNiGWbwZOB6uR4OGdw=="], - "flow-parser": ["flow-parser@0.206.0", "", {}, "sha512-HVzoK3r6Vsg+lKvlIZzaWNBVai+FXTX1wdYhz/wVlH13tb/gOdLXmlTqy6odmTBhT5UoWUbq0k8263Qhr9d88w=="], - "fresh": ["fresh@0.5.2", "", {}, "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q=="], - "fs-extra": ["fs-extra@8.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g=="], - - "fs.realpath": ["fs.realpath@1.0.0", "", {}, "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw=="], - - "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], - - "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], - "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], - "get-stream": ["get-stream@6.0.1", "", {}, "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg=="], - - "glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], - "globals": ["globals@11.12.0", "", {}, "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA=="], "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], - "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], - - "hermes-estree": ["hermes-estree@0.15.0", "", {}, "sha512-lLYvAd+6BnOqWdnNbP/Q8xfl8LOGw4wVjfrNd9Gt8eoFzhNBRVD95n4l2ksfMVOoxuVyegs85g83KS9QOsxbVQ=="], + "hermes-compiler": ["hermes-compiler@250829098.0.10", "", {}, "sha512-TcRlZ0/TlyfJqquRFAWoyElVNnkdYRi/sEp4/Qy8/GYxjg8j2cS9D4MjuaQ+qimkmLN7AmO+44IznRf06mAr0w=="], - "hermes-parser": ["hermes-parser@0.15.0", "", { "dependencies": { "hermes-estree": "0.15.0" } }, "sha512-Q1uks5rjZlE9RjMMjSUCkGrEIPI5pKJILeCtK1VmTj7U4pf3wVPoo+cxfu+s4cBAPy2JzikIIdCZgBoR6x7U1Q=="], + "hermes-estree": ["hermes-estree@0.33.3", "", {}, "sha512-6kzYZHCk8Fy1Uc+t3HGYyJn3OL4aeqKLTyina4UFtWl8I0kSL7OmKThaiX+Uh2f8nGw3mo4Ifxg0M5Zk3/Oeqg=="], - "hermes-profile-transformer": ["hermes-profile-transformer@0.0.6", "", { "dependencies": { "source-map": "^0.7.3" } }, "sha512-cnN7bQUm65UWOy6cbGcCcZ3rpwW8Q/j4OP5aWRhEry4Z2t2aR1cjrbp0BS+KiBN0smvP1caBgAuxutvyvJILzQ=="], + "hermes-parser": ["hermes-parser@0.33.3", "", { "dependencies": { "hermes-estree": "0.33.3" } }, "sha512-Yg3HgaG4CqgyowtYjX/FsnPAuZdHOqSMtnbpylbptsQ9nwwSKsy6uRWcGO5RK0EqiX12q8HvDWKgeAVajRO5DA=="], "http-errors": ["http-errors@2.0.0", "", { "dependencies": { "depd": "2.0.0", "inherits": "2.0.4", "setprototypeof": "1.2.0", "statuses": "2.0.1", "toidentifier": "1.0.1" } }, "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ=="], - "human-signals": ["human-signals@2.1.0", "", {}, "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw=="], - - "iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], - - "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], - - "image-size": ["image-size@1.2.1", "", { "dependencies": { "queue": "6.0.2" }, "bin": { "image-size": "bin/image-size.js" } }, "sha512-rH+46sQJ2dlwfjfhCyNx5thzrv+dtmBIhPHk0zgRUukHzZ/kRueTJXoYYsclBaKcSMBWuGbOFXtioLpzTb5euw=="], - - "import-fresh": ["import-fresh@2.0.0", "", { "dependencies": { "caller-path": "^2.0.0", "resolve-from": "^3.0.0" } }, "sha512-eZ5H8rcgYazHbKC3PG4ClHNykCSxtAhxSSEM+2mb+7evD2CKF5V7c0dNum7AdpDh0ZdICwZY9sRSn8f+KH96sg=="], - - "imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="], - - "inflight": ["inflight@1.0.6", "", { "dependencies": { "once": "^1.3.0", "wrappy": "1" } }, "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA=="], + "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], "invariant": ["invariant@2.2.4", "", { "dependencies": { "loose-envify": "^1.0.0" } }, "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA=="], - "is-arrayish": ["is-arrayish@0.2.1", "", {}, "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg=="], - - "is-core-module": ["is-core-module@2.16.1", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w=="], - - "is-directory": ["is-directory@0.3.1", "", {}, "sha512-yVChGzahRFvbkscn2MlwGismPO12i9+znNruC5gVEntG3qu0xQMzsGg/JFbrsqDOHtHFPci+V5aP5T9I+yeKqw=="], - "is-docker": ["is-docker@2.2.1", "", { "bin": { "is-docker": "cli.js" } }, "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ=="], "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], - "is-interactive": ["is-interactive@1.0.0", "", {}, "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w=="], - "is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="], - "is-plain-object": ["is-plain-object@2.0.4", "", { "dependencies": { "isobject": "^3.0.1" } }, "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og=="], - - "is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="], - - "is-unicode-supported": ["is-unicode-supported@0.1.0", "", {}, "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw=="], - - "is-wsl": ["is-wsl@1.1.0", "", {}, "sha512-gfygJYZ2gLTDlmbWMI0CE2MwnFzSN/2SZfkMlItC4K/JBlsWVDB0bO6XhqcY13YXE7iMcAJnzTCJjPiTeJJ0Mw=="], - - "isarray": ["isarray@1.0.0", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="], + "is-wsl": ["is-wsl@2.2.0", "", { "dependencies": { "is-docker": "^2.0.0" } }, "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww=="], "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], - "isobject": ["isobject@3.0.1", "", {}, "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg=="], - - "jest-environment-node": ["jest-environment-node@29.7.0", "", { "dependencies": { "@jest/environment": "^29.7.0", "@jest/fake-timers": "^29.7.0", "@jest/types": "^29.6.3", "@types/node": "*", "jest-mock": "^29.7.0", "jest-util": "^29.7.0" } }, "sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw=="], - "jest-get-type": ["jest-get-type@29.6.3", "", {}, "sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw=="], - "jest-message-util": ["jest-message-util@29.7.0", "", { "dependencies": { "@babel/code-frame": "^7.12.13", "@jest/types": "^29.6.3", "@types/stack-utils": "^2.0.0", "chalk": "^4.0.0", "graceful-fs": "^4.2.9", "micromatch": "^4.0.4", "pretty-format": "^29.7.0", "slash": "^3.0.0", "stack-utils": "^2.0.3" } }, "sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w=="], - - "jest-mock": ["jest-mock@29.7.0", "", { "dependencies": { "@jest/types": "^29.6.3", "@types/node": "*", "jest-util": "^29.7.0" } }, "sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw=="], - "jest-util": ["jest-util@29.7.0", "", { "dependencies": { "@jest/types": "^29.6.3", "@types/node": "*", "chalk": "^4.0.0", "ci-info": "^3.2.0", "graceful-fs": "^4.2.9", "picomatch": "^2.2.3" } }, "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA=="], "jest-validate": ["jest-validate@29.7.0", "", { "dependencies": { "@jest/types": "^29.6.3", "camelcase": "^6.2.0", "chalk": "^4.0.0", "jest-get-type": "^29.6.3", "leven": "^3.1.0", "pretty-format": "^29.7.0" } }, "sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw=="], "jest-worker": ["jest-worker@29.7.0", "", { "dependencies": { "@types/node": "*", "jest-util": "^29.7.0", "merge-stream": "^2.0.0", "supports-color": "^8.0.0" } }, "sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw=="], - "joi": ["joi@17.13.3", "", { "dependencies": { "@hapi/hoek": "^9.3.0", "@hapi/topo": "^5.1.0", "@sideway/address": "^4.1.5", "@sideway/formula": "^3.0.1", "@sideway/pinpoint": "^2.0.0" } }, "sha512-otDA4ldcIx+ZXsKHWmp0YizCweVRZG96J10b0FevjfuncLO1oX59THoAmHkNubYJ+9gWsYsp5k8v4ib6oDv1fA=="], - "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], - "js-yaml": ["js-yaml@3.14.1", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g=="], - - "jsc-android": ["jsc-android@250231.0.0", "", {}, "sha512-rS46PvsjYmdmuz1OAWXY/1kCYG7pnf1TBqeTiOJr1iDz7s5DLxxC9n/ZMknLDxzYzNVfI7R95MH10emSSG1Wuw=="], - "jsc-safe-url": ["jsc-safe-url@0.2.4", "", {}, "sha512-0wM3YBWtYePOjfyXQH5MWQ8H7sdk5EXSwZvmSLKk2RboVQ2Bu239jycHDz5J/8Blf3K0Qnoy2b6xD+z10MFB+Q=="], - "jscodeshift": ["jscodeshift@0.14.0", "", { "dependencies": { "@babel/core": "^7.13.16", "@babel/parser": "^7.13.16", "@babel/plugin-proposal-class-properties": "^7.13.0", "@babel/plugin-proposal-nullish-coalescing-operator": "^7.13.8", "@babel/plugin-proposal-optional-chaining": "^7.13.12", "@babel/plugin-transform-modules-commonjs": "^7.13.8", "@babel/preset-flow": "^7.13.13", "@babel/preset-typescript": "^7.13.0", "@babel/register": "^7.13.16", "babel-core": "^7.0.0-bridge.0", "chalk": "^4.1.2", "flow-parser": "0.*", "graceful-fs": "^4.2.4", "micromatch": "^4.0.4", "neo-async": "^2.5.0", "node-dir": "^0.1.17", "recast": "^0.21.0", "temp": "^0.8.4", "write-file-atomic": "^2.3.0" }, "peerDependencies": { "@babel/preset-env": "^7.1.6" }, "bin": { "jscodeshift": "bin/jscodeshift.js" } }, "sha512-7eCC1knD7bLUPuSCwXsMZUH51O8jIcoVyKtI6P0XM0IVzlGjckPy3FIwQlorzbN0Sg79oK+RlohN32Mqf/lrYA=="], - "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], - "json-parse-better-errors": ["json-parse-better-errors@1.0.2", "", {}, "sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw=="], - "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], - "jsonfile": ["jsonfile@4.0.0", "", { "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg=="], - - "kind-of": ["kind-of@6.0.3", "", {}, "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw=="], - - "kleur": ["kleur@3.0.3", "", {}, "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w=="], - "leven": ["leven@3.1.0", "", {}, "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A=="], "lighthouse-logger": ["lighthouse-logger@1.4.2", "", { "dependencies": { "debug": "^2.6.9", "marky": "^1.2.2" } }, "sha512-gPWxznF6TKmUHrOQjlVo2UbaL2EJ71mb2CCeRs/2qBpi4L/g4LUVc9+3lKQ6DTUZwJswfM7ainGrLO1+fOqa2g=="], - "locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="], - - "lodash.debounce": ["lodash.debounce@4.0.8", "", {}, "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow=="], - "lodash.throttle": ["lodash.throttle@4.1.1", "", {}, "sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ=="], - "log-symbols": ["log-symbols@4.1.0", "", { "dependencies": { "chalk": "^4.1.0", "is-unicode-supported": "^0.1.0" } }, "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg=="], - - "logkitty": ["logkitty@0.7.1", "", { "dependencies": { "ansi-fragments": "^0.2.1", "dayjs": "^1.8.15", "yargs": "^15.1.0" }, "bin": { "logkitty": "bin/logkitty.js" } }, "sha512-/3ER20CTTbahrCrpYfPn7Xavv9diBROZpoXGVZDWMw4b/X4uuUwAC0ki85tgsdMRONURyIJbcOvS94QsUBYPbQ=="], - "loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="], "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], - "make-dir": ["make-dir@2.1.0", "", { "dependencies": { "pify": "^4.0.1", "semver": "^5.6.0" } }, "sha512-LS9X+dc8KLxXCb8dni79fLIIUA5VyZoyjSMCwTluaXA0o27cCK0bhXkpgw+sTXVpPy/lSO57ilRixqk0vDmtRA=="], - "makeerror": ["makeerror@1.0.12", "", { "dependencies": { "tmpl": "1.0.5" } }, "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg=="], "marky": ["marky@1.3.0", "", {}, "sha512-ocnPZQLNpvbedwTy9kNrQEsknEfgvcLMvOtz3sFeWApDq1MXH1TqkCIx58xlpESsfwQOnuBO9beyQuNGzVvuhQ=="], @@ -770,189 +347,93 @@ "merge-stream": ["merge-stream@2.0.0", "", {}, "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w=="], - "metro": ["metro@0.80.12", "", { "dependencies": { "@babel/code-frame": "^7.0.0", "@babel/core": "^7.20.0", "@babel/generator": "^7.20.0", "@babel/parser": "^7.20.0", "@babel/template": "^7.0.0", "@babel/traverse": "^7.20.0", "@babel/types": "^7.20.0", "accepts": "^1.3.7", "chalk": "^4.0.0", "ci-info": "^2.0.0", "connect": "^3.6.5", "debug": "^2.2.0", "denodeify": "^1.2.1", "error-stack-parser": "^2.0.6", "flow-enums-runtime": "^0.0.6", "graceful-fs": "^4.2.4", "hermes-parser": "0.23.1", "image-size": "^1.0.2", "invariant": "^2.2.4", "jest-worker": "^29.6.3", "jsc-safe-url": "^0.2.2", "lodash.throttle": "^4.1.1", "metro-babel-transformer": "0.80.12", "metro-cache": "0.80.12", "metro-cache-key": "0.80.12", "metro-config": "0.80.12", "metro-core": "0.80.12", "metro-file-map": "0.80.12", "metro-resolver": "0.80.12", "metro-runtime": "0.80.12", "metro-source-map": "0.80.12", "metro-symbolicate": "0.80.12", "metro-transform-plugins": "0.80.12", "metro-transform-worker": "0.80.12", "mime-types": "^2.1.27", "nullthrows": "^1.1.1", "serialize-error": "^2.1.0", "source-map": "^0.5.6", "strip-ansi": "^6.0.0", "throat": "^5.0.0", "ws": "^7.5.10", "yargs": "^17.6.2" }, "bin": { "metro": "src/cli.js" } }, "sha512-1UsH5FzJd9quUsD1qY+zUG4JY3jo3YEMxbMYH9jT6NK3j4iORhlwTK8fYTfAUBhDKjgLfKjAh7aoazNE23oIRA=="], + "metro": ["metro@0.84.6", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/core": "^7.25.2", "@babel/generator": "^7.29.1", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/traverse": "^7.29.0", "@babel/types": "^7.29.0", "accepts": "^2.0.0", "ci-info": "^2.0.0", "connect": "^3.6.5", "debug": "^4.4.0", "error-stack-parser": "^2.0.6", "flow-enums-runtime": "^0.0.6", "graceful-fs": "^4.2.4", "hermes-parser": "0.35.0", "invariant": "^2.2.4", "jest-worker": "^29.7.0", "jsc-safe-url": "^0.2.2", "lodash.throttle": "^4.1.1", "metro-babel-transformer": "0.84.6", "metro-cache": "0.84.6", "metro-cache-key": "0.84.6", "metro-config": "0.84.6", "metro-core": "0.84.6", "metro-file-map": "0.84.6", "metro-resolver": "0.84.6", "metro-runtime": "0.84.6", "metro-source-map": "0.84.6", "metro-symbolicate": "0.84.6", "metro-transform-plugins": "0.84.6", "metro-transform-worker": "0.84.6", "mime-types": "^3.0.1", "nullthrows": "^1.1.1", "serialize-error": "^2.1.0", "source-map": "^0.5.6", "throat": "^5.0.0", "ws": "^7.5.10", "yargs": "^17.6.2" }, "bin": { "metro": "src/cli.js" } }, "sha512-ty/tx/imE5Ph2gvPU788Ckim6RAC2tyZxLz2hWRgC9U7/fGKV7qY/slADy0qHPEw9cpUgc4BfmV3oUsYwm7oeg=="], - "metro-babel-transformer": ["metro-babel-transformer@0.80.12", "", { "dependencies": { "@babel/core": "^7.20.0", "flow-enums-runtime": "^0.0.6", "hermes-parser": "0.23.1", "nullthrows": "^1.1.1" } }, "sha512-YZziRs0MgA3pzCkkvOoQRXjIoVjvrpi/yRlJnObyIvMP6lFdtyG4nUGIwGY9VXnBvxmXD6mPY2e+NSw6JAyiRg=="], + "metro-babel-transformer": ["metro-babel-transformer@0.84.6", "", { "dependencies": { "@babel/core": "^7.25.2", "flow-enums-runtime": "^0.0.6", "hermes-parser": "0.35.0", "metro-cache-key": "0.84.6", "nullthrows": "^1.1.1" } }, "sha512-B1ozl6KxFHbQjXZ2U8WiTPWXylqiD3V4EXyV5r0fqETOrqIrJlBvbVTD82UxIN+B3Vpe4l8G1tb15ocd2RyOQA=="], - "metro-cache": ["metro-cache@0.80.12", "", { "dependencies": { "exponential-backoff": "^3.1.1", "flow-enums-runtime": "^0.0.6", "metro-core": "0.80.12" } }, "sha512-p5kNHh2KJ0pbQI/H7ZBPCEwkyNcSz7OUkslzsiIWBMPQGFJ/xArMwkV7I+GJcWh+b4m6zbLxE5fk6fqbVK1xGA=="], + "metro-cache": ["metro-cache@0.84.6", "", { "dependencies": { "exponential-backoff": "^3.1.1", "flow-enums-runtime": "^0.0.6", "https-proxy-agent": "^7.0.5", "metro-core": "0.84.6" } }, "sha512-KBJVpb02oKNO+jlWQ1Aax7cd11YP0MClDaMgx6C4MhyZ9g95J/dC1Bo6sCzXaU9L9h/w5cQfT5zDkGW5sOj99A=="], - "metro-cache-key": ["metro-cache-key@0.80.12", "", { "dependencies": { "flow-enums-runtime": "^0.0.6" } }, "sha512-o4BspKnugg/pE45ei0LGHVuBJXwRgruW7oSFAeSZvBKA/sGr0UhOGY3uycOgWInnS3v5yTTfiBA9lHlNRhsvGA=="], + "metro-cache-key": ["metro-cache-key@0.84.6", "", { "dependencies": { "flow-enums-runtime": "^0.0.6" } }, "sha512-6tyXt1BZ/3U183XV48oif7Nm65TE+pTo0Vs31u4FxMgqiYQR/SiSr3RoXn3pmy4pd280ZmLmct0x3jZpu0pV4A=="], - "metro-config": ["metro-config@0.80.12", "", { "dependencies": { "connect": "^3.6.5", "cosmiconfig": "^5.0.5", "flow-enums-runtime": "^0.0.6", "jest-validate": "^29.6.3", "metro": "0.80.12", "metro-cache": "0.80.12", "metro-core": "0.80.12", "metro-runtime": "0.80.12" } }, "sha512-4rwOWwrhm62LjB12ytiuR5NgK1ZBNr24/He8mqCsC+HXZ+ATbrewLNztzbAZHtFsrxP4D4GLTGgh96pCpYLSAQ=="], + "metro-config": ["metro-config@0.84.6", "", { "dependencies": { "connect": "^3.6.5", "flow-enums-runtime": "^0.0.6", "jest-validate": "^29.7.0", "metro": "0.84.6", "metro-cache": "0.84.6", "metro-core": "0.84.6", "metro-runtime": "0.84.6", "yaml": "^2.6.1" } }, "sha512-cD2mLEofcuV26kxSV4hfV1Pbuh6igdUhYQriO8ZVeJ7HyZfq4/wqHV0gSR7fEO8NUp9odr60/Kzm5YakmghWwQ=="], - "metro-core": ["metro-core@0.80.12", "", { "dependencies": { "flow-enums-runtime": "^0.0.6", "lodash.throttle": "^4.1.1", "metro-resolver": "0.80.12" } }, "sha512-QqdJ/yAK+IpPs2HU/h5v2pKEdANBagSsc6DRSjnwSyJsCoHlmyJKCaCJ7KhWGx+N4OHxh37hoA8fc2CuZbx0Fw=="], + "metro-core": ["metro-core@0.84.6", "", { "dependencies": { "flow-enums-runtime": "^0.0.6", "lodash.throttle": "^4.1.1", "metro-resolver": "0.84.6" } }, "sha512-ZqYskM8+f3PFMosBDJd8DLqRvT1ltRGhWL/ZfTpBvf4SoadO30VVrdhAoHFVGz3G1CJpWa3fgZH7nnnX/ybllA=="], - "metro-file-map": ["metro-file-map@0.80.12", "", { "dependencies": { "anymatch": "^3.0.3", "debug": "^2.2.0", "fb-watchman": "^2.0.0", "flow-enums-runtime": "^0.0.6", "graceful-fs": "^4.2.4", "invariant": "^2.2.4", "jest-worker": "^29.6.3", "micromatch": "^4.0.4", "node-abort-controller": "^3.1.1", "nullthrows": "^1.1.1", "walker": "^1.0.7" }, "optionalDependencies": { "fsevents": "^2.3.2" } }, "sha512-sYdemWSlk66bWzW2wp79kcPMzwuG32x1ZF3otI0QZTmrnTaaTiGyhE66P1z6KR4n2Eu5QXiABa6EWbAQv0r8bw=="], + "metro-file-map": ["metro-file-map@0.84.6", "", { "dependencies": { "debug": "^4.4.0", "fb-watchman": "^2.0.0", "flow-enums-runtime": "^0.0.6", "graceful-fs": "^4.2.4", "invariant": "^2.2.4", "jest-worker": "^29.7.0", "micromatch": "^4.0.4", "nullthrows": "^1.1.1", "walker": "^1.0.7" } }, "sha512-ov9VywWBsHPt54Zc7/DxWvqjTwxuZvhfeyHg8ZAMGKtf2PcH1wS3EnoBH7In53Rspk/eXVWAO5+avxunPn081w=="], - "metro-minify-terser": ["metro-minify-terser@0.80.12", "", { "dependencies": { "flow-enums-runtime": "^0.0.6", "terser": "^5.15.0" } }, "sha512-muWzUw3y5k+9083ZoX9VaJLWEV2Jcgi+Oan0Mmb/fBNMPqP9xVDuy4pOMn/HOiGndgfh/MK7s4bsjkyLJKMnXQ=="], + "metro-minify-terser": ["metro-minify-terser@0.84.6", "", { "dependencies": { "flow-enums-runtime": "^0.0.6", "terser": "^5.15.0" } }, "sha512-SqhB/Kxrw0XfyW0k8mEscqX/CQopm6Fp3EpdSIPzvKfGgQp9bTMahg9PAdVsXUmIWwErbmFw1VqB+VFBOKksUw=="], - "metro-resolver": ["metro-resolver@0.80.12", "", { "dependencies": { "flow-enums-runtime": "^0.0.6" } }, "sha512-PR24gYRZnYHM3xT9pg6BdbrGbM/Cu1TcyIFBVlAk7qDAuHkUNQ1nMzWumWs+kwSvtd9eZGzHoucGJpTUEeLZAw=="], + "metro-resolver": ["metro-resolver@0.84.6", "", { "dependencies": { "flow-enums-runtime": "^0.0.6" } }, "sha512-PplpGc/OCLxgKeLNsLQO/VHsYDIvkcGGr3zNT7UanHCGRqhxKv245o+naV8cRI4/pcLqyc+9j+J0AJbT3Kp5Sg=="], - "metro-runtime": ["metro-runtime@0.80.12", "", { "dependencies": { "@babel/runtime": "^7.25.0", "flow-enums-runtime": "^0.0.6" } }, "sha512-LIx7+92p5rpI0i6iB4S4GBvvLxStNt6fF0oPMaUd1Weku7jZdfkCZzmrtDD9CSQ6EPb0T9NUZoyXIxlBa3wOCw=="], + "metro-runtime": ["metro-runtime@0.84.6", "", { "dependencies": { "@babel/runtime": "^7.25.0", "flow-enums-runtime": "^0.0.6" } }, "sha512-47EYO/DOai0PA3GoAyDpyXOgHNMwbl/Z9dQlQineDLHkpD42xOwMrIx1crT0+n6F+aGoEyxZmeFwfMzaIqVqKQ=="], - "metro-source-map": ["metro-source-map@0.80.12", "", { "dependencies": { "@babel/traverse": "^7.20.0", "@babel/types": "^7.20.0", "flow-enums-runtime": "^0.0.6", "invariant": "^2.2.4", "metro-symbolicate": "0.80.12", "nullthrows": "^1.1.1", "ob1": "0.80.12", "source-map": "^0.5.6", "vlq": "^1.0.0" } }, "sha512-o+AXmE7hpvM8r8MKsx7TI21/eerYYy2DCDkWfoBkv+jNkl61khvDHlQn0cXZa6lrcNZiZkl9oHSMcwLLIrFmpw=="], + "metro-source-map": ["metro-source-map@0.84.6", "", { "dependencies": { "@babel/traverse": "^7.29.0", "@babel/types": "^7.29.0", "flow-enums-runtime": "^0.0.6", "invariant": "^2.2.4", "metro-symbolicate": "0.84.6", "nullthrows": "^1.1.1", "ob1": "0.84.6", "source-map": "^0.5.6", "vlq": "^1.0.0" } }, "sha512-4p/EplRbgNxhdqA6kAj1AmIq9qqYXYSAjgxuZ73rYA+p037xDDrxK8CyDKNfbn1k6aMuVt8LV0fpt7NHUTKiZw=="], - "metro-symbolicate": ["metro-symbolicate@0.80.12", "", { "dependencies": { "flow-enums-runtime": "^0.0.6", "invariant": "^2.2.4", "metro-source-map": "0.80.12", "nullthrows": "^1.1.1", "source-map": "^0.5.6", "through2": "^2.0.1", "vlq": "^1.0.0" }, "bin": { "metro-symbolicate": "src/index.js" } }, "sha512-/dIpNdHksXkGHZXARZpL7doUzHqSNxgQ8+kQGxwpJuHnDhGkENxB5PS2QBaTDdEcmyTMjS53CN1rl9n1gR6fmw=="], + "metro-symbolicate": ["metro-symbolicate@0.84.6", "", { "dependencies": { "flow-enums-runtime": "^0.0.6", "invariant": "^2.2.4", "metro-source-map": "0.84.6", "nullthrows": "^1.1.1", "source-map": "^0.5.6", "vlq": "^1.0.0" }, "bin": { "metro-symbolicate": "src/index.js" } }, "sha512-wJFyyF5ysbVyoYVWGL6GwVMiNnGllwDU2gF096376fl0fc8IFUlADSyNlswj31K9og2sOilXe/FdyWFPczPUYg=="], - "metro-transform-plugins": ["metro-transform-plugins@0.80.12", "", { "dependencies": { "@babel/core": "^7.20.0", "@babel/generator": "^7.20.0", "@babel/template": "^7.0.0", "@babel/traverse": "^7.20.0", "flow-enums-runtime": "^0.0.6", "nullthrows": "^1.1.1" } }, "sha512-WQWp00AcZvXuQdbjQbx1LzFR31IInlkCDYJNRs6gtEtAyhwpMMlL2KcHmdY+wjDO9RPcliZ+Xl1riOuBecVlPA=="], + "metro-transform-plugins": ["metro-transform-plugins@0.84.6", "", { "dependencies": { "@babel/core": "^7.25.2", "@babel/generator": "^7.29.1", "@babel/template": "^7.28.6", "@babel/traverse": "^7.29.0", "flow-enums-runtime": "^0.0.6", "nullthrows": "^1.1.1" } }, "sha512-pROPMFaj25Y9+3LlLqpBRz/7B/2YPnfEQz/z3juYxkh3FGQ+3c/Qh4E+khPSTdwTw0M6LitLYHMqaHMbQmuBJw=="], - "metro-transform-worker": ["metro-transform-worker@0.80.12", "", { "dependencies": { "@babel/core": "^7.20.0", "@babel/generator": "^7.20.0", "@babel/parser": "^7.20.0", "@babel/types": "^7.20.0", "flow-enums-runtime": "^0.0.6", "metro": "0.80.12", "metro-babel-transformer": "0.80.12", "metro-cache": "0.80.12", "metro-cache-key": "0.80.12", "metro-minify-terser": "0.80.12", "metro-source-map": "0.80.12", "metro-transform-plugins": "0.80.12", "nullthrows": "^1.1.1" } }, "sha512-KAPFN1y3eVqEbKLx1I8WOarHPqDMUa8WelWxaJCNKO/yHCP26zELeqTJvhsQup+8uwB6EYi/sp0b6TGoh6lOEA=="], + "metro-transform-worker": ["metro-transform-worker@0.84.6", "", { "dependencies": { "@babel/core": "^7.25.2", "@babel/generator": "^7.29.1", "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "flow-enums-runtime": "^0.0.6", "metro": "0.84.6", "metro-babel-transformer": "0.84.6", "metro-cache": "0.84.6", "metro-cache-key": "0.84.6", "metro-minify-terser": "0.84.6", "metro-source-map": "0.84.6", "metro-transform-plugins": "0.84.6", "nullthrows": "^1.1.1" } }, "sha512-xLTVrOENaHR9DiuhnHS+zcle0rKsCy2CpXSIx7xD+zhJs+qVRNUyV7Z1zIt/0P/d0cSGVx846R+dIjHEudhcww=="], "micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="], - "mime": ["mime@2.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg=="], - - "mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], - - "mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], + "mime": ["mime@1.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg=="], - "mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="], + "mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], - "minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], + "mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], - "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], - - "mkdirp": ["mkdirp@0.5.6", "", { "dependencies": { "minimist": "^1.2.6" }, "bin": { "mkdirp": "bin/cmd.js" } }, "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw=="], + "mkdirp": ["mkdirp@1.0.4", "", { "bin": { "mkdirp": "bin/cmd.js" } }, "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw=="], "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], - "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], - - "negotiator": ["negotiator@0.6.4", "", {}, "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w=="], - - "neo-async": ["neo-async@2.6.2", "", {}, "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw=="], - - "nocache": ["nocache@3.0.4", "", {}, "sha512-WDD0bdg9mbq6F4mRxEYcPWwfA1vxd0mrvKOyxI7Xj/atfRHVeutzuWByG//jfm4uPzp0y4Kj051EORCBSQMycw=="], - - "node-abort-controller": ["node-abort-controller@3.1.1", "", {}, "sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ=="], - - "node-dir": ["node-dir@0.1.17", "", { "dependencies": { "minimatch": "^3.0.2" } }, "sha512-tmPX422rYgofd4epzrNoOXiE8XFZYOcCq1vD7MAXCDO+O+zndlA2ztdKKMa+EeuBG5tHETpr4ml4RGgpqDCCAg=="], - - "node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="], + "negotiator": ["negotiator@1.1.0", "", { "dependencies": { "content-type": "^2.1.0" } }, "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg=="], "node-int64": ["node-int64@0.4.0", "", {}, "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw=="], "node-releases": ["node-releases@2.0.19", "", {}, "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw=="], - "node-stream-zip": ["node-stream-zip@1.15.0", "", {}, "sha512-LN4fydt9TqhZhThkZIVQnF9cwjU3qmUH9h78Mx/K7d3VvfRqqwthLwJEUOEL0QPZ0XQmNN7be5Ggit5+4dq3Bw=="], - - "normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="], - - "npm-run-path": ["npm-run-path@4.0.1", "", { "dependencies": { "path-key": "^3.0.0" } }, "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw=="], - "nullthrows": ["nullthrows@1.1.1", "", {}, "sha512-2vPPEi+Z7WqML2jZYddDIfy5Dqb0r2fze2zTxNNknZaFpVHU3mFB3R+DWeJWGVx0ecvttSGlJTI+WG+8Z4cDWw=="], - "ob1": ["ob1@0.80.12", "", { "dependencies": { "flow-enums-runtime": "^0.0.6" } }, "sha512-VMArClVT6LkhUGpnuEoBuyjG9rzUyEzg4PDkav6wK1cLhOK02gPCYFxoiB4mqVnrMhDpIzJcrGNAMVi9P+hXrw=="], - - "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], - - "on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="], - - "on-headers": ["on-headers@1.0.2", "", {}, "sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA=="], - - "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], - - "onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="], + "ob1": ["ob1@0.84.6", "", { "dependencies": { "flow-enums-runtime": "^0.0.6" } }, "sha512-+s+6zjd0X68hfAcn92NsEPXnSYOF3O7exw/Fj4UfRTB+UL+Tdjdkzu5/4WquXlzqgvNm8FWuuW6/0Yd8S2rYxw=="], - "open": ["open@6.4.0", "", { "dependencies": { "is-wsl": "^1.1.0" } }, "sha512-IFenVPgF70fSm1keSd2iDBIDIBZkroLeuffXq+wKTzTJlBpesFWojV9lb8mzOfaAzM1sr7HQHuO0vtV0zYekGg=="], + "on-finished": ["on-finished@2.3.0", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww=="], - "ora": ["ora@5.4.1", "", { "dependencies": { "bl": "^4.1.0", "chalk": "^4.1.0", "cli-cursor": "^3.1.0", "cli-spinners": "^2.5.0", "is-interactive": "^1.0.0", "is-unicode-supported": "^0.1.0", "log-symbols": "^4.1.0", "strip-ansi": "^6.0.0", "wcwidth": "^1.0.1" } }, "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ=="], - - "p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="], - - "p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="], - - "p-try": ["p-try@2.2.0", "", {}, "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ=="], - - "parse-json": ["parse-json@4.0.0", "", { "dependencies": { "error-ex": "^1.3.1", "json-parse-better-errors": "^1.0.1" } }, "sha512-aOIos8bujGN93/8Ox/jPLh7RwVnPEysynVFE+fQZyg6jKELEHwzgKdLRFHUgXJL6kylijVSBC4BvN9OmsB48Rw=="], + "open": ["open@7.4.2", "", { "dependencies": { "is-docker": "^2.0.0", "is-wsl": "^2.1.1" } }, "sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q=="], "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], - "path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="], - - "path-is-absolute": ["path-is-absolute@1.0.1", "", {}, "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg=="], - "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], - "path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="], - "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], - "picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], - - "pify": ["pify@4.0.1", "", {}, "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g=="], - - "pirates": ["pirates@4.0.7", "", {}, "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA=="], - - "pkg-dir": ["pkg-dir@3.0.0", "", { "dependencies": { "find-up": "^3.0.0" } }, "sha512-/E57AYkoeQ25qkxMj5PBOVgF8Kiu/h7cYS30Z5+R7WaiCCBfLq58ZI/dSeaEKb9WVJV5n/03QwrN3IeWIFllvw=="], - - "pretty-format": ["pretty-format@26.6.2", "", { "dependencies": { "@jest/types": "^26.6.2", "ansi-regex": "^5.0.0", "ansi-styles": "^4.0.0", "react-is": "^17.0.1" } }, "sha512-7AeGuCYNGmycyQbCqd/3PWH4eOoX/OiCa0uphp57NVTeAGdJGaAliecxwBDHYQCIvrW7aDBZCYeNTP/WX69mkg=="], + "picomatch": ["picomatch@4.0.7", "", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="], - "process-nextick-args": ["process-nextick-args@2.0.1", "", {}, "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="], + "pretty-format": ["pretty-format@29.7.0", "", { "dependencies": { "@jest/schemas": "^29.6.3", "ansi-styles": "^5.0.0", "react-is": "^18.0.0" } }, "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ=="], "promise": ["promise@8.3.0", "", { "dependencies": { "asap": "~2.0.6" } }, "sha512-rZPNPKTOYVNEEKFaq1HqTgOwZD+4/YHS5ukLzQCypkj+OkYx7iv0mA91lJlpPPZ8vMau3IIGj5Qlwrx+8iiSmg=="], - "prompts": ["prompts@2.4.2", "", { "dependencies": { "kleur": "^3.0.3", "sisteransi": "^1.0.5" } }, "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q=="], - - "prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="], - - "queue": ["queue@6.0.2", "", { "dependencies": { "inherits": "~2.0.3" } }, "sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA=="], - "range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="], - "react": ["react@18.2.0", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ=="], + "react": ["react@19.2.3", "", {}, "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA=="], - "react-devtools-core": ["react-devtools-core@4.28.5", "", { "dependencies": { "shell-quote": "^1.6.1", "ws": "^7" } }, "sha512-cq/o30z9W2Wb4rzBefjv5fBalHU0rJGZCHAkf/RHSBWSSYwh8PlQTqqOJmgIIbBtpj27T6FIPXeomIjZtCNVqA=="], + "react-devtools-core": ["react-devtools-core@6.1.5", "", { "dependencies": { "shell-quote": "^1.6.1", "ws": "^7" } }, "sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA=="], - "react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="], + "react-is": ["react-is@19.3.0", "", {}, "sha512-UpMYezM4v5/18F28aC66AEsjXIgE02kyEMH6yLdgLXu/UTfa1Ntwck/nNLrbqJsEXW7gPb0coNO9FQse9WTovA=="], - "react-native": ["react-native@0.73.11", "", { "dependencies": { "@jest/create-cache-key-function": "^29.6.3", "@react-native-community/cli": "12.3.7", "@react-native-community/cli-platform-android": "12.3.7", "@react-native-community/cli-platform-ios": "12.3.7", "@react-native/assets-registry": "0.73.1", "@react-native/codegen": "0.73.3", "@react-native/community-cli-plugin": "0.73.18", "@react-native/gradle-plugin": "0.73.5", "@react-native/js-polyfills": "0.73.1", "@react-native/normalize-colors": "0.73.2", "@react-native/virtualized-lists": "0.73.4", "abort-controller": "^3.0.0", "anser": "^1.4.9", "ansi-regex": "^5.0.0", "base64-js": "^1.5.1", "chalk": "^4.0.0", "deprecated-react-native-prop-types": "^5.0.0", "event-target-shim": "^5.0.1", "flow-enums-runtime": "^0.0.6", "invariant": "^2.2.4", "jest-environment-node": "^29.6.3", "jsc-android": "^250231.0.0", "memoize-one": "^5.0.0", "metro-runtime": "^0.80.3", "metro-source-map": "^0.80.3", "mkdirp": "^0.5.1", "nullthrows": "^1.1.1", "pretty-format": "^26.5.2", "promise": "^8.3.0", "react-devtools-core": "^4.27.7", "react-refresh": "^0.14.0", "react-shallow-renderer": "^16.15.0", "regenerator-runtime": "^0.13.2", "scheduler": "0.24.0-canary-efb381bbf-20230505", "stacktrace-parser": "^0.1.10", "whatwg-fetch": "^3.0.0", "ws": "^6.2.2", "yargs": "^17.6.2" }, "peerDependencies": { "react": "18.2.0" }, "bin": { "react-native": "cli.js" } }, "sha512-yvQIX+ZXOHMFnhmwZ1fBpRI/53k+iLN8DxVf24Fx4ABU63RGAYfyCZC0/3W+5OUVx4KSIZUv4Tv+/NGIieBOwg=="], + "react-native": ["react-native@0.85.2", "", { "dependencies": { "@react-native/assets-registry": "0.85.2", "@react-native/codegen": "0.85.2", "@react-native/community-cli-plugin": "0.85.2", "@react-native/gradle-plugin": "0.85.2", "@react-native/js-polyfills": "0.85.2", "@react-native/normalize-colors": "0.85.2", "@react-native/virtualized-lists": "0.85.2", "abort-controller": "^3.0.0", "anser": "^1.4.9", "ansi-regex": "^5.0.0", "babel-plugin-syntax-hermes-parser": "0.33.3", "base64-js": "^1.5.1", "commander": "^12.0.0", "flow-enums-runtime": "^0.0.6", "hermes-compiler": "250829098.0.10", "invariant": "^2.2.4", "memoize-one": "^5.0.0", "metro-runtime": "^0.84.0", "metro-source-map": "^0.84.0", "nullthrows": "^1.1.1", "pretty-format": "^29.7.0", "promise": "^8.3.0", "react-devtools-core": "^6.1.5", "react-refresh": "^0.14.0", "regenerator-runtime": "^0.13.2", "scheduler": "0.27.0", "semver": "^7.1.3", "stacktrace-parser": "^0.1.10", "tinyglobby": "^0.2.15", "whatwg-fetch": "^3.0.0", "ws": "^7.5.10", "yargs": "^17.6.2" }, "peerDependencies": { "@react-native/jest-preset": "0.85.2", "@types/react": "^19.1.1", "react": "^19.2.3" }, "optionalPeers": ["@react-native/jest-preset", "@types/react"], "bin": { "react-native": "cli.js" } }, "sha512-GFWEPwLYirfj5X8gMtXOWtqX0cqUEURRHETZfFk37VCa4++izrKvGvv24anvuyulXV87NAhVkfNw93rLg3HByw=="], "react-refresh": ["react-refresh@0.14.2", "", {}, "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA=="], - "react-shallow-renderer": ["react-shallow-renderer@16.15.0", "", { "dependencies": { "object-assign": "^4.1.1", "react-is": "^16.12.0 || ^17.0.0 || ^18.0.0" }, "peerDependencies": { "react": "^16.0.0 || ^17.0.0 || ^18.0.0" } }, "sha512-oScf2FqQ9LFVQgA73vr86xl2NaOIX73rh+YFqcOp68CWj56tSfgtGKrEbyhCj0rSijyG9M1CYprTh39fBi5hzA=="], - - "react-test-renderer": ["react-test-renderer@18.2.0", "", { "dependencies": { "react-is": "^18.2.0", "react-shallow-renderer": "^16.15.0", "scheduler": "^0.23.0" }, "peerDependencies": { "react": "^18.2.0" } }, "sha512-JWD+aQ0lh2gvh4NM3bBM42Kx+XybOxCpgYK7F8ugAlpaTSnWsX+39Z4XkOykGZAHrjwwTZT3x3KxswVWxHPUqA=="], - - "readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], - - "readline": ["readline@1.3.0", "", {}, "sha512-k2d6ACCkiNYz222Fs/iNze30rRJ1iIicW7JuX/7/cozvih6YCkFZH+J6mAFDVgv0dRBaAyr4jDqC95R2y4IADg=="], - - "recast": ["recast@0.21.5", "", { "dependencies": { "ast-types": "0.15.2", "esprima": "~4.0.0", "source-map": "~0.6.1", "tslib": "^2.0.1" } }, "sha512-hjMmLaUXAm1hIuTqOdeYObMslq/q+Xff6QE3Y2P+uoHAg2nmVlLBps2hzh1UJDdMtDTMXOFewK6ky51JQIeECg=="], - - "regenerate": ["regenerate@1.4.2", "", {}, "sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A=="], - - "regenerate-unicode-properties": ["regenerate-unicode-properties@10.2.0", "", { "dependencies": { "regenerate": "^1.4.2" } }, "sha512-DqHn3DwbmmPVzeKj9woBadqmXxLvQoQIwu7nopMc72ztvxVmVk2SBhSnx67zuye5TP+lJsb/TBQsjLKhnDf3MA=="], + "react-test-renderer": ["react-test-renderer@19.2.3", "", { "dependencies": { "react-is": "^19.2.3", "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.3" } }, "sha512-TMR1LnSFiWZMJkCgNf5ATSvAheTT2NvKIwiVwdBPHxjBI7n/JbWd4gaZ16DVd9foAXdvDz+sB5yxZTwMjPRxpw=="], "regenerator-runtime": ["regenerator-runtime@0.13.11", "", {}, "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg=="], - "regenerator-transform": ["regenerator-transform@0.15.2", "", { "dependencies": { "@babel/runtime": "^7.8.4" } }, "sha512-hfMp2BoF0qOk3uc5V20ALGDS2ddjQaLrdl7xrGXvAIow7qeWRM2VA2HuCHkUKk9slq3VwEwLNK3DFBqDfPGYtg=="], - - "regexpu-core": ["regexpu-core@6.2.0", "", { "dependencies": { "regenerate": "^1.4.2", "regenerate-unicode-properties": "^10.2.0", "regjsgen": "^0.8.0", "regjsparser": "^0.12.0", "unicode-match-property-ecmascript": "^2.0.0", "unicode-match-property-value-ecmascript": "^2.1.0" } }, "sha512-H66BPQMrv+V16t8xtmq+UC0CBpiTBA60V8ibS1QVReIp8T1z8hwFxqcGzm9K6lgsN7sB5edVH8a+ze6Fqm4weA=="], - - "regjsgen": ["regjsgen@0.8.0", "", {}, "sha512-RvwtGe3d7LvWiDQXeQw8p5asZUmfU1G/l6WbUXeHta7Y2PEIvBTwH6E2EfmYUK8pxcxEdEmaomqyp0vZZ7C+3Q=="], - - "regjsparser": ["regjsparser@0.12.0", "", { "dependencies": { "jsesc": "~3.0.2" }, "bin": { "regjsparser": "bin/parser" } }, "sha512-cnE+y8bz4NhMjISKbgeVJtqNbtf5QpjZP+Bslo+UqkIt9QPnX9q095eiRRASJG1/tz6dlNr6Z5NsBiWYokp6EQ=="], - "require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="], - "require-main-filename": ["require-main-filename@2.0.0", "", {}, "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg=="], - - "resolve": ["resolve@1.22.10", "", { "dependencies": { "is-core-module": "^2.16.0", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w=="], - - "resolve-from": ["resolve-from@3.0.0", "", {}, "sha512-GnlH6vxLymXJNMBo7XP1fJIzBFbdYt49CuTwmB/6N53t+kMPRMFKz783LlQ4tv28XoQfMWinAJX6WCGf2IlaIw=="], - - "restore-cursor": ["restore-cursor@3.1.0", "", { "dependencies": { "onetime": "^5.1.0", "signal-exit": "^3.0.2" } }, "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA=="], + "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], - "rimraf": ["rimraf@2.6.3", "", { "dependencies": { "glob": "^7.1.3" }, "bin": { "rimraf": "./bin.js" } }, "sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA=="], - - "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], - - "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], - - "scheduler": ["scheduler@0.24.0-canary-efb381bbf-20230505", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-ABvovCDe/k9IluqSh4/ISoq8tIJnW8euVAWYt5j/bg6dRnqwQwiGO1F/V4AyK96NGF/FB04FhOUDuWj8IKfABA=="], - - "semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + "semver": ["semver@7.7.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA=="], "send": ["send@0.19.0", "", { "dependencies": { "debug": "2.6.9", "depd": "2.0.0", "destroy": "1.2.0", "encodeurl": "~1.0.2", "escape-html": "~1.0.3", "etag": "~1.8.1", "fresh": "0.5.2", "http-errors": "2.0.0", "mime": "1.6.0", "ms": "2.1.3", "on-finished": "2.4.1", "range-parser": "~1.2.1", "statuses": "2.0.1" } }, "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw=="], @@ -960,65 +441,35 @@ "serve-static": ["serve-static@1.16.2", "", { "dependencies": { "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "parseurl": "~1.3.3", "send": "0.19.0" } }, "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw=="], - "set-blocking": ["set-blocking@2.0.0", "", {}, "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw=="], - "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], - "shallow-clone": ["shallow-clone@3.0.1", "", { "dependencies": { "kind-of": "^6.0.2" } }, "sha512-/6KqX+GVUdqPuPPd2LxDDxzX6CAbjJehAAOKlNpqqUpAqPM6HeL8f+o3a+JsyGjn2lv0WY8UsTgUJjU9Ok55NA=="], - "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], "shell-quote": ["shell-quote@1.8.2", "", {}, "sha512-AzqKpGKjrj7EM6rKVQEPpB288oCfnrEIuyoT9cyF4nmGa7V8Zk6f7RRqYisX8X9m+Q7bd632aZW4ky7EhbQztA=="], - "signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], - - "sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="], - - "slash": ["slash@3.0.0", "", {}, "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="], - - "slice-ansi": ["slice-ansi@2.1.0", "", { "dependencies": { "ansi-styles": "^3.2.0", "astral-regex": "^1.0.0", "is-fullwidth-code-point": "^2.0.0" } }, "sha512-Qu+VC3EwYLldKa1fCxuuvULvSJOKEgk9pi8dZeCVK7TqBfUNTH4sFkk4joj8afVSfAYgJoSOetjx9QWOJ5mYoQ=="], - "source-map": ["source-map@0.5.7", "", {}, "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ=="], "source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="], - "sprintf-js": ["sprintf-js@1.0.3", "", {}, "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="], - - "stack-utils": ["stack-utils@2.0.6", "", { "dependencies": { "escape-string-regexp": "^2.0.0" } }, "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ=="], - "stackframe": ["stackframe@1.3.4", "", {}, "sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw=="], "stacktrace-parser": ["stacktrace-parser@0.1.11", "", { "dependencies": { "type-fest": "^0.7.1" } }, "sha512-WjlahMgHmCJpqzU8bIBy4qtsZdU9lRlcZE3Lvyej6t4tuOuv1vk57OW3MBrj6hXBFx/nNoC9MPMTcr5YA7NQbg=="], - "statuses": ["statuses@2.0.1", "", {}, "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ=="], + "statuses": ["statuses@1.5.0", "", {}, "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA=="], "string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], - "string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="], - - "strip-ansi": ["strip-ansi@5.2.0", "", { "dependencies": { "ansi-regex": "^4.1.0" } }, "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA=="], - - "strip-final-newline": ["strip-final-newline@2.0.0", "", {}, "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA=="], - - "strnum": ["strnum@1.1.2", "", {}, "sha512-vrN+B7DBIoTTZjnPNewwhx6cBA/H+IS7rfW68n7XxC1y7uoiGQBxaKzqucGUgavX15dJgiGztLJ8vxuEzwqBdA=="], + "strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], - "sudo-prompt": ["sudo-prompt@9.2.1", "", {}, "sha512-Mu7R0g4ig9TUuGSxJavny5Rv0egCEtpZRNMrZaYS1vxkiIxGiGUwoezU3LazIQ+KE04hTrTfNPgxU5gzi7F5Pw=="], - - "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], - - "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="], - - "temp": ["temp@0.8.4", "", { "dependencies": { "rimraf": "~2.6.2" } }, "sha512-s0ZZzd0BzYv5tLSptZooSjK8oj6C+c19p7Vqta9+6NPOf7r+fxq0cJe6/oN4LTC79sy5NY8ucOJNgwsKCSbfqg=="], - - "temp-dir": ["temp-dir@2.0.0", "", {}, "sha512-aoBAniQmmwtcKp/7BzsH8Cxzv8OL736p7v1ihGb5e9DJ9kTwGWHrQrVB5+lfVDzfGrdRzXch+ig7LHaY1JTOrg=="], + "supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="], "terser": ["terser@5.39.0", "", { "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.8.2", "commander": "^2.20.0", "source-map-support": "~0.5.20" }, "bin": { "terser": "bin/terser" } }, "sha512-LBAhFyLho16harJoWMg/nZsQYgTrg5jXOn2nCYjRUcZZEdE3qa2zb8QEDRUGVZBW4rlazf2fxkg8tztybTaqWw=="], "throat": ["throat@5.0.0", "", {}, "sha512-fcwX4mndzpLQKBS1DVYhGAcYaYt7vsHNIvQV+WXMvnow5cgjPphq5CaayLaGsjRdSCKZFNGt7/GYAuXaNOiYCA=="], - "through2": ["through2@2.0.5", "", { "dependencies": { "readable-stream": "~2.3.6", "xtend": "~4.0.1" } }, "sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ=="], + "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], "tmpl": ["tmpl@1.0.5", "", {}, "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw=="], @@ -1026,63 +477,29 @@ "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], - "tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="], - - "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - - "type-detect": ["type-detect@4.0.8", "", {}, "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g=="], - "type-fest": ["type-fest@0.7.1", "", {}, "sha512-Ne2YiiGN8bmrmJJEuTWTLJR32nh/JdL1+PSicowtNb0WFpn59GK8/lfD61bVtzguz7b3PBt74nxpv/Pw5po5Rg=="], "typescript": ["typescript@7.0.2", "", { "optionalDependencies": { "@typescript/typescript-aix-ppc64": "7.0.2", "@typescript/typescript-darwin-arm64": "7.0.2", "@typescript/typescript-darwin-x64": "7.0.2", "@typescript/typescript-freebsd-arm64": "7.0.2", "@typescript/typescript-freebsd-x64": "7.0.2", "@typescript/typescript-linux-arm": "7.0.2", "@typescript/typescript-linux-arm64": "7.0.2", "@typescript/typescript-linux-loong64": "7.0.2", "@typescript/typescript-linux-mips64el": "7.0.2", "@typescript/typescript-linux-ppc64": "7.0.2", "@typescript/typescript-linux-riscv64": "7.0.2", "@typescript/typescript-linux-s390x": "7.0.2", "@typescript/typescript-linux-x64": "7.0.2", "@typescript/typescript-netbsd-arm64": "7.0.2", "@typescript/typescript-netbsd-x64": "7.0.2", "@typescript/typescript-openbsd-arm64": "7.0.2", "@typescript/typescript-openbsd-x64": "7.0.2", "@typescript/typescript-sunos-x64": "7.0.2", "@typescript/typescript-win32-arm64": "7.0.2", "@typescript/typescript-win32-x64": "7.0.2" }, "bin": { "tsc": "bin/tsc" } }, "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA=="], "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], - "unicode-canonical-property-names-ecmascript": ["unicode-canonical-property-names-ecmascript@2.0.1", "", {}, "sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg=="], - - "unicode-match-property-ecmascript": ["unicode-match-property-ecmascript@2.0.0", "", { "dependencies": { "unicode-canonical-property-names-ecmascript": "^2.0.0", "unicode-property-aliases-ecmascript": "^2.0.0" } }, "sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q=="], - - "unicode-match-property-value-ecmascript": ["unicode-match-property-value-ecmascript@2.2.0", "", {}, "sha512-4IehN3V/+kkr5YeSSDDQG8QLqO26XpL2XP3GQtqwlT/QYSECAwFztxVHjlbh0+gjJ3XmNLS0zDsbgs9jWKExLg=="], - - "unicode-property-aliases-ecmascript": ["unicode-property-aliases-ecmascript@2.1.0", "", {}, "sha512-6t3foTQI9qne+OZoVQB/8x8rk2k1eVy1gRXhV3oFQ5T6R1dqQ1xtin3XqSlx3+ATBkliTaR/hHyJBm+LVPNM8w=="], - - "universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="], - "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], "update-browserslist-db": ["update-browserslist-db@1.1.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw=="], - "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], - "utils-merge": ["utils-merge@1.0.1", "", {}, "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA=="], - "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], - "vlq": ["vlq@1.0.1", "", {}, "sha512-gQpnTgkubC6hQgdIcRdYGDSDc+SaujOdyesZQMv6JlfQee/9Mp0Qhnys6WxDWvQnL5WZdT7o2Ul187aSt0Rq+w=="], "walker": ["walker@1.0.8", "", { "dependencies": { "makeerror": "1.0.12" } }, "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ=="], - "wcwidth": ["wcwidth@1.0.1", "", { "dependencies": { "defaults": "^1.0.3" } }, "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg=="], - - "webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="], - "whatwg-fetch": ["whatwg-fetch@3.6.20", "", {}, "sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg=="], - "whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="], - "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], - "which-module": ["which-module@2.0.1", "", {}, "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ=="], - "wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], - "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], - - "write-file-atomic": ["write-file-atomic@2.4.3", "", { "dependencies": { "graceful-fs": "^4.1.11", "imurmurhash": "^0.1.4", "signal-exit": "^3.0.2" } }, "sha512-GaETH5wwsX+GcnzhPgKcKjJ6M2Cq3/iZp1WyY/X1CSqrW+jVNM9Y7D8EC2sM4ZG/V8wZlSniJnCKWPmBYAucRQ=="], - - "ws": ["ws@6.2.3", "", { "dependencies": { "async-limiter": "~1.0.0" } }, "sha512-jmTjYU0j60B+vHey6TfR3Z7RD61z/hmxBS3VMSGIrroOWXQEneK1zNuotOUrGyBHQj0yrpsLHPWtigEFd13ndA=="], - - "xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="], + "ws": ["ws@7.5.10", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ=="], "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], @@ -1094,47 +511,51 @@ "yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="], - "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], + "@babel/code-frame/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.25.9", "", {}, "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ=="], - "@babel/runtime/regenerator-runtime": ["regenerator-runtime@0.14.1", "", {}, "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw=="], + "@babel/core/@babel/parser": ["@babel/parser@7.27.0", "", { "dependencies": { "@babel/types": "^7.27.0" }, "bin": "./bin/babel-parser.js" }, "sha512-iaepho73/2Pz7w2eMS0Q5f83+0RKI7i4xmiYeBmDzfRVbQtTOG7Ts0S4HzJVsTMGI9keU8rNfuZr8DKfSt7Yyg=="], - "@jest/environment/@types/node": ["@types/node@22.15.2", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-uKXqKN9beGoMdBfcaTY1ecwz6ctxuJAcUlwE55938g0ZJ8lRxwAZqRz2AJ4pzpt5dHdTPMB863UZ0ESiFUcP7A=="], + "@babel/core/@babel/traverse": ["@babel/traverse@7.27.0", "", { "dependencies": { "@babel/code-frame": "^7.26.2", "@babel/generator": "^7.27.0", "@babel/parser": "^7.27.0", "@babel/template": "^7.27.0", "@babel/types": "^7.27.0", "debug": "^4.3.1", "globals": "^11.1.0" } }, "sha512-19lYZFzYVQkkHkl4Cy4WrAVcqBkgvV2YM2TU3xG6DIwO7O3ecbDPfW3yM3bjAGcqcQHi+CCtjMR3dIEHxsd6bA=="], - "@jest/fake-timers/@types/node": ["@types/node@22.15.2", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-uKXqKN9beGoMdBfcaTY1ecwz6ctxuJAcUlwE55938g0ZJ8lRxwAZqRz2AJ4pzpt5dHdTPMB863UZ0ESiFUcP7A=="], + "@babel/core/@babel/types": ["@babel/types@7.27.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.25.9", "@babel/helper-validator-identifier": "^7.25.9" } }, "sha512-H45s8fVLYjbhFH62dIJ3WtmJ6RSPt/3DRO0ZcT2SUiYiQyz3BLVb9ADEnLl91m74aQPS3AzzeajZHYOalWe3bg=="], - "@jest/types/@types/node": ["@types/node@22.15.2", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-uKXqKN9beGoMdBfcaTY1ecwz6ctxuJAcUlwE55938g0ZJ8lRxwAZqRz2AJ4pzpt5dHdTPMB863UZ0ESiFUcP7A=="], + "@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], - "@react-native-community/cli/semver": ["semver@7.7.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA=="], + "@babel/generator/@babel/parser": ["@babel/parser@7.27.0", "", { "dependencies": { "@babel/types": "^7.27.0" }, "bin": "./bin/babel-parser.js" }, "sha512-iaepho73/2Pz7w2eMS0Q5f83+0RKI7i4xmiYeBmDzfRVbQtTOG7Ts0S4HzJVsTMGI9keU8rNfuZr8DKfSt7Yyg=="], - "@react-native-community/cli-doctor/semver": ["semver@7.7.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA=="], + "@babel/generator/@babel/types": ["@babel/types@7.27.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.25.9", "@babel/helper-validator-identifier": "^7.25.9" } }, "sha512-H45s8fVLYjbhFH62dIJ3WtmJ6RSPt/3DRO0ZcT2SUiYiQyz3BLVb9ADEnLl91m74aQPS3AzzeajZHYOalWe3bg=="], - "@react-native-community/cli-server-api/ws": ["ws@7.5.10", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ=="], + "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], - "@react-native-community/cli-tools/find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="], + "@babel/helper-module-imports/@babel/traverse": ["@babel/traverse@7.27.0", "", { "dependencies": { "@babel/code-frame": "^7.26.2", "@babel/generator": "^7.27.0", "@babel/parser": "^7.27.0", "@babel/template": "^7.27.0", "@babel/types": "^7.27.0", "debug": "^4.3.1", "globals": "^11.1.0" } }, "sha512-19lYZFzYVQkkHkl4Cy4WrAVcqBkgvV2YM2TU3xG6DIwO7O3ecbDPfW3yM3bjAGcqcQHi+CCtjMR3dIEHxsd6bA=="], - "@react-native-community/cli-tools/semver": ["semver@7.7.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA=="], + "@babel/helper-module-imports/@babel/types": ["@babel/types@7.27.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.25.9", "@babel/helper-validator-identifier": "^7.25.9" } }, "sha512-H45s8fVLYjbhFH62dIJ3WtmJ6RSPt/3DRO0ZcT2SUiYiQyz3BLVb9ADEnLl91m74aQPS3AzzeajZHYOalWe3bg=="], - "@react-native/dev-middleware/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], + "@babel/helper-module-transforms/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.25.9", "", {}, "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ=="], - "@react-native/dev-middleware/open": ["open@7.4.2", "", { "dependencies": { "is-docker": "^2.0.0", "is-wsl": "^2.1.1" } }, "sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q=="], + "@babel/helper-module-transforms/@babel/traverse": ["@babel/traverse@7.27.0", "", { "dependencies": { "@babel/code-frame": "^7.26.2", "@babel/generator": "^7.27.0", "@babel/parser": "^7.27.0", "@babel/template": "^7.27.0", "@babel/types": "^7.27.0", "debug": "^4.3.1", "globals": "^11.1.0" } }, "sha512-19lYZFzYVQkkHkl4Cy4WrAVcqBkgvV2YM2TU3xG6DIwO7O3ecbDPfW3yM3bjAGcqcQHi+CCtjMR3dIEHxsd6bA=="], - "accepts/negotiator": ["negotiator@0.6.3", "", {}, "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="], + "@babel/helpers/@babel/types": ["@babel/types@7.27.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.25.9", "@babel/helper-validator-identifier": "^7.25.9" } }, "sha512-H45s8fVLYjbhFH62dIJ3WtmJ6RSPt/3DRO0ZcT2SUiYiQyz3BLVb9ADEnLl91m74aQPS3AzzeajZHYOalWe3bg=="], - "chrome-launcher/@types/node": ["@types/node@22.15.2", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-uKXqKN9beGoMdBfcaTY1ecwz6ctxuJAcUlwE55938g0ZJ8lRxwAZqRz2AJ4pzpt5dHdTPMB863UZ0ESiFUcP7A=="], + "@babel/runtime/regenerator-runtime": ["regenerator-runtime@0.14.1", "", {}, "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw=="], + + "@babel/template/@babel/parser": ["@babel/parser@7.27.0", "", { "dependencies": { "@babel/types": "^7.27.0" }, "bin": "./bin/babel-parser.js" }, "sha512-iaepho73/2Pz7w2eMS0Q5f83+0RKI7i4xmiYeBmDzfRVbQtTOG7Ts0S4HzJVsTMGI9keU8rNfuZr8DKfSt7Yyg=="], - "chrome-launcher/is-wsl": ["is-wsl@2.2.0", "", { "dependencies": { "is-docker": "^2.0.0" } }, "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww=="], + "@babel/template/@babel/types": ["@babel/types@7.27.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.25.9", "@babel/helper-validator-identifier": "^7.25.9" } }, "sha512-H45s8fVLYjbhFH62dIJ3WtmJ6RSPt/3DRO0ZcT2SUiYiQyz3BLVb9ADEnLl91m74aQPS3AzzeajZHYOalWe3bg=="], - "chromium-edge-launcher/@types/node": ["@types/node@22.15.2", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-uKXqKN9beGoMdBfcaTY1ecwz6ctxuJAcUlwE55938g0ZJ8lRxwAZqRz2AJ4pzpt5dHdTPMB863UZ0ESiFUcP7A=="], + "@babel/traverse/@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="], - "chromium-edge-launcher/is-wsl": ["is-wsl@2.2.0", "", { "dependencies": { "is-docker": "^2.0.0" } }, "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww=="], + "@babel/traverse/@babel/generator": ["@babel/generator@7.29.8", "", { "dependencies": { "@babel/parser": "^7.29.8", "@babel/types": "^7.29.8", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg=="], - "chromium-edge-launcher/mkdirp": ["mkdirp@1.0.4", "", { "bin": { "mkdirp": "bin/cmd.js" } }, "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw=="], + "@babel/traverse/@babel/template": ["@babel/template@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg=="], - "chromium-edge-launcher/rimraf": ["rimraf@3.0.2", "", { "dependencies": { "glob": "^7.1.3" }, "bin": { "rimraf": "bin.js" } }, "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA=="], + "@jest/types/@types/node": ["@types/node@22.15.2", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-uKXqKN9beGoMdBfcaTY1ecwz6ctxuJAcUlwE55938g0ZJ8lRxwAZqRz2AJ4pzpt5dHdTPMB863UZ0ESiFUcP7A=="], - "cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + "chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], - "compression/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], + "chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], + + "chrome-launcher/@types/node": ["@types/node@22.15.2", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-uKXqKN9beGoMdBfcaTY1ecwz6ctxuJAcUlwE55938g0ZJ8lRxwAZqRz2AJ4pzpt5dHdTPMB863UZ0ESiFUcP7A=="], "connect/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], @@ -1142,168 +563,116 @@ "finalhandler/encodeurl": ["encodeurl@1.0.2", "", {}, "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w=="], - "finalhandler/on-finished": ["on-finished@2.3.0", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww=="], - - "finalhandler/statuses": ["statuses@1.5.0", "", {}, "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA=="], - - "hermes-profile-transformer/source-map": ["source-map@0.7.4", "", {}, "sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA=="], - - "jest-environment-node/@types/node": ["@types/node@22.15.2", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-uKXqKN9beGoMdBfcaTY1ecwz6ctxuJAcUlwE55938g0ZJ8lRxwAZqRz2AJ4pzpt5dHdTPMB863UZ0ESiFUcP7A=="], - - "jest-message-util/pretty-format": ["pretty-format@29.7.0", "", { "dependencies": { "@jest/schemas": "^29.6.3", "ansi-styles": "^5.0.0", "react-is": "^18.0.0" } }, "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ=="], - - "jest-mock/@types/node": ["@types/node@22.15.2", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-uKXqKN9beGoMdBfcaTY1ecwz6ctxuJAcUlwE55938g0ZJ8lRxwAZqRz2AJ4pzpt5dHdTPMB863UZ0ESiFUcP7A=="], + "http-errors/statuses": ["statuses@2.0.1", "", {}, "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ=="], "jest-util/@types/node": ["@types/node@22.15.2", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-uKXqKN9beGoMdBfcaTY1ecwz6ctxuJAcUlwE55938g0ZJ8lRxwAZqRz2AJ4pzpt5dHdTPMB863UZ0ESiFUcP7A=="], "jest-util/ci-info": ["ci-info@3.9.0", "", {}, "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ=="], - "jest-validate/pretty-format": ["pretty-format@29.7.0", "", { "dependencies": { "@jest/schemas": "^29.6.3", "ansi-styles": "^5.0.0", "react-is": "^18.0.0" } }, "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ=="], + "jest-util/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], "jest-worker/@types/node": ["@types/node@22.15.2", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-uKXqKN9beGoMdBfcaTY1ecwz6ctxuJAcUlwE55938g0ZJ8lRxwAZqRz2AJ4pzpt5dHdTPMB863UZ0ESiFUcP7A=="], - "jest-worker/supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="], - "lighthouse-logger/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], - "logkitty/yargs": ["yargs@15.4.1", "", { "dependencies": { "cliui": "^6.0.0", "decamelize": "^1.2.0", "find-up": "^4.1.0", "get-caller-file": "^2.0.1", "require-directory": "^2.1.1", "require-main-filename": "^2.0.0", "set-blocking": "^2.0.0", "string-width": "^4.2.0", "which-module": "^2.0.0", "y18n": "^4.0.0", "yargs-parser": "^18.1.2" } }, "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A=="], + "metro/@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="], - "make-dir/semver": ["semver@5.7.2", "", { "bin": { "semver": "bin/semver" } }, "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g=="], + "metro/@babel/generator": ["@babel/generator@7.29.8", "", { "dependencies": { "@babel/parser": "^7.29.8", "@babel/types": "^7.29.8", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg=="], - "metro/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], + "metro/@babel/template": ["@babel/template@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg=="], - "metro/hermes-parser": ["hermes-parser@0.23.1", "", { "dependencies": { "hermes-estree": "0.23.1" } }, "sha512-oxl5h2DkFW83hT4DAUJorpah8ou4yvmweUzLJmmr6YV2cezduCdlil1AvU/a/xSsAFo4WUcNA4GoV5Bvq6JffA=="], + "metro/hermes-parser": ["hermes-parser@0.35.0", "", { "dependencies": { "hermes-estree": "0.35.0" } }, "sha512-9JLjeHxBx8T4CAsydZR49PNZUaix+WpQJwu9p2010lu+7Kwl6D/7wYFFJxoz+aXkaaClp9Zfg6W6/zVlSJORaA=="], - "metro/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + "metro-babel-transformer/hermes-parser": ["hermes-parser@0.35.0", "", { "dependencies": { "hermes-estree": "0.35.0" } }, "sha512-9JLjeHxBx8T4CAsydZR49PNZUaix+WpQJwu9p2010lu+7Kwl6D/7wYFFJxoz+aXkaaClp9Zfg6W6/zVlSJORaA=="], - "metro/ws": ["ws@7.5.10", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ=="], + "metro-transform-plugins/@babel/generator": ["@babel/generator@7.29.8", "", { "dependencies": { "@babel/parser": "^7.29.8", "@babel/types": "^7.29.8", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg=="], - "metro-babel-transformer/hermes-parser": ["hermes-parser@0.23.1", "", { "dependencies": { "hermes-estree": "0.23.1" } }, "sha512-oxl5h2DkFW83hT4DAUJorpah8ou4yvmweUzLJmmr6YV2cezduCdlil1AvU/a/xSsAFo4WUcNA4GoV5Bvq6JffA=="], + "metro-transform-plugins/@babel/template": ["@babel/template@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg=="], - "metro-file-map/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], + "metro-transform-worker/@babel/generator": ["@babel/generator@7.29.8", "", { "dependencies": { "@babel/parser": "^7.29.8", "@babel/types": "^7.29.8", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg=="], - "ora/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + "micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], - "pkg-dir/find-up": ["find-up@3.0.0", "", { "dependencies": { "locate-path": "^3.0.0" } }, "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg=="], + "pretty-format/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="], - "pretty-format/@jest/types": ["@jest/types@26.6.2", "", { "dependencies": { "@types/istanbul-lib-coverage": "^2.0.0", "@types/istanbul-reports": "^3.0.0", "@types/node": "*", "@types/yargs": "^15.0.0", "chalk": "^4.0.0" } }, "sha512-fC6QCp7Sc5sX6g8Tvbmj4XUTbyrik0akgRy03yjXbQaBWWNWGE7SGtJk98m0N8nzegD/7SggrUlivxo5ax4KWQ=="], + "send/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], - "pretty-format/react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="], + "send/encodeurl": ["encodeurl@1.0.2", "", {}, "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w=="], - "prop-types/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], + "send/on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="], - "react-devtools-core/ws": ["ws@7.5.10", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ=="], + "send/statuses": ["statuses@2.0.1", "", {}, "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ=="], - "react-test-renderer/scheduler": ["scheduler@0.23.2", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ=="], + "source-map-support/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], - "recast/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], + "terser/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="], - "regjsparser/jsesc": ["jsesc@3.0.2", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-xKqzzWXDttJuOcawBt4KnKHHIf5oQ/Cxax+0PWFG+DFDgHNAdi+TXECADI+RYiFUMmx8792xsMbbgXj4CwnP4g=="], + "wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], - "send/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], + "@babel/core/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@7.25.9", "", {}, "sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA=="], - "send/encodeurl": ["encodeurl@1.0.2", "", {}, "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w=="], + "@babel/core/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.25.9", "", {}, "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ=="], - "send/mime": ["mime@1.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg=="], + "@babel/generator/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@7.25.9", "", {}, "sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA=="], - "slice-ansi/ansi-styles": ["ansi-styles@3.2.1", "", { "dependencies": { "color-convert": "^1.9.0" } }, "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA=="], + "@babel/generator/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.25.9", "", {}, "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ=="], - "slice-ansi/is-fullwidth-code-point": ["is-fullwidth-code-point@2.0.0", "", {}, "sha512-VHskAKYM8RfSFXwee5t5cbN5PZeq1Wrh6qd5bkyiXIf6UQcN6w/A0eXM9r6t8d+GYOh+o6ZhiEnb88LN/Y8m2w=="], + "@babel/helper-module-imports/@babel/traverse/@babel/parser": ["@babel/parser@7.27.0", "", { "dependencies": { "@babel/types": "^7.27.0" }, "bin": "./bin/babel-parser.js" }, "sha512-iaepho73/2Pz7w2eMS0Q5f83+0RKI7i4xmiYeBmDzfRVbQtTOG7Ts0S4HzJVsTMGI9keU8rNfuZr8DKfSt7Yyg=="], - "source-map-support/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], + "@babel/helper-module-imports/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@7.25.9", "", {}, "sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA=="], - "stack-utils/escape-string-regexp": ["escape-string-regexp@2.0.0", "", {}, "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w=="], + "@babel/helper-module-imports/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.25.9", "", {}, "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ=="], - "string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + "@babel/helper-module-transforms/@babel/traverse/@babel/parser": ["@babel/parser@7.27.0", "", { "dependencies": { "@babel/types": "^7.27.0" }, "bin": "./bin/babel-parser.js" }, "sha512-iaepho73/2Pz7w2eMS0Q5f83+0RKI7i4xmiYeBmDzfRVbQtTOG7Ts0S4HzJVsTMGI9keU8rNfuZr8DKfSt7Yyg=="], - "string_decoder/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="], + "@babel/helper-module-transforms/@babel/traverse/@babel/types": ["@babel/types@7.27.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.25.9", "@babel/helper-validator-identifier": "^7.25.9" } }, "sha512-H45s8fVLYjbhFH62dIJ3WtmJ6RSPt/3DRO0ZcT2SUiYiQyz3BLVb9ADEnLl91m74aQPS3AzzeajZHYOalWe3bg=="], - "strip-ansi/ansi-regex": ["ansi-regex@4.1.1", "", {}, "sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g=="], + "@babel/helpers/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@7.25.9", "", {}, "sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA=="], - "terser/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="], + "@babel/helpers/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.25.9", "", {}, "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ=="], - "through2/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="], + "@babel/template/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@7.25.9", "", {}, "sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA=="], - "wrap-ansi/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + "@babel/template/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.25.9", "", {}, "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ=="], - "@jest/environment/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + "@babel/traverse/@babel/generator/@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], - "@jest/fake-timers/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + "@babel/traverse/@babel/generator/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], "@jest/types/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], - "@react-native-community/cli-tools/find-up/locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], - - "@react-native/dev-middleware/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], - - "@react-native/dev-middleware/open/is-wsl": ["is-wsl@2.2.0", "", { "dependencies": { "is-docker": "^2.0.0" } }, "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww=="], - "chrome-launcher/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], - "chromium-edge-launcher/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], - - "compression/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], - "connect/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], "finalhandler/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], - "jest-environment-node/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], - - "jest-message-util/pretty-format/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="], - - "jest-mock/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], - "jest-util/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], - "jest-validate/pretty-format/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="], - "jest-worker/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], "lighthouse-logger/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], - "logkitty/yargs/cliui": ["cliui@6.0.0", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", "wrap-ansi": "^6.2.0" } }, "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ=="], - - "logkitty/yargs/y18n": ["y18n@4.0.3", "", {}, "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ=="], + "metro-babel-transformer/hermes-parser/hermes-estree": ["hermes-estree@0.35.0", "", {}, "sha512-xVx5Opwy8Oo1I5yGpVRhCvWL/iV3M+ylksSKVNlxxD90cpDpR/AR1jLYqK8HWihm065a6UI3HeyAmYzwS8NOOg=="], - "logkitty/yargs/yargs-parser": ["yargs-parser@18.1.3", "", { "dependencies": { "camelcase": "^5.0.0", "decamelize": "^1.2.0" } }, "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ=="], + "metro-transform-plugins/@babel/generator/@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], - "metro-babel-transformer/hermes-parser/hermes-estree": ["hermes-estree@0.23.1", "", {}, "sha512-eT5MU3f5aVhTqsfIReZ6n41X5sYn4IdQL0nvz6yO+MMlPxw49aSARHLg/MSehQftyjnrE8X6bYregzSumqc6cg=="], + "metro-transform-plugins/@babel/generator/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], - "metro-file-map/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], + "metro-transform-plugins/@babel/template/@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="], - "metro/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], + "metro-transform-worker/@babel/generator/@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], - "metro/hermes-parser/hermes-estree": ["hermes-estree@0.23.1", "", {}, "sha512-eT5MU3f5aVhTqsfIReZ6n41X5sYn4IdQL0nvz6yO+MMlPxw49aSARHLg/MSehQftyjnrE8X6bYregzSumqc6cg=="], + "metro-transform-worker/@babel/generator/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], - "pkg-dir/find-up/locate-path": ["locate-path@3.0.0", "", { "dependencies": { "p-locate": "^3.0.0", "path-exists": "^3.0.0" } }, "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A=="], + "metro/@babel/generator/@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], - "pretty-format/@jest/types/@types/node": ["@types/node@22.15.2", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-uKXqKN9beGoMdBfcaTY1ecwz6ctxuJAcUlwE55938g0ZJ8lRxwAZqRz2AJ4pzpt5dHdTPMB863UZ0ESiFUcP7A=="], + "metro/@babel/generator/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], - "pretty-format/@jest/types/@types/yargs": ["@types/yargs@15.0.19", "", { "dependencies": { "@types/yargs-parser": "*" } }, "sha512-2XUaGVmyQjgyAZldf0D0c14vvo/yv0MhQBSTJcejMMaitsn3nxCB6TmH4G0ZQf+uxROOa9mpanoSm8h6SG/1ZA=="], + "metro/hermes-parser/hermes-estree": ["hermes-estree@0.35.0", "", {}, "sha512-xVx5Opwy8Oo1I5yGpVRhCvWL/iV3M+ylksSKVNlxxD90cpDpR/AR1jLYqK8HWihm065a6UI3HeyAmYzwS8NOOg=="], "send/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], - "slice-ansi/ansi-styles/color-convert": ["color-convert@1.9.3", "", { "dependencies": { "color-name": "1.1.3" } }, "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg=="], - - "through2/readable-stream/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="], - - "@react-native-community/cli-tools/find-up/locate-path/p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="], - - "logkitty/yargs/cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], - - "logkitty/yargs/cliui/wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="], - - "logkitty/yargs/yargs-parser/camelcase": ["camelcase@5.3.1", "", {}, "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg=="], - - "pkg-dir/find-up/locate-path/p-locate": ["p-locate@3.0.0", "", { "dependencies": { "p-limit": "^2.0.0" } }, "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ=="], - - "pkg-dir/find-up/locate-path/path-exists": ["path-exists@3.0.0", "", {}, "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ=="], - - "pretty-format/@jest/types/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], - - "slice-ansi/ansi-styles/color-convert/color-name": ["color-name@1.1.3", "", {}, "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw=="], - - "@react-native-community/cli-tools/find-up/locate-path/p-locate/p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], + "@babel/helper-module-transforms/@babel/traverse/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@7.25.9", "", {}, "sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA=="], } } diff --git a/cpp/patch_core/digest.h b/cpp/patch_core/digest.h index a3732b52..51153db4 100644 --- a/cpp/patch_core/digest.h +++ b/cpp/patch_core/digest.h @@ -15,10 +15,11 @@ namespace digest { // (IO-6). Streaming because Android/Harmony read the bundle from the package // (AssetManager / rawfile) rather than from a plain file. // -// Android intentionally does NOT use this implementation: librnpushy.so is a -// prebuilt artifact, so the Java layer hashes with java.security.MessageDigest -// instead. The NIST vectors in the tests anchor both implementations to the -// same standard. +// Android hashes in Java with java.security.MessageDigest instead (backed by +// the platform's native crypto provider): the bytes come from AssetManager +// streams and download sinks on the Java side, so a JNI round trip per chunk +// would buy nothing. The NIST vectors in the tests anchor both +// implementations to the same standard. class Sha256 { public: Sha256(); diff --git a/cpp/patch_core/jni_log.h b/cpp/patch_core/jni_log.h new file mode 100644 index 00000000..d667f698 --- /dev/null +++ b/cpp/patch_core/jni_log.h @@ -0,0 +1,31 @@ +#ifndef PUSHY_PATCH_CORE_JNI_LOG_H_ +#define PUSHY_PATCH_CORE_JNI_LOG_H_ + +#include + +#include "obscured_text.h" + +// Keep the core internal to whichever binary links it (see podspec Core). +#pragma GCC visibility push(hidden) + +namespace pushy { +namespace jni_log { + +// Same logcat tag as the Java layer (Texts.LOG_TAG), stored encoded. +inline const char* Tag() { + static const char* const tag = + text::RevealStatic("2812f5d2bac6664436360afc9ba680694b3301"); + return tag; +} + +} // namespace jni_log +} // namespace pushy + +#pragma GCC visibility pop + +// Error-level logcat line under the SDK's tag. For JNI binding failures that +// would otherwise be swallowed: they must be visible in release builds. +#define PUSHY_JNI_LOGE(...) \ + __android_log_print(ANDROID_LOG_ERROR, ::pushy::jni_log::Tag(), __VA_ARGS__) + +#endif // PUSHY_PATCH_CORE_JNI_LOG_H_ diff --git a/cpp/patch_core/jni_natives.h b/cpp/patch_core/jni_natives.h index eaf6e3fa..cc21aace 100644 --- a/cpp/patch_core/jni_natives.h +++ b/cpp/patch_core/jni_natives.h @@ -9,13 +9,19 @@ namespace pushy { namespace jni_natives { +// Called once from JNI_OnLoad: caches the JNI result classes (global refs), +// their constructors and field IDs. Returns false (after logging what is +// missing) when any lookup failed; the natives that need the missing class +// then throw a RuntimeException instead of defaulting fields. +bool InitResultClassCache(JNIEnv* env); + jint SupportedDiffVersion(JNIEnv*, jclass); jobject SyncStateWithBinaryVersion(JNIEnv* env, jclass, jstring package_version, jstring build_time, jobject state_result); jobject RunStateCore(JNIEnv* env, jclass, jint operation, jobject state_result, jstring string_arg, jboolean flag_a, jboolean flag_b); jobject BuildArchivePlan(JNIEnv* env, jclass, jint patch_type, jobjectArray entry_names, jobjectArray copy_froms, jobjectArray copy_tos, jobjectArray deletes); jobjectArray BuildCopyGroups(JNIEnv* env, jclass, jobjectArray copy_froms, jobjectArray copy_tos); void ApplyDeltaFromSource(JNIEnv* env, jclass, jstring source_root, jstring target_root, jstring origin_bundle_path, jstring bundle_patch_path, jstring bundle_output_path, jstring merge_source_subdir, jboolean enable_merge, jobjectArray copy_froms, jobjectArray copy_tos, jobjectArray deletes, jstring hbc_transform_meta); -void CleanupOldEntries(JNIEnv* env, jclass, jstring root_dir, jstring keep_current, jstring keep_previous, jint max_age_days); +void CleanupOldEntries(JNIEnv* env, jclass, jstring root_dir, jobjectArray keep_names, jint max_age_days, jstring trash_dir); jstring FlowBuildRequestBody(JNIEnv* env, jclass, jstring inputJson); jstring FlowOrderEndpointCandidates(JNIEnv* env, jclass, jstring endpointsJson, jdouble randomSample); jboolean FlowIsValidResponse(JNIEnv* env, jclass, jstring responseText); diff --git a/cpp/patch_core/jni_registration.cpp b/cpp/patch_core/jni_registration.cpp index 09fff307..13bca7c4 100644 --- a/cpp/patch_core/jni_registration.cpp +++ b/cpp/patch_core/jni_registration.cpp @@ -9,6 +9,7 @@ #include #include +#include "jni_log.h" #include "jni_natives.h" #include "obscured_text.h" @@ -47,7 +48,7 @@ const std::vector& NativeClasses() { reinterpret_cast(&pushy::jni_natives::BuildCopyGroups)}, {"3b07e4ddb7af6d49363e3aebd9bea3625f3507e4", "723bfed0b88a274923311bb6e5a7826444205fcdf4daae943d432d07e18c93a9887e5a3655c7c2a4949e3355371df78299937648503c43d9d8ae9a68092f0113fd9887857c422602b9d3d6b880721f210be9c3ee8d8f6a5c3c08b7e5ac82767c153b15ffc9e4bb715056321eade98ba16046320051f7d9bb95207f3d14eacedae1ac585b2f1de98aaebe927e190004ffc3a9833a45773214e4cee385674d277229e3c6b8806c1309081eead8f99f71432d48d7d5ccb2967209663a", reinterpret_cast(&pushy::jni_natives::ApplyDeltaFromSource)}, - {"391bf1d0a09e786a2e3b39f7c2a1996859", "723bfed0b88a274923311bb6e5a7826444205fcdf4daae943d432d07e18c93a9887e5a3655c7c2a4949e3355371df78299937648503c43dc9b99", + {"391bf1d0a09e786a2e3b39f7c2a1996859", "723bfed0b88a274923311bb6e5a7826444205fdad2d1b98373002008e8c4ef8e8e655d3f09b0e189889e6a58791ff1c3adc857554c3216f289e6ba", reinterpret_cast(&pushy::jni_natives::CleanupOldEntries)}, }}, {"3919bbc3ab8a6b512c3e08f0c0b6df60452311edfbc8f780624b2d1de38c86b19560762307efcfa0", // FlowBridge @@ -66,13 +67,17 @@ const std::vector& NativeClasses() { } // Registers each method on its own: a host's R8 build may drop an unused -// class or native method, which must not prevent the rest from binding. +// class or native method, which must not prevent the rest from binding. A +// failure is still logged (release builds too): otherwise the first call of +// the unbound method surfaces as an UnsatisfiedLinkError with no hint why. void RegisterAll(JNIEnv* env) { for (const NativeClass& native_class : NativeClasses()) { const std::string class_name = pushy::text::Reveal(native_class.name); jclass clazz = env->FindClass(class_name.c_str()); if (clazz == nullptr) { env->ExceptionClear(); + PUSHY_JNI_LOGE("JNI binding: class %s not found, %zu native methods unbound", + class_name.c_str(), native_class.methods.size()); continue; } for (const NativeMethod& method : native_class.methods) { @@ -85,6 +90,8 @@ void RegisterAll(JNIEnv* env) { }; if (env->RegisterNatives(clazz, &entry, 1) != JNI_OK) { env->ExceptionClear(); + PUSHY_JNI_LOGE("JNI binding: %s.%s%s could not be registered", + class_name.c_str(), name.c_str(), signature.c_str()); } } env->DeleteLocalRef(clazz); @@ -99,5 +106,8 @@ extern "C" JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM* vm, void*) { return JNI_ERR; } RegisterAll(env); + // Result classes and their field IDs are resolved once here; a failure is + // logged inside and makes the affected natives throw instead of guessing. + pushy::jni_natives::InitResultClassCache(env); return JNI_VERSION_1_6; } diff --git a/cpp/patch_core/patch_core.cpp b/cpp/patch_core/patch_core.cpp index 6dc81a7a..a2e7a476 100644 --- a/cpp/patch_core/patch_core.cpp +++ b/cpp/patch_core/patch_core.cpp @@ -727,11 +727,16 @@ Status CleanupOldEntries( max_age_days, now); } -Status CleanupOldEntries( +namespace { + +// Lists the non-dot entries of root_dir that are not kept and are at least +// max_age_days old: the one selection rule shared by delete and trash. +Status SelectOldEntries( const std::string& root_dir, const std::vector& keep_names, int max_age_days, - std::time_t now) { + std::time_t now, + std::vector* out) { DIR* dir = opendir(root_dir.c_str()); if (!dir) { if (errno == ENOENT) { @@ -765,15 +770,61 @@ Status CleanupOldEntries( if (effective_now - st.st_mtime < max_age_seconds) { continue; } + out->push_back(name); + } + + closedir(dir); + return Status::Ok(); +} + +} // namespace - Status remove_status = RemovePathRecursively(entry_path); +Status CleanupOldEntries( + const std::string& root_dir, + const std::vector& keep_names, + int max_age_days, + std::time_t now) { + std::vector doomed; + Status select_status = + SelectOldEntries(root_dir, keep_names, max_age_days, now, &doomed); + if (!select_status) { + return select_status; + } + for (const std::string& name : doomed) { + Status remove_status = RemovePathRecursively(JoinPath(root_dir, name)); if (!remove_status) { - closedir(dir); return remove_status; } } + return Status::Ok(); +} - closedir(dir); +Status MoveOldEntriesToTrash( + const std::string& root_dir, + const std::vector& keep_names, + int max_age_days, + const std::string& trash_dir, + std::time_t now) { + std::vector doomed; + Status select_status = + SelectOldEntries(root_dir, keep_names, max_age_days, now, &doomed); + if (!select_status || doomed.empty()) { + return select_status; + } + Status dir_status = EnsureDirectory(trash_dir); + if (!dir_status) { + return dir_status; + } + for (const std::string& name : doomed) { + std::string target = JoinPath(trash_dir, name); + for (int suffix = 1; PathExists(target); ++suffix) { + target = JoinPath(trash_dir, name + "." + IntToString(suffix)); + } + const std::string source = JoinPath(root_dir, name); + if (rename(source.c_str(), target.c_str()) != 0) { + return MakeErrnoStatus("Failed to move " + source + " to trash"); + } + } return Status::Ok(); } diff --git a/cpp/patch_core/patch_core.h b/cpp/patch_core/patch_core.h index 099b895a..cf69856d 100644 --- a/cpp/patch_core/patch_core.h +++ b/cpp/patch_core/patch_core.h @@ -89,6 +89,20 @@ Status CleanupOldEntries( int max_age_days, std::time_t now = 0); +// Same selection as CleanupOldEntries, but each doomed entry is renamed into +// trash_dir (created if needed; must be on the same file system, e.g. a +// dot-named child of root_dir so the selection itself skips it) instead of +// being deleted. Renames are cheap, so a caller can hold its version-files +// lock across this call and delete trash_dir's contents after releasing it +// (CleanupOldEntries(trash_dir, {}, 0)). A name already present in trash_dir +// (left over by an interrupted earlier delete) gets a numeric suffix. +Status MoveOldEntriesToTrash( + const std::string& root_dir, + const std::vector& keep_names, + int max_age_days, + const std::string& trash_dir, + std::time_t now = 0); + bool IsSafeRelativePath(const std::string& path); namespace internal { diff --git a/cpp/patch_core/patch_core_android.cpp b/cpp/patch_core/patch_core_android.cpp index 4f8be6a2..89233983 100644 --- a/cpp/patch_core/patch_core_android.cpp +++ b/cpp/patch_core/patch_core_android.cpp @@ -77,15 +77,20 @@ void pushy::jni_natives::CleanupOldEntries( JNIEnv* env, jclass, jstring root_dir, - jstring keep_current, - jstring keep_previous, - jint max_age_days) { + jobjectArray keep_names, + jint max_age_days, + jstring trash_dir) { try { - const pushy::delta::Status status = pushy::delta::CleanupOldEntries( - JStringToString(env, root_dir), - JStringToString(env, keep_current), - JStringToString(env, keep_previous), - static_cast(max_age_days)); + const std::string root = JStringToString(env, root_dir); + const std::vector keep = JArrayToVector(env, keep_names); + const std::string trash = JStringToString(env, trash_dir); + // A trash directory turns the delete into renames (cheap enough to run + // under the caller's version-files lock); the caller purges it later. + const pushy::delta::Status status = trash.empty() + ? pushy::delta::CleanupOldEntries( + root, keep, static_cast(max_age_days)) + : pushy::delta::MoveOldEntriesToTrash( + root, keep, static_cast(max_age_days), trash); if (!status.ok) { ThrowRuntimeException(env, status.message); } diff --git a/cpp/patch_core/tests/patch_core_test.cpp b/cpp/patch_core/tests/patch_core_test.cpp index 98823a40..3fafc8b9 100644 --- a/cpp/patch_core/tests/patch_core_test.cpp +++ b/cpp/patch_core/tests/patch_core_test.cpp @@ -25,6 +25,7 @@ namespace { using pushy::delta::ApplyPatchFromFileSource; using pushy::delta::BundleRebuilder; using pushy::delta::CleanupOldEntries; +using pushy::delta::MoveOldEntriesToTrash; using pushy::delta::CopyOperation; using pushy::delta::FileSourcePatchOptions; using pushy::delta::PatchManifest; @@ -617,6 +618,46 @@ void TestCleanupOldEntriesRemovesOnlyExpiredPaths() { Expect(Exists(JoinPath(root, ".hidden")), "hidden entry should be kept"); } +void TestMoveOldEntriesToTrashKeepsEveryLiveVersion() { + TempDir temp; + const std::string root = JoinPath(temp.path, "trash-root"); + const std::string trash = JoinPath(root, ".trash"); + EnsureDirectory(root); + + WriteFile(JoinPath(root, "current/index.bundlejs"), "current"); + WriteFile(JoinPath(root, "previous/index.bundlejs"), "previous"); + WriteFile(JoinPath(root, "running/index.bundlejs"), "running"); + WriteFile(JoinPath(root, "stale/index.bundlejs"), "stale"); + WriteFile(JoinPath(root, "recent/index.bundlejs"), "recent"); + // Leftover of an interrupted earlier delete with the same name. + WriteFile(JoinPath(trash, "stale/index.bundlejs"), "older stale"); + + const std::time_t now = 1'700'000'000; + const std::time_t old_time = now - (9 * 24 * 60 * 60); + for (const char* name : {"current", "previous", "running", "stale"}) { + SetMtime(JoinPath(root, name), old_time); + } + SetMtime(JoinPath(root, "recent"), now - (2 * 24 * 60 * 60)); + + Status status = MoveOldEntriesToTrash( + root, std::vector{"current", "previous", "running"}, 7, + trash, now); + Expect(status.ok, status.message); + Expect(Exists(JoinPath(root, "current")), "current is kept"); + Expect(Exists(JoinPath(root, "previous")), "previous is kept"); + Expect(Exists(JoinPath(root, "running")), "running is kept"); + Expect(Exists(JoinPath(root, "recent")), "recent is kept"); + Expect(!Exists(JoinPath(root, "stale")), "stale leaves the root"); + Expect(Exists(JoinPath(trash, "stale.1/index.bundlejs")), + "stale lands in trash next to the leftover"); + Expect(Exists(JoinPath(trash, "stale/index.bundlejs")), "leftover untouched"); + + Status purge = CleanupOldEntries(trash, std::vector{}, 0); + Expect(purge.ok, purge.message); + Expect(!Exists(JoinPath(trash, "stale")), "trash is purged"); + Expect(!Exists(JoinPath(trash, "stale.1")), "trash is purged"); +} + void TestStateCoreSyncBinaryVersionResetsUpdates() { State state; state.package_version = "1.0.0"; @@ -1097,6 +1138,7 @@ int main(int argc, char** argv) { {"DirectoryWalkersBoundNestingDepth", TestDirectoryWalkersBoundNestingDepth}, {"HpatchRejectsOversizedLzmaDictionary", TestHpatchRejectsOversizedLzmaDictionary}, {"CleanupOldEntriesRemovesOnlyExpiredPaths", TestCleanupOldEntriesRemovesOnlyExpiredPaths}, + {"MoveOldEntriesToTrashKeepsEveryLiveVersion", TestMoveOldEntriesToTrashKeepsEveryLiveVersion}, {"StateCoreSyncBinaryVersionResetsUpdates", TestStateCoreSyncBinaryVersionResetsUpdates}, {"StateCoreSwitchVersionAndMarkSuccess", TestStateCoreSwitchVersionAndMarkSuccess}, {"StateCoreResolveLaunchStateAndRollback", TestStateCoreResolveLaunchStateAndRollback}, diff --git a/cpp/patch_core/update_core_android.cpp b/cpp/patch_core/update_core_android.cpp index aa442865..00a2d684 100644 --- a/cpp/patch_core/update_core_android.cpp +++ b/cpp/patch_core/update_core_android.cpp @@ -1,5 +1,6 @@ #include +#include "jni_log.h" #include "jni_natives.h" #include "obscured_text.h" @@ -20,20 +21,169 @@ using pushy::jni_util::JStringToString; using pushy::jni_util::ThrowRuntimeException; using pushy::state_ops::StateOperation; +constexpr const char* kStringSig = "Ljava/lang/String;"; + +// JNI result classes, resolved once in JNI_OnLoad (InitResultClassCache) +// instead of FindClass + a GetFieldID per field on every state operation. +// Written only before any native method can run, read-only afterwards. +struct StateResultIds { + bool ready = false; + jclass clazz = nullptr; + jmethodID constructor = nullptr; + jfieldID package_version = nullptr; + jfieldID build_time = nullptr; + jfieldID current_version = nullptr; + jfieldID last_version = nullptr; + jfieldID first_time = nullptr; + jfieldID first_time_ok = nullptr; + jfieldID rolled_back_version = nullptr; + jfieldID changed = nullptr; + jfieldID stale_version_to_delete = nullptr; + jfieldID load_version = nullptr; + jfieldID did_rollback = nullptr; + jfieldID consumed_first_time = nullptr; +}; + +struct PlanResultIds { + bool ready = false; + jclass clazz = nullptr; + jmethodID constructor = nullptr; + jfieldID merge_source_subdir = nullptr; + jfieldID enable_merge = nullptr; +}; + +struct CopyGroupIds { + bool ready = false; + jclass clazz = nullptr; + jclass string_class = nullptr; + jmethodID constructor = nullptr; + jfieldID from = nullptr; + jfieldID to_paths = nullptr; +}; + +StateResultIds g_state; +PlanResultIds g_plan; +CopyGroupIds g_copy_group; + +// Resolves a class to a global ref. Logs and clears the pending exception on +// failure. +jclass FindGlobalClass(JNIEnv* env, const std::string& name) { + jclass local = env->FindClass(name.c_str()); + if (local == nullptr) { + env->ExceptionClear(); + PUSHY_JNI_LOGE("JNI result class %s not found", name.c_str()); + return nullptr; + } + auto* global = static_cast(env->NewGlobalRef(local)); + env->DeleteLocalRef(local); + return global; +} + +// A missing field must never silently default: a lost firstTimeOk would read +// as false and roll back every launch. Logs the exact member. +bool LookupField( + JNIEnv* env, + jclass clazz, + const std::string& class_name, + const char* name, + const char* signature, + jfieldID* out) { + *out = env->GetFieldID(clazz, name, signature); + if (*out == nullptr) { + env->ExceptionClear(); + PUSHY_JNI_LOGE("JNI field %s.%s (%s) not found", class_name.c_str(), name, + signature); + return false; + } + return true; +} + +bool LookupConstructor( + JNIEnv* env, jclass clazz, const std::string& class_name, jmethodID* out) { + *out = env->GetMethodID(clazz, "", "()V"); + if (*out == nullptr) { + env->ExceptionClear(); + PUSHY_JNI_LOGE("JNI no-arg constructor of %s not found", class_name.c_str()); + return false; + } + return true; +} + +bool InitStateResultIds(JNIEnv* env) { + const std::string name = pushy::text::Reveal( + "3919bbc3ab8a6b512c3e08f0c0b6df60452311edfbc8f780624b2d1de38c93a99b63511201f9cd97878c695522"); + StateResultIds ids; + ids.clazz = FindGlobalClass(env, name); + if (ids.clazz == nullptr) { + return false; + } + bool ok = LookupConstructor(env, ids.clazz, name, &ids.constructor); + ok &= LookupField(env, ids.clazz, name, "packageVersion", kStringSig, &ids.package_version); + ok &= LookupField(env, ids.clazz, name, "buildTime", kStringSig, &ids.build_time); + ok &= LookupField(env, ids.clazz, name, "currentVersion", kStringSig, &ids.current_version); + ok &= LookupField(env, ids.clazz, name, "lastVersion", kStringSig, &ids.last_version); + ok &= LookupField(env, ids.clazz, name, "firstTime", "Z", &ids.first_time); + ok &= LookupField(env, ids.clazz, name, "firstTimeOk", "Z", &ids.first_time_ok); + ok &= LookupField(env, ids.clazz, name, "rolledBackVersion", kStringSig, &ids.rolled_back_version); + ok &= LookupField(env, ids.clazz, name, "changed", "Z", &ids.changed); + ok &= LookupField(env, ids.clazz, name, "staleVersionToDelete", kStringSig, &ids.stale_version_to_delete); + ok &= LookupField(env, ids.clazz, name, "loadVersion", kStringSig, &ids.load_version); + ok &= LookupField(env, ids.clazz, name, "didRollback", "Z", &ids.did_rollback); + ok &= LookupField(env, ids.clazz, name, "consumedFirstTime", "Z", &ids.consumed_first_time); + ids.ready = ok; + g_state = ids; + return ok; +} + +bool InitPlanResultIds(JNIEnv* env) { + const std::string name = pushy::text::Reveal( + "3919bbc3ab8a6b512c3e08f0c0b6df60452311edfbc8f780624b2d1de38c81af997f5d270bdbc9b181974c55371dc2c8b9926855"); + PlanResultIds ids; + ids.clazz = FindGlobalClass(env, name); + if (ids.clazz == nullptr) { + return false; + } + bool ok = LookupConstructor(env, ids.clazz, name, &ids.constructor); + ok &= LookupField(env, ids.clazz, name, "mergeSourceSubdir", kStringSig, &ids.merge_source_subdir); + ok &= LookupField(env, ids.clazz, name, "enableMerge", "Z", &ids.enable_merge); + ids.ready = ok; + g_plan = ids; + return ok; +} + +bool InitCopyGroupIds(JNIEnv* env) { + const std::string name = pushy::text::Reveal( + "3919bbc3ab8a6b512c3e08f0c0b6df60452311edfbc8f780624b2d1de38c83b28a6e732301fed897878c695522"); + CopyGroupIds ids; + ids.clazz = FindGlobalClass(env, name); + ids.string_class = FindGlobalClass(env, "java/lang/String"); + if (ids.clazz == nullptr || ids.string_class == nullptr) { + return false; + } + bool ok = LookupConstructor(env, ids.clazz, name, &ids.constructor); + ok &= LookupField(env, ids.clazz, name, "from", kStringSig, &ids.from); + ok &= LookupField(env, ids.clazz, name, "toPaths", "[Ljava/lang/String;", &ids.to_paths); + ids.ready = ok; + g_copy_group = ids; + return ok; +} + +// Throws (and returns false) when the class needed by a native is unusable; +// the reason was logged once at load time. +bool RequireReady(JNIEnv* env, bool ready, const char* what) { + if (!ready) { + ThrowRuntimeException( + env, std::string("JNI result class unavailable: ") + what + + " (see the load-time log for the missing member)"); + } + return ready; +} + void SetStringField( JNIEnv* env, jobject target, - jclass target_class, - const char* field_name, + jfieldID field, const std::string& value) { - jfieldID field = - env->GetFieldID(target_class, field_name, "Ljava/lang/String;"); - if (field == nullptr) { - // Clear the pending NoSuchFieldError so subsequent JNI calls are not - // executed with an exception pending (undefined behavior / CheckJNI abort). - env->ExceptionClear(); - return; - } if (value.empty()) { env->SetObjectField(target, field, nullptr); return; @@ -44,31 +194,11 @@ void SetStringField( env->DeleteLocalRef(java_value); } -void SetBooleanField( - JNIEnv* env, - jobject target, - jclass target_class, - const char* field_name, - bool value) { - jfieldID field = env->GetFieldID(target_class, field_name, "Z"); - if (field == nullptr) { - env->ExceptionClear(); - return; - } +void SetBooleanField(JNIEnv* env, jobject target, jfieldID field, bool value) { env->SetBooleanField(target, field, value ? JNI_TRUE : JNI_FALSE); } -std::string GetStringField( - JNIEnv* env, - jobject target, - jclass target_class, - const char* field_name) { - jfieldID field = - env->GetFieldID(target_class, field_name, "Ljava/lang/String;"); - if (field == nullptr) { - env->ExceptionClear(); - return std::string(); - } +std::string GetStringField(JNIEnv* env, jobject target, jfieldID field) { auto* value = static_cast(env->GetObjectField(target, field)); std::string result = JStringToString(env, value); if (value != nullptr) { @@ -77,60 +207,28 @@ std::string GetStringField( return result; } -bool GetBooleanField( - JNIEnv* env, - jobject target, - jclass target_class, - const char* field_name) { - jfieldID field = env->GetFieldID(target_class, field_name, "Z"); - if (field == nullptr) { - env->ExceptionClear(); - return false; - } +bool GetBooleanField(JNIEnv* env, jobject target, jfieldID field) { return env->GetBooleanField(target, field) == JNI_TRUE; } -pushy::state::State ReadState( - const std::string& package_version, - const std::string& build_time, - const std::string& current_version, - const std::string& last_version, - bool first_time, - bool first_time_ok, - const std::string& rolled_back_version) { - pushy::state::State state; - state.package_version = package_version; - state.build_time = build_time; - state.current_version = current_version; - state.last_version = last_version; - state.first_time = first_time; - state.first_time_ok = first_time_ok; - state.rolled_back_version = rolled_back_version; - return state; -} - +// Caller has checked g_state.ready. pushy::state::State ReadStateFromResult(JNIEnv* env, jobject state_result) { + pushy::state::State state; if (state_result == nullptr) { - return pushy::state::State(); - } - - jclass state_class = env->GetObjectClass(state_result); - if (state_class == nullptr) { - return pushy::state::State(); + return state; } - - pushy::state::State state = ReadState( - GetStringField(env, state_result, state_class, "packageVersion"), - GetStringField(env, state_result, state_class, "buildTime"), - GetStringField(env, state_result, state_class, "currentVersion"), - GetStringField(env, state_result, state_class, "lastVersion"), - GetBooleanField(env, state_result, state_class, "firstTime"), - GetBooleanField(env, state_result, state_class, "firstTimeOk"), - GetStringField(env, state_result, state_class, "rolledBackVersion")); - env->DeleteLocalRef(state_class); + state.package_version = GetStringField(env, state_result, g_state.package_version); + state.build_time = GetStringField(env, state_result, g_state.build_time); + state.current_version = GetStringField(env, state_result, g_state.current_version); + state.last_version = GetStringField(env, state_result, g_state.last_version); + state.first_time = GetBooleanField(env, state_result, g_state.first_time); + state.first_time_ok = GetBooleanField(env, state_result, g_state.first_time_ok); + state.rolled_back_version = + GetStringField(env, state_result, g_state.rolled_back_version); return state; } +// Caller has checked g_state.ready. jobject NewStateCoreResult( JNIEnv* env, const pushy::state::State& state, @@ -139,116 +237,67 @@ jobject NewStateCoreResult( const std::string& load_version, bool did_rollback, bool consumed_first_time) { - jclass result_class = - env->FindClass( - pushy::text::Reveal("3919bbc3ab8a6b512c3e08f0c0b6df60452311edfbc8f780624b2d1de38c93a99b63511201f9cd97878c695522").c_str()); - if (result_class == nullptr) { - return nullptr; - } - - jmethodID constructor = env->GetMethodID(result_class, "", "()V"); - if (constructor == nullptr) { - return nullptr; - } - - jobject result = env->NewObject(result_class, constructor); + jobject result = env->NewObject(g_state.clazz, g_state.constructor); if (result == nullptr) { return nullptr; } - SetStringField(env, result, result_class, "packageVersion", state.package_version); - SetStringField(env, result, result_class, "buildTime", state.build_time); - SetStringField(env, result, result_class, "currentVersion", state.current_version); - SetStringField(env, result, result_class, "lastVersion", state.last_version); - SetBooleanField(env, result, result_class, "firstTime", state.first_time); - SetBooleanField(env, result, result_class, "firstTimeOk", state.first_time_ok); + SetStringField(env, result, g_state.package_version, state.package_version); + SetStringField(env, result, g_state.build_time, state.build_time); + SetStringField(env, result, g_state.current_version, state.current_version); + SetStringField(env, result, g_state.last_version, state.last_version); + SetBooleanField(env, result, g_state.first_time, state.first_time); + SetBooleanField(env, result, g_state.first_time_ok, state.first_time_ok); SetStringField( - env, - result, - result_class, - "rolledBackVersion", - state.rolled_back_version); - SetBooleanField(env, result, result_class, "changed", changed); + env, result, g_state.rolled_back_version, state.rolled_back_version); + SetBooleanField(env, result, g_state.changed, changed); SetStringField( - env, - result, - result_class, - "staleVersionToDelete", - stale_version_to_delete); - SetStringField(env, result, result_class, "loadVersion", load_version); - SetBooleanField(env, result, result_class, "didRollback", did_rollback); + env, result, g_state.stale_version_to_delete, stale_version_to_delete); + SetStringField(env, result, g_state.load_version, load_version); + SetBooleanField(env, result, g_state.did_rollback, did_rollback); SetBooleanField( - env, - result, - result_class, - "consumedFirstTime", - consumed_first_time); + env, result, g_state.consumed_first_time, consumed_first_time); return result; } jobject NewArchivePatchPlanResult( JNIEnv* env, const pushy::archive_patch::ArchivePatchPlan& plan) { - jclass result_class = - env->FindClass( - pushy::text::Reveal("3919bbc3ab8a6b512c3e08f0c0b6df60452311edfbc8f780624b2d1de38c81af997f5d270bdbc9b181974c55371dc2c8b9926855").c_str()); - if (result_class == nullptr) { + if (!RequireReady(env, g_plan.ready, "archive plan result")) { return nullptr; } - - jmethodID constructor = env->GetMethodID(result_class, "", "()V"); - if (constructor == nullptr) { - return nullptr; - } - - jobject result = env->NewObject(result_class, constructor); + jobject result = env->NewObject(g_plan.clazz, g_plan.constructor); if (result == nullptr) { return nullptr; } - SetStringField( - env, - result, - result_class, - "mergeSourceSubdir", - plan.merge_source_subdir); - SetBooleanField(env, result, result_class, "enableMerge", plan.enable_merge); + SetStringField(env, result, g_plan.merge_source_subdir, plan.merge_source_subdir); + SetBooleanField(env, result, g_plan.enable_merge, plan.enable_merge); return result; } +// Caller has checked g_copy_group.ready. jobject NewCopyGroupResult( JNIEnv* env, - jclass result_class, - jclass string_class, const pushy::archive_patch::CopyGroup& group) { - jmethodID constructor = env->GetMethodID(result_class, "", "()V"); - if (constructor == nullptr) { - return nullptr; - } - - jobject result = env->NewObject(result_class, constructor); + jobject result = env->NewObject(g_copy_group.clazz, g_copy_group.constructor); if (result == nullptr) { return nullptr; } - SetStringField(env, result, result_class, "from", group.from); - jfieldID to_paths_field = - env->GetFieldID(result_class, "toPaths", "[Ljava/lang/String;"); - if (to_paths_field != nullptr) { - jobjectArray to_paths = env->NewObjectArray( - static_cast(group.to_paths.size()), string_class, nullptr); - if (to_paths != nullptr) { - for (jsize index = 0; index < static_cast(group.to_paths.size()); - ++index) { - jstring value = env->NewStringUTF(group.to_paths[index].c_str()); - env->SetObjectArrayElement(to_paths, index, value); - env->DeleteLocalRef(value); - } - env->SetObjectField(result, to_paths_field, to_paths); - env->DeleteLocalRef(to_paths); + SetStringField(env, result, g_copy_group.from, group.from); + jobjectArray to_paths = env->NewObjectArray( + static_cast(group.to_paths.size()), g_copy_group.string_class, + nullptr); + if (to_paths != nullptr) { + for (jsize index = 0; index < static_cast(group.to_paths.size()); + ++index) { + jstring value = env->NewStringUTF(group.to_paths[index].c_str()); + env->SetObjectArrayElement(to_paths, index, value); + env->DeleteLocalRef(value); } - } else { - env->ExceptionClear(); + env->SetObjectField(result, g_copy_group.to_paths, to_paths); + env->DeleteLocalRef(to_paths); } return result; @@ -296,6 +345,14 @@ jobject MakeStateResult( } // namespace +bool pushy::jni_natives::InitResultClassCache(JNIEnv* env) { + // Every class is attempted so the log names all missing members at once. + bool ok = InitStateResultIds(env); + ok &= InitPlanResultIds(env); + ok &= InitCopyGroupIds(env); + return ok; +} + // 客户端可消费的 diff 轨道版本(能力上报,而非 SDK 版本映射);JS 层经 // getConstants 暴露,随 checkUpdate 以 diffV 上报,服务端据此下发 v2 轨道。 jint pushy::jni_natives::SupportedDiffVersion( @@ -314,6 +371,9 @@ jobject pushy::jni_natives::SyncStateWithBinaryVersion( // allocations) must not unwind through the JNI boundary: convert them to a // Java RuntimeException like every other failure of these entry points. try { + if (!RequireReady(env, g_state.ready, "state result")) { + return nullptr; + } pushy::state::State state = ReadStateFromResult(env, state_result); pushy::state::BinaryVersionSyncResult result = pushy::state::SyncBinaryVersion( state, @@ -337,6 +397,9 @@ jobject pushy::jni_natives::RunStateCore( jboolean flag_a, jboolean flag_b) { try { + if (!RequireReady(env, g_state.ready, "state result")) { + return nullptr; + } const pushy::state::State state = ReadStateFromResult(env, state_result); switch (static_cast(operation)) { case StateOperation::kSwitchVersion: @@ -451,30 +514,20 @@ jobjectArray pushy::jni_natives::BuildCopyGroups( return nullptr; } - jclass result_class = - env->FindClass( - pushy::text::Reveal("3919bbc3ab8a6b512c3e08f0c0b6df60452311edfbc8f780624b2d1de38c83b28a6e732301fed897878c695522").c_str()); - if (result_class == nullptr) { + if (!RequireReady(env, g_copy_group.ready, "copy group result")) { return nullptr; } - - jclass string_class = env->FindClass("java/lang/String"); - if (string_class == nullptr) { - return nullptr; - } - jobjectArray result = env->NewObjectArray( - static_cast(groups.size()), result_class, nullptr); + static_cast(groups.size()), g_copy_group.clazz, nullptr); if (result == nullptr) { return nullptr; } for (jsize index = 0; index < static_cast(groups.size()); ++index) { - jobject group = NewCopyGroupResult(env, result_class, string_class, groups[index]); + jobject group = NewCopyGroupResult(env, groups[index]); env->SetObjectArrayElement(result, index, group); env->DeleteLocalRef(group); } - env->DeleteLocalRef(string_class); return result; } catch (const std::exception& error) { ThrowRuntimeException(env, error.what()); diff --git a/cpp/update_flow_core/tests/config-equivalence-vectors.json b/cpp/update_flow_core/tests/config-equivalence-vectors.json new file mode 100644 index 00000000..e93b72b8 --- /dev/null +++ b/cpp/update_flow_core/tests/config-equivalence-vectors.json @@ -0,0 +1,353 @@ +{ + "description": "Contract C1: native config JSON equivalence, replayed by the JS (src/updateFlowCore.ts), Harmony (UpdateContext.ts), Android (ConfigEquivalence.java) and iOS (PushyConfigsEquivalent) implementations. Two strings are equivalent iff both parse to JSON objects whose canonical forms deep-equal (key order ignored). Canonical: absent keys take {disabled:false, queryUrls:[], rnu:\"\", rn:\"\", afterDownload:\"none\"}; each string in endpoints/queryUrls (when an array) loses ALL trailing \"/\", then entries are deduplicated keeping the first occurrence. Present values are never coerced (0 is not false, null is not a default). Anything that is not a JSON object is never equivalent, not even to itself. Every case must hold in both argument orders. Hand-maintained: edit this file, then keep every platform green.", + "cases": [ + { + "name": "identical", + "a": "{\"appKey\":\"k\",\"endpoints\":[\"https://a.example.com\"]}", + "b": "{\"appKey\":\"k\",\"endpoints\":[\"https://a.example.com\"]}", + "equivalent": true + }, + { + "name": "key order is ignored", + "a": "{\"appKey\":\"k\",\"packageVersion\":\"1.0\",\"endpoints\":[\"https://a.example.com\"]}", + "b": "{\"endpoints\":[\"https://a.example.com\"],\"packageVersion\":\"1.0\",\"appKey\":\"k\"}", + "equivalent": true + }, + { + "name": "absent disabled equals false", + "a": "{\"appKey\":\"k\",\"endpoints\":[\"https://a.example.com\"]}", + "b": "{\"appKey\":\"k\",\"endpoints\":[\"https://a.example.com\"],\"disabled\":false}", + "equivalent": true + }, + { + "name": "absent queryUrls equals []", + "a": "{\"appKey\":\"k\",\"endpoints\":[\"https://a.example.com\"]}", + "b": "{\"appKey\":\"k\",\"endpoints\":[\"https://a.example.com\"],\"queryUrls\":[]}", + "equivalent": true + }, + { + "name": "absent rnu equals \"\"", + "a": "{\"appKey\":\"k\",\"endpoints\":[\"https://a.example.com\"]}", + "b": "{\"appKey\":\"k\",\"endpoints\":[\"https://a.example.com\"],\"rnu\":\"\"}", + "equivalent": true + }, + { + "name": "absent rn equals \"\"", + "a": "{\"appKey\":\"k\",\"endpoints\":[\"https://a.example.com\"]}", + "b": "{\"appKey\":\"k\",\"endpoints\":[\"https://a.example.com\"],\"rn\":\"\"}", + "equivalent": true + }, + { + "name": "absent afterDownload equals \"none\"", + "a": "{\"appKey\":\"k\",\"endpoints\":[\"https://a.example.com\"]}", + "b": "{\"appKey\":\"k\",\"endpoints\":[\"https://a.example.com\"],\"afterDownload\":\"none\"}", + "equivalent": true + }, + { + "name": "every default explicit vs all absent", + "a": "{\"appKey\":\"k\",\"endpoints\":[\"https://a.example.com\"]}", + "b": "{\"disabled\":false,\"queryUrls\":[],\"rnu\":\"\",\"rn\":\"\",\"afterDownload\":\"none\",\"appKey\":\"k\",\"endpoints\":[\"https://a.example.com\"]}", + "equivalent": true + }, + { + "name": "empty object vs explicit defaults", + "a": "{}", + "b": "{\"disabled\":false,\"queryUrls\":[],\"rnu\":\"\",\"rn\":\"\",\"afterDownload\":\"none\"}", + "equivalent": true + }, + { + "name": "disabled true is not the default", + "a": "{\"appKey\":\"k\",\"endpoints\":[\"https://a.example.com\"]}", + "b": "{\"appKey\":\"k\",\"endpoints\":[\"https://a.example.com\"],\"disabled\":true}", + "equivalent": false + }, + { + "name": "disabled true on both sides", + "a": "{\"appKey\":\"k\",\"endpoints\":[\"https://a.example.com\"],\"disabled\":true}", + "b": "{\"disabled\":true,\"appKey\":\"k\",\"endpoints\":[\"https://a.example.com\"]}", + "equivalent": true + }, + { + "name": "disabled 0 is not false", + "a": "{\"appKey\":\"k\",\"endpoints\":[\"https://a.example.com\"],\"disabled\":0}", + "b": "{\"appKey\":\"k\",\"endpoints\":[\"https://a.example.com\"],\"disabled\":false}", + "equivalent": false + }, + { + "name": "disabled 0 is not the absent default", + "a": "{\"appKey\":\"k\",\"endpoints\":[\"https://a.example.com\"],\"disabled\":0}", + "b": "{\"appKey\":\"k\",\"endpoints\":[\"https://a.example.com\"]}", + "equivalent": false + }, + { + "name": "non-default afterDownload is a change", + "a": "{\"appKey\":\"k\",\"endpoints\":[\"https://a.example.com\"]}", + "b": "{\"appKey\":\"k\",\"endpoints\":[\"https://a.example.com\"],\"afterDownload\":\"setNeedUpdate\"}", + "equivalent": false + }, + { + "name": "non-empty rn is a change", + "a": "{\"appKey\":\"k\",\"endpoints\":[\"https://a.example.com\"]}", + "b": "{\"appKey\":\"k\",\"endpoints\":[\"https://a.example.com\"],\"rn\":\"0.80.0\"}", + "equivalent": false + }, + { + "name": "null rn is not the absent default", + "a": "{\"appKey\":\"k\",\"endpoints\":[\"https://a.example.com\"],\"rn\":null}", + "b": "{\"appKey\":\"k\",\"endpoints\":[\"https://a.example.com\"]}", + "equivalent": false + }, + { + "name": "null queryUrls is not the absent default", + "a": "{\"appKey\":\"k\",\"endpoints\":[\"https://a.example.com\"],\"queryUrls\":null}", + "b": "{\"appKey\":\"k\",\"endpoints\":[\"https://a.example.com\"]}", + "equivalent": false + }, + { + "name": "null equals null", + "a": "{\"appKey\":\"k\",\"endpoints\":[\"https://a.example.com\"],\"extra\":null}", + "b": "{\"extra\":null,\"appKey\":\"k\",\"endpoints\":[\"https://a.example.com\"]}", + "equivalent": true + }, + { + "name": "packageVersion present vs absent (no default)", + "a": "{\"appKey\":\"k\",\"endpoints\":[\"https://a.example.com\"],\"packageVersion\":\"1.0\"}", + "b": "{\"appKey\":\"k\",\"endpoints\":[\"https://a.example.com\"]}", + "equivalent": false + }, + { + "name": "empty packageVersion vs absent (no default)", + "a": "{\"appKey\":\"k\",\"endpoints\":[\"https://a.example.com\"],\"packageVersion\":\"\"}", + "b": "{\"appKey\":\"k\",\"endpoints\":[\"https://a.example.com\"]}", + "equivalent": false + }, + { + "name": "empty appKey vs absent appKey (no default)", + "a": "{\"appKey\":\"\"}", + "b": "{}", + "equivalent": false + }, + { + "name": "different appKey", + "a": "{\"appKey\":\"k\",\"endpoints\":[\"https://a.example.com\"]}", + "b": "{\"appKey\":\"other\",\"endpoints\":[\"https://a.example.com\"]}", + "equivalent": false + }, + { + "name": "endpoints [] vs absent endpoints (no default)", + "a": "{\"appKey\":\"k\",\"endpoints\":[]}", + "b": "{\"appKey\":\"k\"}", + "equivalent": false + }, + { + "name": "single trailing slash stripped in endpoints", + "a": "{\"appKey\":\"k\",\"endpoints\":[\"https://a.example.com/\"]}", + "b": "{\"appKey\":\"k\",\"endpoints\":[\"https://a.example.com\"]}", + "equivalent": true + }, + { + "name": "all trailing slashes stripped in endpoints", + "a": "{\"appKey\":\"k\",\"endpoints\":[\"https://a.example.com///\"]}", + "b": "{\"appKey\":\"k\",\"endpoints\":[\"https://a.example.com\"]}", + "equivalent": true + }, + { + "name": "trailing slash stripped in queryUrls", + "a": "{\"appKey\":\"k\",\"endpoints\":[\"https://a.example.com\"],\"queryUrls\":[\"https://q.example.com/endpoints.json/\"]}", + "b": "{\"appKey\":\"k\",\"endpoints\":[\"https://a.example.com\"],\"queryUrls\":[\"https://q.example.com/endpoints.json\"]}", + "equivalent": true + }, + { + "name": "slash-only entry becomes empty string", + "a": "{\"appKey\":\"k\",\"endpoints\":[\"//\"]}", + "b": "{\"appKey\":\"k\",\"endpoints\":[\"\"]}", + "equivalent": true + }, + { + "name": "trailing path slash stripped, inner slashes kept", + "a": "{\"appKey\":\"k\",\"endpoints\":[\"https://a.example.com/api/\"]}", + "b": "{\"appKey\":\"k\",\"endpoints\":[\"https://a.example.com/api\"]}", + "equivalent": true + }, + { + "name": "inner double slash is significant", + "a": "{\"appKey\":\"k\",\"endpoints\":[\"https://a.example.com//api\"]}", + "b": "{\"appKey\":\"k\",\"endpoints\":[\"https://a.example.com/api\"]}", + "equivalent": false + }, + { + "name": "host case is significant", + "a": "{\"appKey\":\"k\",\"endpoints\":[\"https://A.example.com\"]}", + "b": "{\"appKey\":\"k\",\"endpoints\":[\"https://a.example.com\"]}", + "equivalent": false + }, + { + "name": "trailing slashes only normalized in endpoints/queryUrls", + "a": "{\"appKey\":\"k\",\"endpoints\":[\"https://a.example.com\"],\"homepage\":\"https://h.example.com/\"}", + "b": "{\"appKey\":\"k\",\"endpoints\":[\"https://a.example.com\"],\"homepage\":\"https://h.example.com\"}", + "equivalent": false + }, + { + "name": "endpoints as a bare string are not normalized", + "a": "{\"appKey\":\"k\",\"endpoints\":\"https://a.example.com/\"}", + "b": "{\"appKey\":\"k\",\"endpoints\":\"https://a.example.com\"}", + "equivalent": false + }, + { + "name": "duplicates dropped after stripping, first occurrence kept", + "a": "{\"appKey\":\"k\",\"endpoints\":[\"https://a.example.com/\",\"https://b.example.com\",\"https://a.example.com\"]}", + "b": "{\"appKey\":\"k\",\"endpoints\":[\"https://a.example.com\",\"https://b.example.com\"]}", + "equivalent": true + }, + { + "name": "dedupe keeps first-occurrence order", + "a": "{\"appKey\":\"k\",\"endpoints\":[\"https://b.example.com\",\"https://a.example.com\",\"https://b.example.com/\"]}", + "b": "{\"appKey\":\"k\",\"endpoints\":[\"https://b.example.com\",\"https://a.example.com\"]}", + "equivalent": true + }, + { + "name": "dedupe does not sort", + "a": "{\"appKey\":\"k\",\"endpoints\":[\"https://b.example.com\",\"https://a.example.com\",\"https://b.example.com/\"]}", + "b": "{\"appKey\":\"k\",\"endpoints\":[\"https://a.example.com\",\"https://b.example.com\"]}", + "equivalent": false + }, + { + "name": "queryUrls deduped", + "a": "{\"appKey\":\"k\",\"endpoints\":[\"https://a.example.com\"],\"queryUrls\":[\"https://q.example.com//\",\"https://q.example.com\"]}", + "b": "{\"appKey\":\"k\",\"endpoints\":[\"https://a.example.com\"],\"queryUrls\":[\"https://q.example.com\"]}", + "equivalent": true + }, + { + "name": "non-string list entries are deduped too", + "a": "{\"appKey\":\"k\",\"endpoints\":[1,1.0,\"https://a.example.com\",{\"x\":1},{\"x\":1}]}", + "b": "{\"appKey\":\"k\",\"endpoints\":[1,\"https://a.example.com/\",{\"x\":1}]}", + "equivalent": true + }, + { + "name": "endpoint order is significant", + "a": "{\"appKey\":\"k\",\"endpoints\":[\"https://a.example.com\",\"https://b.example.com\"]}", + "b": "{\"appKey\":\"k\",\"endpoints\":[\"https://b.example.com\",\"https://a.example.com\"]}", + "equivalent": false + }, + { + "name": "queryUrls order is significant", + "a": "{\"appKey\":\"k\",\"endpoints\":[\"https://a.example.com\"],\"queryUrls\":[\"https://q1.example.com\",\"https://q2.example.com\"]}", + "b": "{\"appKey\":\"k\",\"endpoints\":[\"https://a.example.com\"],\"queryUrls\":[\"https://q2.example.com\",\"https://q1.example.com\"]}", + "equivalent": false + }, + { + "name": "extra unknown key on one side", + "a": "{\"appKey\":\"k\",\"endpoints\":[\"https://a.example.com\"]}", + "b": "{\"appKey\":\"k\",\"endpoints\":[\"https://a.example.com\"],\"extra\":1}", + "equivalent": false + }, + { + "name": "same unknown key on both sides", + "a": "{\"appKey\":\"k\",\"endpoints\":[\"https://a.example.com\"],\"extra\":1}", + "b": "{\"extra\":1,\"appKey\":\"k\",\"endpoints\":[\"https://a.example.com\"]}", + "equivalent": true + }, + { + "name": "number 1 equals 1.0", + "a": "{\"appKey\":\"k\",\"n\":1}", + "b": "{\"appKey\":\"k\",\"n\":1.0}", + "equivalent": true + }, + { + "name": "string \"1\" is not number 1", + "a": "{\"appKey\":\"k\",\"endpoints\":[\"https://a.example.com\"],\"n\":\"1\"}", + "b": "{\"appKey\":\"k\",\"endpoints\":[\"https://a.example.com\"],\"n\":1}", + "equivalent": false + }, + { + "name": "nested object key order is ignored", + "a": "{\"appKey\":\"k\",\"endpoints\":[\"https://a.example.com\"],\"extra\":{\"x\":1,\"y\":[1,{\"p\":true,\"q\":null}]}}", + "b": "{\"extra\":{\"y\":[1,{\"q\":null,\"p\":true}],\"x\":1},\"appKey\":\"k\",\"endpoints\":[\"https://a.example.com\"]}", + "equivalent": true + }, + { + "name": "nested array order is significant", + "a": "{\"appKey\":\"k\",\"endpoints\":[\"https://a.example.com\"],\"extra\":{\"y\":[1,2]}}", + "b": "{\"appKey\":\"k\",\"endpoints\":[\"https://a.example.com\"],\"extra\":{\"y\":[2,1]}}", + "equivalent": false + }, + { + "name": "unicode is compared without normalization", + "a": "{\"appKey\":\"caf\\u00e9\"}", + "b": "{\"appKey\":\"cafe\\u0301\"}", + "equivalent": false + }, + { + "name": "surrounding whitespace is irrelevant", + "a": " \n{\"appKey\":\"k\"}\t ", + "b": "{\"appKey\":\"k\"}", + "equivalent": true + }, + { + "name": "JSON array is never equivalent, even to itself", + "a": "[]", + "b": "[]", + "equivalent": false + }, + { + "name": "JSON array of an object is not an object", + "a": "[{\"appKey\":\"k\"}]", + "b": "{\"appKey\":\"k\"}", + "equivalent": false + }, + { + "name": "JSON string is never equivalent", + "a": "\"{}\"", + "b": "\"{}\"", + "equivalent": false + }, + { + "name": "JSON number is never equivalent", + "a": "1", + "b": "1", + "equivalent": false + }, + { + "name": "JSON null is never equivalent", + "a": "null", + "b": "null", + "equivalent": false + }, + { + "name": "JSON boolean is never equivalent", + "a": "true", + "b": "true", + "equivalent": false + }, + { + "name": "invalid JSON is never equivalent, even to itself", + "a": "{", + "b": "{", + "equivalent": false + }, + { + "name": "invalid JSON (unterminated) vs valid object", + "a": "{\"appKey\":\"k\"", + "b": "{\"appKey\":\"k\"}", + "equivalent": false + }, + { + "name": "empty string is never equivalent", + "a": "", + "b": "", + "equivalent": false + }, + { + "name": "trailing content after the object is invalid JSON", + "a": "{\"appKey\":\"k\"} x", + "b": "{\"appKey\":\"k\"}", + "equivalent": false + }, + { + "name": "two concatenated objects are invalid JSON", + "a": "{\"appKey\":\"k\"}{\"appKey\":\"k\"}", + "b": "{\"appKey\":\"k\"}{\"appKey\":\"k\"}", + "equivalent": false + } + ] +} diff --git a/BUNDLEHASH_MIGRATION.md b/docs/BUNDLEHASH_MIGRATION.md similarity index 100% rename from BUNDLEHASH_MIGRATION.md rename to docs/BUNDLEHASH_MIGRATION.md diff --git a/NATIVE_CHECKUPDATE_DESIGN.md b/docs/NATIVE_CHECKUPDATE_DESIGN.md similarity index 100% rename from NATIVE_CHECKUPDATE_DESIGN.md rename to docs/NATIVE_CHECKUPDATE_DESIGN.md diff --git a/NATIVE_CHECK_FOLLOWUPS.md b/docs/NATIVE_CHECK_FOLLOWUPS.md similarity index 100% rename from NATIVE_CHECK_FOLLOWUPS.md rename to docs/NATIVE_CHECK_FOLLOWUPS.md diff --git a/NATIVE_HOST_API_MIGRATION.md b/docs/NATIVE_HOST_API_MIGRATION.md similarity index 79% rename from NATIVE_HOST_API_MIGRATION.md rename to docs/NATIVE_HOST_API_MIGRATION.md index 3b79d6a2..473162b5 100644 --- a/NATIVE_HOST_API_MIGRATION.md +++ b/docs/NATIVE_HOST_API_MIGRATION.md @@ -31,7 +31,15 @@ just to call this API: resolution consumes first-load and rollback markers. The call starts, joins or reuses the process's existing native round. It does not reload the running React Native instance or display UI. A successful selection is for the **next launch**; preparation can also download without selecting a bundle, -according to the existing `afterDownload` policy. Callback threading, cancellation, +according to the existing `afterDownload` policy. + +**Config ownership.** With `nativeConfigSource: 'native'` the host keeps the +configuration and JS never writes it. In the default `'javascript'` mode the host +may provision the first launch with `configure` and JS takes over once it runs; the +last completed write wins. A write that is equivalent to the stored configuration +(same values after defaults are filled in, endpoint trailing `/` removed and +duplicates dropped, key order ignored) is not a change: it neither invalidates the +response cache nor cancels an in-flight round. Callback threading, cancellation, request deduplication, configuration invalidation and rescue behavior are unchanged. Result fields (`status`, `reason`, `hash`, `activated`) and existing status strings, @@ -60,6 +68,10 @@ the API names that existed in the versions they document. 这是原生源码接口的破坏性重命名,不改变下载、下次启动选包、回滚或救援行为。 配置完成后,先走宿主正常的 bundle 解析流程,再调用 `prepareBundle`; 不要为调用接口再次解析 bundle,也不要新建另一个 Harmony provider。 +`nativeConfigSource: 'native'` 时配置归宿主,JS 不写入;默认 `'javascript'` +模式下可由原生 `configure` 提供首启配置、JS 运行后接管,以最后完成的写入为准。 +与已存配置等价的写入(补齐默认值、去掉服务地址末尾 `/` 并去重、忽略键序后相同) +不算变更,不会清空响应缓存,也不会作废进行中的轮次。 `activated` 仍表示已为下次启动选定,而不是当前实例已经重载。 JS SDK、JS 原生桥接接口、服务端路径、配置值、结果字段/状态值和持久化键不变。 diff --git a/harmony/pushy/src/main/cpp/pushy.cpp b/harmony/pushy/src/main/cpp/pushy.cpp index 3b5aeedf..31f35e7b 100644 --- a/harmony/pushy/src/main/cpp/pushy.cpp +++ b/harmony/pushy/src/main/cpp/pushy.cpp @@ -5,7 +5,10 @@ #include #include +#include +#include #include +#include #include #include "archive_patch_core.h" @@ -685,6 +688,69 @@ struct Sha256FileWork { std::string hex; }; +// Digest of a byte range of an fd the caller keeps open (rawfile +// descriptors: the embedded bundle lives at [offset, offset + length) inside +// the HAP). The fd is never closed here; JS closes it after the promise +// settles. +struct Sha256RangeWork { + napi_async_work work = nullptr; + napi_deferred deferred = nullptr; + int fd = -1; + int64_t offset = 0; + int64_t length = 0; + std::string hex; + std::string error; +}; + +// pread loop (positioned: never moves a file offset the caller may share). +// A short read before `length` bytes is an error, not EOF. +void HashFdRange(Sha256RangeWork* w) { + pushy::digest::Sha256 hasher; + std::vector buffer(256 * 1024); + int64_t position = w->offset; + int64_t remaining = w->length; + while (remaining > 0) { + const size_t want = static_cast( + remaining < static_cast(buffer.size()) + ? remaining + : static_cast(buffer.size())); + const ssize_t read = pread(w->fd, buffer.data(), want, static_cast(position)); + if (read < 0) { + if (errno == EINTR) { + continue; + } + w->error = "sha256HexRawFdAsync: read failed (errno " + + std::to_string(errno) + ")"; + return; + } + if (read == 0) { + w->error = "sha256HexRawFdAsync: unexpected end of file"; + return; + } + hasher.Update(buffer.data(), static_cast(read)); + position += read; + remaining -= read; + } + w->hex = hasher.HexDigest(); +} + +bool GetNonNegativeInt64( + napi_env env, + napi_value value, + const char* name, + int64_t* out_value) { + napi_valuetype type = napi_undefined; + if (value == nullptr || !GetValueType(env, value, &type) || + type != napi_number || + napi_get_value_int64(env, value, out_value) != napi_ok || + *out_value < 0) { + ThrowError(env, std::string("sha256HexRawFdAsync: ") + name + + " must be a non-negative number"); + return false; + } + return true; +} + napi_value ApplyPatchFromFileSource(napi_env env, napi_callback_info info) { napi_value args[1] = {nullptr}; size_t argc = 1; @@ -950,6 +1016,87 @@ napi_value Sha256HexFileAsync(napi_env env, napi_callback_info info) { return promise; } +// sha256HexRawFdAsync(fd, offset, length) -> Promise, hashed on a libuv +// worker thread. Used for the embedded bundle (resourceManager.getRawFd): the +// UI thread neither reads the multi-MB bundle into memory nor hashes it. +// Rejects on any read failure or short read. +napi_value Sha256HexRawFdAsync(napi_env env, napi_callback_info info) { + napi_value args[3] = {nullptr, nullptr, nullptr}; + size_t argc = 3; + if (!GetArgCount(env, info, &argc, args) || argc < 3) { + ThrowError(env, "sha256HexRawFdAsync: expected (fd, offset, length)"); + return nullptr; + } + int32_t fd = -1; + if (!GetInt32(env, args[0], &fd)) { + return nullptr; + } + if (fd < 0) { + ThrowError(env, "sha256HexRawFdAsync: fd must be non-negative"); + return nullptr; + } + int64_t offset = 0; + int64_t length = 0; + if (!GetNonNegativeInt64(env, args[1], "offset", &offset) || + !GetNonNegativeInt64(env, args[2], "length", &length)) { + return nullptr; + } + + auto* work_data = new Sha256RangeWork(); + work_data->fd = fd; + work_data->offset = offset; + work_data->length = length; + + napi_value promise = nullptr; + if (napi_create_promise(env, &work_data->deferred, &promise) != napi_ok) { + delete work_data; + ThrowError(env, "Unable to create promise"); + return nullptr; + } + + napi_value resource_name = nullptr; + napi_create_string_utf8( + env, "sha256HexRawFdAsync", NAPI_AUTO_LENGTH, &resource_name); + if (napi_create_async_work( + env, + nullptr, + resource_name, + [](napi_env, void* data) { + HashFdRange(static_cast(data)); + }, + [](napi_env cb_env, napi_status status, void* data) { + auto* w = static_cast(data); + if (status != napi_ok) { + RejectDeferredWithMessage(cb_env, w->deferred, "async work aborted"); + } else if (!w->error.empty() || w->hex.empty()) { + RejectDeferredWithMessage( + cb_env, + w->deferred, + w->error.empty() ? "sha256HexRawFdAsync: no digest" + : w->error.c_str()); + } else { + napi_resolve_deferred(cb_env, w->deferred, NewString(cb_env, w->hex)); + } + napi_delete_async_work(cb_env, w->work); + delete w; + }, + work_data, + &work_data->work) != napi_ok) { + RejectDeferredWithMessage( + env, work_data->deferred, "Unable to create async work"); + delete work_data; + return promise; + } + if (napi_queue_async_work(env, work_data->work) != napi_ok) { + napi_delete_async_work(env, work_data->work); + RejectDeferredWithMessage( + env, work_data->deferred, "Unable to queue async work"); + delete work_data; + return promise; + } + return promise; +} + bool ExportFunction( napi_env env, napi_value exports, @@ -972,71 +1119,6 @@ bool ExportFunction( } // namespace -// sha256(Uint8Array | ArrayBuffer) -> 小写 hex。同步:bundleHash 的输入是 -// rawfile 里的 bundle(已整体读入内存,与 pdiff 的读法一致),哈希本身毫秒级。 -static napi_value MakeUtf8String(napi_env env, const std::string& value); - -// Streaming file digest (bounded memory). Synchronous: small files only — -// archives and bundles go through sha256HexFileAsync. -static napi_value Sha256HexFile(napi_env env, napi_callback_info info) { - size_t argc = 1; - napi_value args[1] = {nullptr}; - if (!GetArgCount(env, info, &argc, args) || argc < 1) { - ThrowError(env, "sha256HexFile: missing path argument"); - return nullptr; - } - bool ok = false; - std::string path = GetString(env, args[0], &ok); - if (!ok) { - // GetString already raised the pending exception. - return nullptr; - } - return MakeUtf8String(env, pushy::digest::Sha256File(path)); -} - -static napi_value Sha256Hex(napi_env env, napi_callback_info info) { - size_t argc = 1; - napi_value args[1] = {nullptr}; - if (!GetArgCount(env, info, &argc, args) || argc < 1) { - ThrowError(env, "sha256Hex: missing data argument"); - return nullptr; - } - - void* data = nullptr; - size_t length = 0; - bool is_typedarray = false; - napi_is_typedarray(env, args[0], &is_typedarray); - if (is_typedarray) { - napi_typedarray_type type; - napi_value arraybuffer = nullptr; - size_t offset = 0; - if (napi_get_typedarray_info( - env, args[0], &type, &length, &data, &arraybuffer, &offset) != - napi_ok) { - ThrowError(env, "sha256Hex: cannot read typed array"); - return nullptr; - } - } else { - bool is_arraybuffer = false; - napi_is_arraybuffer(env, args[0], &is_arraybuffer); - if (!is_arraybuffer || - napi_get_arraybuffer_info(env, args[0], &data, &length) != napi_ok) { - ThrowError(env, "sha256Hex: expected Uint8Array or ArrayBuffer"); - return nullptr; - } - } - - pushy::digest::Sha256 hasher; - if (length > 0) { - hasher.Update(static_cast(data), length); - } - const std::string hex = hasher.HexDigest(); - - napi_value result = nullptr; - napi_create_string_utf8(env, hex.c_str(), hex.size(), &result); - return result; -} - // CRC32(zip/zlib 多项式)。同步:输入是已在内存的 rawfile 资源字节,用于 // pdiff 拷贝前与 __diff.json copiesCrc 比对(不符则整次 patch 失败落 full)。 static napi_value Crc32(napi_env env, napi_callback_info info) { @@ -1217,9 +1299,8 @@ napi_value Init(napi_env env, napi_value exports) { !ExportFunction(env, exports, "buildCopyGroups", BuildCopyGroups) || !ExportFunction(env, exports, "applyDeltaFromSource", ApplyPatchFromFileSource) || !ExportFunction(env, exports, "cleanupOldEntries", CleanupOldEntries) || - !ExportFunction(env, exports, "sha256Hex", Sha256Hex) || - !ExportFunction(env, exports, "sha256HexFile", Sha256HexFile) || !ExportFunction(env, exports, "sha256HexFileAsync", Sha256HexFileAsync) || + !ExportFunction(env, exports, "sha256HexRawFdAsync", Sha256HexRawFdAsync) || !ExportFunction(env, exports, "crc32", Crc32) || !ExportFunction(env, exports, "getSupportedDiffVersion", GetSupportedDiffVersion) || !ExportFunction(env, exports, "buildRequestBody", FlowBuildRequestBody) || diff --git a/harmony/pushy/src/main/ets/ArchiveLimits.ts b/harmony/pushy/src/main/ets/ArchiveLimits.ts index 29908895..44ca0674 100644 --- a/harmony/pushy/src/main/ets/ArchiveLimits.ts +++ b/harmony/pushy/src/main/ets/ArchiveLimits.ts @@ -22,10 +22,21 @@ export const UNKNOWN_LENGTH_FREE_SPACE_PROBE_BYTES = 8 * 1024 * 1024; /** * 解压前的总量校验(zlib.getOriginalSize 给出的归档解压后总字节数)。 - * zlib.decompressFile 没有逐条钩子,C++ 头文件里的逐条压缩比在这里只能按 - * 整包近似:总解压量超过上限、或(总量大于 RATIO_CHECK_MIN_BYTES 时)总压缩 - * 比超过 MAX_COMPRESSION_RATIO 即拒绝——20MB 归档 100:1 的 2GB 载荷在解压前 - * 就被挡下,而不是先解出来再量。 + * + * 与 archive_limits.h 的偏差(有意为之,无法消除):头文件的压缩比上限是 + * **逐条**的(单条解压量 > RATIO_CHECK_MIN_BYTES 时才查),但鸿蒙的 + * @kit.BasicServicesKit zlib 只提供整包解压量(getOriginalSize)与整包 + * 解压(decompressFile),拿不到任何条目的压缩/解压尺寸,也没有逐条钩子。 + * 因此这里只能按整包近似:总解压量超过上限、或(总量大于 + * RATIO_CHECK_MIN_BYTES 时)总压缩比超过 MAX_COMPRESSION_RATIO 即拒绝—— + * 20MB 归档 100:1 的 2GB 载荷在解压前就被挡下,而不是先解出来再量。 + * 两个方向的差异: + * - 更松:单条高压缩比条目被同包大量不可压缩数据"稀释"时整包比可能不超限 + * (其总量仍受 MAX_TOTAL_UNCOMPRESSED_BYTES 与解压后的逐条 + * MAX_ENTRY_BYTES 约束,伤害有界); + * - 更严:大量各自 < RATIO_CHECK_MIN_BYTES 的高压缩比小文件,逐条不查, + * 但累加后整包比超限会被拒绝(真实更新包不会出现 100:1 的整包比)。 + * 解压后 measureExtractedDirectory 再逐条核对条目数/单条大小/总字节数。 */ export function checkUncompressedSize( archiveBytes: number, @@ -51,6 +62,63 @@ export function checkUncompressedSize( } } +// 解压量取不到时拒绝的专属错误细节:与"超限"拒绝区分开,日志与遥测里一眼 +// 可辨(典型原因:设备 zlib 不认该归档格式/归档损坏)。不做任何放行回退。 +export const ORIGINAL_SIZE_UNAVAILABLE = 'archive expansion size unavailable'; + +/** + * zlib.getOriginalSize 的结果校验。failure 非 undefined 表示调用本身抛错 + * (其消息);否则校验返回值必须是有限非负数。任何一种都拒绝归档:不知道 + * 展开量就放行,等于允许一个 20MB 的归档在解压后的统计跑到之前先写满磁盘。 + */ +export function checkOriginalSize( + size: number | undefined, + failure?: string, +): number { + if (failure !== undefined) { + throw createPushyError( + ERROR_DELTA_FAILED, + `${ORIGINAL_SIZE_UNAVAILABLE}: getOriginalSize failed: ${failure}`, + ); + } + if (typeof size !== 'number' || !Number.isFinite(size) || size < 0) { + throw createPushyError( + ERROR_DELTA_FAILED, + `${ORIGINAL_SIZE_UNAVAILABLE}: getOriginalSize returned ${String(size)}`, + ); + } + return size; +} + +export interface FreeSpaceProbePlan { + /** 本次写入前是否需要探测可用空间。 */ + probe: boolean; + /** 探测时要求的字节数(不含 FREE_DISK_MARGIN_BYTES)。 */ + reserve: number; + /** 计划后的"已预留到"位置(相对本次响应体起点)。 */ + reservedUntil: number; +} + +/** + * 未知长度下载(分块/编码响应体)的磁盘探测计划:响应到达时只能预留安全 + * 余量,所以任何会写过已预留位置的写入之前都要探测,每次至少预留接下来的 + * UNKNOWN_LENGTH_FREE_SPACE_PROBE_BYTES(单个 chunk 更大时按 chunk 算)—— + * 任何写入都吃不到余量。已知总长时由响应到达时的一次性检查覆盖,不探测。 + * writtenBefore 是本 chunk 之前已收到的字节数(相对本次响应体)。 + */ +export function planUnknownLengthFreeSpaceProbe( + knownTotal: number, + writtenBefore: number, + chunkBytes: number, + reservedUntil: number, +): FreeSpaceProbePlan { + const reserve = Math.max(UNKNOWN_LENGTH_FREE_SPACE_PROBE_BYTES, chunkBytes); + if (knownTotal > 0 || writtenBefore + chunkBytes <= reservedUntil) { + return { probe: false, reserve, reservedUntil }; + } + return { probe: true, reserve, reservedUntil: writtenBefore + reserve }; +} + /** * 目标所在文件系统必须还能再写 bytesToWrite + 安全余量。取不到可用空间 * (返回 0/异常)视为未知,放行。 diff --git a/harmony/pushy/src/main/ets/DownloadTask.ts b/harmony/pushy/src/main/ets/DownloadTask.ts index 2bd61342..d7e960b2 100644 --- a/harmony/pushy/src/main/ets/DownloadTask.ts +++ b/harmony/pushy/src/main/ets/DownloadTask.ts @@ -4,7 +4,7 @@ import common from '@ohos.app.ability.common'; import { zlib } from '@kit.BasicServicesKit'; import { EventHub } from './EventHub'; import { DownloadTaskParams } from './DownloadTaskParams'; -import { saveFileToSandbox } from './SaveFile'; +import { saveFileToSandbox, withRawFd } from './SaveFile'; import { util } from '@kit.ArkTS'; import NativePatchCore, { ARCHIVE_PATCH_TYPE_FROM_PACKAGE, @@ -16,10 +16,11 @@ import logger from './Logger'; import { MAX_ARCHIVE_BYTES, MAX_MANIFEST_BYTES, - UNKNOWN_LENGTH_FREE_SPACE_PROBE_BYTES, + checkOriginalSize, checkUncompressedSize, ensureFreeSpace, measureExtractedDirectory, + planUnknownLengthFreeSpaceProbe, } from './ArchiveLimits'; import { HARMONY_BUNDLE_FILE_NAME, @@ -127,6 +128,7 @@ function toArrayBufferSlice( const DIFF_MANIFEST_ENTRY = '__diff.json'; const TEMP_ORIGIN_BUNDLE_ENTRY = '.origin.bundle.harmony.js'; +const EMBEDDED_BUNDLE_RAWFILE = 'bundle.harmony.js'; const FILE_COPY_BUFFER_SIZE = 64 * 1024; const DOWNLOAD_CALL_TIMEOUT_MS = 10 * 60 * 1000; @@ -328,27 +330,28 @@ export class DownloadTask { /** * 解压前读取归档解压后的总字节数(zlib.getOriginalSize,API 12——RNOH 的 - * 最低 API 已覆盖)。读不到就拒绝归档:zlib.decompressFile 没有逐条钩子, - * 不知道展开量就放行等于允许一个 20MB 的归档在 measureExtractedDirectory - * 跑到之前先把磁盘写满。 + * 最低 API 已覆盖)。读不到就拒绝归档(checkOriginalSize):zlib.decompressFile + * 没有逐条钩子,不知道展开量就放行等于允许一个 20MB 的归档在 + * measureExtractedDirectory 跑到之前先把磁盘写满。拒绝带专属错误细节 + * (ORIGINAL_SIZE_UNAVAILABLE)并单独记日志,与"超限"拒绝可区分。 */ private async readOriginalSize(archiveFile: string): Promise { let size: number | undefined; + let failure: string | undefined; try { size = await zlib.getOriginalSize(archiveFile); } catch (e) { - throw createPushyError( - ERROR_DELTA_FAILED, - `cannot determine archive expansion size: ${getErrorMessage(e)}`, - ); + failure = getErrorMessage(e); } - if (typeof size !== 'number' || !Number.isFinite(size) || size < 0) { - throw createPushyError( - ERROR_DELTA_FAILED, - `cannot determine archive expansion size: ${String(size)}`, + try { + return checkOriginalSize(size, failure); + } catch (error) { + logger.error( + TAG, + `Refusing archive, expansion size unknown: ${getErrorMessage(error)}`, ); + throw error; } - return size; } /** @@ -480,8 +483,12 @@ export class DownloadTask { } - private async applyBundlePatchFromFileSource( - originContent: ArrayBuffer, + /** + * pdiff 的 bundle 补丁:hpatch 需要文件源,先把二进制内嵌的 bundle 从 + * rawfile descriptor 异步流式拷到 staging 里的临时文件(不整包读进内存、 + * 不在 UI 线程同步 IO),补丁完成后删除。 + */ + private async applyBundlePatchFromEmbeddedBundle( workingDirectory: string, bundleDeltaPath: string, outputFile: string, @@ -489,7 +496,11 @@ export class DownloadTask { ): Promise { const originBundlePath = `${workingDirectory}/${TEMP_ORIGIN_BUNDLE_ENTRY}`; try { - await this.writeFileContent(originBundlePath, originContent); + await withRawFd( + this.context.resourceManager, + EMBEDDED_BUNDLE_RAWFILE, + descriptor => saveFileToSandbox(descriptor, originBundlePath), + ); await NativePatchCore.applyDeltaFromSource({ copyFroms: [], copyTos: [], @@ -514,50 +525,14 @@ export class DownloadTask { } } + // 沙箱内文件复制(同一 rawfile 落到多个目标时,首个之外的目标从首个复制)。 + // fileIo.copyFile 在工作线程整文件复制并覆盖目标,无需逐块搬运。 private async copySandboxFile( sourceFile: string, targetFile: string, ): Promise { - let reader: fileIo.File | null = null; - let writer: fileIo.File | null = null; - const buffer = new ArrayBuffer(FILE_COPY_BUFFER_SIZE); - let offset = 0; - - try { - reader = await fileIo.open(sourceFile, fileIo.OpenMode.READ_ONLY); - await this.ensureParentDirectory(targetFile); - if (fileIo.accessSync(targetFile)) { - await fileIo.unlink(targetFile); - } - writer = await fileIo.open( - targetFile, - fileIo.OpenMode.CREATE | fileIo.OpenMode.WRITE_ONLY, - ); - - while (true) { - const readLength = await fileIo.read(reader.fd, buffer, { - offset, - length: FILE_COPY_BUFFER_SIZE, - }); - if (readLength <= 0) { - break; - } - - await fileIo.write(writer.fd, buffer.slice(0, readLength)); - offset += readLength; - - if (readLength < FILE_COPY_BUFFER_SIZE) { - break; - } - } - } finally { - if (reader) { - await fileIo.close(reader); - } - if (writer) { - await fileIo.close(writer); - } - } + await this.ensureParentDirectory(targetFile); + await fileIo.copyFile(sourceFile, targetFile); } private async downloadFile(params: DownloadTaskParams): Promise { @@ -689,19 +664,17 @@ export class DownloadTask { `archive too large: exceeded ${MAX_ARCHIVE_BYTES}`, ); } - // 未知长度:响应到达时只能预留安全余量,所以任何会写过已预留字节数的 - // 写入之前先探测,每次至少预留接下来的 PROBE 字节(单个 chunk 更大时按 - // chunk 算)——任何写入都吃不到余量(归档上限本身远大于余量)。失败保留 - // partial 供下次续传。 - const writtenBefore = received - data.byteLength; - const probeFreeSpace = totalAll <= 0 && received > freeSpaceReservedUntil; - const reserve = Math.max( - UNKNOWN_LENGTH_FREE_SPACE_PROBE_BYTES, + // 未知长度:见 planUnknownLengthFreeSpaceProbe。失败保留 partial 供 + // 下次续传。 + const plan = planUnknownLengthFreeSpaceProbe( + totalAll, + received - data.byteLength, data.byteLength, + freeSpaceReservedUntil, ); - if (probeFreeSpace) { - freeSpaceReservedUntil = writtenBefore + reserve; - } + freeSpaceReservedUntil = plan.reservedUntil; + const probeFreeSpace = plan.probe; + const reserve = plan.reserve; writeQueue = writeQueue.then(async () => { if (!writer || writeError) { return; @@ -1109,12 +1082,7 @@ export class DownloadTask { if (!fileIo.accessSync(bundleDeltaPath)) { throw createPushyError(ERROR_DELTA_FAILED, 'bundle delta not found'); } - const resourceManager = this.context.resourceManager; - const originContent = await resourceManager.getRawFileContent( - 'bundle.harmony.js', - ); - await this.applyBundlePatchFromFileSource( - originContent.buffer as ArrayBuffer, + await this.applyBundlePatchFromEmbeddedBundle( work, bundleDeltaPath, `${work}/${HARMONY_BUNDLE_FILE_NAME}`, @@ -1218,28 +1186,15 @@ export class DownloadTask { continue; } - const fromContent = await resourceManager.getRawFd(currentFrom); - try { - const firstTarget = targets[0]; - const restTargets = targets.slice(1); - await this.ensureParentDirectories(targets); - if (fileIo.accessSync(firstTarget)) { - await fileIo.unlink(firstTarget); - } - saveFileToSandbox(fromContent, firstTarget); - await Promise.all( - restTargets.map(target => this.copySandboxFile(firstTarget, target)), - ); - } finally { - try { - await resourceManager.closeRawFd(currentFrom); - } catch (closeError) { - logger.error( - TAG, - `Failed to close raw fd for ${currentFrom}: ${getErrorMessage(closeError)}`, - ); - } - } + const firstTarget = targets[0]; + const restTargets = targets.slice(1); + await this.ensureParentDirectories(targets); + await withRawFd(resourceManager, currentFrom, descriptor => + saveFileToSandbox(descriptor, firstTarget), + ); + await Promise.all( + restTargets.map(target => this.copySandboxFile(firstTarget, target)), + ); } } catch (error) { const coded = toPushyError(error, ERROR_DELTA_FAILED); diff --git a/harmony/pushy/src/main/ets/NativePatchCore.ts b/harmony/pushy/src/main/ets/NativePatchCore.ts index 2cba7d34..5f32d7ae 100644 --- a/harmony/pushy/src/main/ets/NativePatchCore.ts +++ b/harmony/pushy/src/main/ets/NativePatchCore.ts @@ -84,13 +84,12 @@ interface NativePatchCoreBindings { keepNames: string[], maxAgeDays: number, ): Promise; - /** sha256(小写 hex)。同步:输入是已在内存的 rawfile bundle,哈希毫秒级 */ - sha256Hex(data: Uint8Array): string; - // 文件流式 SHA-256(小写 hex;不可读时空串)。同步,仅限小文件——整包/ - // 整 bundle 用下面的异步版本,避免冻结 UI 线程。 - sha256HexFile(path: string): string; - // 同上,在 libuv 工作线程计算;文件不可读时 reject。 + // 文件流式 SHA-256(小写 hex),在 libuv 工作线程计算;文件不可读时 reject。 sha256HexFileAsync(path: string): Promise; + // fd 上 [offset, offset + length) 区间的 SHA-256(小写 hex),工作线程 + // pread 流式计算(内嵌 bundle 走 resourceManager.getRawFd)。fd 由调用方 + // 持有,promise 落定前不得关闭;读失败/提前 EOF 时 reject。 + sha256HexRawFdAsync(fd: number, offset: number, length: number): Promise; /** CRC32(zip/zlib 多项式)。pdiff 拷贝前与 copiesCrc 比对用 */ crc32(data: Uint8Array | ArrayBuffer): number; /** 原生 patch 内核可消费的 diff 轨道版本(2 = hdiffv2 轨道) */ diff --git a/harmony/pushy/src/main/ets/SaveFile.ts b/harmony/pushy/src/main/ets/SaveFile.ts index a809162c..0b1ffdbd 100644 --- a/harmony/pushy/src/main/ets/SaveFile.ts +++ b/harmony/pushy/src/main/ets/SaveFile.ts @@ -1,34 +1,107 @@ import resourceManager from '@ohos.resourceManager'; -import fs, { ReadOptions } from '@ohos.file.fs'; +import fs from '@ohos.file.fs'; +import logger from './Logger'; +import { getErrorMessage } from './ErrorCodes'; -const COPY_BUFFER_SIZE = 64 * 1024; +const TAG = 'SaveFile'; +// 每次 read/write 都是一次工作线程往返:块大一些,几 MB 的 bundle 只需 +// 十几次往返;内存上限仍是一个块。 +const COPY_BUFFER_SIZE = 512 * 1024; -export const saveFileToSandbox = ( +// resourceManager 按文件名跟踪 rawfile descriptor(closeRawFd 只收名字): +// 同名的 getRawFd/closeRawFd 若交错,一方的 close 可能在另一方读到一半时 +// 关掉它的 fd。同名访问按到达顺序串行(不同名互不影响)。 +const rawFdChains = new Map>(); + +/** + * 打开 rawfile descriptor 交给 job,job 结束(无论成败)后关闭。同名串行。 + * descriptor 指向 HAP 内 [offset, offset + length) 区间,job 只能按 offset + * 定位读取。 + */ +export async function withRawFd( + manager: resourceManager.ResourceManager, + name: string, + job: (descriptor: resourceManager.RawFileDescriptor) => Promise, +): Promise { + const previous = rawFdChains.get(name) ?? Promise.resolve(); + let release: () => void = () => {}; + const held = new Promise(resolve => { + release = resolve; + }); + const tail = previous.then(() => held); + rawFdChains.set(name, tail); + try { + await previous; + const descriptor = await manager.getRawFd(name); + try { + return await job(descriptor); + } finally { + try { + await manager.closeRawFd(name); + } catch (closeError) { + logger.error( + TAG, + `Failed to close raw fd for ${name}: ${getErrorMessage(closeError)}`, + ); + } + } + } finally { + release(); + if (rawFdChains.get(name) === tail) { + rawFdChains.delete(name); + } + } +} + +async function writeFully( + fd: number, + buffer: ArrayBuffer, + length: number, +): Promise { + let written = 0; + while (written < length) { + const chunk = written === 0 ? buffer : buffer.slice(written, length); + const count = await fs.write(fd, chunk, { length: length - written }); + if (count <= 0) { + throw new Error(`Short write: ${written}/${length} bytes`); + } + written += count; + } +} + +/** + * 把 rawfile descriptor 指向的区间异步拷贝到沙箱文件(覆盖)。全程异步 + * fileIo:调用方在 UI 线程(TurboModule),同步 IO 会冻结界面。读到预期 + * 长度之前遇到 EOF 视为错误。 + */ +export async function saveFileToSandbox( from: resourceManager.RawFileDescriptor, toPath: string, -) => { - let to = fs.openSync(toPath, fs.OpenMode.READ_WRITE | fs.OpenMode.CREATE); - - const buffer = new ArrayBuffer(COPY_BUFFER_SIZE); - let currentOffset = from.offset; +): Promise { + const to = await fs.open( + toPath, + fs.OpenMode.WRITE_ONLY | fs.OpenMode.CREATE | fs.OpenMode.TRUNC, + ); + const buffer = new ArrayBuffer( + Math.max(1, Math.min(COPY_BUFFER_SIZE, from.length)), + ); + let position = from.offset; let remaining = from.length; try { while (remaining > 0) { - const readOption: ReadOptions = { - offset: currentOffset, - length: Math.min(COPY_BUFFER_SIZE, remaining), - }; - const readLength = fs.readSync(from.fd, buffer, readOption); + const readLength = await fs.read(from.fd, buffer, { + offset: position, + length: Math.min(buffer.byteLength, remaining), + }); if (readLength <= 0) { throw new Error(`Unexpected EOF while copying resource to ${toPath}`); } - - fs.writeSync(to.fd, buffer, { length: readLength }); - currentOffset += readLength; + await writeFully(to.fd, buffer, readLength); + position += readLength; remaining -= readLength; } } finally { - fs.close(to); + await fs.close(to); } -}; +} diff --git a/harmony/pushy/src/main/ets/SyncCoordinator.ts b/harmony/pushy/src/main/ets/SyncCoordinator.ts index f812f3a2..6bbaa05d 100644 --- a/harmony/pushy/src/main/ets/SyncCoordinator.ts +++ b/harmony/pushy/src/main/ets/SyncCoordinator.ts @@ -137,28 +137,62 @@ function startNativeRound( }); } -// A preflight skip must not consume the process's only round: configuration -// may arrive after the delayed startup timer on a first-ever launch. -function configurationError(context: UpdateContext): BundlePreparationResult | undefined { +interface ConfigRead { + json: string; + config: NativeConfig; + /** Trimmed; the same value gates the round and builds the request URL. */ + appKey: string; + /** Set when the persisted configuration cannot start a round. */ + rejection?: BundlePreparationResult; +} + +// The single configuration gate shared by the preflight, the host API and +// the round itself, so the three can never disagree about what is runnable. +function readNativeConfig(context: UpdateContext): ConfigRead { const json = context.getKv(KEY_CONFIG); + const empty: NativeConfig = {}; if (!json) { - return bundlePreparationResult('skipped', 'not_configured'); - } + return { + json: '', + config: empty, + appKey: '', + rejection: bundlePreparationResult('skipped', 'not_configured'), + }; + } + const invalid: ConfigRead = { + json, + config: empty, + appKey: '', + rejection: bundlePreparationResult('failed', 'invalid_config'), + }; + let config: NativeConfig; try { - const config = JSON.parse(json) as NativeConfig; - if (!config || typeof config !== 'object' || Array.isArray(config)) { - return bundlePreparationResult('failed', 'invalid_config'); - } - if (config.disabled) { - return bundlePreparationResult('skipped', 'disabled'); - } - if (typeof config.appKey !== 'string' || config.appKey.trim().length === 0) { - return bundlePreparationResult('failed', 'invalid_config'); - } + config = JSON.parse(json) as NativeConfig; } catch (e) { - return bundlePreparationResult('failed', 'invalid_config'); + return invalid; } - return undefined; + if (!config || typeof config !== 'object' || Array.isArray(config)) { + return invalid; + } + if (config.disabled) { + return { + json, + config, + appKey: '', + rejection: bundlePreparationResult('skipped', 'disabled'), + }; + } + const appKey = typeof config.appKey === 'string' ? config.appKey.trim() : ''; + if (!appKey) { + return invalid; + } + return { json, config, appKey }; +} + +// A preflight skip must not consume the process's only round: configuration +// may arrive after the delayed startup timer on a first-ever launch. +function configurationError(context: UpdateContext): BundlePreparationResult | undefined { + return readNativeConfig(context).rejection; } export async function prepareBundleNative( @@ -167,24 +201,11 @@ export async function prepareBundleNative( if (scheduledContext !== context) { return bundlePreparationResult('skipped', 'not_initialized'); } - const configJson = context.getKv(KEY_CONFIG); - if (!configJson) { - return bundlePreparationResult('skipped', 'not_configured'); - } - try { - const config = JSON.parse(configJson) as NativeConfig; - if (!config || typeof config !== 'object' || Array.isArray(config)) { - return bundlePreparationResult('failed', 'invalid_config'); - } - if (config.disabled) { - return bundlePreparationResult('skipped', 'disabled'); - } - if (typeof config.appKey !== 'string' || !config.appKey) { - return bundlePreparationResult('failed', 'invalid_config'); - } - } catch (e) { - return bundlePreparationResult('failed', 'invalid_config'); + const read = readNativeConfig(context); + if (read.rejection !== undefined) { + return read.rejection; } + const configJson = read.json; const result = await startNativeRound(context, scheduledRollback); if (roundConfigGeneration !== context.getNativeConfigGeneration() || configJson !== roundConfigJson || configJson !== context.getKv(KEY_CONFIG)) { @@ -205,13 +226,11 @@ export function recordJsRound(config: string): void { jsCompletedConfig = config; } -// JS 已用与原生完全相同的配置拿到有效响应(§10.3):延迟轮次就是一次重复请求。 +// JS 已用与原生等价的配置(C1:规范形式相同,而非逐字相同——宿主持有的配置 +// 与 JS 序列化格式不同)拿到有效响应(§10.3):延迟轮次就是一次重复请求。 // 只有计划内的轮次会问这个问题——JS 没起来、启动即崩、检查失败时都不会有信号。 function hasJsRound(context: UpdateContext): boolean { - return ( - jsCompletedConfig !== undefined && - jsCompletedConfig === context.getKv(KEY_CONFIG) - ); + return !!jsCompletedConfig && context.isStoredNativeConfig(jsCompletedConfig); } export function scheduleNativeSync( @@ -252,33 +271,15 @@ async function runOnce( roundGeneration = resetGeneration; roundConfigGeneration = context.getNativeConfigGeneration(); roundResult = bundlePreparationResult('failed', 'check_failed'); - const configJson = context.getKv(KEY_CONFIG); - roundConfigJson = configJson; - if (!configJson) { - // No persisted configuration: report the rollout gate to native callers. - roundResult = bundlePreparationResult('skipped', 'not_configured'); - return; - } - let config: NativeConfig; - try { - config = JSON.parse(configJson) as NativeConfig; - if (!config || typeof config !== 'object' || Array.isArray(config)) { - roundResult = bundlePreparationResult('failed', 'invalid_config'); - return; - } - } catch (e) { - roundResult = bundlePreparationResult('failed', 'invalid_config'); - return; - } - if (config.disabled) { - roundResult = bundlePreparationResult('skipped', 'disabled'); - return; - } - const appKey = config.appKey ?? ''; - if (!appKey) { - roundResult = bundlePreparationResult('failed', 'invalid_config'); + const read = readNativeConfig(context); + roundConfigJson = read.json; + if (read.rejection !== undefined) { + roundResult = read.rejection; return; } + const configJson = read.json; + const config = read.config; + const appKey = read.appKey; // 从这里起本轮开始做真实工作:留下面包屑,死于轮中时下次启动零延迟续传。 logger.info( TAG, diff --git a/harmony/pushy/src/main/ets/UpdateContext.ts b/harmony/pushy/src/main/ets/UpdateContext.ts index f30a3861..c0e6027e 100644 --- a/harmony/pushy/src/main/ets/UpdateContext.ts +++ b/harmony/pushy/src/main/ets/UpdateContext.ts @@ -27,6 +27,7 @@ import NativePatchCore, { StateCoreResult, } from './NativePatchCore'; import { assertSafePathComponent } from './PathUtils'; +import { withRawFd } from './SaveFile'; import { ERROR_SWITCH_VERSION_FAILED, createPushyError, @@ -48,6 +49,89 @@ interface OptionalPreferencesApi { getAllSync?: () => Record; } +// C1 跨平台配置等价(JS/Android/iOS 同一规则):缺省键补默认值、端点/ +// 发现地址去尾斜杠并保序去重、忽略键序。等价的配置不算"变更"——宿主重复 +// configure、或 JS 同步一份只差格式的同一配置,都不应取消在飞轮次或丢弃 +// 响应缓存。放在本文件(而非独立模块):本文件被测试原样求值,只能依赖 +// 自身源码。 +const NATIVE_CONFIG_DEFAULTS: Record = { + disabled: false, + queryUrls: [], + rnu: '', + rn: '', + afterDownload: 'none', +}; +const NATIVE_CONFIG_URL_LISTS: string[] = ['endpoints', 'queryUrls']; + +function canonicalJson(value: unknown): string { + if (Array.isArray(value)) { + return `[${value.map(item => canonicalJson(item)).join(',')}]`; + } + if (value !== null && typeof value === 'object') { + const record = value as Record; + const keys = Object.keys(record) + .filter(key => record[key] !== undefined) + .sort(); + return `{${keys + .map(key => `${JSON.stringify(key)}:${canonicalJson(record[key])}`) + .join(',')}}`; + } + return JSON.stringify(value) ?? 'null'; +} + +/** 配置 JSON 的规范形式;不是可解析的 JSON 对象时返回 undefined。 */ +export function canonicalNativeConfig(json: string): string | undefined { + let parsed: unknown; + try { + parsed = JSON.parse(json); + } catch (e) { + return undefined; + } + if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) { + return undefined; + } + const config: Record = {}; + const source = parsed as Record; + for (const key of Object.keys(source)) { + config[key] = source[key]; + } + for (const key of Object.keys(NATIVE_CONFIG_DEFAULTS)) { + if (config[key] === undefined) { + config[key] = NATIVE_CONFIG_DEFAULTS[key]; + } + } + for (const key of NATIVE_CONFIG_URL_LISTS) { + const list = config[key]; + if (!Array.isArray(list)) { + continue; + } + const seen = new Set(); + const normalized: unknown[] = []; + for (const item of list as unknown[]) { + const value = typeof item === 'string' ? item.replace(/\/+$/, '') : item; + const identity = canonicalJson(value); + if (!seen.has(identity)) { + seen.add(identity); + normalized.push(value); + } + } + config[key] = normalized; + } + return canonicalJson(config); +} + +/** 两份配置 JSON 是否等价(C1)。任一不是可解析的 JSON 对象即不等价。 */ +export function nativeConfigsEquivalent( + a: string | undefined, + b: string | undefined, +): boolean { + if (a === undefined || b === undefined) { + return false; + } + const left = canonicalNativeConfig(a); + return left !== undefined && left === canonicalNativeConfig(b); +} + interface PersistOptions { removeStaleHash?: boolean; cleanUp?: boolean; @@ -460,19 +544,29 @@ export class UpdateContext { this.flushInBackground('sync binary version'); } - /** 写入并落盘;flushSync 不可用时以 flush() 的结果拒绝。 */ - /** Shared JS/native configuration writer; all mutations precede the first await. */ private static nativeConfigGeneration: number = 0; public getNativeConfigGeneration(): number { return UpdateContext.nativeConfigGeneration; } + /** 与持久化的配置是否等价(C1)。 */ + public isStoredNativeConfig(config: string | undefined): boolean { + return nativeConfigsEquivalent(config, this.getKv(KEY_CONFIG)); + } + + /** + * Shared JS/native configuration writer; all mutations precede the first + * await. An equivalent configuration (C1) is not a change: the stored string, + * response cache and generations stay as they are. + */ public setNativeConfig(config: string): Promise { if (!this.getKv('uuid')) { this.preferences.putSync('uuid', util.generateRandomUUID()); } - if (this.getKv(KEY_CONFIG) !== config) { + const current = this.getKv(KEY_CONFIG); + // Byte-identical is trivially no change, even for a value C1 cannot parse. + if (!current || (current !== config && !nativeConfigsEquivalent(current, config))) { // Also guards A -> B -> A replacements and late download commits. UpdateContext.resetGeneration += 1; UpdateContext.nativeConfigGeneration += 1; @@ -484,6 +578,7 @@ export class UpdateContext { return this.flushPreferences('persist configuration'); } + /** 写入并落盘;flushSync 不可用时以 flush() 的结果拒绝。 */ public setKv(key: string, value: string): Promise { this.preferences.putSync(key, value); return this.flushPreferences(`set key ${key}`); @@ -952,29 +1047,56 @@ export class UpdateContext { } } + // 进行中的摘要计算:JS getBundleHash 与原生轮次可能同时要它,共用一次。 + private bundleHashTask: Promise | null = null; + /** * bundleHash = 二进制内嵌 JS bundle 的 sha256 —— 标识二进制本身,与当前运行 * 的热更版本无关。哈希对象与 pdiff 的源读取完全一致:rawfile 的 * bundle.harmony.js(DownloadTask.doPatchFromApp 同一读法)。懒计算 + 缓存; * 永不失败——空串表示"未知",服务端回退 buildTime 启发式。 */ - public async getBundleHash(): Promise { + public getBundleHash(): Promise { if (UpdateContext.DEBUG) { // debug 下 bundle 由 metro 提供,与 dev 删 buildTime 的行为对齐。 - return ''; + return Promise.resolve(''); } const cachePrefix = `${this.getPackageVersion()}|${this.getBundleModifiedTime()}|`; const cached = this.readString(UpdateContext.KEY_BUNDLE_HASH_CACHE); if (cached.startsWith(cachePrefix)) { - return cached.slice(cachePrefix.length); + return Promise.resolve(cached.slice(cachePrefix.length)); + } + if (!this.bundleHashTask) { + const task = this.computeBundleHash(cachePrefix); + this.bundleHashTask = task; + task.then( + () => { + this.bundleHashTask = null; + }, + () => { + this.bundleHashTask = null; + }, + ); } + return this.bundleHashTask; + } + + private async computeBundleHash(cachePrefix: string): Promise { let hash = ''; try { - // 异步读:TurboModule 跑在 UI 线程,同步读几 MB 会卡主线程一次。C++ - // 哈希本身毫秒级,同步无妨。 - const content = - await this.context.resourceManager.getRawFileContent('bundle.harmony.js'); - hash = NativePatchCore.sha256Hex(content); + // 内嵌 bundle 有几 MB:不读进内存,也不在 UI 线程(TurboModule 所在) + // 哈希——native 工作线程按 rawfile descriptor 的区间 pread 流式计算。 + // fd 在 promise 落定后才关闭(withRawFd)。 + hash = await withRawFd( + this.context.resourceManager, + 'bundle.harmony.js', + descriptor => + NativePatchCore.sha256HexRawFdAsync( + descriptor.fd, + descriptor.offset, + descriptor.length, + ), + ); } catch (error) { // rawfile 缺 bundle(未打包 release bundle)是合法的"未知",不是错误。 logger.info(TAG, `Cannot hash embedded bundle: ${getErrorMessage(error)}`); diff --git a/harmony/pushy/src/test/ArchiveLimits.test.ets b/harmony/pushy/src/test/ArchiveLimits.test.ets new file mode 100644 index 00000000..02c9b2e7 --- /dev/null +++ b/harmony/pushy/src/test/ArchiveLimits.test.ets @@ -0,0 +1,104 @@ +import { describe, it, expect } from '@ohos/hypium'; +import { + ORIGINAL_SIZE_UNAVAILABLE, + UNKNOWN_LENGTH_FREE_SPACE_PROBE_BYTES, + checkOriginalSize, + planUnknownLengthFreeSpaceProbe, +} from '../main/ets/ArchiveLimits'; +import { ERROR_DELTA_FAILED, PushyError } from '../main/ets/ErrorCodes'; + +const PROBE = UNKNOWN_LENGTH_FREE_SPACE_PROBE_BYTES; + +function refusal(run: () => number): PushyError | null { + try { + run(); + } catch (e) { + return e as PushyError; + } + return null; +} + +export default function archiveLimitsTest() { + // 未知长度下载的磁盘探测:任何写入都不得越过已探测预留的位置。 + describe('planUnknownLengthFreeSpaceProbe', () => { + it('never probes when the total length is known', 0, () => { + const plan = planUnknownLengthFreeSpaceProbe(1000, 0, 4096, 0); + expect(plan.probe).assertFalse(); + expect(plan.reservedUntil).assertEqual(0); + }); + + it('probes the first chunk and reserves PROBE bytes ahead', 0, () => { + const plan = planUnknownLengthFreeSpaceProbe(0, 0, 4096, 0); + expect(plan.probe).assertTrue(); + expect(plan.reserve).assertEqual(PROBE); + expect(plan.reservedUntil).assertEqual(PROBE); + }); + + it('skips chunks that stay inside the reservation', 0, () => { + const plan = planUnknownLengthFreeSpaceProbe(0, 4096, 4096, PROBE); + expect(plan.probe).assertFalse(); + expect(plan.reservedUntil).assertEqual(PROBE); + // Ending exactly on the reserved boundary is still covered. + const edge = planUnknownLengthFreeSpaceProbe(0, PROBE - 10, 10, PROBE); + expect(edge.probe).assertFalse(); + }); + + it('re-probes a chunk that would cross the reservation', 0, () => { + const plan = planUnknownLengthFreeSpaceProbe(0, PROBE - 10, 11, PROBE); + expect(plan.probe).assertTrue(); + expect(plan.reservedUntil).assertEqual(PROBE - 10 + PROBE); + }); + + it('reserves a chunk larger than PROBE by its own size', 0, () => { + const big = PROBE * 2 + 1; + const plan = planUnknownLengthFreeSpaceProbe(0, 100, big, 0); + expect(plan.probe).assertTrue(); + expect(plan.reserve).assertEqual(big); + expect(plan.reservedUntil).assertEqual(100 + big); + }); + + it('walking a stream never writes past a probed reservation', 0, () => { + let reservedUntil = 0; + let written = 0; + let probes = 0; + const chunk = 300 * 1024; + for (let i = 0; i < 100; i++) { + const plan = planUnknownLengthFreeSpaceProbe(0, written, chunk, reservedUntil); + if (plan.probe) { + probes += 1; + } + reservedUntil = plan.reservedUntil; + written += chunk; + expect(written <= reservedUntil).assertTrue(); + } + expect(probes < 100).assertTrue(); + }); + }); + + // getOriginalSize 读不到:拒绝(不放行),且带专属错误细节可诊断。 + describe('checkOriginalSize', () => { + it('passes a finite non-negative size through', 0, () => { + expect(checkOriginalSize(0)).assertEqual(0); + expect(checkOriginalSize(12345)).assertEqual(12345); + }); + + it('refuses when getOriginalSize threw, with its own detail', 0, () => { + const error = refusal(() => checkOriginalSize(undefined, 'boom')); + expect(error !== null).assertTrue(); + expect(error!.code).assertEqual(ERROR_DELTA_FAILED); + expect(error!.message.includes(ORIGINAL_SIZE_UNAVAILABLE)).assertTrue(); + expect(error!.message.includes('getOriginalSize failed: boom')).assertTrue(); + }); + + it('refuses an unusable result, distinct from the thrown case', 0, () => { + const sizes: (number | undefined)[] = [undefined, NaN, -1, Infinity]; + for (const size of sizes) { + const error = refusal(() => checkOriginalSize(size)); + expect(error !== null).assertTrue(); + expect(error!.code).assertEqual(ERROR_DELTA_FAILED); + expect(error!.message.includes(ORIGINAL_SIZE_UNAVAILABLE)).assertTrue(); + expect(error!.message.includes('getOriginalSize returned')).assertTrue(); + } + }); + }); +} diff --git a/harmony/pushy/src/test/List.test.ets b/harmony/pushy/src/test/List.test.ets index 59c91747..d55166f9 100644 --- a/harmony/pushy/src/test/List.test.ets +++ b/harmony/pushy/src/test/List.test.ets @@ -5,6 +5,8 @@ import downloadTaskParamsTest from './DownloadTaskParams.test'; import contentRangeTest from './ContentRange.test'; import installRecordTest from './InstallRecord.test'; import errorCodesTest from './ErrorCodes.test'; +import archiveLimitsTest from './ArchiveLimits.test'; +import nativeConfigTest from './NativeConfig.test'; export default function testsuite() { manifestParsingTest(); @@ -14,4 +16,6 @@ export default function testsuite() { contentRangeTest(); installRecordTest(); errorCodesTest(); + archiveLimitsTest(); + nativeConfigTest(); } diff --git a/harmony/pushy/src/test/NativeConfig.test.ets b/harmony/pushy/src/test/NativeConfig.test.ets new file mode 100644 index 00000000..a727d43e --- /dev/null +++ b/harmony/pushy/src/test/NativeConfig.test.ets @@ -0,0 +1,59 @@ +import { describe, it, expect } from '@ohos/hypium'; +import { canonicalNativeConfig, nativeConfigsEquivalent } from '../main/ets/UpdateContext'; + +// C1:跨平台配置等价规则(与 JS/Android/iOS 一致)。 +export default function nativeConfigTest() { + describe('nativeConfigsEquivalent', () => { + it('ignores key order', 0, () => { + expect(nativeConfigsEquivalent( + '{"appKey":"k","endpoints":["https://a"]}', + '{"endpoints":["https://a"],"appKey":"k"}', + )).assertTrue(); + }); + + it('fills absent keys with their defaults', 0, () => { + expect(nativeConfigsEquivalent( + '{"appKey":"k"}', + '{"appKey":"k","disabled":false,"queryUrls":[],"rnu":"","rn":"","afterDownload":"none"}', + )).assertTrue(); + expect(nativeConfigsEquivalent('{"appKey":"k"}', '{"appKey":"k","disabled":true}')) + .assertFalse(); + expect(nativeConfigsEquivalent('{"appKey":"k"}', '{"appKey":"k","rn":"0.82"}')) + .assertFalse(); + }); + + it('strips trailing slashes and dedupes URL lists, keeping first order', 0, () => { + expect(nativeConfigsEquivalent( + '{"appKey":"k","endpoints":["https://a/","https://b","https://a"],"queryUrls":["https://q/"]}', + '{"appKey":"k","endpoints":["https://a","https://b"],"queryUrls":["https://q"]}', + )).assertTrue(); + expect(nativeConfigsEquivalent( + '{"appKey":"k","endpoints":["https://a","https://b"]}', + '{"appKey":"k","endpoints":["https://b","https://a"]}', + )).assertFalse(); + // Every trailing slash goes, not just one. + expect(nativeConfigsEquivalent( + '{"appKey":"k","endpoints":["https://b.example.com//"]}', + '{"appKey":"k","endpoints":["https://b.example.com"]}', + )).assertTrue(); + }); + + it('treats unparseable or non-object JSON as not equivalent', 0, () => { + expect(nativeConfigsEquivalent('{', '{"appKey":"k"}')).assertFalse(); + expect(nativeConfigsEquivalent('[]', '{}')).assertFalse(); + // Not even against itself: only JSON objects have a canonical form. + expect(nativeConfigsEquivalent('{', '{')).assertFalse(); + expect(nativeConfigsEquivalent('"x"', '"x"')).assertFalse(); + expect(nativeConfigsEquivalent(undefined, '{"appKey":"k"}')).assertFalse(); + expect(canonicalNativeConfig('null') === undefined).assertTrue(); + }); + + it('compares every other field exactly', 0, () => { + expect(nativeConfigsEquivalent('{"appKey":"k"}', '{"appKey":"k2"}')).assertFalse(); + expect(nativeConfigsEquivalent( + '{"appKey":"k","packageVersion":"1"}', + '{"appKey":"k"}', + )).assertFalse(); + }); + }); +} diff --git a/harmony/pushy/src/test/check-constant-parity.js b/harmony/pushy/src/test/check-constant-parity.js index b61cdd8e..0a997898 100644 --- a/harmony/pushy/src/test/check-constant-parity.js +++ b/harmony/pushy/src/test/check-constant-parity.js @@ -79,6 +79,9 @@ const pairs = [ header: 'cpp/patch_core/archive_limits.h', mirror: 'harmony/pushy/src/main/ets/ArchiveLimits.ts', mirrorName: (cppName) => screamingSnake(cppName), + // Must always be parsed from the header (guards the parser itself: a + // regex regression would otherwise silently drop the pair's coverage). + required: ['UnknownLengthFreeSpaceProbeBytes', 'MaxArchiveBytes'], }, { header: 'cpp/patch_core/install_record.h', @@ -109,6 +112,11 @@ for (const pair of pairs) { problems.push(`${pair.header}: no constexpr constants parsed`); continue; } + for (const name of pair.required || []) { + if (!cpp.has(name)) { + problems.push(`${pair.header}: k${name} not parsed (required)`); + } + } for (const [cppName, cppValue] of cpp) { const mirrorName = pair.mirrorName(cppName); checked += 1; diff --git a/ios/RCTPushy/RCTPushy.h b/ios/RCTPushy/RCTPushy.h index 21f225ca..add73525 100644 --- a/ios/RCTPushy/RCTPushy.h +++ b/ios/RCTPushy/RCTPushy.h @@ -26,8 +26,20 @@ typedef void (^RCTPushyBundlePreparationCompletion)(NSDictionary * Result keys: status (skipped/noUpdate/downloaded/failed/cancelled), reason, * hash (empty when nothing was installed), activated (selected for NEXT * launch, not a reload of the running instance). + * + * Waits at most 30 minutes for the round (longer than any round takes). */ + (void)prepareBundleWithCompletion:(RCTPushyBundlePreparationCompletion _Nullable)completion NS_SWIFT_NAME(prepareBundle(completion:)); +/** + * Same as prepareBundleWithCompletion:, but the completion runs after at most + * `timeout` seconds. On expiry the result is status failed, reason timeout; + * the round keeps running in the background and its result still applies to + * the next launch. + */ ++ (void)prepareBundleWithTimeout:(NSTimeInterval)timeout + completion:(RCTPushyBundlePreparationCompletion _Nullable)completion + NS_SWIFT_NAME(prepareBundle(timeout:completion:)); + @end diff --git a/ios/RCTPushy/RCTPushy.mm b/ios/RCTPushy/RCTPushy.mm index 8a7c78b4..cbcef107 100644 --- a/ios/RCTPushy/RCTPushy.mm +++ b/ios/RCTPushy/RCTPushy.mm @@ -1,4 +1,4 @@ -#import "RCTPushy.h" +#import "RCTPushyInternal.h" #import "RCTPushyConfiguration.h" #import "RCTPushyDownloader.h" #import "ZipArchive.h" @@ -31,12 +31,6 @@ #import #import -// RN's log macros pass __FILE__, which would embed this file's absolute build -// path in the binary; log with the bare file name instead. -#if RCTLOG_ENABLED -#undef _RCTLog -#define _RCTLog(lvl, ...) _RCTLogNativeInternal(lvl, __FILE_NAME__, __LINE__, __VA_ARGS__) -#endif #import #import @@ -44,7 +38,7 @@ #include // Immutable host-facing snapshot; activated always means NEXT launch. -static NSDictionary *PushyHostResult(NSString *status, NSString *reason, +PUSHY_HIDDEN NSDictionary *PushyHostResult(NSString *status, NSString *reason, NSString *hash, BOOL activated) { return @{@"status": status, @"reason": reason ?: @"", @"hash": hash ?: @"", @"activated": @(activated)}; @@ -66,39 +60,66 @@ static NSString *const paramCurrentVersion = @"currentVersion"; static NSString *const paramIsFirstTime = @"isFirstTime"; static NSString *const paramIsFirstLoadOk = @"isFirstLoadOK"; -static NSString *const keyUuid = @"REACTNATIVECN_PUSHY_UUID"; +PUSHY_HIDDEN NSString *const keyUuid = @"REACTNATIVECN_PUSHY_UUID"; static NSString *const keyHashInfo = @"REACTNATIVECN_PUSHY_HASH_"; static NSString *const keyFirstLoadMarked = @"REACTNATIVECN_PUSHY_FIRSTLOADMARKED_KEY"; static NSString *const keyRolledBackMarked = @"REACTNATIVECN_PUSHY_ROLLEDBACKMARKED_KEY"; -static NSString *const KeyPackageUpdatedMarked = RCTPushyRevealText("0832d5f29aa549710b0939daf88ca058790f3dded7e888b451640d2ec3f69099bb43711523cafa8ea7bb4372132a"); +// Encoded constants (RCTPushyRevealText) are decoded on first use, not by +// static initializers at image load. +PUSHY_HIDDEN NSString *PushyRevealOnce(NSString *__strong *slot, dispatch_once_t *once, + const char *hex) { + dispatch_once(once, ^{ + *slot = RCTPushyRevealText(hex); + }); + return *slot; +} + +static NSString *KeyPackageUpdatedMarked(void) { + static NSString *value; + static dispatch_once_t once; + return PushyRevealOnce(&value, &once, "0832d5f29aa549710b0939daf88ca058790f3dded7e888b451640d2ec3f69099bb43711523cafa8ea7bb4372132a"); +} // bundleHash cache: "|" where cacheKey identifies the // installed binary (packageVersion + embedded bundle size + mtime). Recomputed // only when the key changes, i.e. once per install. static NSString *const keyBundleHashCache = @"REACTNATIVECN_PUSHY_BUNDLEHASH_KEY"; // Raw JSON persisted by JS (syncNativeConfig) for the native cold-start // update check; parsed on read by the orchestrator. Absent = check disabled. -static NSString *const keyNativeConfig = @"REACTNATIVECN_PUSHY_NATIVE_CONFIG_KEY"; +PUSHY_HIDDEN NSString *const keyNativeConfig = @"REACTNATIVECN_PUSHY_NATIVE_CONFIG_KEY"; // Raw response cache written by the native cold-start check for the JS side // to reuse (§10.3), scoped to the request and config that produced it. -static NSString *const keyNativeCheckCache = @"REACTNATIVECN_PUSHY_NATIVE_CHECK_RESP_KEY"; +PUSHY_HIDDEN NSString *const keyNativeCheckCache = @"REACTNATIVECN_PUSHY_NATIVE_CHECK_RESP_KEY"; // Set when a native check round starts, cleared when it ends (§11.4). Residue // on the next launch means the previous process died mid-round (a crash // rescue was truncated): that launch resumes immediately instead of waiting. -static NSString *const keyNativeCheckIncomplete = @"REACTNATIVECN_PUSHY_NATIVE_CHECK_INCOMPLETE_KEY"; +PUSHY_HIDDEN NSString *const keyNativeCheckIncomplete = @"REACTNATIVECN_PUSHY_NATIVE_CHECK_INCOMPLETE_KEY"; static NSString *const PushyErrorDomain = @"cn.reactnative.pushy"; // file def static NSString * const BUNDLE_FILE_NAME = @"index.bundlejs"; static NSString * const SOURCE_PATCH_NAME = @"__diff.json"; -static NSString * const BUNDLE_PATCH_NAME = RCTPushyRevealText("3319f0d4b6c56a502c3b10fcdca0de7d4b3307e9"); +static NSString *BUNDLE_PATCH_NAME(void) { + static NSString *value; + static dispatch_once_t once; + return PushyRevealOnce(&value, &once, "3319f0d4b6c56a502c3b10fcdca0de7d4b3307e9"); +} // Protocol strings and version-info flags read by JS, stored encoded // (RCTPushyRevealText) so they do not appear as plain binary strings. -static NSString * const PushyOptionUrlKey = RCTPushyRevealText("2f07f0d0ba8e5d572e"); -static NSString * const PushyIpaDeltaSuffix = RCTPushyRevealText("741ee4d0e09b69512137"); -static NSString * const PushyPpkDeltaSuffix = RCTPushyRevealText("7407e4dae09b69512137"); -static NSString * const PushyInfoCrashHold = RCTPushyRevealText("3905f5c2a6b96d56212a19"); -static NSString * const PushyInfoForceBoot = RCTPushyRevealText("3c18e6d2aba9674a360d19ead5a695"); -static NSString * const PushyStatusNone = RCTPushyRevealText("3418c1c1aa8a7c40"); +PUSHY_HIDDEN NSString *PushyOptionUrlKey(void) { + static NSString *value; + static dispatch_once_t once; + return PushyRevealOnce(&value, &once, "2f07f0d0ba8e5d572e"); +} +static NSString *PushyIpaDeltaSuffix(void) { + static NSString *value; + static dispatch_once_t once; + return PushyRevealOnce(&value, &once, "741ee4d0e09b69512137"); +} +static NSString *PushyPpkDeltaSuffix(void) { + static NSString *value; + static dispatch_once_t once; + return PushyRevealOnce(&value, &once, "7407e4dae09b69512137"); +} #define VERSION_COMPLETE_FILE_NAME_LITERAL ".pushy-complete" static NSString * const VERSION_COMPLETE_FILE_NAME = @VERSION_COMPLETE_FILE_NAME_LITERAL; @@ -112,26 +133,16 @@ return [NSString stringWithUTF8String:code]; } -// cInfo.os label prefix; must match the JS side (core.ts) so the server -// buckets tvOS devices apart from iOS ones. -#if TARGET_OS_TV -static NSString * const PushyOsName = @"tvos"; -#else -static NSString * const PushyOsName = @"ios"; -#endif - // event def static NSString * const EVENT_PROGRESS_DOWNLOAD = @"RCTPushyDownloadProgress"; static NSString * const PARAM_PROGRESS_HASH = @"hash"; static NSString * const PARAM_PROGRESS_RECEIVED = @"received"; static NSString * const PARAM_PROGRESS_TOTAL = @"total"; -static NSTimeInterval PushyMonotonicNow(void) { +PUSHY_HIDDEN NSTimeInterval PushyMonotonicNow(void) { return [NSProcessInfo processInfo].systemUptime; } -static std::string PushyToStdString(NSString *value); - static NSString *PushyStagingDirForVersionDir(NSString *versionDir) { return [versionDir stringByAppendingString:@(pushy::install_record::kStagingSuffix)]; } @@ -155,9 +166,17 @@ static NSTimeInterval PushyMonotonicNow(void) { return [object isKindOfClass:[NSDictionary class]] ? (NSDictionary *)object : nil; } +// Whether a non-empty record names this version under the current schema. +static BOOL PushyInstallRecordMatches(NSDictionary *record, NSString *hash) { + return [record[@"schema"] isKindOfClass:[NSNumber class]] + && [record[@"schema"] intValue] == pushy::install_record::kSchema + && [record[@"versionHash"] isKindOfClass:[NSString class]] + && [record[@"versionHash"] isEqualToString:hash]; +} + // Presence check (launch / dedup paths, no digest work): bundle present and // the record exists and, unless legacy-empty, names this version. -static BOOL PushyHasCompletedVersionAtPath(NSString *versionDir, NSString *hash) { +PUSHY_HIDDEN BOOL PushyHasCompletedVersionAtPath(NSString *versionDir, NSString *hash) { NSString *bundlePath = [versionDir stringByAppendingPathComponent:BUNDLE_FILE_NAME]; if (![[NSFileManager defaultManager] fileExistsAtPath:bundlePath]) { return NO; @@ -166,37 +185,69 @@ static BOOL PushyHasCompletedVersionAtPath(NSString *versionDir, NSString *hash) if (record == nil) { return NO; } - if (record.count == 0) { - return YES; + return record.count == 0 || PushyInstallRecordMatches(record, hash); +} + +// Identity of a file's bytes on disk without reading them: a rewrite or an +// atomic replacement (new inode) changes it. +struct PushyFileStamp { + bool exists = false; + dev_t device = 0; + ino_t inode = 0; + off_t size = 0; + struct timespec modified = {0, 0}; + + bool operator==(const PushyFileStamp &other) const { + return exists == other.exists && device == other.device && inode == other.inode + && size == other.size && modified.tv_sec == other.modified.tv_sec + && modified.tv_nsec == other.modified.tv_nsec; } - return [record[@"schema"] isKindOfClass:[NSNumber class]] - && [record[@"schema"] intValue] == pushy::install_record::kSchema - && [record[@"versionHash"] isKindOfClass:[NSString class]] - && [record[@"versionHash"] isEqualToString:hash]; + bool operator!=(const PushyFileStamp &other) const { return !(*this == other); } +}; + +static PushyFileStamp PushyStampFile(NSString *path) { + PushyFileStamp stamp; + struct stat info; + if (path != nil && stat(path.fileSystemRepresentation, &info) == 0) { + stamp.exists = true; + stamp.device = info.st_dev; + stamp.inode = info.st_ino; + stamp.size = info.st_size; + stamp.modified = info.st_mtimespec; + } + return stamp; } // Activation check: re-hashes the bundle when the record carries a digest. // Returns nil when the directory may be activated, the reason otherwise. -static NSString *PushyVerifyInstallForActivation(NSString *versionDir, NSString *hash) { +// Slow (a full read of the bundle): never call it under the state lock. On +// success *recordOut and *stampOut describe exactly what was verified, so the +// caller can confirm under the lock that nothing changed since. +static NSString *PushyVerifyInstallForActivation(NSString *versionDir, NSString *hash, + NSDictionary **recordOut, + PushyFileStamp *stampOut) { + NSString *bundlePath = [versionDir stringByAppendingPathComponent:BUNDLE_FILE_NAME]; + PushyFileStamp before = PushyStampFile(bundlePath); NSDictionary *record = PushyReadInstallRecord(versionDir); if (record == nil) { return [NSString stringWithFormat:@"Bundle version %@ has no valid completion record.", hash]; } + *recordOut = record; + *stampOut = before; if (record.count == 0) { return nil; } - if (![record[@"schema"] isKindOfClass:[NSNumber class]] - || [record[@"schema"] intValue] != pushy::install_record::kSchema - || ![record[@"versionHash"] isKindOfClass:[NSString class]] - || ![record[@"versionHash"] isEqualToString:hash]) { + if (!PushyInstallRecordMatches(record, hash)) { return [NSString stringWithFormat:@"Bundle version %@ completion record mismatch.", hash]; } NSString *expected = record[@"bundleSha256"]; if (![expected isKindOfClass:[NSString class]] || expected.length == 0) { return nil; } - std::string actual = pushy::digest::Sha256File( - PushyToStdString([versionDir stringByAppendingPathComponent:BUNDLE_FILE_NAME])); + std::string actual = pushy::digest::Sha256File(PushyToStdString(bundlePath)); + if (PushyStampFile(bundlePath) != before) { + return [NSString stringWithFormat:@"Bundle version %@ changed while being verified.", hash]; + } if ([[expected lowercaseString] isEqualToString:[NSString stringWithUTF8String:actual.c_str()]]) { return nil; } @@ -319,23 +370,18 @@ - (void)zipArchiveDidUnzipFileAtIndex:(NSInteger)fileIndex } -typedef NS_ENUM(NSInteger, PushyType) { - PushyTypeFullDownload = 1, - PushyTypePatchFromPackage = 2, - PushyTypePatchFromPpk = 3, -}; - static std::atomic ignoreRollback{false}; // Whether the host asked +bundleURL for the bundle to load (the // isUsingBundleUrl constant, mirroring Android/Harmony). NO means the app // runs a bundle this SDK never resolved, so updates cannot take effect. static std::atomic pushyIsUsingBundleUrl{false}; -// Bumped by resetToPackagedBundle. The cold-start check runs for minutes and +// Bumped by resetToPackagedBundle only (config replacement has its own +// pushyNativeConfigGeneration). The cold-start check runs for minutes and // may already hold a decision when the app resets to the packaged bundle; it // samples this counter and abandons activation (and its response cache) when // the value moved, so an in-flight rescue can never resurrect the version the // app just reset away from. -static std::atomic pushyResetGeneration{0}; +PUSHY_HIDDEN std::atomic pushyResetGeneration{0}; // The version whose bundle this process actually loaded (resolved in // +bundleURL). resetToPackagedBundle must not delete its directory: update // assets (images/fonts) are read from it on demand at runtime, so wiping it @@ -470,7 +516,7 @@ static void PushyFinishDownload(NSString *hash, NSError *error) { // overwritten by a concurrent bundleURL and the version being rolled back. static os_unfair_lock pushyStateLock = OS_UNFAIR_LOCK_INIT; -static void PushyWithStateLock(void (NS_NOESCAPE ^block)(void)) { +PUSHY_HIDDEN void PushyWithStateLock(void (NS_NOESCAPE ^block)(void)) { #if DEBUG // Not re-entrant: a nested acquisition deadlocks in release, so catch it // where it is written. @@ -484,7 +530,7 @@ static void PushyWithStateLock(void (NS_NOESCAPE ^block)(void)) { } } -static std::string PushyToStdString(NSString *value) { +PUSHY_HIDDEN std::string PushyToStdString(NSString *value) { if (value == nil) { return std::string(); } @@ -500,11 +546,11 @@ static void PushyWithStateLock(void (NS_NOESCAPE ^block)(void)) { }]; } -static NSUserDefaults *PushyDefaults(void) { +PUSHY_HIDDEN NSUserDefaults *PushyDefaults(void) { return [NSUserDefaults standardUserDefaults]; } -static NSString *PushyFromStdString(const std::string &value) { +PUSHY_HIDDEN NSString *PushyFromStdString(const std::string &value) { if (value.empty()) { return nil; } @@ -519,7 +565,7 @@ static void PushySetNullableString(NSUserDefaults *defaults, NSString *key, NSSt } } -static NSString *PushyHashInfoKey(NSString *hash) { +PUSHY_HIDDEN NSString *PushyHashInfoKey(NSString *hash) { return [keyHashInfo stringByAppendingString:hash ?: @""]; } @@ -537,7 +583,7 @@ static BOOL PushyStringIsBlank(NSString *value) { // Server-provided identifiers (hash/originHash) are used as child names under // the download root; anything that could resolve outside of it (path // separators, "..", ".") must be rejected before touching the filesystem. -static BOOL PushyIsSafePathComponent(NSString *value) { +PUSHY_HIDDEN BOOL PushyIsSafePathComponent(NSString *value) { if (PushyStringIsBlank(value)) { return NO; } @@ -647,7 +693,7 @@ static BOOL PushyPatchManifestFromJson(NSDictionary *json, return [defaults stringForKey:key] ?: [defaults stringForKey:legacyKey]; } -static pushy::state::State PushyStateFromDefaults(NSUserDefaults *defaults) { +PUSHY_HIDDEN pushy::state::State PushyStateFromDefaults(NSUserDefaults *defaults) { pushy::state::State state; state.package_version = PushyToStdString( PushyBinaryIdentityValue(defaults, paramPackageVersion, legacyParamPackageVersion)); @@ -699,7 +745,7 @@ static void PushyApplyStateToDefaults(NSUserDefaults *defaults, const pushy::sta // hold it. Lets the cold-start check commit its whole result (version info, // switch, response cache) inside one lock acquisition, so resetToPackagedBundle // can never interleave between the generation check and the writes. -static void PushySwitchVersionLocked(NSString *hash) { +PUSHY_HIDDEN void PushySwitchVersionLocked(NSString *hash) { NSUserDefaults *defaults = PushyDefaults(); pushy::state::State next = pushy::state::SwitchVersion( PushyStateFromDefaults(defaults), @@ -710,187 +756,7 @@ static void PushySwitchVersionLocked(NSString *hash) { ignoreRollback = false; } -@interface RCTPushy () -- (void)fetchPackage:(PushyType)type - options:(NSDictionary *)options - resolver:(RCTPromiseResolveBlock)resolve - rejecter:(RCTPromiseRejectBlock)reject; -- (void)performFetch:(PushyType)type - options:(NSDictionary *)options - callback:(void (^)(NSError *error))callback; -- (NSError *)restartBridgeWithReason:(NSString *)reason; -- (void)unzipDownloadedPackage:(NSString *)zipFilePath - hash:(NSString *)hash - type:(PushyType)type - originHash:(NSString *)originHash - callback:(void (^)(NSError *error))callback; -- (void)finishDownloadedPackage:(NSString *)hash - type:(PushyType)type - originHash:(NSString *)originHash - callback:(void (^)(NSError *error))callback; -- (void)applyDeltaForHash:(NSString *)hash - type:(PushyType)type - fromBundle:(NSString *)bundleOrigin - source:(NSString *)sourceOrigin - callback:(void (^)(NSError *error))callback; -- (BOOL)switchVersion:(NSString *)hash error:(NSError **)error; -- (BOOL)ensureDirectoryExistsAtPath:(NSString *)path; -+ (void)excludeFromBackup:(NSString *)path; -- (void)unzipFileAtPath:(NSString *)path - toDestination:(NSString *)destination - completionHandler:(void (^)(NSError *error))completionHandler; -+ (NSString *)downloadDir; -+ (NSURL *)binaryBundleURL; -+ (NSString *)packageVersion; -+ (NSString *)buildTime; -@end - -// Native cold-start update check (NATIVE_CHECKUPDATE_DESIGN §10): runs once -// per process, a few seconds after launch, entirely independent of the app -// bundle — this is what lets a bricked hot update be replaced on the next -// launch. Decisions come from cpp/update_flow_core; this class is IO glue. -@interface RCTPushyOrchestrator : NSObject -+ (void)persistConfiguration:(NSString *)config; -+ (BOOL)hasRunnableConfig; -+ (NSDictionary *)prepareBundle; -+ (void)scheduleFromColdStart:(NSString *)launchRolledBackVersion; -#if TARGET_OS_TV && !DEBUG -+ (BOOL)restorePurgedLaunch:(NSString *)purgedVersion - rolledBack:(NSString *)launchRolledBackVersion; -#endif -+ (void)recordJsRound:(NSString *)config; -+ (void)startRoundWithDeadline:(NSTimeInterval)deadlineUptime; -+ (void)runOnce:(NSString *)launchRolledBackVersion deadline:(NSTimeInterval)deadlineUptime; -+ (void)runHoldRoundWithDeadline:(NSTimeInterval)deadlineUptime; -+ (BOOL)commitRoundWithGeneration:(uint64_t)generation - hashInfo:(NSDictionary *)hashInfoEntry - activate:(NSString *)hashToActivate - responseText:(NSString *)responseText - request:(NSString *)requestBody - config:(NSString *)configJson - responseAt:(long long)responseAtSeconds - activated:(BOOL *)activatedOut; -@end - -// One round per process, whoever starts it first — the delayed cold-start -// path or the crash-rescue thread (§11.3). The semaphore lets the rescue -// wait out an in-flight round instead of racing it. -static std::atomic pushyRoundStarted{false}; -static std::atomic pushyRoundCompleted{false}; -// Flipped the moment a crash is being held. JS is dead from that point on, -// so there is no second decision maker: the round force-activates whatever -// it downloads (§11.3). -static std::atomic pushyCrashHoldActive{false}; -static std::atomic pushyHoldAttempted{false}; -// Set when this process's round is the tvOS purged-version restore. -static std::atomic pushyPurgeRestoreActive{false}; -// True while +bundleURL waits for that restore. Only then may the round -// activate what it downloads on its own (JS has not started to decide), since -// the launch resolves the bundle again afterwards. Closed once the wait ends, -// timeout or not; guarded by the state lock that every activation commits -// under, so a commit lands either before the close (and launches) or after it -// (and is left to JS) — never selected behind the packaged bundle's back. -static bool pushyPurgeRestoreWindowOpen = false; -static dispatch_semaphore_t pushyRoundDone; -static NSString *pushyLaunchRolledBackForHold = nil; -// A version this process downloaded but left for JS to activate. If the -// process then crashes, JS will never activate it — the crash handler -// activates it directly (bounded local work, no network). The generation is -// the one the round committed under: a reset that lands afterwards bumps it, -// and the late activation must lose to that reset exactly like the round -// itself would. Guarded by @synchronized (RCTPushyOrchestrator class). -static NSString *pushyUnactivatedHash = nil; -static uint64_t pushyUnactivatedGeneration = 0; -static NSUncaughtExceptionHandler *pushyPreviousExceptionHandler = NULL; -// ≈ process start: scheduleFromColdStart runs during the first bundleURL. -static NSTimeInterval pushyProcessAnchorUptime = 0; -// Config JSON for which JS reported a completed check in this process -// (markJsCheckCompleted). Process-scoped by design. Guarded by -// @synchronized (RCTPushyOrchestrator class). -static NSString *pushyJsCompletedConfig = nil; -// The group supplements (rather than consumes) the crash-rescue semaphore. -static dispatch_group_t pushyHostRoundGroup; -static std::atomic pushySyncReady{false}; -static NSDictionary *pushyHostRoundResult = nil; -static NSString *pushyHostRoundConfig = nil; -static uint64_t pushyHostRoundGeneration = 0; -static std::atomic pushyNativeConfigGeneration{0}; -static uint64_t pushyHostRoundConfigGeneration = 0; - -static const NSTimeInterval kPushyHoldTriggerUptime = 60; -static const NSTimeInterval kPushyHoldBudgetBackgroundThread = 10; -// A held main thread freezes UI teardown; stay well under the watchdog. -static const NSTimeInterval kPushyHoldBudgetMainThread = 3.5; -// The purged-version restore blocks +bundleURL, usually inside -// application:didFinishLaunching: — stay clear of the launch watchdog. -static const NSTimeInterval kPushyPurgeRestoreBudget = 12; - -static void PushyMaybeHoldProcess(void) { - // Once per process; a second crashing thread passes straight through - // instead of waiting behind the first (§11.3: prefer under-rescuing over - // wedging the teardown). - bool expected = false; - if (!pushyHoldAttempted.compare_exchange_strong(expected, true)) { - return; - } - NSTimeInterval uptime = PushyMonotonicNow() - pushyProcessAnchorUptime; - bool roundInFlight = pushyRoundStarted.load() && !pushyRoundCompleted.load(); - // Early crashes are the brick signature; a crash with an in-flight round - // is worth finishing regardless of uptime. Everything else is an ordinary - // crash whose UX must not be delayed. - if (uptime >= kPushyHoldTriggerUptime && !roundInFlight) { - return; - } - NSTimeInterval budget = [NSThread isMainThread] - ? kPushyHoldBudgetMainThread - : kPushyHoldBudgetBackgroundThread; - NSTimeInterval deadline = PushyMonotonicNow() + budget; - NSLog(@"RCTPushy -- crash hold: holding process for up to %.1fs " - @"(uptime %.1fs)", budget, uptime); - - // The rescue runs on its own queue and the dying thread only waits with a - // hard timeout: even a deadlocked rescue (a thread that died holding a - // lock — uncaught ObjC exceptions skip @finally) can only delay the - // process death, never prevent it. - dispatch_semaphore_t done = dispatch_semaphore_create(0); - dispatch_async(dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0), ^{ - @try { - [RCTPushyOrchestrator runHoldRoundWithDeadline:deadline]; - } @catch (NSException *exception) { - NSLog(@"RCTPushy -- crash hold failed: %@", exception.reason); - } - dispatch_semaphore_signal(done); - }); - if (dispatch_semaphore_wait(done, dispatch_time(DISPATCH_TIME_NOW, - (int64_t)(budget * NSEC_PER_SEC))) != 0) { - NSLog(@"RCTPushy -- crash hold: budget exhausted, letting go"); - } -} - -// Crash-moment brick rescue (§11): when the app is dying of an uncaught -// exception during startup (RCTFatal raises NSException in release), the -// process is still alive and JS will never run again — a natural, -// false-positive-free window to finish the cold-start check. The previous -// handler (crash reporters chain the same way) always runs afterwards. -static void PushyCrashHoldExceptionHandler(NSException *exception) { - @try { - PushyMaybeHoldProcess(); - } @catch (NSException *inner) { - // The dying process owes the previous handler its turn no matter - // what the rescue did. - } - if (pushyPreviousExceptionHandler != NULL) { - pushyPreviousExceptionHandler(exception); - } -} -static void PushyInstallCrashHoldHandler(void) { - static dispatch_once_t onceToken; - dispatch_once(&onceToken, ^{ - pushyPreviousExceptionHandler = NSGetUncaughtExceptionHandler(); - NSSetUncaughtExceptionHandler(&PushyCrashHoldExceptionHandler); - }); -} // Single-flight for the digest in PushyBundleHashSync: the delayed cold-start // round (utility queue) and JS getBundleHash (_fileQueue) can both miss the @@ -919,7 +785,7 @@ static void PushyInstallCrashHoldHandler(void) { // main thread). Without it only the cache is consulted: a crash rescue on // the main thread has a 3.5s budget in total, and hashing a large bundle on // a fresh install would spend all of it before the first request. -static NSString *PushyBundleHashSync(BOOL computeIfMissing) { +PUSHY_HIDDEN NSString *PushyBundleHashSync(BOOL computeIfMissing) { NSString *path = [[RCTPushy binaryBundleURL] path]; if (path == nil) { return @""; @@ -964,6 +830,36 @@ static void PushyInstallCrashHoldHandler(void) { } } +// Host preparation runs off the caller's thread; the completion is delivered +// on the main queue. Calls run concurrently so each timeout counts from its +// own call: a serial queue would hold a later prepareBundle(timeout:) behind an +// earlier call's whole wait. Nothing here needs serializing — claimRound is an +// atomic compare-exchange and the round group (entered once at setup) releases +// every waiter when the round ends. +static void PushyRunHostPreparation(NSDictionary *(^work)(void), + RCTPushyBundlePreparationCompletion completion) { + static dispatch_queue_t hostQueue; + static dispatch_once_t once; + dispatch_once(&once, ^{ + hostQueue = dispatch_queue_create("cn.reactnative.pushy.host-sync", DISPATCH_QUEUE_CONCURRENT); + }); + dispatch_async(hostQueue, ^{ + NSDictionary *result; + @try { + result = work(); + } @catch (NSException *exception) { + RCTLogWarn(@"RCTPushy -- native host sync failed: %@", exception.reason); + result = PushyHostResult(@"failed", @"internal_error", nil, NO); + } + if (completion != nil) { + NSDictionary *snapshot = [result copy]; + dispatch_async(dispatch_get_main_queue(), ^{ + completion(snapshot); + }); + } + }); +} + @implementation RCTPushy { dispatch_queue_t _fileQueue; bool hasListeners; @@ -1024,7 +920,7 @@ + (NSURL *)resolveLaunchBundleURL:(NSString **)launchRolledBackVersionOut PushyToStdString(curBuildTime) ); if (sync.changed) { - [defaults setObject:@(YES) forKey:KeyPackageUpdatedMarked]; + [defaults setObject:@(YES) forKey:KeyPackageUpdatedMarked()]; state = sync.state; PushyApplyStateToDefaults(defaults, state); } @@ -1148,26 +1044,17 @@ + (void)configure:(NSDictionary *)options + (void)prepareBundleWithCompletion:(RCTPushyBundlePreparationCompletion)completion { - static dispatch_queue_t hostQueue; - static dispatch_once_t once; - dispatch_once(&once, ^{ - hostQueue = dispatch_queue_create("cn.reactnative.pushy.host-sync", DISPATCH_QUEUE_SERIAL); - }); - dispatch_async(hostQueue, ^{ - NSDictionary *result; - @try { - result = [RCTPushyOrchestrator prepareBundle]; - } @catch (NSException *exception) { - RCTLogWarn(@"RCTPushy -- native host sync failed: %@", exception.reason); - result = PushyHostResult(@"failed", @"internal_error", nil, NO); - } - if (completion != nil) { - NSDictionary *snapshot = [result copy]; - dispatch_async(dispatch_get_main_queue(), ^{ - completion(snapshot); - }); - } - }); + PushyRunHostPreparation(^NSDictionary * { + return [RCTPushyOrchestrator prepareBundle]; + }, completion); +} + ++ (void)prepareBundleWithTimeout:(NSTimeInterval)timeout + completion:(RCTPushyBundlePreparationCompletion)completion +{ + PushyRunHostPreparation(^NSDictionary * { + return [RCTPushyOrchestrator prepareBundleWithin:timeout]; + }, completion); } + (BOOL)requiresMainQueueSetup @@ -1217,8 +1104,8 @@ - (NSDictionary *)constantsToExport [self clearInvalidFiles]; } - if ([[defaults objectForKey:KeyPackageUpdatedMarked] boolValue]) { - [defaults removeObjectForKey:KeyPackageUpdatedMarked]; + if ([[defaults objectForKey:KeyPackageUpdatedMarked()] boolValue]) { + [defaults removeObjectForKey:KeyPackageUpdatedMarked()]; [self clearInvalidFiles]; } }); @@ -1494,7 +1381,7 @@ - (instancetype)init } } [defaults removeObjectForKey:keyFirstLoadMarked]; - [defaults removeObjectForKey:KeyPackageUpdatedMarked]; + [defaults removeObjectForKey:KeyPackageUpdatedMarked()]; // A cached response still advertises the version this reset just // removed; dropping it stops the JS side from reusing that answer. [defaults removeObjectForKey:keyNativeCheckCache]; @@ -1600,7 +1487,7 @@ - (NSError *)restartBridgeWithReason:(NSString *)reason - (void)performFetch:(PushyType)type options:(NSDictionary *)options callback:(void (^)(NSError *error))callback { - NSString *updateUrl = PushyOptionString(options, PushyOptionUrlKey); + NSString *updateUrl = PushyOptionString(options, PushyOptionUrlKey()); NSString *hash = PushyOptionString(options, @"hash"); if (PushyStringIsBlank(updateUrl) || !PushyIsSafePathComponent(hash)) { @@ -1827,7 +1714,7 @@ - (void)applyDeltaForHash:(NSString *)hash NSString *unzipDir = PushyStagingDirForVersionDir( [[RCTPushy downloadDir] stringByAppendingPathComponent:hash]); NSString *sourcePatch = [unzipDir stringByAppendingPathComponent:SOURCE_PATCH_NAME]; - NSString *bundlePatch = [unzipDir stringByAppendingPathComponent:BUNDLE_PATCH_NAME]; + NSString *bundlePatch = [unzipDir stringByAppendingPathComponent:BUNDLE_PATCH_NAME()]; NSString *destination = [unzipDir stringByAppendingPathComponent:BUNDLE_FILE_NAME]; long long manifestBytes = RCTPushyFileSize(sourcePatch); @@ -1868,7 +1755,7 @@ - (void)applyDeltaForHash:(NSString *)hash entryNames.push_back(PushyToStdString(SOURCE_PATCH_NAME)); } if ([[NSFileManager defaultManager] fileExistsAtPath:bundlePatch isDirectory:NULL]) { - entryNames.push_back(PushyToStdString(BUNDLE_PATCH_NAME)); + entryNames.push_back(PushyToStdString(BUNDLE_PATCH_NAME())); } pushy::archive_patch::ArchivePatchPlan plan; @@ -1904,7 +1791,7 @@ - (void)applyDeltaForHash:(NSString *)hash // 存在时透传给 patch 内核执行 T(origin) → hpatch → T⁻¹;缺失走现状路径。 NSDictionary *hbcTransform = json[@"hbcTransform"]; if ([hbcTransform isKindOfClass:[NSDictionary class]]) { - NSDictionary *meta = hbcTransform[BUNDLE_PATCH_NAME]; + NSDictionary *meta = hbcTransform[BUNDLE_PATCH_NAME()]; if ([meta isKindOfClass:[NSDictionary class]]) { NSError *metaError = nil; NSData *metaData = [NSJSONSerialization dataWithJSONObject:meta options:0 error:&metaError]; @@ -1933,40 +1820,80 @@ - (BOOL)switchVersion:(NSString *)hash error:(NSError **)error return NO; } - __block NSError *switchError = nil; - PushyWithStateLock(^{ - // Same rule as Android: only a version this SDK recorded as completely - // installed (bundle + marker) may be activated. Versions activated - // before markers existed are grandfathered through current/last - // state; any other markerless directory may be a crash-left partial - // install or a directory something else put there. - NSString *versionDir = [[RCTPushy downloadDir] stringByAppendingPathComponent:hash]; + // Same rule as Android: only a version this SDK recorded as completely + // installed (bundle + marker) may be activated. Versions activated before + // markers existed are grandfathered through current/last state; any other + // markerless directory may be a crash-left partial install or a directory + // something else put there. + NSString *versionDir = [[RCTPushy downloadDir] stringByAppendingPathComponent:hash]; + std::string hashStd = PushyToStdString(hash); + BOOL (^isLegacyActivated)(void) = ^BOOL { pushy::state::State state = PushyStateFromDefaults(PushyDefaults()); - std::string hashStd = PushyToStdString(hash); - BOOL legacyActivated = state.current_version == hashStd || state.last_version == hashStd; - if (!PushyHasCompletedVersionAtPath(versionDir, hash) && !legacyActivated) { - BOOL hasBundle = [[NSFileManager defaultManager] fileExistsAtPath: - [versionDir stringByAppendingPathComponent:BUNDLE_FILE_NAME]]; - switchError = PushyErrorWithCode( - pushy::error_codes::kSwitchVersionFailed, - [NSString stringWithFormat:@"Bundle version %@ %@", hash, - hasBundle ? @"is incomplete." : @"not found."]); - return; - } - if (!legacyActivated) { + return state.current_version == hashStd || state.last_version == hashStd; + }; + NSError *(^incompleteError)(void) = ^NSError * { + BOOL hasBundle = [[NSFileManager defaultManager] fileExistsAtPath: + [versionDir stringByAppendingPathComponent:BUNDLE_FILE_NAME]]; + return PushyErrorWithCode( + pushy::error_codes::kSwitchVersionFailed, + [NSString stringWithFormat:@"Bundle version %@ %@", hash, + hasBundle ? @"is incomplete." : @"not found."]); + }; + + // The digest check reads the whole bundle, so it runs OUTSIDE the state + // lock: +bundleURL takes that lock on the main thread at launch. + __block BOOL legacyActivated = NO; + PushyWithStateLock(^{ + legacyActivated = isLegacyActivated(); + }); + NSError *switchError = nil; + NSDictionary *verifiedRecord = nil; + PushyFileStamp verifiedStamp; + BOOL verified = NO; + if (!legacyActivated) { + if (!PushyHasCompletedVersionAtPath(versionDir, hash)) { + switchError = incompleteError(); + } else { // The record's bundle digest must match the bytes on disk before // the next launch is pointed at them. - NSString *reason = PushyVerifyInstallForActivation(versionDir, hash); + NSString *reason = PushyVerifyInstallForActivation( + versionDir, hash, &verifiedRecord, &verifiedStamp); if (reason != nil) { switchError = PushyErrorWithCode(pushy::error_codes::kSwitchVersionFailed, reason); - return; + } else { + verified = YES; } } - PushySwitchVersionLocked(hash); - }); - if (switchError != nil) { + } + + __block NSError *lockedError = switchError; + if (lockedError == nil) { + NSDictionary *record = verifiedRecord; + PushyFileStamp stamp = verifiedStamp; + PushyWithStateLock(^{ + if (!isLegacyActivated()) { + // Re-check cheaply that the directory is still the one that + // was verified: same record, same bundle file (size, mtime, + // inode). + if (!PushyHasCompletedVersionAtPath(versionDir, hash)) { + lockedError = incompleteError(); + return; + } + NSString *bundlePath = [versionDir stringByAppendingPathComponent:BUNDLE_FILE_NAME]; + if (!verified || ![PushyReadInstallRecord(versionDir) isEqual:record] + || PushyStampFile(bundlePath) != stamp) { + lockedError = PushyErrorWithCode( + pushy::error_codes::kSwitchVersionFailed, + [NSString stringWithFormat:@"Bundle version %@ changed while switching.", hash]); + return; + } + } + PushySwitchVersionLocked(hash); + }); + } + if (lockedError != nil) { if (error != NULL) { - *error = switchError; + *error = lockedError; } return NO; } @@ -2128,9 +2055,9 @@ - (NSString *)zipExtension:(PushyType)type { switch (type) { case PushyTypePatchFromPackage: - return PushyIpaDeltaSuffix; + return PushyIpaDeltaSuffix(); case PushyTypePatchFromPpk: - return PushyPpkDeltaSuffix; + return PushyPpkDeltaSuffix(); case PushyTypeFullDownload: break; } @@ -2214,861 +2141,3 @@ + (NSString *)buildTime #endif @end - -#pragma mark - native cold-start check orchestration - -// Refuses an https→http downgrade before the redirected request — and with -// it the POST body (appKey, uuid, versions) — leaves the device. The shared -// session would follow first and only the response could be rejected; ATS -// blocks plaintext by default, this covers apps that opted out with -// NSAllowsArbitraryLoads. Same rule as RCTPushyDownloader. Returning nil -// delivers the 3xx itself as the final response, which the status check in -// PushyHttpRequest then treats as a failed endpoint. -@interface PushyRequestRedirectGuard : NSObject -@end - -@implementation PushyRequestRedirectGuard - -- (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task -willPerformHTTPRedirection:(NSHTTPURLResponse *)response - newRequest:(NSURLRequest *)request - completionHandler:(void (^)(NSURLRequest *))completionHandler -{ - NSString *originalScheme = [task.originalRequest.URL.scheme lowercaseString]; - NSString *nextScheme = [request.URL.scheme lowercaseString]; - if ([originalScheme isEqualToString:@"https"] && [nextScheme isEqualToString:@"http"]) { - completionHandler(nil); - return; - } - completionHandler(request); -} - -@end - -// Blocking JSON HTTP round-trip on the orchestrator's utility thread. Returns -// the response body on 2xx, nil on any failure. The semaphore timeout is a -// backstop over the request's own timeoutInterval. -static NSString *PushyHttpRequest(NSString *urlString, NSString *method, - NSString *body, NSTimeInterval timeout) { - NSURL *url = [NSURL URLWithString:urlString]; - if (url == nil) { - return nil; - } - NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:url]; - request.HTTPMethod = method; - request.timeoutInterval = timeout; - [request setValue:@"application/json" forHTTPHeaderField:@"Accept"]; - if (body != nil) { - [request setValue:@"application/json" forHTTPHeaderField:@"Content-Type"]; - request.HTTPBody = [body dataUsingEncoding:NSUTF8StringEncoding]; - } - dispatch_semaphore_t sem = dispatch_semaphore_create(0); - __block NSString *result = nil; - // A delegate session (not the shared one) so the redirect guard sees the - // 3xx before it is followed; the completion handler still receives the - // final response. - NSURLSession *session = [NSURLSession - sessionWithConfiguration:[NSURLSessionConfiguration defaultSessionConfiguration] - delegate:[PushyRequestRedirectGuard new] - delegateQueue:nil]; - NSURLSessionDataTask *task = [session dataTaskWithRequest:request - completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) { - NSHTTPURLResponse *httpResponse = - [response isKindOfClass:[NSHTTPURLResponse class]] - ? (NSHTTPURLResponse *)response - : nil; - NSInteger status = httpResponse.statusCode; - if (error == nil && httpResponse != nil && status >= 200 && status < 300 - && data != nil) { - result = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding]; - } - dispatch_semaphore_signal(sem); - }]; - [task resume]; - // The session retains its delegate until invalidated: let this one task - // settle, then release everything. - [session finishTasksAndInvalidate]; - if (dispatch_semaphore_wait( - sem, dispatch_time(DISPATCH_TIME_NOW, - (int64_t)((timeout + 5) * NSEC_PER_SEC))) != 0) { - [task cancel]; - return nil; - } - return result; -} - -static NSString *PushyNormalizeEndpointBase(NSString *base) { - while ([base hasSuffix:@"/"]) { - base = [base substringToIndex:base.length - 1]; - } - return base; -} - -static BOOL PushyIsValidResponse(NSString *responseText) { - if (responseText == nil) { - return NO; - } - // Shared schema rule (update_flow_core::IsValidResponse): a 200 with - // `{"error": ...}` is a failed endpoint, not a verdict. - return flowcore::IsValidResponse(PushyToStdString(responseText)) ? YES : NO; -} - -@implementation RCTPushyOrchestrator - -+ (void)persistConfiguration:(NSString *)config { - PushyWithStateLock(^{ - NSUserDefaults *defaults = PushyDefaults(); - if ([defaults stringForKey:keyUuid].length == 0) { - [defaults setObject:[NSUUID UUID].UUIDString forKey:keyUuid]; - } - if ([[defaults stringForKey:keyNativeConfig] isEqualToString:config]) { - return; - } - // The same generation protects reset and replacement of the request - // identity/policy, including a late crash-rescue activation. - pushyResetGeneration.fetch_add(1); - pushyNativeConfigGeneration.fetch_add(1); - [defaults setObject:config forKey:keyNativeConfig]; - [defaults removeObjectForKey:keyNativeCheckCache]; - [self recordJsRound:nil]; - }); - if (pushySyncReady.load() && [self hasRunnableConfig]) { - PushyInstallCrashHoldHandler(); - } -} - -+ (BOOL)hasRunnableConfig { - NSString *json = [PushyDefaults() stringForKey:keyNativeConfig]; - if (json.length == 0) { - return NO; - } - bool ok = false; - flowjson::Value config = flowjson::Parse(PushyToStdString(json), &ok); - return ok && config.IsObject() && !config.Get("disabled").Truthy() - && !config.Get("appKey").AsString().empty(); -} - -// Process-wide round state, set up by whichever launch path needs a round -// first: the delayed cold-start check or the tvOS purged-version restore. -+ (void)prepareProcess:(NSString *)launchRolledBackVersion { - static dispatch_once_t onceToken; - dispatch_once(&onceToken, ^{ - pushyRoundDone = dispatch_semaphore_create(0); - pushyHostRoundGroup = dispatch_group_create(); - dispatch_group_enter(pushyHostRoundGroup); - pushyProcessAnchorUptime = PushyMonotonicNow(); - pushyLaunchRolledBackForHold = [launchRolledBackVersion copy]; - pushySyncReady.store(true); - // The crash-hold rescue shares the orchestrator's rollout gate: no - // persisted config, no handler (§11.3). - if ([PushyDefaults() stringForKey:keyNativeConfig].length > 0) { - PushyInstallCrashHoldHandler(); - } - }); -} - -+ (void)scheduleFromColdStart:(NSString *)launchRolledBackVersion { -#if !DEBUG - // Once per process; a few seconds of delay keeps the check away from the - // cold-start critical path (§7 R5) — its result targets the NEXT launch. - // Unless the previous process died mid-round (residual incomplete - // marker), in which case every launch second counts (§11.4). - static dispatch_once_t onceToken; - dispatch_once(&onceToken, ^{ - [self prepareProcess:launchRolledBackVersion]; - NSUserDefaults *defaults = PushyDefaults(); - int64_t delaySeconds = - [defaults objectForKey:keyNativeCheckIncomplete] != nil ? 0 : 5; - dispatch_after(dispatch_time(DISPATCH_TIME_NOW, delaySeconds * NSEC_PER_SEC), - dispatch_get_global_queue(QOS_CLASS_UTILITY, 0), ^{ - if ([self hasJsRound]) { - // Not consuming the round: a later crash rescue may still - // need it. - NSLog(@"RCTPushy -- native sync skipped: JS already queried in this process"); - return; - } - [self startRoundWithDeadline:0]; - }); - }); -#endif -} - -#if TARGET_OS_TV && !DEBUG -// Runs this process's round before the first bundle loads, blocking the -// caller for at most kPushyPurgeRestoreBudget. The state no longer has a -// current version, so the server answers with the latest version for the -// packaged bundle, and the round activates it while the restore window is -// open. Returns NO when no round can run; otherwise the caller must resolve -// the launch bundle again: it launches whatever was activated before the -// window closed, or the packaged bundle. The round stops at the same -// deadline, keeping a partial download for the next check to resume. -+ (BOOL)restorePurgedLaunch:(NSString *)purgedVersion - rolledBack:(NSString *)launchRolledBackVersion { - [self prepareProcess:launchRolledBackVersion]; - if (![self hasRunnableConfig]) { - RCTLogWarn(@"RCTPushy -- version %@ was purged and no native sync is " - @"configured; launching the packaged bundle", purgedVersion); - return NO; - } - PushyWithStateLock(^{ - pushyPurgeRestoreWindowOpen = true; - }); - pushyPurgeRestoreActive.store(true); - NSTimeInterval deadline = PushyMonotonicNow() + kPushyPurgeRestoreBudget; - dispatch_semaphore_t done = dispatch_semaphore_create(0); - dispatch_async(dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0), ^{ - [self startRoundWithDeadline:deadline]; - dispatch_semaphore_signal(done); - }); - BOOL timedOut = dispatch_semaphore_wait(done, dispatch_time(DISPATCH_TIME_NOW, - (int64_t)(kPushyPurgeRestoreBudget * NSEC_PER_SEC))) != 0; - // The round's deadline does not bound a slow response (the request - // timeout is an idle timeout) nor a version already on disk, so the close - // is what stops a late activation — not the deadline. - PushyWithStateLock(^{ - pushyPurgeRestoreWindowOpen = false; - }); - if (timedOut) { - RCTLogWarn(@"RCTPushy -- purged version %@ restore timed out", purgedVersion); - } else { - NSDictionary *result = pushyHostRoundResult; - RCTLogInfo(@"RCTPushy -- purged version %@ restore: %@ %@ %@", purgedVersion, - result[@"status"], result[@"reason"], result[@"hash"]); - } - return YES; -} -#endif - -+ (NSDictionary *)prepareBundle { -#if DEBUG - return PushyHostResult(@"skipped", @"debug", nil, NO); -#else - if (!pushySyncReady.load()) { - return PushyHostResult(@"skipped", @"not_initialized", nil, NO); - } - NSString *configJson = [PushyDefaults() stringForKey:keyNativeConfig]; - if (configJson.length == 0) { - return PushyHostResult(@"skipped", @"not_configured", nil, NO); - } - id config = [NSJSONSerialization JSONObjectWithData: - [configJson dataUsingEncoding:NSUTF8StringEncoding] options:0 error:nil]; - if (![config isKindOfClass:NSDictionary.class]) { - return PushyHostResult(@"failed", @"invalid_config", nil, NO); - } - id disabled = config[@"disabled"]; - if ([disabled respondsToSelector:@selector(boolValue)] && [disabled boolValue]) { - return PushyHostResult(@"skipped", @"disabled", nil, NO); - } - id appKey = config[@"appKey"]; - if (![appKey isKindOfClass:NSString.class] || [appKey length] == 0) { - return PushyHostResult(@"failed", @"invalid_config", nil, NO); - } - [self startRoundWithDeadline:0]; - // A group is broadcast-style. Sharing the rescue semaphore would let one - // waiter consume the only signal and leave the other waiting forever. - if (!pushyRoundStarted.load()) { - return PushyHostResult(@"skipped", @"config_changed", nil, NO); - } - dispatch_group_wait(pushyHostRoundGroup, DISPATCH_TIME_FOREVER); - if (pushyHostRoundConfigGeneration != pushyNativeConfigGeneration.load() - || ![configJson isEqualToString:pushyHostRoundConfig] - || ![configJson isEqualToString:[PushyDefaults() stringForKey:keyNativeConfig]]) { - return PushyHostResult(@"cancelled", @"config_changed", nil, NO); - } - if (pushyHostRoundGeneration != pushyResetGeneration.load()) { - return PushyHostResult(@"cancelled", @"reset", nil, NO); - } - return pushyHostRoundResult ?: PushyHostResult(@"failed", @"internal_error", nil, NO); -#endif -} - -+ (void)recordJsRound:(NSString *)config { - @synchronized (RCTPushyOrchestrator.class) { - pushyJsCompletedConfig = [config copy]; - } -} - -// True when JS already obtained a valid response in this process for the -// exact config the native round would use (§10.3): the delayed round is -// then a duplicate request. Only the scheduled round asks — the crash-rescue -// path still runs, JS is dead by then. -+ (BOOL)hasJsRound { - NSString *jsConfig; - @synchronized (RCTPushyOrchestrator.class) { - jsConfig = pushyJsCompletedConfig; - } - if (jsConfig == nil) { - return NO; - } - NSString *persisted = [PushyDefaults() stringForKey:keyNativeConfig]; - return persisted != nil && [persisted isEqualToString:jsConfig]; -} - -// Runs the process's single round on the calling thread if nobody has -// started it yet. deadlineUptime > 0 (crash rescue) caps every HTTP call and -// download phase to the remaining budget. -+ (void)startRoundWithDeadline:(NSTimeInterval)deadlineUptime { - // Missing/disabled configuration is a preflight skip, not a used round. - if (![self hasRunnableConfig]) { - return; - } - bool expected = false; - if (!pushyRoundStarted.compare_exchange_strong(expected, true)) { - return; - } - @try { - [self runOnce:pushyLaunchRolledBackForHold deadline:deadlineUptime]; - } @catch (NSException *exception) { - // The rescue path must never take the app down with it. - RCTLogWarn(@"RCTPushy -- native sync crashed: %@", exception.reason); - pushyHostRoundResult = PushyHostResult(@"failed", @"internal_error", nil, NO); - } @finally { - pushyRoundCompleted.store(true); - dispatch_semaphore_signal(pushyRoundDone); - dispatch_group_leave(pushyHostRoundGroup); - } -} - -// Crash-rescue entry (§11.3), called from the handler's worker queue while -// the uncaught-exception handler holds the dying process. Ensures this -// process's round runs to completion within the budget, then activates a -// downloaded-but-unactivated version if one exists — the last chance before -// the process is gone. -+ (void)runHoldRoundWithDeadline:(NSTimeInterval)deadlineUptime { - pushyCrashHoldActive.store(true); - [self startRoundWithDeadline:deadlineUptime]; - if (pushyRoundStarted.load() && !pushyRoundCompleted.load()) { - NSTimeInterval remaining = deadlineUptime - PushyMonotonicNow(); - if (remaining > 0) { - dispatch_semaphore_wait(pushyRoundDone, dispatch_time(DISPATCH_TIME_NOW, - (int64_t)(remaining * NSEC_PER_SEC))); - } - } - [self activatePendingVersion]; -} - -// The alert-strategy variant of the §10.7 hole: the round downloaded a fix -// but deferred activation to JS, and JS is now dead. Activation is local and -// bounded (a state switch under the commit lock). -+ (void)activatePendingVersion { - NSString *hash = nil; - uint64_t generation = 0; - @synchronized (self) { - hash = pushyUnactivatedHash; - generation = pushyUnactivatedGeneration; - } - if (hash == nil) { - return; - } - // Unlike Android's switchVersion, PushySwitchVersionLocked does not - // validate the version directory; never point the next launch at bytes - // that are gone (e.g. wiped by a reset whose generation bump we would - // catch below anyway, or by cleanup). - NSString *versionDir = [[RCTPushy downloadDir] stringByAppendingPathComponent:hash]; - if (!PushyHasCompletedVersionAtPath(versionDir, hash)) { - NSLog(@"RCTPushy -- crash hold: version %@ no longer on disk, dropping activation", hash); - return; - } - NSDictionary *hashInfoEntry = nil; - NSString *existingInfo = [PushyDefaults() stringForKey:PushyHashInfoKey(hash)]; - NSData *existingData = [existingInfo dataUsingEncoding:NSUTF8StringEncoding]; - id parsed = existingData == nil ? nil : - [NSJSONSerialization JSONObjectWithData:existingData options:0 error:nil]; - if ([parsed isKindOfClass:[NSDictionary class]]) { - NSMutableDictionary *merged = [parsed mutableCopy]; - merged[PushyInfoCrashHold] = @YES; - hashInfoEntry = @{@"hash": hash, @"info": merged}; - } - BOOL committed = [self commitRoundWithGeneration:generation - hashInfo:hashInfoEntry - activate:hash - responseText:nil - request:nil - config:nil - responseAt:0 - activated:NULL]; - if (committed) { - @synchronized (self) { - pushyUnactivatedHash = nil; - } - NSLog(@"RCTPushy -- crash hold: activated downloaded version %@", hash); - } else { - NSLog(@"RCTPushy -- crash hold: reset since download, dropping activation"); - } -} - -// A bare module instance drives the existing download/patch pipeline without -// a bridge: the file queue is process-global and progress events are gated on -// hasListeners (never set without a bridge). -+ (RCTPushy *)engine { - static RCTPushy *engine; - static dispatch_once_t onceToken; - dispatch_once(&onceToken, ^{ - engine = [[RCTPushy alloc] init]; - }); - return engine; -} - -+ (void)runOnce:(NSString *)launchRolledBackVersion deadline:(NSTimeInterval)deadlineUptime { - const uint64_t resetGeneration = pushyResetGeneration.load(); - pushyHostRoundGeneration = resetGeneration; - pushyHostRoundConfigGeneration = pushyNativeConfigGeneration.load(); - pushyHostRoundResult = PushyHostResult(@"failed", @"check_failed", nil, NO); - NSUserDefaults *defaults = PushyDefaults(); - NSString *configJson = [defaults stringForKey:keyNativeConfig]; - pushyHostRoundConfig = [configJson copy]; - if (configJson.length == 0) { - pushyHostRoundResult = PushyHostResult(@"skipped", @"not_configured", nil, NO); - return; - } - bool ok = false; - flowjson::Value config = flowjson::Parse(PushyToStdString(configJson), &ok); - if (!ok || !config.IsObject()) { - pushyHostRoundResult = PushyHostResult(@"failed", @"invalid_config", nil, NO); - return; - } - if (config.Get("disabled").Truthy()) { - pushyHostRoundResult = PushyHostResult(@"skipped", @"disabled", nil, NO); - return; - } - NSString *appKey = PushyFromStdString(config.Get("appKey").AsString()); - if (appKey.length == 0) { - pushyHostRoundResult = PushyHostResult(@"failed", @"invalid_config", nil, NO); - return; - } - // Preserve the interrupted-round breadcrumb and reset-safe atomic commit. - [defaults setObject:@YES forKey:keyNativeCheckIncomplete]; - @try { - [self runConfiguredRound:config - configJson:configJson - appKey:appKey - launchRolledBackVersion:launchRolledBackVersion - resetGeneration:resetGeneration - deadline:deadlineUptime]; - } @finally { - [defaults removeObjectForKey:keyNativeCheckIncomplete]; - } -} - -+ (void)runConfiguredRound:(const flowjson::Value &)config - configJson:(NSString *)configJson - appKey:(NSString *)appKey - launchRolledBackVersion:(NSString *)launchRolledBackVersion - resetGeneration:(uint64_t)resetGeneration - deadline:(NSTimeInterval)deadlineUptime { - NSUserDefaults *defaults = PushyDefaults(); - NSString *packageVersion = - PushyFromStdString(config.Get("packageVersion").AsString()); - if (packageVersion.length == 0) { - packageVersion = [RCTPushy packageVersion]; - } - - __block NSString *currentVersion = nil; - PushyWithStateLock(^{ - pushy::state::State state = PushyStateFromDefaults(PushyDefaults()); - currentVersion = PushyFromStdString(state.current_version); - }); - NSString *rolledBackVersion = launchRolledBackVersion; - NSString *uuid = [defaults stringForKey:keyUuid] ?: @""; - - flowjson::Value identity = flowjson::Value::Object(); - identity.Set("packageVersion", - flowjson::Value::String(PushyToStdString(packageVersion))); - if (currentVersion != nil) { - identity.Set("currentVersion", - flowjson::Value::String(PushyToStdString(currentVersion))); - } - identity.Set("uuid", flowjson::Value::String(PushyToStdString(uuid))); - if (rolledBackVersion != nil) { - identity.Set("rolledBackVersion", - flowjson::Value::String(PushyToStdString(rolledBackVersion))); - } - - flowjson::Value cInfo = flowjson::Value::Object(); - cInfo.Set("rnu", config.Get("rnu")); - cInfo.Set("rn", config.Get("rn")); - cInfo.Set("os", flowjson::Value::String(PushyToStdString([NSString - stringWithFormat:@"%@ %@", PushyOsName, [[UIDevice currentDevice] systemVersion]]))); - cInfo.Set("uuid", flowjson::Value::String(PushyToStdString(uuid))); - - flowjson::Value input = flowjson::Value::Object(); - input.Set("packageVersion", identity.Get("packageVersion")); - if (currentVersion != nil) { - input.Set("currentVersion", identity.Get("currentVersion")); - } - input.Set("buildTime", - flowjson::Value::String(PushyToStdString([RCTPushy buildTime]))); - input.Set("cInfo", cInfo); - input.Set("supportedDiffVersion", - flowjson::Value::Number(pushy::hbc::kSupportedDiffVersion)); - // Cache-only while a crash is being held (§11.3 budget); computed (and - // shared with JS getBundleHash) on the ordinary delayed round. - input.Set("bundleHash", - flowjson::Value::String(PushyToStdString( - PushyBundleHashSync(!pushyCrashHoldActive.load())))); - - std::string bodyJson = - flowjson::Stringify(flowcore::BuildRequestBody(input)); - NSString *body = [NSString stringWithUTF8String:bodyJson.c_str()]; - if (body == nil) { - RCTLogWarn(@"RCTPushy -- native sync: request body is not valid UTF-8"); - pushyHostRoundResult = PushyHostResult(@"failed", @"invalid_request", nil, NO); - return; - } - - NSString *responseText = [self runQueryRequest:config - appKey:appKey - body:body - deadline:deadlineUptime]; - if (responseText == nil) { - RCTLogInfo(@"RCTPushy -- native sync: no endpoint reachable, giving up until next launch"); - return; - } - long long responseAtSeconds = (long long)[[NSDate date] timeIntervalSince1970]; - - flowjson::Value decision = flowcore::HandleResponse( - PushyToStdString(responseText), identity, false, - config.Get("afterDownload").AsString()); - if (decision.Get("action").AsString() != "download") { - BOOL committed = [self commitRoundWithGeneration:resetGeneration - hashInfo:nil - activate:nil - responseText:responseText - request:body - config:configJson - responseAt:responseAtSeconds - activated:NULL]; - pushyHostRoundResult = committed - ? PushyHostResult(PushyStatusNone, PushyFromStdString(decision.Get("reason").AsString()), nil, NO) - : PushyHostResult(@"cancelled", @"reset", nil, NO); - RCTLogInfo(@"RCTPushy -- native sync: nothing to do (%s)", - decision.Get("reason").AsString().c_str()); - return; - } - NSString *hash = PushyFromStdString(decision.Get("hash").AsString()); - if (!PushyIsSafePathComponent(hash)) { - pushyHostRoundResult = PushyHostResult(@"failed", @"invalid_response", nil, NO); - return; - } - - NSString *versionDir = [[RCTPushy downloadDir] stringByAppendingPathComponent:hash]; - BOOL downloaded = PushyHasCompletedVersionAtPath(versionDir, hash); - if (!downloaded) { - downloaded = [self performAttempts:decision.Get("attempts") - hash:hash - originHash:currentVersion - deadline:deadlineUptime]; - } - if (!downloaded) { - BOOL committed = [self commitRoundWithGeneration:resetGeneration - hashInfo:nil - activate:nil - responseText:responseText - request:body - config:configJson - responseAt:responseAtSeconds - activated:NULL]; - pushyHostRoundResult = committed - ? PushyHostResult(@"failed", @"download_failed", nil, NO) - : PushyHostResult(@"cancelled", @"reset", nil, NO); - return; - } - - // Persist name/description/metaInfo alongside the version, mirroring the - // JS side's setLocalHashInfo after a successful download. - const flowjson::Value &info = decision.Get("info"); - NSMutableDictionary *versionInfo = [NSMutableDictionary dictionary]; - for (const char *key : {"name", "description", "metaInfo"}) { - if (info.Get(key).IsString()) { - versionInfo[@(key)] = PushyFromStdString(info.Get(key).AsString()) ?: @""; - } - } - // A forceBoot activation is the brick-rescue path: mark it in the - // persisted info so JS can report force_boot_rescue when this version - // survives to markSuccess. Only the server-sent directive counts — a - // silent-strategy activation is ordinary delivery. - if (info.Get("config").Get("forceBoot").Truthy()) { - versionInfo[PushyInfoForceBoot] = @YES; - } - if (pushyCrashHoldActive.load()) { - versionInfo[PushyInfoCrashHold] = @YES; - } - // Silent strategies or a server-marked forceBoot version (per-version - // remote override — the brick rescue) activate for the next launch; - // otherwise activation stays with the JS side (§6/§10.1). Unless a crash - // is being held: JS is dead, deferring to it would leave the fix on disk - // forever (§11.3). Or the launch is blocked on reinstalling a version - // tvOS purged: JS has not started, and the whole point is to boot it — - // commitRoundWithGeneration drops that one once the restore window closed. - BOOL activate = decision.Get("activate").Truthy() || pushyCrashHoldActive.load() - || pushyPurgeRestoreActive.load(); - BOOL activated = NO; - BOOL committed = [self commitRoundWithGeneration:resetGeneration - hashInfo:@{@"hash": hash, @"info": versionInfo} - activate:activate ? hash : nil - responseText:responseText - request:body - config:configJson - responseAt:responseAtSeconds - activated:&activated]; - activate = activated; - if (!committed) { - RCTLogInfo(@"RCTPushy -- native sync: reset during round, dropping result"); - } else if (activate) { - @synchronized (self) { - pushyUnactivatedHash = nil; - } - RCTLogInfo(@"RCTPushy -- native sync: downloaded %@ and set for next launch", hash); - } else { - // Remembered so a crash later in this process can still activate it - // (activatePendingVersion) — JS never will. - @synchronized (self) { - pushyUnactivatedHash = [hash copy]; - pushyUnactivatedGeneration = resetGeneration; - } - RCTLogInfo(@"RCTPushy -- native sync: downloaded %@, activation left to JS", hash); - } - pushyHostRoundResult = committed - ? PushyHostResult(@"downloaded", @"", hash, activate) - : PushyHostResult(@"cancelled", @"reset", nil, NO); -} - -// Everything a round persists — version info, the activation, the response -// cache — is written inside ONE state-lock acquisition that first re-checks the -// reset generation. resetToPackagedBundle bumps that generation under the same -// lock, so there is no compare-and-act window: either the whole round commits, -// or the reset wins and none of it does. -+ (BOOL)commitRoundWithGeneration:(uint64_t)generation - hashInfo:(NSDictionary *)hashInfoEntry - activate:(NSString *)hashToActivate - responseText:(NSString *)responseText - request:(NSString *)requestBody - config:(NSString *)configJson - responseAt:(long long)responseAtSeconds - activated:(BOOL *)activatedOut { - // responseText is nil for the crash handler's activation-only commit - // (activatePendingVersion): no round ran, so there is no cache to write. - NSData *cacheData = nil; - if (responseText != nil) { - NSDictionary *cacheEntry = @{ - @"ts": @(responseAtSeconds), - @"body": responseText, - @"request": requestBody, - @"config": configJson, - }; - cacheData = [NSJSONSerialization dataWithJSONObject:cacheEntry options:0 error:nil]; - } - __block BOOL committed = NO; - __block BOOL activated = NO; - PushyWithStateLock(^{ - if (pushyResetGeneration.load() != generation) { - return; - } - NSString *activation = hashToActivate; - NSDictionary *info = hashInfoEntry[@"info"]; - // The tvOS purged-version restore round (a round commit, not the - // crash handler's activation-only one): it may take over this launch - // only while +bundleURL still waits for it; after that the version is - // left to JS unless a crash is being held (JS is dead then). - if (responseText != nil && pushyPurgeRestoreActive.load()) { - if (pushyPurgeRestoreWindowOpen) { - if (activation != nil && info != nil) { - NSMutableDictionary *marked = [info mutableCopy]; - marked[@"purgeRestore"] = @YES; - info = marked; - } - } else if (!pushyCrashHoldActive.load()) { - activation = nil; - } - } - NSUserDefaults *defaults = PushyDefaults(); - if (hashInfoEntry != nil) { - NSData *infoData = [NSJSONSerialization dataWithJSONObject:info - options:0 - error:nil]; - if (infoData != nil) { - [defaults setObject:[[NSString alloc] initWithData:infoData encoding:NSUTF8StringEncoding] - forKey:PushyHashInfoKey(hashInfoEntry[@"hash"])]; - } - } - if (activation != nil) { - PushySwitchVersionLocked(activation); - activated = YES; - } - if (cacheData != nil) { - [defaults setObject:[[NSString alloc] initWithData:cacheData encoding:NSUTF8StringEncoding] - forKey:keyNativeCheckCache]; - } - committed = YES; - }); - if (activatedOut != NULL) { - *activatedOut = activated; - } - return committed; -} - -// Sequential fallback over the ordered candidates (§5.1): one request at a -// time with its own timeout; after the configured round fails, queryUrls -// discovery merges remote candidates (excluding the already-tried) for one -// more round. No hedged race on purpose — this path is latency-insensitive. -// Per-request timeout, capped to the crash-rescue budget when one is active. -// <= 0 means the budget is gone and the round must stop issuing requests. -static NSTimeInterval PushyQueryRequestTimeout(NSTimeInterval deadlineUptime) { - if (deadlineUptime <= 0) { - return 10; - } - return MIN(10, deadlineUptime - PushyMonotonicNow()); -} - -+ (NSString *)runQueryRequest:(const flowjson::Value &)config - appKey:(NSString *)appKey - body:(NSString *)body - deadline:(NSTimeInterval)deadlineUptime { - double sample = arc4random() / 4294967296.0; - flowjson::Value ordered = - flowcore::OrderEndpointCandidates(config.Get("endpoints"), sample); - NSMutableSet *tried = [NSMutableSet set]; - const NSUInteger maxHttpAttempts = 8; - NSUInteger httpAttempts = 0; - for (const auto &endpoint : ordered.elements()) { - NSString *base = PushyNormalizeEndpointBase( - PushyFromStdString(endpoint.AsString())); - if (base.length == 0 || [tried containsObject:base]) { - continue; - } - if (httpAttempts++ >= maxHttpAttempts) { - return nil; - } - [tried addObject:base]; - NSTimeInterval timeout = PushyQueryRequestTimeout(deadlineUptime); - if (timeout <= 0) { - return nil; - } - NSString *response = PushyHttpRequest( - [NSString stringWithFormat:@"%@%@%@", base, RCTPushyQueryPath(), appKey], - @"POST", body, timeout); - if (PushyIsValidResponse(response)) { - return response; - } - } - for (const auto &queryUrl : config.Get("queryUrls").elements()) { - NSString *listUrl = PushyFromStdString(queryUrl.AsString()); - if (listUrl == nil) { - continue; - } - if (httpAttempts++ >= maxHttpAttempts) { - return nil; - } - NSTimeInterval listTimeout = PushyQueryRequestTimeout(deadlineUptime); - if (listTimeout <= 0) { - return nil; - } - NSString *listText = PushyHttpRequest(listUrl, @"GET", nil, listTimeout); - if (listText == nil) { - continue; - } - bool ok = false; - flowjson::Value remote = flowjson::Parse(PushyToStdString(listText), &ok); - if (!ok || !remote.IsArray()) { - continue; - } - for (const auto &endpoint : remote.elements()) { - NSString *base = PushyNormalizeEndpointBase( - PushyFromStdString(endpoint.AsString())); - if (base.length == 0 || [tried containsObject:base]) { - continue; - } - if (httpAttempts++ >= maxHttpAttempts) { - return nil; - } - [tried addObject:base]; - NSTimeInterval timeout = PushyQueryRequestTimeout(deadlineUptime); - if (timeout <= 0) { - return nil; - } - NSString *response = PushyHttpRequest( - [NSString stringWithFormat:@"%@%@%@", base, RCTPushyQueryPath(), appKey], - @"POST", body, timeout); - if (PushyIsValidResponse(response)) { - return response; - } - } - break; // one successfully fetched remote list is enough - } - return nil; -} - -+ (BOOL)performAttempts:(const flowjson::Value &)attempts - hash:(NSString *)hash - originHash:(NSString *)originHash - deadline:(NSTimeInterval)holdDeadline { - RCTPushy *engine = [self engine]; - // Crash-rescue budget caps every phase; 0 keeps the normal 10min windows. - NSTimeInterval incrementalDeadline = PushyMonotonicNow() + 600; - if (holdDeadline > 0) { - incrementalDeadline = MIN(incrementalDeadline, holdDeadline); - } - NSTimeInterval fullDeadline = 0; - for (const auto &attempt : attempts.elements()) { - const std::string &type = attempt.Get("type").AsString(); - PushyType pushyType = type == "diff" ? PushyTypePatchFromPpk - : type == "pdiff" ? PushyTypePatchFromPackage - : PushyTypeFullDownload; - if (pushyType == PushyTypePatchFromPpk && originHash.length == 0) { - continue; // diff patches from the running version; none running - } - BOOL isFullAttempt = pushyType == PushyTypeFullDownload; - if (isFullAttempt && fullDeadline == 0) { - // diff/pdiff cannot starve the last-resort full download. - fullDeadline = PushyMonotonicNow() + 600; - if (holdDeadline > 0) { - fullDeadline = MIN(fullDeadline, holdDeadline); - } - } - NSTimeInterval deadline = isFullAttempt ? fullDeadline : incrementalDeadline; - for (const auto &urlValue : attempt.Get("urls").elements()) { - NSString *url = PushyFromStdString(urlValue.AsString()); - if (url == nil) { - continue; - } - NSTimeInterval remaining = deadline - PushyMonotonicNow(); - if (remaining <= 0) { - if (isFullAttempt) { - return NO; - } - break; - } - NSMutableDictionary *options = - [@{ - PushyOptionUrlKey: url, - @"hash": hash, - @"deadlineUptime": @(deadline), - } mutableCopy]; - if (pushyType == PushyTypePatchFromPpk) { - options[@"originHash"] = originHash; - } - dispatch_semaphore_t sem = dispatch_semaphore_create(0); - __block NSError *resultError = nil; - [engine performFetch:pushyType options:options callback:^(NSError *error) { - resultError = error; - dispatch_semaphore_signal(sem); - }]; - if (dispatch_semaphore_wait(sem, dispatch_time(DISPATCH_TIME_NOW, - (int64_t)(remaining * NSEC_PER_SEC))) != 0) { - RCTLogWarn(@"RCTPushy -- native sync: %s attempt timed out", type.c_str()); - if (isFullAttempt) { - return NO; - } - break; - } - if (resultError == nil) { - return YES; - } - RCTLogInfo(@"RCTPushy -- native sync: %s attempt failed: %@", - type.c_str(), resultError.localizedDescription); - } - } - return NO; -} - -@end diff --git a/ios/RCTPushy/RCTPushyConfiguration.mm b/ios/RCTPushy/RCTPushyConfiguration.mm index 2714837d..793bbbca 100644 --- a/ios/RCTPushy/RCTPushyConfiguration.mm +++ b/ios/RCTPushy/RCTPushyConfiguration.mm @@ -6,14 +6,23 @@ NSString *RCTPushyRevealText(const char *hex) { size_t length = strlen(hex) / 2; - NSMutableString *text = [NSMutableString stringWithCapacity:length]; + unsigned char *bytes = (unsigned char *)malloc(length > 0 ? length : 1); + if (bytes == NULL) { + return @""; + } unsigned char base = PushyTextKeyBase; for (size_t i = 0; i < length; i++) { char pair[3] = {hex[i * 2], hex[i * 2 + 1], 0}; unsigned char byte = (unsigned char)strtoul(pair, NULL, 16); - [text appendFormat:@"%c", (char)(byte ^ (unsigned char)(base + 0x1D * i))]; + bytes[i] = byte ^ (unsigned char)(base + 0x1D * i); } - return text; + // One decode into a byte buffer (the texts are ASCII, see + // scripts/encode-native-text.ts) instead of a format call per character. + NSString *text = [[NSString alloc] initWithBytes:bytes + length:length + encoding:NSUTF8StringEncoding]; + free(bytes); + return text ?: @""; } NSString *RCTPushyQueryPath(void) { @@ -47,9 +56,13 @@ static void PushyConfigInvalid(NSString *message) { name, base ? @"a non-empty" : @"an"]); } NSMutableArray *result = [NSMutableArray array]; - NSRegularExpression *authority = [NSRegularExpression regularExpressionWithPattern: - @"^https?://(\\[[0-9a-fA-F:]+\\]|[a-zA-Z0-9.-]+)(:[0-9]+)?([/?#]|$)" - options:0 error:nil]; + static NSRegularExpression *authority; + static dispatch_once_t once; + dispatch_once(&once, ^{ + authority = [NSRegularExpression regularExpressionWithPattern: + @"^https?://(\\[[0-9a-fA-F:]+\\]|[a-zA-Z0-9.-]+)(:[0-9]+)?([/?#]|$)" + options:0 error:nil]; + }); for (id item in value) { NSString *url = [PushyConfigString(item, name, NO) stringByTrimmingCharactersInSet: NSCharacterSet.whitespaceAndNewlineCharacterSet]; diff --git a/ios/RCTPushy/RCTPushyInternal.h b/ios/RCTPushy/RCTPushyInternal.h new file mode 100644 index 00000000..28c02a42 --- /dev/null +++ b/ios/RCTPushy/RCTPushyInternal.h @@ -0,0 +1,117 @@ +// Internal to the RCTPushy pod: what RCTPushy.mm (module, install pipeline, +// launch resolution) and RCTPushyOrchestrator.mm (native round) share. Not a +// public header. +#pragma once + +#import "RCTPushy.h" +#import +#import + +#include +#include + +#include "../../cpp/patch_core/state_core.h" + +// RN's log macros pass __FILE__, which would embed this file's absolute build +// path in the binary; log with the bare file name instead. +#if RCTLOG_ENABLED +#undef _RCTLog +#define _RCTLog(lvl, ...) _RCTLogNativeInternal(lvl, __FILE_NAME__, __LINE__, __VA_ARGS__) +#endif + +// Shared across the pod's translation units, never exported from a dynamic +// framework. +#define PUSHY_HIDDEN __attribute__((visibility("hidden"))) + +typedef NS_ENUM(NSInteger, PushyType) { + PushyTypeFullDownload = 1, + PushyTypePatchFromPackage = 2, + PushyTypePatchFromPpk = 3, +}; + +@interface RCTPushy () +- (void)fetchPackage:(PushyType)type + options:(NSDictionary *)options + resolver:(RCTPromiseResolveBlock)resolve + rejecter:(RCTPromiseRejectBlock)reject; +- (void)performFetch:(PushyType)type + options:(NSDictionary *)options + callback:(void (^)(NSError *error))callback; +- (NSError *)restartBridgeWithReason:(NSString *)reason; +- (void)unzipDownloadedPackage:(NSString *)zipFilePath + hash:(NSString *)hash + type:(PushyType)type + originHash:(NSString *)originHash + callback:(void (^)(NSError *error))callback; +- (void)finishDownloadedPackage:(NSString *)hash + type:(PushyType)type + originHash:(NSString *)originHash + callback:(void (^)(NSError *error))callback; +- (void)applyDeltaForHash:(NSString *)hash + type:(PushyType)type + fromBundle:(NSString *)bundleOrigin + source:(NSString *)sourceOrigin + callback:(void (^)(NSError *error))callback; +- (BOOL)switchVersion:(NSString *)hash error:(NSError **)error; +- (BOOL)ensureDirectoryExistsAtPath:(NSString *)path; ++ (void)excludeFromBackup:(NSString *)path; +- (void)unzipFileAtPath:(NSString *)path + toDestination:(NSString *)destination + completionHandler:(void (^)(NSError *error))completionHandler; ++ (NSString *)downloadDir; ++ (NSURL *)binaryBundleURL; ++ (NSString *)packageVersion; ++ (NSString *)buildTime; +@end + +// Native cold-start update check (NATIVE_CHECKUPDATE_DESIGN §10): runs once +// per process, a few seconds after launch, entirely independent of the app +// bundle — this is what lets a bricked hot update be replaced on the next +// launch. Decisions come from cpp/update_flow_core; this class is IO glue. +@interface RCTPushyOrchestrator : NSObject ++ (void)persistConfiguration:(NSString *)config; ++ (BOOL)hasRunnableConfig; ++ (NSDictionary *)prepareBundle; ++ (NSDictionary *)prepareBundleWithin:(NSTimeInterval)timeout; ++ (void)scheduleFromColdStart:(NSString *)launchRolledBackVersion; +#if TARGET_OS_TV && !DEBUG ++ (BOOL)restorePurgedLaunch:(NSString *)purgedVersion + rolledBack:(NSString *)launchRolledBackVersion; +#endif ++ (void)recordJsRound:(NSString *)config; ++ (void)startRoundWithDeadline:(NSTimeInterval)deadlineUptime; ++ (BOOL)claimRound; ++ (void)runClaimedRoundWithDeadline:(NSTimeInterval)deadlineUptime; ++ (void)runOnce:(NSString *)launchRolledBackVersion deadline:(NSTimeInterval)deadlineUptime; ++ (void)runHoldRoundWithDeadline:(NSTimeInterval)deadlineUptime; ++ (BOOL)commitRoundWithGeneration:(uint64_t)generation + configGeneration:(uint64_t)configGeneration + hashInfo:(NSDictionary *)hashInfoEntry + activate:(NSString *)hashToActivate + responseText:(NSString *)responseText + request:(NSString *)requestBody + config:(NSString *)configJson + responseAt:(long long)responseAtSeconds + activated:(BOOL *)activatedOut; +@end + +extern PUSHY_HIDDEN NSString *const keyUuid; +extern PUSHY_HIDDEN NSString *const keyNativeConfig; +extern PUSHY_HIDDEN NSString *const keyNativeCheckCache; +extern PUSHY_HIDDEN NSString *const keyNativeCheckIncomplete; +extern PUSHY_HIDDEN std::atomic pushyResetGeneration; +PUSHY_HIDDEN NSDictionary *PushyHostResult(NSString *status, NSString *reason, NSString *hash, BOOL activated); +PUSHY_HIDDEN NSTimeInterval PushyMonotonicNow(void); +PUSHY_HIDDEN std::string PushyToStdString(NSString *value); +PUSHY_HIDDEN NSString *PushyFromStdString(const std::string &value); +PUSHY_HIDDEN NSUserDefaults *PushyDefaults(void); +PUSHY_HIDDEN void PushyWithStateLock(void (NS_NOESCAPE ^block)(void)); +PUSHY_HIDDEN NSString *PushyHashInfoKey(NSString *hash); +PUSHY_HIDDEN BOOL PushyIsSafePathComponent(NSString *value); +PUSHY_HIDDEN BOOL PushyHasCompletedVersionAtPath(NSString *versionDir, NSString *hash); +PUSHY_HIDDEN void PushySwitchVersionLocked(NSString *hash); +PUSHY_HIDDEN NSString *PushyBundleHashSync(BOOL computeIfMissing); +PUSHY_HIDDEN NSString *PushyOptionUrlKey(void); +PUSHY_HIDDEN NSString *PushyRevealOnce(NSString *__strong *slot, dispatch_once_t *once, const char *hex); + +PUSHY_HIDDEN pushy::state::State PushyStateFromDefaults(NSUserDefaults *defaults); diff --git a/ios/RCTPushy/RCTPushyOrchestrator.mm b/ios/RCTPushy/RCTPushyOrchestrator.mm new file mode 100644 index 00000000..7ebcdee4 --- /dev/null +++ b/ios/RCTPushy/RCTPushyOrchestrator.mm @@ -0,0 +1,1258 @@ +#import "RCTPushyInternal.h" +#import "RCTPushyConfiguration.h" +#include "../../cpp/patch_core/error_codes.h" +#include "../../cpp/patch_core/hbc_transform_wire.h" +#include "../../cpp/update_flow_core/flow_json.h" +#include "../../cpp/update_flow_core/update_flow_core.h" + +#include +#if TARGET_OS_TV +#import +#include +#include +#endif + +// Version-info flags and the no-op status read by JS, stored encoded +// (RCTPushyRevealText) so they do not appear as plain binary strings. +static NSString *PushyInfoCrashHold(void) { + static NSString *value; + static dispatch_once_t once; + return PushyRevealOnce(&value, &once, "3905f5c2a6b96d56212a19"); +} +static NSString *PushyInfoForceBoot(void) { + static NSString *value; + static dispatch_once_t once; + return PushyRevealOnce(&value, &once, "3c18e6d2aba9674a360d19ead5a695"); +} +static NSString *PushyStatusNone(void) { + static NSString *value; + static dispatch_once_t once; + return PushyRevealOnce(&value, &once, "3418c1c1aa8a7c40"); +} + +// cInfo.os label prefix; must match the JS side (core.ts) so the server +// buckets tvOS devices apart from iOS ones. +#if TARGET_OS_TV +static NSString * const PushyOsName = @"tvos"; +#else +static NSString * const PushyOsName = @"ios"; +#endif + +// One round per process, whoever starts it first — the delayed cold-start +// path or the crash-rescue thread (§11.3). The semaphore lets the rescue +// wait out an in-flight round instead of racing it. +static std::atomic pushyRoundStarted{false}; +static std::atomic pushyRoundCompleted{false}; +// Flipped the moment a crash is being held. JS is dead from that point on, +// so there is no second decision maker: the round force-activates whatever +// it downloads (§11.3). +static std::atomic pushyCrashHoldActive{false}; +static std::atomic pushyHoldAttempted{false}; +// Set when this process's round is the tvOS purged-version restore. +static std::atomic pushyPurgeRestoreActive{false}; +// True while +bundleURL waits for that restore. Only then may the round +// activate what it downloads on its own (JS has not started to decide), since +// the launch resolves the bundle again afterwards. Closed once the wait ends, +// timeout or not; guarded by the state lock that every activation commits +// under, so a commit lands either before the close (and launches) or after it +// (and is left to JS) — never selected behind the packaged bundle's back. +static bool pushyPurgeRestoreWindowOpen = false; +static dispatch_semaphore_t pushyRoundDone; +static NSString *pushyLaunchRolledBackForHold = nil; +// A version this process downloaded but left for JS to activate. If the +// process then crashes, JS will never activate it — the crash handler +// activates it directly (bounded local work, no network). The generation is +// the one the round committed under: a reset that lands afterwards bumps it, +// and the late activation must lose to that reset exactly like the round +// itself would. Guarded by @synchronized (RCTPushyOrchestrator class). +static NSString *pushyUnactivatedHash = nil; +static uint64_t pushyUnactivatedGeneration = 0; +static uint64_t pushyUnactivatedConfigGeneration = 0; +static NSUncaughtExceptionHandler *pushyPreviousExceptionHandler = NULL; +// ≈ process start: scheduleFromColdStart runs during the first bundleURL. +static NSTimeInterval pushyProcessAnchorUptime = 0; +// Config JSON for which JS reported a completed check in this process +// (markJsCheckCompleted). Process-scoped by design. Guarded by +// @synchronized (RCTPushyOrchestrator class). +static NSString *pushyJsCompletedConfig = nil; +// The group supplements (rather than consumes) the crash-rescue semaphore. +static dispatch_group_t pushyHostRoundGroup; +static std::atomic pushySyncReady{false}; +static NSDictionary *pushyHostRoundResult = nil; +static NSString *pushyHostRoundConfig = nil; +static uint64_t pushyHostRoundGeneration = 0; +// Bumped by persistConfiguration when the config really changes (C1 +// equivalence fails); resets keep pushyResetGeneration to themselves. +static std::atomic pushyNativeConfigGeneration{0}; +static uint64_t pushyHostRoundConfigGeneration = 0; + +static const NSTimeInterval kPushyHoldTriggerUptime = 60; +static const NSTimeInterval kPushyHoldBudgetBackgroundThread = 10; +// A held main thread freezes UI teardown; stay well under the watchdog. +static const NSTimeInterval kPushyHoldBudgetMainThread = 3.5; +// The purged-version restore blocks +bundleURL, usually inside +// application:didFinishLaunching: — stay clear of the launch watchdog. The +// budget is measured from process start; with less than the minimum left the +// restore is not attempted. +#if TARGET_OS_TV && !DEBUG +static const NSTimeInterval kPushyPurgeRestoreBudget = 12; +static const NSTimeInterval kPushyPurgeRestoreMinimumBudget = 2; + +// Seconds since the kernel started this process; 0 when unknown. +static NSTimeInterval PushyProcessElapsed(void) { + struct kinfo_proc info; + size_t size = sizeof(info); + int mib[4] = {CTL_KERN, KERN_PROC, KERN_PROC_PID, getpid()}; + if (sysctl(mib, 4, &info, &size, NULL, 0) != 0 || size == 0) { + return 0; + } + struct timeval now; + gettimeofday(&now, NULL); + const struct timeval start = info.kp_proc.p_starttime; + NSTimeInterval elapsed = (now.tv_sec - start.tv_sec) + + (now.tv_usec - start.tv_usec) / 1e6; + return elapsed > 0 ? elapsed : 0; +} + +// NO only when the system positively reports no usable network path; an +// unknown state (no answer within the short wait) lets the restore try. +static BOOL PushyHasNetworkPath(void) { + dispatch_queue_t queue = dispatch_queue_create("cn.reactnative.pushy.path", DISPATCH_QUEUE_SERIAL); + nw_path_monitor_t monitor = nw_path_monitor_create(); + dispatch_semaphore_t answered = dispatch_semaphore_create(0); + __block nw_path_status_t status = nw_path_status_invalid; + nw_path_monitor_set_queue(monitor, queue); + nw_path_monitor_set_update_handler(monitor, ^(nw_path_t path) { + BOOL first = status == nw_path_status_invalid; + status = nw_path_get_status(path); + if (first) { + dispatch_semaphore_signal(answered); + } + }); + nw_path_monitor_start(monitor); + dispatch_semaphore_wait(answered, dispatch_time(DISPATCH_TIME_NOW, NSEC_PER_SEC / 2)); + nw_path_monitor_cancel(monitor); + __block nw_path_status_t result; + dispatch_sync(queue, ^{ + result = status; + }); + return result != nw_path_status_unsatisfied; +} +#endif +// prepareBundleWithCompletion: (no explicit timeout) waits this long at most: +// past the longest round (8 query requests, then two 600s download phases). +static const NSTimeInterval kPushyHostPrepareDefaultTimeout = 1800; + +static void PushyMaybeHoldProcess(void) { + // Once per process; a second crashing thread passes straight through + // instead of waiting behind the first (§11.3: prefer under-rescuing over + // wedging the teardown). + bool expected = false; + if (!pushyHoldAttempted.compare_exchange_strong(expected, true)) { + return; + } + NSTimeInterval uptime = PushyMonotonicNow() - pushyProcessAnchorUptime; + bool roundInFlight = pushyRoundStarted.load() && !pushyRoundCompleted.load(); + // Early crashes are the brick signature; a crash with an in-flight round + // is worth finishing regardless of uptime. Everything else is an ordinary + // crash whose UX must not be delayed. + if (uptime >= kPushyHoldTriggerUptime && !roundInFlight) { + return; + } + NSTimeInterval budget = [NSThread isMainThread] + ? kPushyHoldBudgetMainThread + : kPushyHoldBudgetBackgroundThread; + NSTimeInterval deadline = PushyMonotonicNow() + budget; + NSLog(@"RCTPushy -- crash hold: holding process for up to %.1fs " + @"(uptime %.1fs)", budget, uptime); + + // The rescue runs on its own queue and the dying thread only waits with a + // hard timeout: even a deadlocked rescue (a thread that died holding a + // lock — uncaught ObjC exceptions skip @finally) can only delay the + // process death, never prevent it. + dispatch_semaphore_t done = dispatch_semaphore_create(0); + dispatch_async(dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0), ^{ + @try { + [RCTPushyOrchestrator runHoldRoundWithDeadline:deadline]; + } @catch (NSException *exception) { + NSLog(@"RCTPushy -- crash hold failed: %@", exception.reason); + } + dispatch_semaphore_signal(done); + }); + if (dispatch_semaphore_wait(done, dispatch_time(DISPATCH_TIME_NOW, + (int64_t)(budget * NSEC_PER_SEC))) != 0) { + NSLog(@"RCTPushy -- crash hold: budget exhausted, letting go"); + } +} + +// Crash-moment brick rescue (§11): when the app is dying of an uncaught +// exception during startup (RCTFatal raises NSException in release), the +// process is still alive and JS will never run again — a natural, +// false-positive-free window to finish the cold-start check. The previous +// handler (crash reporters chain the same way) always runs afterwards. +static void PushyCrashHoldExceptionHandler(NSException *exception) { + @try { + PushyMaybeHoldProcess(); + } @catch (NSException *inner) { + // The dying process owes the previous handler its turn no matter + // what the rescue did. + } + if (pushyPreviousExceptionHandler != NULL) { + pushyPreviousExceptionHandler(exception); + } +} + +static void PushyInstallCrashHoldHandler(void) { + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + pushyPreviousExceptionHandler = NSGetUncaughtExceptionHandler(); + NSSetUncaughtExceptionHandler(&PushyCrashHoldExceptionHandler); + }); +} + +#pragma mark - native cold-start check orchestration + +// Refuses an https→http downgrade before the redirected request — and with +// it the POST body (appKey, uuid, versions) — leaves the device. The shared +// session would follow first and only the response could be rejected; ATS +// blocks plaintext by default, this covers apps that opted out with +// NSAllowsArbitraryLoads. Same rule as RCTPushyDownloader. Returning nil +// delivers the 3xx itself as the final response, which the status check in +// PushyHttpRequest then treats as a failed endpoint. +@interface PushyRequestRedirectGuard : NSObject +@end + +@implementation PushyRequestRedirectGuard + +- (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task +willPerformHTTPRedirection:(NSHTTPURLResponse *)response + newRequest:(NSURLRequest *)request + completionHandler:(void (^)(NSURLRequest *))completionHandler +{ + NSString *originalScheme = [task.originalRequest.URL.scheme lowercaseString]; + NSString *nextScheme = [request.URL.scheme lowercaseString]; + if ([originalScheme isEqualToString:@"https"] && [nextScheme isEqualToString:@"http"]) { + completionHandler(nil); + return; + } + completionHandler(request); +} + +@end + +// One long-lived delegate session (not the shared one) so the redirect guard +// sees the 3xx before it is followed; completion handlers still receive the +// final response. Never invalidated: it lives as long as the process. +static NSURLSession *PushyRequestSession(void) { + static NSURLSession *session; + static dispatch_once_t once; + dispatch_once(&once, ^{ + session = [NSURLSession + sessionWithConfiguration:[NSURLSessionConfiguration defaultSessionConfiguration] + delegate:[PushyRequestRedirectGuard new] + delegateQueue:nil]; + }); + return session; +} + +// Same shape as -[UIDevice systemVersion] ("17.4", "17.4.1"), which is +// main-thread-only UIKit; the round runs on a background queue. +static NSString *PushyOsVersion(void) { + NSOperatingSystemVersion version = [NSProcessInfo processInfo].operatingSystemVersion; + if (version.patchVersion > 0) { + return [NSString stringWithFormat:@"%ld.%ld.%ld", (long)version.majorVersion, + (long)version.minorVersion, (long)version.patchVersion]; + } + return [NSString stringWithFormat:@"%ld.%ld", (long)version.majorVersion, + (long)version.minorVersion]; +} + +// Blocking JSON HTTP round-trip on the orchestrator's utility thread. Returns +// the response body on 2xx, nil on any failure. The semaphore timeout is a +// backstop over the request's own timeoutInterval. +static NSString *PushyHttpRequest(NSString *urlString, NSString *method, + NSString *body, NSTimeInterval timeout) { + NSURL *url = [NSURL URLWithString:urlString]; + if (url == nil) { + return nil; + } + NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:url]; + request.HTTPMethod = method; + request.timeoutInterval = timeout; + [request setValue:@"application/json" forHTTPHeaderField:@"Accept"]; + if (body != nil) { + [request setValue:@"application/json" forHTTPHeaderField:@"Content-Type"]; + request.HTTPBody = [body dataUsingEncoding:NSUTF8StringEncoding]; + } + dispatch_semaphore_t sem = dispatch_semaphore_create(0); + __block NSString *result = nil; + NSURLSessionDataTask *task = [PushyRequestSession() dataTaskWithRequest:request + completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) { + NSHTTPURLResponse *httpResponse = + [response isKindOfClass:[NSHTTPURLResponse class]] + ? (NSHTTPURLResponse *)response + : nil; + NSInteger status = httpResponse.statusCode; + if (error == nil && httpResponse != nil && status >= 200 && status < 300 + && data != nil) { + result = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding]; + } + dispatch_semaphore_signal(sem); + }]; + [task resume]; + if (dispatch_semaphore_wait( + sem, dispatch_time(DISPATCH_TIME_NOW, + (int64_t)((timeout + 5) * NSEC_PER_SEC))) != 0) { + [task cancel]; + return nil; + } + return result; +} + +static NSString *PushyNormalizeEndpointBase(NSString *base) { + while ([base hasSuffix:@"/"]) { + base = [base substringToIndex:base.length - 1]; + } + return base; +} + +static BOOL PushyIsValidResponse(NSString *responseText) { + if (responseText == nil) { + return NO; + } + // Shared schema rule (update_flow_core::IsValidResponse): a 200 with + // `{"error": ...}` is a failed endpoint, not a verdict. + return flowcore::IsValidResponse(PushyToStdString(responseText)) ? YES : NO; +} + +// Canonical config equality, shared with the JS, Android and Harmony sides: +// two config JSON strings are equivalent iff their canonical forms deep-equal. +// Canonical form: the parsed object with absent keys defaulted +// (disabled:false, queryUrls:[], rnu:"", rn:"", afterDownload:"none") and the +// endpoints/queryUrls entries stripped of trailing '/' and deduplicated in +// first-occurrence order; key order is irrelevant. Unparseable is never +// equivalent. Lets a host-owned config (sorted keys) and the same config as JS +// serializes it count as one, instead of a "change" that cancels the round. +static BOOL PushyJsonIsBool(id value) { + return [value isKindOfClass:NSNumber.class] + && CFGetTypeID((__bridge CFTypeRef)value) == CFBooleanGetTypeID(); +} + +static BOOL PushyJsonEqual(id a, id b) { + if (PushyJsonIsBool(a) != PushyJsonIsBool(b)) { + return NO; // false is not 0 + } + if ([a isKindOfClass:NSDictionary.class] && [b isKindOfClass:NSDictionary.class]) { + if ([a count] != [b count]) { + return NO; + } + for (id key in (NSDictionary *)a) { + id other = ((NSDictionary *)b)[key]; + if (other == nil || !PushyJsonEqual(((NSDictionary *)a)[key], other)) { + return NO; + } + } + return YES; + } + if ([a isKindOfClass:NSArray.class] && [b isKindOfClass:NSArray.class]) { + if ([a count] != [b count]) { + return NO; + } + for (NSUInteger i = 0; i < [a count]; i++) { + if (!PushyJsonEqual(((NSArray *)a)[i], ((NSArray *)b)[i])) { + return NO; + } + } + return YES; + } + return [a isEqual:b]; +} + +static id PushyCanonicalUrlList(id value) { + if (![value isKindOfClass:NSArray.class]) { + return value; + } + NSMutableArray *result = [NSMutableArray array]; + for (id item in (NSArray *)value) { + id entry = [item isKindOfClass:NSString.class] ? PushyNormalizeEndpointBase(item) : item; + BOOL seen = NO; + for (id existing in result) { + if (PushyJsonEqual(existing, entry)) { + seen = YES; + break; + } + } + if (!seen) { + [result addObject:entry]; + } + } + return result; +} + +static NSDictionary *PushyCanonicalConfig(NSString *json) { + NSData *data = [json dataUsingEncoding:NSUTF8StringEncoding]; + id object = data == nil ? nil + : [NSJSONSerialization JSONObjectWithData:data options:0 error:nil]; + if (![object isKindOfClass:NSDictionary.class]) { + return nil; + } + NSMutableDictionary *config = [object mutableCopy]; + NSDictionary *defaults = @{@"disabled": @NO, @"queryUrls": @[], @"rnu": @"", + @"rn": @"", @"afterDownload": @"none"}; + for (NSString *key in defaults) { + if (config[key] == nil) { + config[key] = defaults[key]; + } + } + for (NSString *key in @[@"endpoints", @"queryUrls"]) { + if (config[key] != nil) { + config[key] = PushyCanonicalUrlList(config[key]); + } + } + return config; +} + +static BOOL PushyConfigsEquivalent(NSString *a, NSString *b) { + NSDictionary *left = a == nil ? nil : PushyCanonicalConfig(a); + NSDictionary *right = b == nil ? nil : PushyCanonicalConfig(b); + return left != nil && right != nil && PushyJsonEqual(left, right); +} + +// The persisted config a round may run with. Every gate (crash-hold handler +// install, round start, prepareBundle, the round itself) reads it through +// PushyLoadRunnableConfig, so they agree on what "configured" and "disabled" +// mean. disabled follows JS truthiness, like the flow core. +struct PushyRunnableConfig { + NSString *json; + flowjson::Value value; + NSString *appKey; +}; + +// YES when a round may run; otherwise *reason is not_configured, disabled or +// invalid_config. +static BOOL PushyLoadRunnableConfig(PushyRunnableConfig *config, NSString **reason) { + NSString *failure = nil; + NSString *json = [PushyDefaults() stringForKey:keyNativeConfig]; + config->json = [json copy]; + if (json.length == 0) { + failure = @"not_configured"; + } else { + bool ok = false; + flowjson::Value value = flowjson::Parse(PushyToStdString(json), &ok); + NSString *appKey = ok && value.IsObject() + ? PushyFromStdString(value.Get("appKey").AsString()) : nil; + if (!ok || !value.IsObject()) { + failure = @"invalid_config"; + } else if (value.Get("disabled").Truthy()) { + failure = @"disabled"; + } else if (appKey.length == 0) { + failure = @"invalid_config"; + } else { + config->value = value; + config->appKey = appKey; + } + } + if (reason != NULL) { + *reason = failure; + } + return failure == nil; +} + +// Host-facing result for a config that cannot run: absent or disabled is a +// skip, a malformed one a failure. +static NSDictionary *PushyUnrunnableConfigResult(NSString *reason) { + BOOL skip = [reason isEqualToString:@"not_configured"] || [reason isEqualToString:@"disabled"]; + return PushyHostResult(skip ? @"skipped" : @"failed", reason, nil, NO); +} + +@implementation RCTPushyOrchestrator + ++ (void)persistConfiguration:(NSString *)config { + PushyWithStateLock(^{ + NSUserDefaults *defaults = PushyDefaults(); + if ([defaults stringForKey:keyUuid].length == 0) { + [defaults setObject:[NSUUID UUID].UUIDString forKey:keyUuid]; + } + if (PushyConfigsEquivalent([defaults stringForKey:keyNativeConfig], config)) { + // The same config in another serialization (key order, trailing + // slashes, defaulted keys). Not a change: the stored text, the + // response cache and any in-flight round all stay valid. + return; + } + // A different request identity/policy: rounds started under the old + // one must not commit (pushyNativeConfigGeneration, checked by + // commitRoundWithGeneration next to the reset generation), and the + // cached response and JS-completed marker belong to the old one. + pushyNativeConfigGeneration.fetch_add(1); + [defaults setObject:config forKey:keyNativeConfig]; + [defaults removeObjectForKey:keyNativeCheckCache]; + [self recordJsRound:nil]; + }); + if (pushySyncReady.load() && [self hasRunnableConfig]) { + PushyInstallCrashHoldHandler(); + } +} + ++ (BOOL)hasRunnableConfig { + PushyRunnableConfig config; + return PushyLoadRunnableConfig(&config, NULL); +} + +// Process-wide round state, set up by whichever launch path needs a round +// first: the delayed cold-start check or the tvOS purged-version restore. ++ (void)prepareProcess:(NSString *)launchRolledBackVersion { + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + pushyRoundDone = dispatch_semaphore_create(0); + pushyHostRoundGroup = dispatch_group_create(); + dispatch_group_enter(pushyHostRoundGroup); + pushyProcessAnchorUptime = PushyMonotonicNow(); + pushyLaunchRolledBackForHold = [launchRolledBackVersion copy]; + pushySyncReady.store(true); + // The crash-hold rescue shares the orchestrator's rollout gate: no + // runnable config, no handler (§11.3). persistConfiguration installs + // it once a runnable config arrives later in the process. + if ([self hasRunnableConfig]) { + PushyInstallCrashHoldHandler(); + } + }); +} + ++ (void)scheduleFromColdStart:(NSString *)launchRolledBackVersion { +#if !DEBUG + // Once per process; a few seconds of delay keeps the check away from the + // cold-start critical path (§7 R5) — its result targets the NEXT launch. + // Unless the previous process died mid-round (residual incomplete + // marker), in which case every launch second counts (§11.4). + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + [self prepareProcess:launchRolledBackVersion]; + NSUserDefaults *defaults = PushyDefaults(); + int64_t delaySeconds = + [defaults objectForKey:keyNativeCheckIncomplete] != nil ? 0 : 5; + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, delaySeconds * NSEC_PER_SEC), + dispatch_get_global_queue(QOS_CLASS_UTILITY, 0), ^{ + if ([self hasJsRound]) { + // Not consuming the round: a later crash rescue may still + // need it. + NSLog(@"RCTPushy -- native sync skipped: JS already queried in this process"); + return; + } + [self startRoundWithDeadline:0]; + }); + }); +#endif +} + +#if TARGET_OS_TV && !DEBUG +// Runs this process's round before the first bundle loads, blocking the +// caller for at most what is left of kPushyPurgeRestoreBudget since process +// start. The state no longer has a +// current version, so the server answers with the latest version for the +// packaged bundle, and the round activates it while the restore window is +// open. Returns NO when no round can run; otherwise the caller must resolve +// the launch bundle again: it launches whatever was activated before the +// window closed, or the packaged bundle. The round stops at the same +// deadline, keeping a partial download for the next check to resume. ++ (BOOL)restorePurgedLaunch:(NSString *)purgedVersion + rolledBack:(NSString *)launchRolledBackVersion { + [self prepareProcess:launchRolledBackVersion]; + if (![self hasRunnableConfig]) { + RCTLogWarn(@"RCTPushy -- version %@ was purged and no native sync is " + @"configured; launching the packaged bundle", purgedVersion); + return NO; + } + // Offline: the round could only burn the launch budget. + if (!PushyHasNetworkPath()) { + RCTLogWarn(@"RCTPushy -- version %@ was purged and there is no network; " + @"launching the packaged bundle", purgedVersion); + return NO; + } + // The launch watchdog counts from process start, not from this call: + // whatever the host did before resolving the bundle is already spent. + NSTimeInterval budget = kPushyPurgeRestoreBudget - PushyProcessElapsed(); + if (budget < kPushyPurgeRestoreMinimumBudget) { + RCTLogWarn(@"RCTPushy -- version %@ was purged and the launch budget is " + @"spent; launching the packaged bundle", purgedVersion); + return NO; + } + PushyWithStateLock(^{ + pushyPurgeRestoreWindowOpen = true; + }); + pushyPurgeRestoreActive.store(true); + NSTimeInterval deadline = PushyMonotonicNow() + budget; + dispatch_semaphore_t done = dispatch_semaphore_create(0); + dispatch_async(dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0), ^{ + [self startRoundWithDeadline:deadline]; + dispatch_semaphore_signal(done); + }); + BOOL timedOut = dispatch_semaphore_wait(done, dispatch_time(DISPATCH_TIME_NOW, + (int64_t)(budget * NSEC_PER_SEC))) != 0; + // The round's deadline does not bound a slow response (the request + // timeout is an idle timeout) nor a version already on disk, so the close + // is what stops a late activation — not the deadline. + PushyWithStateLock(^{ + pushyPurgeRestoreWindowOpen = false; + }); + if (timedOut) { + RCTLogWarn(@"RCTPushy -- purged version %@ restore timed out", purgedVersion); + } else { + NSDictionary *result = pushyHostRoundResult; + RCTLogInfo(@"RCTPushy -- purged version %@ restore: %@ %@ %@", purgedVersion, + result[@"status"], result[@"reason"], result[@"hash"]); + } + return YES; +} +#endif + ++ (NSDictionary *)prepareBundle { + return [self prepareBundleWithin:kPushyHostPrepareDefaultTimeout]; +} + +// Waits at most `timeout` seconds for this process's round. The round itself +// is not cut short by the host's wait (its result still serves the next +// launch); the host gets failed/timeout instead of a result. ++ (NSDictionary *)prepareBundleWithin:(NSTimeInterval)timeout { +#if DEBUG + return PushyHostResult(@"skipped", @"debug", nil, NO); +#else + if (!pushySyncReady.load()) { + return PushyHostResult(@"skipped", @"not_initialized", nil, NO); + } + PushyRunnableConfig runnable; + NSString *reason = nil; + if (!PushyLoadRunnableConfig(&runnable, &reason)) { + return PushyUnrunnableConfigResult(reason); + } + NSString *configJson = runnable.json; + // Run the round on its own queue rather than this caller's thread, so the + // wait below can be bounded. + if ([self claimRound]) { + dispatch_async(dispatch_get_global_queue(QOS_CLASS_UTILITY, 0), ^{ + [self runClaimedRoundWithDeadline:0]; + }); + } + // A group is broadcast-style. Sharing the rescue semaphore would let one + // waiter consume the only signal and leave the other waiting forever. + if (!pushyRoundStarted.load()) { + return PushyHostResult(@"skipped", @"config_changed", nil, NO); + } + if (!(timeout > 0)) { + timeout = 0; // also NaN + } + if (dispatch_group_wait(pushyHostRoundGroup, dispatch_time(DISPATCH_TIME_NOW, + (int64_t)(MIN(timeout, (NSTimeInterval)(INT64_MAX / NSEC_PER_SEC)) * NSEC_PER_SEC))) != 0) { + return PushyHostResult(@"failed", @"timeout", nil, NO); + } + if (pushyHostRoundConfigGeneration != pushyNativeConfigGeneration.load() + || !PushyConfigsEquivalent(configJson, pushyHostRoundConfig) + || !PushyConfigsEquivalent(configJson, [PushyDefaults() stringForKey:keyNativeConfig])) { + return PushyHostResult(@"cancelled", @"config_changed", nil, NO); + } + if (pushyHostRoundGeneration != pushyResetGeneration.load()) { + return PushyHostResult(@"cancelled", @"reset", nil, NO); + } + return pushyHostRoundResult ?: PushyHostResult(@"failed", @"internal_error", nil, NO); +#endif +} + ++ (void)recordJsRound:(NSString *)config { + @synchronized (RCTPushyOrchestrator.class) { + pushyJsCompletedConfig = [config copy]; + } +} + +// True when JS already obtained a valid response in this process for a config +// equivalent to the one the native round would use (§10.3): the delayed round is +// then a duplicate request. Only the scheduled round asks — the crash-rescue +// path still runs, JS is dead by then. ++ (BOOL)hasJsRound { + NSString *jsConfig; + @synchronized (RCTPushyOrchestrator.class) { + jsConfig = pushyJsCompletedConfig; + } + if (jsConfig == nil) { + return NO; + } + return PushyConfigsEquivalent([PushyDefaults() stringForKey:keyNativeConfig], jsConfig); +} + +// Runs the process's single round on the calling thread if nobody has +// started it yet. deadlineUptime > 0 (crash rescue) caps every HTTP call and +// download phase to the remaining budget. ++ (void)startRoundWithDeadline:(NSTimeInterval)deadlineUptime { + if ([self claimRound]) { + [self runClaimedRoundWithDeadline:deadlineUptime]; + } +} + +// Claims this process's single round. Missing/disabled configuration is a +// preflight skip, not a used round. A YES must be followed by exactly one +// runClaimedRoundWithDeadline:. ++ (BOOL)claimRound { + if (![self hasRunnableConfig]) { + return NO; + } + bool expected = false; + return pushyRoundStarted.compare_exchange_strong(expected, true); +} + ++ (void)runClaimedRoundWithDeadline:(NSTimeInterval)deadlineUptime { + @try { + [self runOnce:pushyLaunchRolledBackForHold deadline:deadlineUptime]; + } @catch (NSException *exception) { + // The rescue path must never take the app down with it. + RCTLogWarn(@"RCTPushy -- native sync crashed: %@", exception.reason); + pushyHostRoundResult = PushyHostResult(@"failed", @"internal_error", nil, NO); + } @finally { + pushyRoundCompleted.store(true); + dispatch_semaphore_signal(pushyRoundDone); + dispatch_group_leave(pushyHostRoundGroup); + } +} + +// Crash-rescue entry (§11.3), called from the handler's worker queue while +// the uncaught-exception handler holds the dying process. Ensures this +// process's round runs to completion within the budget, then activates a +// downloaded-but-unactivated version if one exists — the last chance before +// the process is gone. ++ (void)runHoldRoundWithDeadline:(NSTimeInterval)deadlineUptime { + pushyCrashHoldActive.store(true); + [self startRoundWithDeadline:deadlineUptime]; + if (pushyRoundStarted.load() && !pushyRoundCompleted.load()) { + NSTimeInterval remaining = deadlineUptime - PushyMonotonicNow(); + if (remaining > 0) { + dispatch_semaphore_wait(pushyRoundDone, dispatch_time(DISPATCH_TIME_NOW, + (int64_t)(remaining * NSEC_PER_SEC))); + } + } + [self activatePendingVersion]; +} + +// The alert-strategy variant of the §10.7 hole: the round downloaded a fix +// but deferred activation to JS, and JS is now dead. Activation is local and +// bounded (a state switch under the commit lock). ++ (void)activatePendingVersion { + NSString *hash = nil; + uint64_t generation = 0; + uint64_t configGeneration = 0; + @synchronized (self) { + hash = pushyUnactivatedHash; + generation = pushyUnactivatedGeneration; + configGeneration = pushyUnactivatedConfigGeneration; + } + if (hash == nil) { + return; + } + // Unlike Android's switchVersion, PushySwitchVersionLocked does not + // validate the version directory; never point the next launch at bytes + // that are gone (e.g. wiped by a reset whose generation bump we would + // catch below anyway, or by cleanup). + NSString *versionDir = [[RCTPushy downloadDir] stringByAppendingPathComponent:hash]; + if (!PushyHasCompletedVersionAtPath(versionDir, hash)) { + NSLog(@"RCTPushy -- crash hold: version %@ no longer on disk, dropping activation", hash); + return; + } + NSDictionary *hashInfoEntry = nil; + NSString *existingInfo = [PushyDefaults() stringForKey:PushyHashInfoKey(hash)]; + NSData *existingData = [existingInfo dataUsingEncoding:NSUTF8StringEncoding]; + id parsed = existingData == nil ? nil : + [NSJSONSerialization JSONObjectWithData:existingData options:0 error:nil]; + if ([parsed isKindOfClass:[NSDictionary class]]) { + NSMutableDictionary *merged = [parsed mutableCopy]; + merged[PushyInfoCrashHold()] = @YES; + hashInfoEntry = @{@"hash": hash, @"info": merged}; + } + BOOL committed = [self commitRoundWithGeneration:generation + configGeneration:configGeneration + hashInfo:hashInfoEntry + activate:hash + responseText:nil + request:nil + config:nil + responseAt:0 + activated:NULL]; + if (committed) { + @synchronized (self) { + pushyUnactivatedHash = nil; + } + NSLog(@"RCTPushy -- crash hold: activated downloaded version %@", hash); + } else { + NSLog(@"RCTPushy -- crash hold: reset or new config since download, dropping activation"); + } +} + +// A bare module instance drives the existing download/patch pipeline without +// a bridge: the file queue is process-global and progress events are gated on +// hasListeners (never set without a bridge). ++ (RCTPushy *)engine { + static RCTPushy *engine; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + engine = [[RCTPushy alloc] init]; + }); + return engine; +} + ++ (void)runOnce:(NSString *)launchRolledBackVersion deadline:(NSTimeInterval)deadlineUptime { + // Generations first, config second: a config replaced in between is + // caught by the commit's generation check. + const uint64_t resetGeneration = pushyResetGeneration.load(); + const uint64_t configGeneration = pushyNativeConfigGeneration.load(); + pushyHostRoundGeneration = resetGeneration; + pushyHostRoundConfigGeneration = configGeneration; + pushyHostRoundResult = PushyHostResult(@"failed", @"check_failed", nil, NO); + NSUserDefaults *defaults = PushyDefaults(); + PushyRunnableConfig config; + NSString *reason = nil; + BOOL runnable = PushyLoadRunnableConfig(&config, &reason); + pushyHostRoundConfig = config.json; + if (!runnable) { + pushyHostRoundResult = PushyUnrunnableConfigResult(reason); + return; + } + // Preserve the interrupted-round breadcrumb and reset-safe atomic commit. + [defaults setObject:@YES forKey:keyNativeCheckIncomplete]; + @try { + [self runConfiguredRound:config.value + configJson:config.json + appKey:config.appKey + launchRolledBackVersion:launchRolledBackVersion + resetGeneration:resetGeneration + configGeneration:configGeneration + deadline:deadlineUptime]; + } @finally { + [defaults removeObjectForKey:keyNativeCheckIncomplete]; + } +} + ++ (void)runConfiguredRound:(const flowjson::Value &)config + configJson:(NSString *)configJson + appKey:(NSString *)appKey + launchRolledBackVersion:(NSString *)launchRolledBackVersion + resetGeneration:(uint64_t)resetGeneration + configGeneration:(uint64_t)configGeneration + deadline:(NSTimeInterval)deadlineUptime { + NSUserDefaults *defaults = PushyDefaults(); + NSString *packageVersion = + PushyFromStdString(config.Get("packageVersion").AsString()); + if (packageVersion.length == 0) { + packageVersion = [RCTPushy packageVersion]; + } + + __block NSString *currentVersion = nil; + PushyWithStateLock(^{ + pushy::state::State state = PushyStateFromDefaults(PushyDefaults()); + currentVersion = PushyFromStdString(state.current_version); + }); + NSString *rolledBackVersion = launchRolledBackVersion; + NSString *uuid = [defaults stringForKey:keyUuid] ?: @""; + + flowjson::Value identity = flowjson::Value::Object(); + identity.Set("packageVersion", + flowjson::Value::String(PushyToStdString(packageVersion))); + if (currentVersion != nil) { + identity.Set("currentVersion", + flowjson::Value::String(PushyToStdString(currentVersion))); + } + identity.Set("uuid", flowjson::Value::String(PushyToStdString(uuid))); + if (rolledBackVersion != nil) { + identity.Set("rolledBackVersion", + flowjson::Value::String(PushyToStdString(rolledBackVersion))); + } + + flowjson::Value cInfo = flowjson::Value::Object(); + cInfo.Set("rnu", config.Get("rnu")); + cInfo.Set("rn", config.Get("rn")); + cInfo.Set("os", flowjson::Value::String(PushyToStdString([NSString + stringWithFormat:@"%@ %@", PushyOsName, PushyOsVersion()]))); + cInfo.Set("uuid", flowjson::Value::String(PushyToStdString(uuid))); + + flowjson::Value input = flowjson::Value::Object(); + input.Set("packageVersion", identity.Get("packageVersion")); + if (currentVersion != nil) { + input.Set("currentVersion", identity.Get("currentVersion")); + } + input.Set("buildTime", + flowjson::Value::String(PushyToStdString([RCTPushy buildTime]))); + input.Set("cInfo", cInfo); + input.Set("supportedDiffVersion", + flowjson::Value::Number(pushy::hbc::kSupportedDiffVersion)); + // Cache-only while a crash is being held (§11.3 budget) or the launch is + // blocked on the tvOS purged-version restore; computed (and shared with JS + // getBundleHash) on the ordinary delayed round. + input.Set("bundleHash", + flowjson::Value::String(PushyToStdString(PushyBundleHashSync( + !(pushyCrashHoldActive.load() || pushyPurgeRestoreActive.load()))))); + + std::string bodyJson = + flowjson::Stringify(flowcore::BuildRequestBody(input)); + NSString *body = [NSString stringWithUTF8String:bodyJson.c_str()]; + if (body == nil) { + RCTLogWarn(@"RCTPushy -- native sync: request body is not valid UTF-8"); + pushyHostRoundResult = PushyHostResult(@"failed", @"invalid_request", nil, NO); + return; + } + + NSString *responseText = [self runQueryRequest:config + appKey:appKey + body:body + deadline:deadlineUptime]; + if (responseText == nil) { + RCTLogInfo(@"RCTPushy -- native sync: no endpoint reachable, giving up until next launch"); + return; + } + long long responseAtSeconds = (long long)[[NSDate date] timeIntervalSince1970]; + + flowjson::Value decision = flowcore::HandleResponse( + PushyToStdString(responseText), identity, false, + config.Get("afterDownload").AsString()); + if (decision.Get("action").AsString() != "download") { + BOOL committed = [self commitRoundWithGeneration:resetGeneration + configGeneration:configGeneration + hashInfo:nil + activate:nil + responseText:responseText + request:body + config:configJson + responseAt:responseAtSeconds + activated:NULL]; + pushyHostRoundResult = committed + ? PushyHostResult(PushyStatusNone(), PushyFromStdString(decision.Get("reason").AsString()), nil, NO) + : PushyHostResult(@"cancelled", @"reset", nil, NO); + RCTLogInfo(@"RCTPushy -- native sync: nothing to do (%s)", + decision.Get("reason").AsString().c_str()); + return; + } + NSString *hash = PushyFromStdString(decision.Get("hash").AsString()); + if (!PushyIsSafePathComponent(hash)) { + pushyHostRoundResult = PushyHostResult(@"failed", @"invalid_response", nil, NO); + return; + } + + NSString *versionDir = [[RCTPushy downloadDir] stringByAppendingPathComponent:hash]; + BOOL downloaded = PushyHasCompletedVersionAtPath(versionDir, hash); + if (!downloaded) { + downloaded = [self performAttempts:decision.Get("attempts") + hash:hash + originHash:currentVersion + deadline:deadlineUptime]; + } + if (!downloaded) { + BOOL committed = [self commitRoundWithGeneration:resetGeneration + configGeneration:configGeneration + hashInfo:nil + activate:nil + responseText:responseText + request:body + config:configJson + responseAt:responseAtSeconds + activated:NULL]; + pushyHostRoundResult = committed + ? PushyHostResult(@"failed", @"download_failed", nil, NO) + : PushyHostResult(@"cancelled", @"reset", nil, NO); + return; + } + + // Persist name/description/metaInfo alongside the version, mirroring the + // JS side's setLocalHashInfo after a successful download. + const flowjson::Value &info = decision.Get("info"); + NSMutableDictionary *versionInfo = [NSMutableDictionary dictionary]; + for (const char *key : {"name", "description", "metaInfo"}) { + if (info.Get(key).IsString()) { + versionInfo[@(key)] = PushyFromStdString(info.Get(key).AsString()) ?: @""; + } + } + // A forceBoot activation is the brick-rescue path: mark it in the + // persisted info so JS can report force_boot_rescue when this version + // survives to markSuccess. Only the server-sent directive counts — a + // silent-strategy activation is ordinary delivery. + if (info.Get("config").Get("forceBoot").Truthy()) { + versionInfo[PushyInfoForceBoot()] = @YES; + } + if (pushyCrashHoldActive.load()) { + versionInfo[PushyInfoCrashHold()] = @YES; + } + // Silent strategies or a server-marked forceBoot version (per-version + // remote override — the brick rescue) activate for the next launch; + // otherwise activation stays with the JS side (§6/§10.1). Unless a crash + // is being held: JS is dead, deferring to it would leave the fix on disk + // forever (§11.3). Or the launch is blocked on reinstalling a version + // tvOS purged: JS has not started, and the whole point is to boot it — + // commitRoundWithGeneration drops that one once the restore window closed. + BOOL activate = decision.Get("activate").Truthy() || pushyCrashHoldActive.load() + || pushyPurgeRestoreActive.load(); + BOOL activated = NO; + BOOL committed = [self commitRoundWithGeneration:resetGeneration + configGeneration:configGeneration + hashInfo:@{@"hash": hash, @"info": versionInfo} + activate:activate ? hash : nil + responseText:responseText + request:body + config:configJson + responseAt:responseAtSeconds + activated:&activated]; + activate = activated; + if (!committed) { + RCTLogInfo(@"RCTPushy -- native sync: reset during round, dropping result"); + } else if (activate) { + @synchronized (self) { + pushyUnactivatedHash = nil; + } + RCTLogInfo(@"RCTPushy -- native sync: downloaded %@ and set for next launch", hash); + } else { + // Remembered so a crash later in this process can still activate it + // (activatePendingVersion) — JS never will. + @synchronized (self) { + pushyUnactivatedHash = [hash copy]; + pushyUnactivatedGeneration = resetGeneration; + pushyUnactivatedConfigGeneration = configGeneration; + } + RCTLogInfo(@"RCTPushy -- native sync: downloaded %@, activation left to JS", hash); + } + pushyHostRoundResult = committed + ? PushyHostResult(@"downloaded", @"", hash, activate) + : PushyHostResult(@"cancelled", @"reset", nil, NO); +} + +// Everything a round persists — version info, the activation, the response +// cache — is written inside ONE state-lock acquisition that first re-checks the +// reset and config generations. resetToPackagedBundle bumps that generation under the same +// lock, so there is no compare-and-act window: either the whole round commits, +// or the reset wins and none of it does. ++ (BOOL)commitRoundWithGeneration:(uint64_t)generation + configGeneration:(uint64_t)configGeneration + hashInfo:(NSDictionary *)hashInfoEntry + activate:(NSString *)hashToActivate + responseText:(NSString *)responseText + request:(NSString *)requestBody + config:(NSString *)configJson + responseAt:(long long)responseAtSeconds + activated:(BOOL *)activatedOut { + // responseText is nil for the crash handler's activation-only commit + // (activatePendingVersion): no round ran, so there is no cache to write. + NSData *cacheData = nil; + if (responseText != nil) { + NSDictionary *cacheEntry = @{ + @"ts": @(responseAtSeconds), + @"body": responseText, + @"request": requestBody, + @"config": configJson, + }; + cacheData = [NSJSONSerialization dataWithJSONObject:cacheEntry options:0 error:nil]; + } + __block BOOL committed = NO; + __block BOOL activated = NO; + PushyWithStateLock(^{ + if (pushyResetGeneration.load() != generation) { + return; + } + // The config the round ran with was replaced (persistConfiguration + // bumps this under the same lock). + if (pushyNativeConfigGeneration.load() != configGeneration) { + return; + } + NSString *activation = hashToActivate; + NSDictionary *info = hashInfoEntry[@"info"]; + // The tvOS purged-version restore round (a round commit, not the + // crash handler's activation-only one): it may take over this launch + // only while +bundleURL still waits for it; after that the version is + // left to JS unless a crash is being held (JS is dead then). + if (responseText != nil && pushyPurgeRestoreActive.load()) { + if (pushyPurgeRestoreWindowOpen) { + if (activation != nil && info != nil) { + NSMutableDictionary *marked = [info mutableCopy]; + marked[@"purgeRestore"] = @YES; + info = marked; + } + } else if (!pushyCrashHoldActive.load()) { + activation = nil; + } + } + NSUserDefaults *defaults = PushyDefaults(); + if (hashInfoEntry != nil) { + NSData *infoData = [NSJSONSerialization dataWithJSONObject:info + options:0 + error:nil]; + if (infoData != nil) { + [defaults setObject:[[NSString alloc] initWithData:infoData encoding:NSUTF8StringEncoding] + forKey:PushyHashInfoKey(hashInfoEntry[@"hash"])]; + } + } + if (activation != nil) { + PushySwitchVersionLocked(activation); + activated = YES; + } + if (cacheData != nil) { + [defaults setObject:[[NSString alloc] initWithData:cacheData encoding:NSUTF8StringEncoding] + forKey:keyNativeCheckCache]; + } + committed = YES; + }); + if (activatedOut != NULL) { + *activatedOut = activated; + } + return committed; +} + +// Sequential fallback over the ordered candidates (§5.1): one request at a +// time with its own timeout; after the configured round fails, queryUrls +// discovery merges remote candidates (excluding the already-tried) for one +// more round. No hedged race on purpose — this path is latency-insensitive. +// Per-request timeout, capped to the crash-rescue budget when one is active. +// <= 0 means the budget is gone and the round must stop issuing requests. +static NSTimeInterval PushyQueryRequestTimeout(NSTimeInterval deadlineUptime) { + if (deadlineUptime <= 0) { + return 10; + } + return MIN(10, deadlineUptime - PushyMonotonicNow()); +} + ++ (NSString *)runQueryRequest:(const flowjson::Value &)config + appKey:(NSString *)appKey + body:(NSString *)body + deadline:(NSTimeInterval)deadlineUptime { + NSMutableSet *tried = [NSMutableSet set]; + const NSUInteger maxHttpAttempts = 8; + __block NSUInteger httpAttempts = 0; + // Spends one attempt of the shared budget. NO once the attempt cap or the + // deadline is exhausted (the round must stop issuing requests); the + // per-request timeout lands in *timeout otherwise. + BOOL (^takeAttempt)(NSTimeInterval *) = ^BOOL(NSTimeInterval *timeout) { + if (httpAttempts++ >= maxHttpAttempts) { + return NO; + } + *timeout = PushyQueryRequestTimeout(deadlineUptime); + return *timeout > 0; + }; + // Tries each not-yet-tried endpoint base in order and returns the first + // valid response; *stop is set when the budget ran out. + NSString *(^queryEndpoints)(const flowjson::Value &, BOOL *) = + ^NSString *(const flowjson::Value &endpoints, BOOL *stop) { + for (const auto &endpoint : endpoints.elements()) { + NSString *base = PushyNormalizeEndpointBase( + PushyFromStdString(endpoint.AsString())); + if (base.length == 0 || [tried containsObject:base]) { + continue; + } + NSTimeInterval timeout = 0; + if (!takeAttempt(&timeout)) { + *stop = YES; + return nil; + } + [tried addObject:base]; + NSString *response = PushyHttpRequest( + [NSString stringWithFormat:@"%@%@%@", base, RCTPushyQueryPath(), appKey], + @"POST", body, timeout); + if (PushyIsValidResponse(response)) { + return response; + } + } + return nil; + }; + + BOOL stop = NO; + double sample = arc4random() / 4294967296.0; + NSString *response = queryEndpoints( + flowcore::OrderEndpointCandidates(config.Get("endpoints"), sample), &stop); + if (response != nil || stop) { + return response; + } + for (const auto &queryUrl : config.Get("queryUrls").elements()) { + NSString *listUrl = PushyFromStdString(queryUrl.AsString()); + if (listUrl == nil) { + continue; + } + NSTimeInterval listTimeout = 0; + if (!takeAttempt(&listTimeout)) { + return nil; + } + NSString *listText = PushyHttpRequest(listUrl, @"GET", nil, listTimeout); + if (listText == nil) { + continue; + } + bool ok = false; + flowjson::Value remote = flowjson::Parse(PushyToStdString(listText), &ok); + if (!ok || !remote.IsArray()) { + continue; + } + // One successfully fetched remote list is enough. + return queryEndpoints(remote, &stop); + } + return nil; +} + ++ (BOOL)performAttempts:(const flowjson::Value &)attempts + hash:(NSString *)hash + originHash:(NSString *)originHash + deadline:(NSTimeInterval)holdDeadline { + RCTPushy *engine = [self engine]; + // Crash-rescue budget caps every phase; 0 keeps the normal 10min windows. + NSTimeInterval incrementalDeadline = PushyMonotonicNow() + 600; + if (holdDeadline > 0) { + incrementalDeadline = MIN(incrementalDeadline, holdDeadline); + } + NSTimeInterval fullDeadline = 0; + for (const auto &attempt : attempts.elements()) { + const std::string &type = attempt.Get("type").AsString(); + PushyType pushyType = type == "diff" ? PushyTypePatchFromPpk + : type == "pdiff" ? PushyTypePatchFromPackage + : PushyTypeFullDownload; + if (pushyType == PushyTypePatchFromPpk && originHash.length == 0) { + continue; // diff patches from the running version; none running + } + BOOL isFullAttempt = pushyType == PushyTypeFullDownload; + if (isFullAttempt && fullDeadline == 0) { + // diff/pdiff cannot starve the last-resort full download. + fullDeadline = PushyMonotonicNow() + 600; + if (holdDeadline > 0) { + fullDeadline = MIN(fullDeadline, holdDeadline); + } + } + NSTimeInterval deadline = isFullAttempt ? fullDeadline : incrementalDeadline; + for (const auto &urlValue : attempt.Get("urls").elements()) { + NSString *url = PushyFromStdString(urlValue.AsString()); + if (url == nil) { + continue; + } + NSTimeInterval remaining = deadline - PushyMonotonicNow(); + if (remaining <= 0) { + if (isFullAttempt) { + return NO; + } + break; + } + NSMutableDictionary *options = + [@{ + PushyOptionUrlKey(): url, + @"hash": hash, + @"deadlineUptime": @(deadline), + } mutableCopy]; + if (pushyType == PushyTypePatchFromPpk) { + options[@"originHash"] = originHash; + } + dispatch_semaphore_t sem = dispatch_semaphore_create(0); + __block NSError *resultError = nil; + [engine performFetch:pushyType options:options callback:^(NSError *error) { + resultError = error; + dispatch_semaphore_signal(sem); + }]; + if (dispatch_semaphore_wait(sem, dispatch_time(DISPATCH_TIME_NOW, + (int64_t)(remaining * NSEC_PER_SEC))) != 0) { + RCTLogWarn(@"RCTPushy -- native sync: %s attempt timed out", type.c_str()); + if (isFullAttempt) { + return NO; + } + break; + } + if (resultError == nil) { + return YES; + } + RCTLogInfo(@"RCTPushy -- native sync: %s attempt failed: %@", + type.c_str(), resultError.localizedDescription); + } + } + return NO; +} + +@end diff --git a/package.json b/package.json index 394bfb02..6b53ba34 100644 --- a/package.json +++ b/package.json @@ -30,8 +30,6 @@ "LICENSE" ], "scripts": { - "prepublishOnly": "NODE_ENV=production bun scripts/prepublish.ts", - "publish:local": "SKIP_NATIVE_BUILD=1 npm publish", "prepack": "bun submodule && bun lint", "lint": "biome check src && tsc --noEmit && node scripts/check-harmony-types.js && node scripts/check-native-spec-parity.js", "lint:harmony": "node scripts/check-harmony-types.js", @@ -89,26 +87,21 @@ "react-native": "*" }, "homepage": "https://github.com/reactnativecn/react-native-update#readme", - "dependencies": { - "nanoid": "^3.3.3" - }, "codegenConfig": { "name": "RCTPushySpec", "type": "modules", "jsSrcsDir": "src" }, "devDependencies": { - "@babel/core": "^7.25.8", "@biomejs/biome": "^2.5.5", - "@react-native/babel-preset": "^0.73.21", - "@react-native/typescript-config": "0.84.1", + "@react-native/typescript-config": "0.85.2", "@types/bun": "^1.3.11", "@types/node": "^25.5.0", "@types/react": "^19.2.14", - "@types/react-test-renderer": "18.0.7", - "react": "18.2.0", - "react-native": "0.73", - "react-test-renderer": "18.2.0", + "@types/react-test-renderer": "19.1.0", + "react": "19.2.3", + "react-native": "0.85.2", + "react-test-renderer": "19.2.3", "typescript": "^7.0.2" } } diff --git a/react-native-update.podspec b/react-native-update.podspec index 8b48e5aa..225b61b9 100644 --- a/react-native-update.podspec +++ b/react-native-update.podspec @@ -59,10 +59,21 @@ Pod::Spec.new do |s| # anything below 15.0 there, so a hard-coded 11.0 fails the build. final_ios_deployment_target = defined?(min_ios_version_supported) ? min_ios_version_supported : '11.0' + # tvOS has its own version line: react-native-tvos reports it through + # min_supported_versions[:tvos] (stock React Native has no :tvos entry). + # 15.1 is react-native-tvos's own floor since 0.76, and clears the same + # Xcode 27 resource-bundle minimum as iOS. + final_tvos_deployment_target = begin + defined?(min_supported_versions) ? min_supported_versions[:tvos] : nil + rescue StandardError + nil + end + final_tvos_deployment_target ||= '15.1' if valid_expo_project # --- Try to find and parse ExpoModulesCore.podspec only if it's an Expo project --- parsed_expo_ios_target = nil + parsed_expo_tvos_target = nil expo_modules_core_podspec_path = begin package_json_path = `node -p "#{expo_modules_core_package_json_js}"`.strip File.join(File.dirname(package_json_path), 'ExpoModulesCore.podspec') if $?.success? && package_json_path && !package_json_path.empty? @@ -76,6 +87,8 @@ Pod::Spec.new do |s| match = content.match(/s\.platforms\s*=\s*\{[\s\S]*?:ios\s*=>\s*'([^\']+)'/) # Match within s.platforms hash if match && match[1] parsed_expo_ios_target = match[1] + tvos_match = content.match(/s\.platforms\s*=\s*\{[\s\S]*?:tvos\s*=>\s*'([^\']+)'/) + parsed_expo_tvos_target = tvos_match[1] if tvos_match else match = content.match(/s\.platform\s*=\s*:ios,\s*'([^\']+)'/) # Fallback to s.platform = :ios, 'version' if match && match[1] @@ -89,12 +102,15 @@ Pod::Spec.new do |s| if parsed_expo_ios_target final_ios_deployment_target = parsed_expo_ios_target end + if parsed_expo_tvos_target + final_tvos_deployment_target = parsed_expo_tvos_target + end end # tvOS (react-native-tvos) shares the iOS sources; the only platform # differences live behind TARGET_OS_TV in RCTPushy.mm (download directory, # purged-cache recovery). - s.platforms = { :ios => final_ios_deployment_target, :tvos => final_ios_deployment_target } + s.platforms = { :ios => final_ios_deployment_target, :tvos => final_tvos_deployment_target } s.name = package['name'] s.version = package['version'] @@ -109,6 +125,9 @@ Pod::Spec.new do |s| s.source = { :git => 'https://github.com/reactnativecn/react-native-update.git', :tag => "#{s.version}" } s.libraries = 'bz2', 'z' + # tvOS checks for a network path before blocking a launch on the + # purged-version restore (nw_path_monitor). + s.tvos.frameworks = 'Network' # No search path into the pod source: every include under ios/ resolves # relative to the including file (the cpp/ core via ../../cpp/...), and the # former absolute "#{podspec_dir}/ios" entry made the generated xcconfig @@ -193,7 +212,13 @@ Pod::Spec.new do |s| s.subspec 'RCTPushy' do |ss| ss.dependency 'react-native-update/Core' ss.source_files = ['ios/RCTPushy/*.{h,m,mm}'] - ss.public_header_files = ['ios/RCTPushy/*.h'] + ss.public_header_files = ['ios/RCTPushy/RCTPushy.h', + 'ios/RCTPushy/RCTPushyConfiguration.h', + 'ios/RCTPushy/RCTPushyDownloader.h'] + # RCTPushyInternal.h is shared between RCTPushy.mm and + # RCTPushyOrchestrator.mm only; it pulls in C++ headers, so it must stay + # out of the module umbrella. + ss.private_header_files = ['ios/RCTPushy/RCTPushyInternal.h'] end # Conditionally add Expo subspec and check ExpoModulesCore version diff --git a/scripts/check-native-spec-parity.js b/scripts/check-native-spec-parity.js index 5202a7b7..1bf769b0 100644 --- a/scripts/check-native-spec-parity.js +++ b/scripts/check-native-spec-parity.js @@ -8,7 +8,7 @@ // A method missing on one platform is `undefined` on the JS side, and the JS // feature-detects then treat the whole capability as "old native" and skip it // silently — exactly how the native cold-start check shipped dark on Harmony -// for three releases (NATIVE_CHECK_FOLLOWUPS.md, 2026-08-13). +// for three releases (docs/NATIVE_CHECK_FOLLOWUPS.md, 2026-08-13). const fs = require('fs'); const path = require('path'); diff --git a/scripts/check-packlist.js b/scripts/check-packlist.js index d78924d5..0933528e 100644 --- a/scripts/check-packlist.js +++ b/scripts/check-packlist.js @@ -37,7 +37,8 @@ const forbidden = [ /^\.github\//, /^\.gitmodules$/, /^biome\.json$/, - /^(BUNDLEHASH|NATIVE_CHECK|CODE_AUDIT)[A-Z_]*\.md$/, + /^docs\//, + /^CODE_AUDIT\.md$/, /\.(log|tgz|zip|ppk|apk)$/, ]; @@ -49,6 +50,7 @@ const required = [ 'android/src/main/java/cn/reactnative/modules/update/UpdateContext.java', 'android/jni/hpatch.c', 'ios/RCTPushy/RCTPushy.mm', + 'ios/RCTPushy/RCTPushyOrchestrator.mm', 'react-native-update.podspec', 'cpp/patch_core/patch_core.cpp', 'cpp/patch_core/archive_limits.h', diff --git a/scripts/generate-flow-vectors.ts b/scripts/generate-flow-vectors.ts index 93f122d9..d7c19c65 100644 --- a/scripts/generate-flow-vectors.ts +++ b/scripts/generate-flow-vectors.ts @@ -13,7 +13,7 @@ // serialization — the C++ side mirrors both (Kind::Undefined members are // skipped by stringify). -import { fileURLToPath } from 'node:url'; +import { fileURLToPath, URL } from 'node:url'; import { buildCheckRequestBody, decideDownload, diff --git a/scripts/prepublish.ts b/scripts/prepublish.ts deleted file mode 100644 index a925d185..00000000 --- a/scripts/prepublish.ts +++ /dev/null @@ -1,189 +0,0 @@ -#!/usr/bin/env bun - -import { access, readFile, writeFile } from 'node:fs/promises'; -import path from 'node:path'; -import { $ } from 'bun'; - -function normalizeVersion(version: string): string { - return version - .trim() - .replace(/^refs\/tags\//, '') - .replace(/^v/, ''); -} - -const SEMVER_REGEX = /^\d+\.\d+\.\d+(-.+)?$/; - -function isValidSemver(version: string): boolean { - return SEMVER_REGEX.test(version); -} - -function getVersionFromEnvironment(): string | null { - const candidates = [ - process.env.RELEASE_VERSION, - process.env.CI_COMMIT_TAG, - process.env.GITHUB_REF_TYPE === 'tag' ? process.env.GITHUB_REF_NAME : null, - process.env.GITHUB_REF?.startsWith('refs/tags/') - ? process.env.GITHUB_REF - : null, - ]; - - for (const candidate of candidates) { - if (candidate?.trim()) { - return normalizeVersion(candidate); - } - } - - return null; -} - -function getShellErrorMessage(error: unknown): string { - if ( - typeof error === 'object' && - error !== null && - 'stderr' in error && - typeof error.stderr === 'string' && - error.stderr.trim() - ) { - return error.stderr.trim(); - } - - if (error instanceof Error && error.message.trim()) { - return error.message.trim(); - } - - return 'Unknown git error'; -} - -async function getVersionFromGit(): Promise { - try { - return normalizeVersion(await $`git describe --tags --always`.text()); - } catch (error) { - const message = getShellErrorMessage(error); - - if (message.includes('detected dubious ownership')) { - throw new Error( - 'Git refused to read repository metadata because this checkout is not marked as safe. Configure safe.directory in CI or provide RELEASE_VERSION/GITHUB_REF_NAME.', - { cause: error } - ); - } - - throw new Error(`Unable to resolve publish version from git: ${message}`, { - cause: error, - }); - } -} - -async function resolveVersion(): Promise { - return getVersionFromEnvironment() ?? (await getVersionFromGit()); -} - -async function modifyPackageJson({ - version, -}: { - version: string; -}): Promise { - const packageJsonPath = path.join(__dirname, '..', 'package.json'); - - try { - await access(packageJsonPath); - } catch { - throw new Error(`package.json not found at ${packageJsonPath}`); - } - - console.log('Reading package.json...'); - const packageJsonContent = await readFile(packageJsonPath, 'utf-8'); - const packageJson = JSON.parse(packageJsonContent); - - packageJson.version = version; - - console.log('Writing modified package.json...'); - - await writeFile( - packageJsonPath, - JSON.stringify(packageJson, null, 2), - 'utf-8' - ); - - console.log('package.json has been modified for publishing'); -} - -function isGitHubCI(): boolean { - return process.env.GITHUB_ACTIONS === 'true'; -} - -function shouldSkipNativeBuild(): boolean { - return ( - process.argv.includes('--skip') || process.env.SKIP_NATIVE_BUILD === '1' - ); -} - -async function buildNativeArtifacts(): Promise { - console.log('Building Harmony HAR...'); - const harResult = Bun.spawnSync(['npm', 'run', 'build:harmony-har'], { - cwd: path.join(__dirname, '..'), - stdio: ['inherit', 'inherit', 'inherit'], - }); - if (harResult.exitCode !== 0) { - throw new Error( - `Harmony HAR build failed with exit code ${harResult.exitCode}` - ); - } - - console.log('Building Android SO...'); - const soResult = Bun.spawnSync(['npm', 'run', 'build:so'], { - cwd: path.join(__dirname, '..'), - stdio: ['inherit', 'inherit', 'inherit'], - }); - if (soResult.exitCode !== 0) { - throw new Error( - `Android SO build failed with exit code ${soResult.exitCode}` - ); - } -} - -async function main(): Promise { - try { - if (isGitHubCI()) { - const version = await resolveVersion(); - if (!isValidSemver(version)) { - throw new Error( - `Resolved version "${version}" is not a valid semantic version. ` + - `Please ensure RELEASE_VERSION, GITHUB_REF, or GITHUB_REF_NAME is set correctly, ` + - `or that the git history is fully fetched.` - ); - } - console.log(`Using publish version ${version}`); - await modifyPackageJson({ version }); - // Last gate for any CI publish path: publish.yml is expected to have - // replaced android/lib with CI-built binaries by now — verify here so a - // future workflow that skips that step can never silently ship stale - // committed .so files. Pure Node, runs in the HarmonyOS docker image too. - console.log('Verifying Android native libraries...'); - const verifyResult = Bun.spawnSync( - ['node', path.join(__dirname, 'verify-android-so.js')], - { stdio: ['inherit', 'inherit', 'inherit'] } - ); - if (verifyResult.exitCode !== 0) { - throw new Error( - `Android .so verification failed with exit code ${verifyResult.exitCode}` - ); - } - } else { - console.log( - 'ℹ️ Not in GitHub CI, skipping version resolution and package.json modification' - ); - if (shouldSkipNativeBuild()) { - console.log('ℹ️ --skip flag detected, skipping native artifacts build'); - } else { - await buildNativeArtifacts(); - } - } - - console.log('✅ Prepublish script completed successfully'); - } catch (error) { - console.error('❌ Prepublish script failed:', error); - process.exit(1); - } -} - -main(); diff --git a/scripts/test-ios-purge-restore.sh b/scripts/test-ios-purge-restore.sh index 1ced56d9..fb476abc 100755 --- a/scripts/test-ios-purge-restore.sh +++ b/scripts/test-ios-purge-restore.sh @@ -26,9 +26,9 @@ build() { destination="$BUILD_DIR/$variant" mkdir -p "$destination" if [ "$variant" = baseline ]; then - python3 "$TEST_DIR/extract.py" "$ROOT_DIR/ios/RCTPushy/RCTPushy.mm" "$destination" + python3 "$TEST_DIR/extract.py" "$ROOT_DIR/ios/RCTPushy" "$destination" else - python3 "$TEST_DIR/extract.py" "$ROOT_DIR/ios/RCTPushy/RCTPushy.mm" "$destination" --mutation "$variant" + python3 "$TEST_DIR/extract.py" "$ROOT_DIR/ios/RCTPushy" "$destination" --mutation "$variant" fi # Compile the real production bodies and state_core; only their external I/O # collaborators live in the test host. No RN, CocoaPods, simulator or network. diff --git a/scripts/tests/ios-purge-restore/README.md b/scripts/tests/ios-purge-restore/README.md index b3718f8e..92a5e2f8 100644 --- a/scripts/tests/ios-purge-restore/README.md +++ b/scripts/tests/ios-purge-restore/README.md @@ -1,7 +1,8 @@ # Deterministic native purge-restore ordering tests Follow-up to #646. These tests exercise the tvOS Release branch of the real -`RCTPushy.mm` startup/commit/reset code in a macOS Foundation-only executable. +`RCTPushy.mm` / `RCTPushyOrchestrator.mm` startup/commit/reset code in a macOS +Foundation-only executable. There are no production code changes, React Native mocks to install, CocoaPods, simulator builds, network requests, or sleeps. @@ -25,8 +26,10 @@ python3 -m unittest discover -s scripts/tests/ios-purge-restore -p test_extract. ## Production code, not a second state machine -`extract.py` reads the checkout's `ios/RCTPushy/RCTPushy.mm` on every build. It -copies the exact definitions (with `#line` locations) of `bundleURL`, +`extract.py` reads the checkout's `ios/RCTPushy/RCTPushy.mm` and +`RCTPushyOrchestrator.mm` on every build (each definition must exist exactly +once across them; pod-internal `PUSHY_HIDDEN` definitions count like `static` +ones). It copies the exact definitions (with `#line` locations) of `bundleURL`, `resolveLaunchBundleURL`, `restorePurgedLaunch`, `commitRoundWithGeneration`, `resetToPackagedBundle`, their state/defaults helpers, and the relevant globals into build-only `.inc` files. These are compiled unchanged with the actual @@ -39,8 +42,9 @@ or compilation instead of silently testing a stale copy. The host replaces only collaborators: React Native export/logging plumbing, application paths/configuration, defaults storage, network round delivery, -cold-start scheduling, the launch wait's return value/completion delivery, and -post-reset filesystem cleanup. Bundle-existence checks use real temporary files. +cold-start scheduling, the launch wait's return value/completion delivery, the +time elapsed since process start, the network-path probe, and post-reset +filesystem cleanup. Bundle-existence checks use real temporary files. The production state lock is real; every defaults mutation asserts ownership of that same `os_unfair_lock`. @@ -77,6 +81,14 @@ preferences domain. | `reset_wins` | Pause reset inside the real state lock; start stale commit; release reset | Old generation rejects **all** commit writes: no current/last version, version metadata or response cache; install UUID survives | | `commit_wins` | Pause commit inside the real state lock; start reset; release commit | Earlier commit succeeds, then reset clears its state, metadata and cache; a delayed done signal cannot restore it | | `complete_in_time` | Commit and done signal both arrive before wait returns | Normal successful restore still launches B with first-load protection | +| `late_launch` | 4.5s already elapsed since process start | Round deadline and launch wait shrink to the remaining 7.5s; B still launches | +| `offline_skip` | Network-path probe reports no path | No window, no round, no wait; packaged bundle launches | +| `spent_budget_skip` | 10.5s already elapsed (under the 2s minimum left) | Same as offline: the restore is not attempted | + +`config_equivalence` additionally replays the cross-platform canonical config +equality vectors in `cpp/update_flow_core/tests/config-equivalence-vectors.json` +(contract C1, shared with the JS/Harmony bun test and the Android JUnit test) +against the extracted `PushyConfigsEquivalent`, in both argument orders. A real GCD worker captures the request generation and blocks at a controlled response boundary. Semaphores establish each ordering. Reset races are exercised @@ -84,7 +96,8 @@ in both legal lock orders, with one operation paused while it owns the lock and the competing operation started on another thread. Five-second waits are only fail-fast deadlock guards, never timing assumptions. The launch wait itself is injected, so the tests do not spend 12 seconds per case. They still assert that -production requests a 12-second (not 13-second) wait budget. +production requests exactly the 12-second budget minus the time already spent +since process start. ## Negative controls diff --git a/scripts/tests/ios-purge-restore/extract.py b/scripts/tests/ios-purge-restore/extract.py index b94a5c5e..5d4af402 100644 --- a/scripts/tests/ios-purge-restore/extract.py +++ b/scripts/tests/ios-purge-restore/extract.py @@ -46,8 +46,13 @@ def definition(source: str, pattern: str, label: str) -> tuple[int, str]: return definitions[0] +# File-local (static) or shared within the pod (PUSHY_HIDDEN, see +# RCTPushyInternal.h); the test host compiles everything in one unit. +LINKAGE = r'(?:static|PUSHY_HIDDEN)' + + def function(source: str, name: str) -> tuple[int, str]: - return definition(source, rf'^static\b[^\n;]*?\b{re.escape(name)}\s*\(', name) + return definition(source, rf'^{LINKAGE}\b[^\n;]*?\b{re.escape(name)}\s*\(', name) def method(source: str, name: str) -> tuple[int, str]: @@ -59,7 +64,7 @@ def exported_method(source: str, name: str) -> tuple[int, str]: def declaration(source: str, name: str) -> tuple[int, str]: - matches = list(re.finditer(rf'^static\b[^\n;]*\b{re.escape(name)}\b[^\n;]*;', + matches = list(re.finditer(rf'^{LINKAGE}\b[^\n;]*\b{re.escape(name)}\b[^\n;]*;', mask_literals(source), re.MULTILINE)) if len(matches) != 1: raise ValueError(f'{name}: expected one static declaration, found {len(matches)}') @@ -73,16 +78,19 @@ def declaration(source: str, name: str) -> tuple[int, str]: 'legacyParamPackageVersion', 'legacyParamBuildTime', 'paramLastVersion', 'paramCurrentVersion', 'paramIsFirstTime', 'paramIsFirstLoadOk', 'keyUuid', 'keyHashInfo', 'keyFirstLoadMarked', 'keyRolledBackMarked', - 'KeyPackageUpdatedMarked', 'keyNativeCheckCache', 'BUNDLE_FILE_NAME', + 'keyNativeCheckCache', 'BUNDLE_FILE_NAME', 'pushyStateLock', 'ignoreRollback', 'pushyIsUsingBundleUrl', - 'pushyResetGeneration', 'pushyLaunchVersion', 'pushyCrashHoldActive', - 'pushyPurgeRestoreActive', 'pushyPurgeRestoreWindowOpen', - 'pushyHostRoundResult', 'kPushyPurgeRestoreBudget', + 'pushyResetGeneration', 'pushyNativeConfigGeneration', 'pushyLaunchVersion', + 'pushyCrashHoldActive', 'pushyPurgeRestoreActive', 'pushyPurgeRestoreWindowOpen', + 'pushyHostRoundResult', 'kPushyPurgeRestoreBudget', 'kPushyPurgeRestoreMinimumBudget', ] HELPERS = [ - 'PushyWithStateLock', 'PushyToStdString', 'PushyFromStdString', + 'PushyRevealOnce', 'KeyPackageUpdatedMarked', 'PushyWithStateLock', 'PushyToStdString', 'PushyFromStdString', 'PushySetNullableString', 'PushyHashInfoKey', 'PushyBinaryIdentityValue', 'PushyStateFromDefaults', 'PushyApplyStateToDefaults', 'PushySwitchVersionLocked', + # Canonical config equality (cross-platform contract), tested directly. + 'PushyNormalizeEndpointBase', 'PushyJsonIsBool', 'PushyJsonEqual', + 'PushyCanonicalUrlList', 'PushyCanonicalConfig', 'PushyConfigsEquivalent', ] MUTATIONS = ('late-activation', 'skip-reresolve', 'ignore-reset') @@ -103,37 +111,60 @@ def mutate(text: str, mutation: str) -> str: return mutated -def generate(source_path: Path, output: Path, mutation: str | None = None) -> None: - source = source_path.read_text(encoding='utf-8') - restore = method(source, 'restorePurgedLaunch') - commit = method(source, 'commitRoundWithGeneration') +# The sources the module is split across (RCTPushy.mm: module and launch +# resolution; RCTPushyOrchestrator.mm: the native round). +SOURCES = ('RCTPushy.mm', 'RCTPushyOrchestrator.mm') + + +def locate(sources: list[tuple[Path, str]], extractor, name: str) -> tuple[Path, int, str]: + """Exactly one definition across all sources; each keeps its own file.""" + found = [] + for path, text in sources: + try: + line, snippet = extractor(text, name) + except ValueError as error: + if 'found 0' in str(error): + continue + raise + found.append((path, line, snippet)) + if len(found) != 1: + raise ValueError(f'{name}: expected one definition across sources, found {len(found)}') + return found[0] + + +def generate(source_dir: Path, output: Path, mutation: str | None = None) -> None: + sources = [(source_dir / name, (source_dir / name).read_text(encoding='utf-8')) + for name in SOURCES] + restore = locate(sources, method, 'restorePurgedLaunch') + commit = locate(sources, method, 'commitRoundWithGeneration') if mutation == 'skip-reresolve': - restore = (restore[0], mutate(restore[1], mutation)) + restore = (restore[0], restore[1], mutate(restore[2], mutation)) elif mutation: - commit = (commit[0], mutate(commit[1], mutation)) + commit = (commit[0], commit[1], mutate(commit[2], mutation)) sections = { - 'globals.inc': [declaration(source, name) for name in GLOBALS], - 'helpers.inc': [function(source, name) for name in HELPERS], - 'pushy.inc': [method(source, 'bundleURL'), method(source, 'resolveLaunchBundleURL'), - exported_method(source, 'resetToPackagedBundle')], + 'globals.inc': [locate(sources, declaration, name) for name in GLOBALS], + 'helpers.inc': [locate(sources, function, name) for name in HELPERS], + 'pushy.inc': [locate(sources, method, 'bundleURL'), + locate(sources, method, 'resolveLaunchBundleURL'), + locate(sources, exported_method, 'resetToPackagedBundle')], 'orchestrator.inc': [restore, commit], } output.mkdir(parents=True, exist_ok=True) - filename = json.dumps(str(source_path.resolve())) for name, snippets in sections.items(): output.joinpath(name).write_text(''.join( - f'#line {line} {filename}\n{text}\n\n' for line, text in snippets + f'#line {line} {json.dumps(str(path.resolve()))}\n{text}\n\n' + for path, line, text in snippets ), encoding='utf-8') def main() -> None: parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument('source', type=Path) + parser.add_argument('source_dir', type=Path, help='ios/RCTPushy') parser.add_argument('output', type=Path) parser.add_argument('--mutation', choices=MUTATIONS) args = parser.parse_args() try: - generate(args.source, args.output, args.mutation) + generate(args.source_dir, args.output, args.mutation) except (OSError, ValueError) as error: parser.exit(1, f'Native test extraction failed: {error}\n') diff --git a/scripts/tests/ios-purge-restore/purge_restore_test.mm b/scripts/tests/ios-purge-restore/purge_restore_test.mm index 184cae9a..ed9fc2fd 100644 --- a/scripts/tests/ios-purge-restore/purge_restore_test.mm +++ b/scripts/tests/ios-purge-restore/purge_restore_test.mm @@ -27,6 +27,9 @@ static void TestLog(NSString *, ...) {} #define RCTLogWarn TestLog #define RCTLogError TestLog +// Pod-internal linkage (RCTPushyInternal.h); one translation unit here. +#define PUSHY_HIDDEN + #include "globals.inc" static const char *testName = "setup"; @@ -61,6 +64,9 @@ static void Await(dispatch_semaphore_t gate, const char *description) { static BOOL resetResolved; static int scheduleCount; static int64_t waitBudget; +static NSTimeInterval processElapsed; +static BOOL networkPath; +static NSTimeInterval ExpectedBudget(void) { return 12 - processElapsed; } static NSString *root; // In-memory collaborator, deliberately NOT an NSUserDefaults subclass: inherited @@ -171,6 +177,7 @@ + (void)scheduleFromColdStart:(NSString *)rolledBack; + (void)startRoundWithDeadline:(NSTimeInterval)deadline; + (BOOL)restorePurgedLaunch:(NSString *)purged rolledBack:(NSString *)rolledBack; + (BOOL)commitRoundWithGeneration:(uint64_t)generation + configGeneration:(uint64_t)configGeneration hashInfo:(NSDictionary *)hashInfo activate:(NSString *)hash responseText:(NSString *)response @@ -277,7 +284,7 @@ + (void)scheduleFromColdStart:(NSString *)rolledBack { (void)rolledBack; ++sched + (void)startRoundWithDeadline:(NSTimeInterval)deadline { @autoreleasepool { actor = Actor::Commit; - Expect(deadline == 100 + kPushyPurgeRestoreBudget, "round receives the launch deadline"); + Expect(deadline == 100 + ExpectedBudget(), "round receives the launch deadline"); requestGeneration = pushyResetGeneration.load(); Signal(requestStarted); Await(allowCommit, "release the native check response"); @@ -285,6 +292,7 @@ + (void)startRoundWithDeadline:(NSTimeInterval)deadline { // B represents an already-complete on-disk download. Do not reproduce // the activation policy here: the production commit must enforce it. roundCommitted = [self commitRoundWithGeneration:requestGeneration + configGeneration:pushyNativeConfigGeneration.load() hashInfo:@{@"hash": @"B", @"info": @{@"name": @"B", @"forceBootRescue": @YES}} activate:@"B" responseText:@"{\"update\":true,\"hash\":\"B\"}" request:@"request" config:@"config" responseAt:123 activated:&roundActivated]; @@ -293,6 +301,10 @@ + (void)startRoundWithDeadline:(NSTimeInterval)deadline { actor = Actor::None; } } +// Launch-budget collaborators: time already spent since process start and +// whether the system reports a network path. +static NSTimeInterval PushyProcessElapsed(void) { return processElapsed; } +static BOOL PushyHasNetworkPath(void) { return networkPath; } #define dispatch_semaphore_wait TestLaunchWait #define dispatch_semaphore_signal TestCompletionSignal #define dispatch_time TestLaunchDeadline @@ -323,6 +335,8 @@ static void SetUp(Order selected) { resetResolved = NO; scheduleCount = 0; waitBudget = 0; + processElapsed = 0; + networkPath = YES; testDefaults = [TestDefaults new]; engine = [RCTPushy new]; root = [NSTemporaryDirectory() stringByAppendingPathComponent:NSUUID.UUID.UUIDString]; @@ -335,6 +349,7 @@ static void SetUp(Order selected) { atomically:YES encoding:NSUTF8StringEncoding error:NULL], "create packaged fixture bundle"); PushyWithStateLock(^{ pushyResetGeneration.store(0); + pushyNativeConfigGeneration.store(0); ignoreRollback.store(false); pushyIsUsingBundleUrl.store(false); pushyLaunchVersion = nil; @@ -396,7 +411,8 @@ static void ExpectRestored(NSURL *url) { static void Finish(void) { if (order != Order::CompleteInTime) { Signal(allowSignal); } Await(workerSettled, "worker's delayed signal delivered before teardown"); - Expect(waitBudget == (int64_t)(12 * NSEC_PER_SEC), "launch wait must stay at 12 seconds, not 13"); + Expect(waitBudget == (int64_t)(ExpectedBudget() * NSEC_PER_SEC), + "launch wait must be 12 seconds minus the time since process start"); Expect(scheduleCount == 1, "bundleURL must retain its finally scheduling path"); Expect(!pushyPurgeRestoreWindowOpen, "every completed launch must close its restore window"); Expect([[NSFileManager defaultManager] removeItemAtPath:root error:NULL], "remove fixture files"); @@ -448,6 +464,31 @@ static void CompleteInTime(void) { Finish(); } +// Time spent before +bundleURL counts against the launch budget. +static void LateLaunch(void) { + SetUp(Order::CompleteInTime); + processElapsed = 4.5; + ExpectRestored([RCTPushy bundleURL]); + Expect(roundCommitted && roundActivated, "restore still runs on the remaining budget"); + Finish(); +} + +// No restore attempt (no wait, no round) when it cannot help: offline, or +// the launch budget is already spent. +static void Skipped(BOOL offline) { + SetUp(Order::CompleteInTime); + networkPath = !offline; + processElapsed = offline ? 0 : 10.5; + ExpectPackaged([RCTPushy bundleURL]); + Expect(waitBudget == 0, "skipped restore must not wait"); + Expect(scheduleCount == 1, "bundleURL must retain its finally scheduling path"); + Expect(!pushyPurgeRestoreWindowOpen && !pushyPurgeRestoreActive.load(), + "skipped restore must not open its window"); + Expect(dispatch_semaphore_wait(requestStarted, DISPATCH_TIME_NOW) != 0, + "skipped restore must not start the round"); + Expect([[NSFileManager defaultManager] removeItemAtPath:root error:NULL], "remove fixture files"); +} + // This contract test deliberately does not call SetUp: a new collaborator must // start empty on its own, not only because the fixture reset some known keys. static void DefaultsIsolation(void) { @@ -469,6 +510,32 @@ static void DefaultsIsolation(void) { }); } +// Canonical config equality shared with JS/Android/Harmony: equal canonical +// forms are the same config (persistConfiguration no-op, hasJsRound match). +// Replays the cross-platform vectors every implementation consumes; the file +// is located from this source file (/scripts/tests/ios-purge-restore/). +static void ConfigEquivalence(void) { + NSString *repo = [[[[@__FILE__ stringByDeletingLastPathComponent] + stringByDeletingLastPathComponent] stringByDeletingLastPathComponent] + stringByDeletingLastPathComponent]; + NSString *path = [repo stringByAppendingPathComponent: + @"cpp/update_flow_core/tests/config-equivalence-vectors.json"]; + NSData *data = [NSData dataWithContentsOfFile:path]; + Expect(data != nil, "config-equivalence-vectors.json is readable"); + NSDictionary *file = [NSJSONSerialization JSONObjectWithData:data options:0 error:nil]; + NSArray *cases = [file isKindOfClass:NSDictionary.class] ? file[@"cases"] : nil; + Expect([cases isKindOfClass:NSArray.class] && cases.count >= 30, "vector file has cases"); + for (NSDictionary *vector in cases) { + NSString *a = vector[@"a"], *b = vector[@"b"]; + bool equivalent = [vector[@"equivalent"] boolValue]; + std::string why = std::string("vector \"") + [vector[@"name"] UTF8String] + "\""; + Expect(PushyConfigsEquivalent(a, b) == equivalent, why.c_str()); + Expect(PushyConfigsEquivalent(b, a) == equivalent, (why + " (reversed)").c_str()); + } + Expect(!PushyConfigsEquivalent(nil, @"{\"appKey\":\"k\"}"), "no stored config is a change"); + std::printf("[PASS] config_equivalence: %lu shared vectors\n", (unsigned long)cases.count); +} + int main(int argc, char **argv) { @autoreleasepool { // These intentional failures run in separate processes. Erasing the @@ -487,6 +554,8 @@ int main(int argc, char **argv) { testName = "defaults_isolation"; DefaultsIsolation(); std::printf("[PASS] defaults_isolation\n"); + testName = "config_equivalence"; + ConfigEquivalence(); struct Case { const char *name; void (*run)(); }; const Case cases[] = { {"late_commit", LateCommit}, @@ -494,6 +563,9 @@ int main(int argc, char **argv) { {"reset_wins", [] { ResetOrdering(Order::ResetFirst); }}, {"commit_wins", [] { ResetOrdering(Order::CommitFirst); }}, {"complete_in_time", CompleteInTime}, + {"late_launch", LateLaunch}, + {"offline_skip", [] { Skipped(YES); }}, + {"spent_budget_skip", [] { Skipped(NO); }}, }; int ran = 0; for (const auto &test : cases) { diff --git a/scripts/verify-android-so.sh b/scripts/verify-android-so.sh deleted file mode 100755 index 82d53679..00000000 --- a/scripts/verify-android-so.sh +++ /dev/null @@ -1,5 +0,0 @@ -#!/usr/bin/env bash -# Thin wrapper kept for backward compatibility; the actual verification lives -# in verify-android-so.js (pure-node ELF .dynsym reader, no binutils needed — -# llvm-nm/nm are absent on some CI images, e.g. the HarmonyOS docker image). -exec node "$(dirname "${BASH_SOURCE[0]}")/verify-android-so.js" diff --git a/src/__tests__/client.test.ts b/src/__tests__/client.test.ts index d146efa5..e652fb42 100644 --- a/src/__tests__/client.test.ts +++ b/src/__tests__/client.test.ts @@ -475,6 +475,62 @@ describe('Pushy server config', () => { ); }); + test('concurrent markSuccess calls share one native call and one report', async () => { + let resolveNative = () => {}; + const nativeMarkSuccess = mock( + () => + new Promise((resolve) => { + resolveNative = resolve; + }) + ); + const logger = mock(() => {}); + setupClientMocks({ isFirstTime: true, markSuccess: nativeMarkSuccess }); + const { Pushy, sharedState } = await importFreshClient( + 'mark-success-concurrent' + ); + const client = new Pushy({ appKey: 'demo-app', logger }); + + const first = client.markSuccess(); + const second = client.markSuccess(); + resolveNative(); + await Promise.all([first, second]); + await client.markSuccess(); + + expect(nativeMarkSuccess).toHaveBeenCalledTimes(1); + expect(sharedState.marked).toBe(true); + expect( + logger.mock.calls.filter(([event]: any[]) => event.type === 'markSuccess') + ).toHaveLength(1); + }); + + test('a failed markSuccess can be retried', async () => { + let calls = 0; + const nativeMarkSuccess = mock(() => + ++calls === 1 ? Promise.reject(Error('busy')) : Promise.resolve() + ); + setupClientMocks({ isFirstTime: true, markSuccess: nativeMarkSuccess }); + const { Pushy, sharedState } = + await importFreshClient('mark-success-retry'); + const client = new Pushy({ appKey: 'demo-app' }); + const errors: any[] = []; + client.onError((e: any) => { + errors.push(e); + }); + + const results = await Promise.allSettled([ + client.markSuccess(), + client.markSuccess(), + ]); + expect(results.map((r) => r.status)).toEqual(['rejected', 'rejected']); + // One failure, one report — not one per caller. + expect(errors).toHaveLength(1); + expect(sharedState.marked).toBe(false); + + await client.markSuccess(); + expect(nativeMarkSuccess).toHaveBeenCalledTimes(2); + expect(sharedState.marked).toBe(true); + }); + test('waits for beforeReload before switching version', async () => { const calls: string[] = []; const reloadUpdate = mock(() => { @@ -1002,6 +1058,7 @@ describe('downloadUpdate fallback chain', () => { addProgressListener = mock(() => ({ remove: mock(() => {}) })), setLocalHashInfo = mock(() => {}), fetchWithTimeout = mock(() => Promise.resolve()), + testUrls = mock((urls?: string[]) => Promise.resolve(urls?.[0] || null)), }: { downloadPatchFromPpk?: ReturnType; downloadPatchFromPackage?: ReturnType; @@ -1009,6 +1066,7 @@ describe('downloadUpdate fallback chain', () => { addProgressListener?: ReturnType; setLocalHashInfo?: ReturnType; fetchWithTimeout?: ReturnType; + testUrls?: ReturnType; } = {}) => { setupClientMocks({ downloadPatchFromPpk, @@ -1038,10 +1096,11 @@ describe('downloadUpdate fallback chain', () => { log: mock(() => {}), noop: () => {}, promiseAny: mock(() => Promise.resolve()), - testUrls: (urls?: string[]) => Promise.resolve(urls?.[0] || null), + testUrls, })); return { + testUrls, downloadPatchFromPpk, downloadPatchFromPackage, downloadFullUpdate, @@ -1353,6 +1412,35 @@ describe('downloadUpdate fallback chain', () => { expect(downloadFullUpdate).toHaveBeenCalledTimes(2); }); + test('mirrors are ranked once per strategy across retries; a failed winner is demoted', async () => { + const fullUrls: string[] = []; + let fullCalls = 0; + const { testUrls } = setupDownloadMocks({ + downloadPatchFromPpk: mock(() => Promise.reject(Error('diff fail'))), + downloadPatchFromPackage: mock(() => Promise.reject(Error('pdiff fail'))), + downloadFullUpdate: mock(({ updateUrl }: { updateUrl: string }) => { + fullUrls.push(updateUrl); + // Attempt 0 fails on both mirrors; attempt 1 succeeds on its first. + return ++fullCalls <= 2 + ? Promise.reject(Error('full fail')) + : Promise.resolve(); + }), + }); + const { Pushy, sharedState } = await importFreshClient('dl-rank-once'); + sharedState.downloadedHash = undefined; + const client = new Pushy({ appKey: 'demo-app', maxRetries: 3 }); + + expect(await client.downloadUpdate(twoMirrors)).toBe('new-hash'); + // diff, pdiff, full: one HEAD race each, not one per attempt. + expect(testUrls).toHaveBeenCalledTimes(3); + expect(fullUrls).toEqual([ + 'https://cdn-a.example.com/full.ppk', + 'https://cdn-b.example.com/full.ppk', + // The HEAD winner failed: the retry starts from the other mirror. + 'https://cdn-b.example.com/full.ppk', + ]); + }); + test('defaults to 3 retries when maxRetries is not set', async () => { const { downloadFullUpdate } = setupDownloadMocks({ downloadPatchFromPpk: mock(() => Promise.reject(Error('diff fail'))), @@ -2165,6 +2253,27 @@ describe('syncNativeConfig', () => { expect(syncNativeConfig).toHaveBeenCalledTimes(2); }); + test('switching ownership to native and back writes the config again', async () => { + const syncNativeConfig = mock(() => Promise.resolve()); + setupClientMocks({ syncNativeConfig }); + const { Pushy } = await importFreshClient('sync-config-owner-roundtrip'); + const client = new Pushy({ appKey: 'demo-app' }); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + expect(syncNativeConfig).toHaveBeenCalledTimes(1); + + // While native owns it, the host may have stored anything: the old JS + // value says nothing about native storage any more. + client.setOptions({ nativeConfigSource: 'native' }); + client.setOptions({ nativeConfigSource: 'javascript' }); + + expect(syncNativeConfig).toHaveBeenCalledTimes(2); + expect(syncNativeConfig.mock.calls[1]).toEqual( + syncNativeConfig.mock.calls[0] + ); + }); + test('an older native module without the method is skipped silently', async () => { setupClientMocks(); const { Pushy } = await importFreshClient('sync-config-4'); @@ -2368,6 +2477,132 @@ describe('native check cache reuse', () => { expect(await client.checkUpdate()).toEqual(networkResult); }); + test('an expired cache is judged on its age alone and never read again', async () => { + const getNativeCheckCache = mock(() => + Promise.resolve( + JSON.stringify({ + ts: Math.floor(Date.now() / 1000) - 600, + body: JSON.stringify({ upToDate: true }), + // Never parsed: the age check comes first. + request: '{not json', + config: '{not json', + }) + ) + ); + const syncNativeConfig = mock(() => Promise.resolve()); + setupClientMocks({ getNativeCheckCache, syncNativeConfig }); + const networkResult = { update: true, hash: 'net-hash' }; + (globalThis as any).fetch = mock(async () => + createJsonResponse(networkResult) + ); + const { Pushy } = await importFreshClient('native-cache-expired-once'); + const client = new Pushy({ appKey: 'demo-app' }); + + const parse = spyOn(JSON, 'parse'); + let parsedCacheFields: unknown[][] = []; + try { + expect(await client.checkUpdate()).toEqual(networkResult); + parsedCacheFields = parse.mock.calls.filter( + ([text]) => text === '{not json' + ); + } finally { + parse.mockRestore(); + } + expect(parsedCacheFields).toHaveLength(0); + // A different request (outside the dedup window's identity) re-checks, + // but the native cache is not consulted again in this process. + expect(await client.checkUpdate({ toHash: 'other' })).toEqual( + networkResult + ); + expect(getNativeCheckCache).toHaveBeenCalledTimes(1); + }); + + test('an absent cache is not read again in the same process', async () => { + const getNativeCheckCache = mock(() => Promise.resolve(null)); + const syncNativeConfig = mock(() => Promise.resolve()); + setupClientMocks({ getNativeCheckCache, syncNativeConfig }); + const networkResult = { upToDate: true }; + (globalThis as any).fetch = mock(async () => + createJsonResponse(networkResult) + ); + const { Pushy } = await importFreshClient('native-cache-absent-once'); + const client = new Pushy({ appKey: 'demo-app' }); + + await client.checkUpdate(); + await client.checkUpdate({ toHash: 'other' }); + + expect(getNativeCheckCache).toHaveBeenCalledTimes(1); + }); + + test('a config that is canonically equal (C1) still matches the cache', async () => { + const cachedResult = { upToDate: true }; + let configJson = ''; + const syncNativeConfig = mock((value: string) => { + configJson = value; + return Promise.resolve(); + }); + const getNativeCheckCache = mock(() => { + // What a native normalizer might have stored: trailing slashes, a + // duplicate endpoint, default-valued keys spelled out, other key order. + const { endpoints, ...rest } = JSON.parse(configJson); + const nativeConfig = { + disabled: false, + endpoints: [...endpoints.map((url: string) => `${url}/`), endpoints[0]], + ...rest, + }; + return Promise.resolve( + JSON.stringify({ + ts: Math.floor(Date.now() / 1000) - 10, + body: JSON.stringify(cachedResult), + request: expectedRequestBody, + config: JSON.stringify(nativeConfig), + }) + ); + }); + setupClientMocks({ getNativeCheckCache, syncNativeConfig }); + const { Pushy } = await importFreshClient('native-cache-canonical-config'); + const client = new Pushy({ appKey: 'demo-app' }); + + expect(await client.checkUpdate()).toEqual(cachedResult); + }); + + test('markJsCheckCompleted is sent once per config after it succeeds', async () => { + const syncNativeConfig = mock(() => Promise.resolve()); + let markCalls = 0; + const markJsCheckCompleted = mock(() => + ++markCalls === 1 ? Promise.reject(Error('busy')) : Promise.resolve() + ); + setupClientMocks({ syncNativeConfig, markJsCheckCompleted }); + (globalThis as any).fetch = mock(async () => + createJsonResponse({ upToDate: true }) + ); + const { Pushy } = await importFreshClient('mark-js-check-once'); + const client = new Pushy({ appKey: 'demo-app' }); + const flushAll = async () => { + for (let i = 0; i < 5; i++) { + await Promise.resolve(); + } + }; + + // The first acknowledgement failed: the next completed check resends. + await client.checkUpdate({ round: 1 }); + await flushAll(); + await client.checkUpdate({ round: 2 }); + await flushAll(); + expect(markJsCheckCompleted).toHaveBeenCalledTimes(2); + + // Acknowledged for this config: further checks stay quiet... + await client.checkUpdate({ round: 3 }); + await flushAll(); + expect(markJsCheckCompleted).toHaveBeenCalledTimes(2); + + // ...until the config changes. + client.setOptions({ updateStrategy: 'silentAndNow' }); + await client.checkUpdate({ round: 4 }); + await flushAll(); + expect(markJsCheckCompleted).toHaveBeenCalledTimes(3); + }); + test('a cache timestamp from the future is never reused', async () => { let configJson = ''; const syncNativeConfig = mock((value: string) => { diff --git a/src/__tests__/configEquivalenceVectors.test.ts b/src/__tests__/configEquivalenceVectors.test.ts new file mode 100644 index 00000000..d8e2a141 --- /dev/null +++ b/src/__tests__/configEquivalenceVectors.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, test } from 'bun:test'; +import { readFileSync } from 'node:fs'; +import { URL } from 'node:url'; +import { runInNewContext } from 'node:vm'; +import vectorsFile from '../../cpp/update_flow_core/tests/config-equivalence-vectors.json'; +import { nativeConfigsEquivalent } from '../updateFlowCore'; + +// Contract C1 (native config equivalence) is implemented four times: here, +// Harmony UpdateContext.ts, Android ConfigEquivalence.java and iOS +// PushyConfigsEquivalent. All four replay the same committed vectors; this +// file covers the two TypeScript ones, ConfigEquivalenceTest (JUnit) and +// scripts/test-ios-purge-restore.sh the natives. +interface Vector { + name: string; + a: string; + b: string; + equivalent: boolean; +} +const vectors = vectorsFile.cases as Vector[]; + +// UpdateContext.ts imports @ohos modules, so it is evaluated with its imports +// stripped (as in nativeConfiguration.test.ts); the C1 functions only depend +// on the file's own source. +function harmonyEquivalence(): (a: string, b: string) => boolean { + const source = readFileSync( + new URL( + '../../harmony/pushy/src/main/ets/UpdateContext.ts', + import.meta.url + ), + 'utf8' + ) + .replace(/^import[\s\S]*?;\r?\n/gm, '') + .replace(/^export\s*\{[^}]*\}(?:\s*from\s*[^;]*)?;\r?\n/gm, '') + .replace(/^export /gm, ''); + const js = new Bun.Transpiler({ loader: 'ts' }).transformSync(source); + return runInNewContext(`${js}\nnativeConfigsEquivalent;`, {}); +} + +const implementations: Array<[string, (a: string, b: string) => boolean]> = [ + ['js', nativeConfigsEquivalent], + ['harmony', harmonyEquivalence()], +]; + +describe('C1 config equivalence vectors', () => { + test('vector file is well formed', () => { + expect(vectors.length).toBeGreaterThanOrEqual(30); + expect(new Set(vectors.map((v) => v.name)).size).toBe(vectors.length); + expect(vectors.some((v) => v.equivalent)).toBe(true); + expect(vectors.some((v) => !v.equivalent)).toBe(true); + }); + + for (const [platform, equivalent] of implementations) { + describe(platform, () => { + for (const vector of vectors) { + test(vector.name, () => { + expect(equivalent(vector.a, vector.b)).toBe(vector.equivalent); + expect(equivalent(vector.b, vector.a)).toBe(vector.equivalent); + }); + } + }); + } +}); diff --git a/src/__tests__/core.test.ts b/src/__tests__/core.test.ts index 0a09dd1e..e9489624 100644 --- a/src/__tests__/core.test.ts +++ b/src/__tests__/core.test.ts @@ -46,10 +46,6 @@ describe('core info parsing', () => { version: { major: 0, minor: 73, patch: 0 }, })); - mock.module('nanoid/non-secure', () => ({ - nanoid: () => 'mock-uuid', - })); - // Use a unique query parameter to bypass cache if supported, or just rely on fresh environment per file. // In Bun, you can sometimes use a cache buster if it's dynamic import. await importFreshCore('error'); @@ -178,9 +174,6 @@ describe('web platform', () => { mock.module('react-native/Libraries/Core/ReactNativeVersion', () => ({ version: { major: 0, minor: 73, patch: 0 }, })); - mock.module('nanoid/non-secure', () => ({ - nanoid: () => 'web-uuid', - })); const consoleError = spyOn(console, 'error').mockImplementation(() => {}); const consoleWarn = spyOn(console, 'warn').mockImplementation(() => {}); @@ -193,8 +186,7 @@ describe('web platform', () => { expect(typeof core.currentVersion).toBe('string'); expect(typeof core.packageVersion).toBe('string'); expect(typeof core.buildTime).toBe('string'); - expect(typeof core.cInfo.uuid).toBe('string'); - expect(core.cInfo.uuid.length).toBeGreaterThan(0); + expect(core.cInfo.uuid).toMatch(/^[A-Za-z0-9_-]{21}$/); expect(core.currentVersionInfo).toEqual({}); expect(core.supportedDiffVersion).toBe(0); expect(core.getBundleHash()).toBe(''); diff --git a/src/__tests__/jniRegistration.test.ts b/src/__tests__/jniRegistration.test.ts index dd81fb82..7835eaab 100644 --- a/src/__tests__/jniRegistration.test.ts +++ b/src/__tests__/jniRegistration.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from 'bun:test'; import { readdirSync, readFileSync } from 'node:fs'; +import { URL } from 'node:url'; import { decodeNativeText } from '../../scripts/encode-native-text'; // JNI_OnLoad binds Android native methods from an encoded name/signature table diff --git a/src/__tests__/nativeConfiguration.test.ts b/src/__tests__/nativeConfiguration.test.ts index 4da9cded..1c548cd3 100644 --- a/src/__tests__/nativeConfiguration.test.ts +++ b/src/__tests__/nativeConfiguration.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from 'bun:test'; import { readFileSync } from 'node:fs'; +import { URL } from 'node:url'; import { runInNewContext } from 'node:vm'; import type { PushyConfiguration } from '../../harmony/pushy/src/main/ets/PushyConfiguration'; import { normalizePushyConfiguration } from '../../harmony/pushy/src/main/ets/PushyConfiguration'; diff --git a/src/__tests__/nativeHostApi.test.ts b/src/__tests__/nativeHostApi.test.ts index 2b9eb771..5c6f3b34 100644 --- a/src/__tests__/nativeHostApi.test.ts +++ b/src/__tests__/nativeHostApi.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from 'bun:test'; import { readFileSync } from 'node:fs'; +import { URL } from 'node:url'; import { runInNewContext } from 'node:vm'; import type { BundlePreparationResult } from '../../harmony/pushy/src/main/ets/BundlePreparationResult'; import { diff --git a/src/__tests__/nativeHostApiNaming.test.ts b/src/__tests__/nativeHostApiNaming.test.ts index e29aba68..0439a351 100644 --- a/src/__tests__/nativeHostApiNaming.test.ts +++ b/src/__tests__/nativeHostApiNaming.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from 'bun:test'; import { existsSync, readdirSync, readFileSync } from 'node:fs'; +import { URL } from 'node:url'; const root = new URL('../../', import.meta.url); const android = 'android/src/main/java/cn/reactnative/modules/update/'; diff --git a/src/__tests__/nativeTextEncoding.test.ts b/src/__tests__/nativeTextEncoding.test.ts index 682e76c8..ebc04ba4 100644 --- a/src/__tests__/nativeTextEncoding.test.ts +++ b/src/__tests__/nativeTextEncoding.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from 'bun:test'; import { readdirSync, readFileSync } from 'node:fs'; +import { URL } from 'node:url'; import { normalizePushyConfiguration } from '../../harmony/pushy/src/main/ets/PushyConfiguration'; import { QUERY_PATH, revealText } from '../../harmony/pushy/src/main/ets/Texts'; import { diff --git a/src/__tests__/provider.render.test.tsx b/src/__tests__/provider.render.test.tsx index d8e1f502..eea8efcb 100644 --- a/src/__tests__/provider.render.test.tsx +++ b/src/__tests__/provider.render.test.tsx @@ -660,4 +660,36 @@ describe('UpdateProvider rendering', () => { ); expect(mockAlert).not.toHaveBeenCalled(); }); + test('the launch deep link is handled once, even when the strategy callbacks change', async () => { + const { Linking } = await import('react-native'); + const getInitialURL = mock(() => + Promise.resolve('app://open?type=__rnPushyVersionHash&data=test-hash') + ); + (Linking as any).getInitialURL = getInitialURL; + const client = createClient({ + updateStrategy: 'alwaysAlert', + checkStrategy: null, + }); + client.checkUpdate.mockImplementation(async () => ({ upToDate: true })); + await renderProvider(client); + + const testChecks = () => + client.checkUpdate.mock.calls.filter( + (call: any[]) => call[0]?.toHash === 'test-hash' + ).length; + expect(getInitialURL).toHaveBeenCalledTimes(1); + expect(testChecks()).toBe(1); + + // A strategy change rebuilds checkUpdate and with it parseTestPayload; + // the Linking effect used to re-run and replay the initial URL. + for (const updateStrategy of ['silentAndNow', 'silentAndLater']) { + await TestRenderer.act(async () => { + client.setOptions({ updateStrategy }); + await flush(); + }); + } + + expect(getInitialURL).toHaveBeenCalledTimes(1); + expect(testChecks()).toBe(1); + }); }); diff --git a/src/__tests__/updateFlowCore.test.ts b/src/__tests__/updateFlowCore.test.ts index 8c7343da..331d5c93 100644 --- a/src/__tests__/updateFlowCore.test.ts +++ b/src/__tests__/updateFlowCore.test.ts @@ -3,9 +3,11 @@ import type { CheckResult } from '../type'; import { buildCheckFingerprint, buildCheckRequestBody, + canonicalizeNativeConfig, decideDownload, isMirrorRetryableCode, isValidCheckResult, + nativeConfigsEquivalent, orderEndpointCandidates, } from '../updateFlowCore'; @@ -335,3 +337,87 @@ describe('isValidCheckResult', () => { expect(isValidCheckResult({ update: 1 })).toBe(false); }); }); + +describe('native config canonical equality (C1)', () => { + const base = { + appKey: 'demo-app', + packageVersion: '1.0.0', + endpoints: ['https://a.example.com', 'https://b.example.com'], + queryUrls: ['https://q.example.com/endpoints.json'], + afterDownload: 'setNeedUpdate', + rnu: '10.0.0', + rn: '0.73.0', + }; + const json = (value: unknown) => JSON.stringify(value); + + test('key order is ignored', () => { + const reordered = Object.fromEntries(Object.entries(base).reverse()); + expect(nativeConfigsEquivalent(json(base), json(reordered))).toBe(true); + }); + + test('absent keys equal their native defaults', () => { + const minimal = { + appKey: 'demo-app', + endpoints: ['https://a.example.com'], + }; + const explicit = { + ...minimal, + disabled: false, + queryUrls: [], + rnu: '', + rn: '', + afterDownload: 'none', + }; + expect(nativeConfigsEquivalent(json(minimal), json(explicit))).toBe(true); + expect( + nativeConfigsEquivalent( + json(minimal), + json({ ...minimal, afterDownload: 'setNeedUpdate' }) + ) + ).toBe(false); + }); + + test('endpoint lists ignore trailing slashes and duplicates, not order', () => { + const noisy = { + ...base, + endpoints: [ + 'https://a.example.com/', + 'https://a.example.com', + 'https://b.example.com//', + ], + queryUrls: [ + 'https://q.example.com/endpoints.json/', + 'https://q.example.com/endpoints.json', + ], + }; + expect(nativeConfigsEquivalent(json(base), json(noisy))).toBe(true); + expect(canonicalizeNativeConfig(json(noisy))?.endpoints).toEqual([ + 'https://a.example.com', + 'https://b.example.com', + ]); + const swapped = { + ...base, + endpoints: ['https://b.example.com', 'https://a.example.com'], + }; + expect(nativeConfigsEquivalent(json(base), json(swapped))).toBe(false); + }); + + test('a real difference is detected', () => { + expect( + nativeConfigsEquivalent( + json(base), + json({ ...base, appKey: 'another-app' }) + ) + ).toBe(false); + expect( + nativeConfigsEquivalent(json(base), json({ ...base, extra: 1 })) + ).toBe(false); + }); + + test('unparseable or non-object JSON is never equivalent', () => { + expect(nativeConfigsEquivalent('not json', 'not json')).toBe(false); + expect(nativeConfigsEquivalent('null', 'null')).toBe(false); + expect(nativeConfigsEquivalent('[]', '[]')).toBe(false); + expect(nativeConfigsEquivalent(json(base), '')).toBe(false); + }); +}); diff --git a/src/__tests__/utils.test.ts b/src/__tests__/utils.test.ts index 828cadb2..542ee78b 100644 --- a/src/__tests__/utils.test.ts +++ b/src/__tests__/utils.test.ts @@ -18,6 +18,7 @@ mock.module('../i18n', () => { import { computeProgress, + createNonSecureId, enhancedFetch, fetchWithTimeout, info, @@ -440,3 +441,39 @@ describe('log gating (1.11)', () => { } }); }); + +describe('createNonSecureId', () => { + test('matches the nanoid default format (21 URL-safe chars)', () => { + const ids = new Set(); + for (let i = 0; i < 50; i++) { + const id = createNonSecureId(); + expect(id).toMatch(/^[A-Za-z0-9_-]{21}$/); + ids.add(id); + } + expect(ids.size).toBe(50); + expect(createNonSecureId(8)).toHaveLength(8); + }); +}); + +describe('testUrls on web', () => { + test('resolves the first url without probing or throwing', async () => { + mock.module('react-native', () => ({ Platform: { OS: 'web' } })); + const fetchMock = mock(async () => { + throw new Error('must not probe on web'); + }); + (globalThis as any).fetch = fetchMock; + const cacheKey = 'web-ping'; + const webUtils: typeof import('../utils') = await import( + `../utils?${cacheKey}` + ); + + expect(webUtils.isWeb).toBe(true); + expect( + await webUtils.testUrls([ + 'https://a.example.com', + 'https://b.example.com', + ]) + ).toBe('https://a.example.com'); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); diff --git a/src/client.ts b/src/client.ts index eb71cd49..e0cd24d5 100644 --- a/src/client.ts +++ b/src/client.ts @@ -55,6 +55,7 @@ import { decideDownload, isMirrorRetryableCode, isValidCheckResult, + nativeConfigsEquivalent, } from './updateFlowCore'; import { assertWeb, @@ -157,6 +158,19 @@ export const sharedState: { applyingUpdate: false, }; +// The native cold-start check writes its response cache at most once per +// launch (and skips it once JS has checked), so once this process has seen it +// absent or expired, later reads are near-certain misses: skip the bridge +// call from then on. The rare miss costs one network check. A fresh entry +// that only mismatches the request or config does not set this: a later check +// with the original options (e.g. after `extra` reverts) can still reuse it. +let nativeCheckCacheExhausted = false; +const NATIVE_CHECK_CACHE_MAX_AGE_S = 120; + +// In-flight native markSuccess shared by concurrent callers, so the native +// call and its telemetry happen once. Cleared once settled. +let markingPromise: Promise | undefined; + // A reset/new switch invalidates ownership of older async completions and // watchdogs before the process-wide applyingUpdate flag can be reused. let applyingUpdateGeneration = 0; @@ -242,6 +256,8 @@ export class Pushy { private syncedNativeConfigJson?: string; private pendingNativeConfigJson?: string; private nativeConfigSyncInFlight = false; + // Config last acknowledged by markJsCheckCompleted. + private jsCheckCompletedConfigJson?: string; private reportedInvalidUpdates = new Set(); private reportedExceptions = new WeakSet(); private globalErrorCleanup?: () => void; @@ -422,6 +438,7 @@ export class Pushy { private flushNativeConfig = () => { if (this.options.nativeConfigSource === 'native') { this.pendingNativeConfigJson = undefined; + this.syncedNativeConfigJson = undefined; return; } if (this.nativeConfigSyncInFlight) { @@ -443,7 +460,12 @@ export class Pushy { } syncResult .then(() => { - this.syncedNativeConfigJson = configJson; + // A switch to 'native' while this write was in flight hands storage + // to the host; remembering the write would skip it after switching + // back. + if (this.options.nativeConfigSource !== 'native') { + this.syncedNativeConfigJson = configJson; + } }) .catch((e: any) => { log('syncNativeConfig failed:', e?.message || e); @@ -469,11 +491,14 @@ export class Pushy { return; } const configJson = this.getNativeConfigJson(); - if (!configJson) { + if (!configJson || configJson === this.jsCheckCompletedConfigJson) { return; } try { - Promise.resolve(PushyModule.markJsCheckCompleted(configJson)).catch( + Promise.resolve(PushyModule.markJsCheckCompleted(configJson)).then( + () => { + this.jsCheckCompletedConfigJson = configJson; + }, (e: any) => { log('markJsCheckCompleted failed:', e?.message || e); } @@ -485,7 +510,11 @@ export class Pushy { private syncNativeConfig = () => { if (this.options.nativeConfigSource === 'native') { + // The host owns the stored config now; forget what JS last wrote so a + // switch back to 'javascript' writes again instead of deduping + // against a value native storage no longer holds. this.pendingNativeConfigJson = undefined; + this.syncedNativeConfigJson = undefined; return; } if ( @@ -1044,34 +1073,35 @@ export class Pushy { requestBody: Record ): Promise => { try { - if (__DEV__ || typeof PushyModule.getNativeCheckCache !== 'function') { + if ( + __DEV__ || + nativeCheckCacheExhausted || + typeof PushyModule.getNativeCheckCache !== 'function' + ) { return undefined; } const raw = await Promise.resolve(PushyModule.getNativeCheckCache()); if (!raw || typeof raw !== 'string') { + nativeCheckCacheExhausted = true; return undefined; } const entry = JSON.parse(raw); - const config = this.getNativeConfig(); - const cachedRequest = - typeof entry?.request === 'string' - ? JSON.parse(entry.request) - : undefined; - const cachedConfig = - typeof entry?.config === 'string' - ? JSON.parse(entry.config) - : undefined; - if ( - typeof entry?.ts !== 'number' || - typeof entry?.body !== 'string' || - !this.jsonValuesEqual(cachedRequest, requestBody) || - !config || - !this.jsonValuesEqual(cachedConfig, config) - ) { + // Age first: it is the common miss and needs no further parsing. + const ageSeconds = + typeof entry?.ts === 'number' ? Date.now() / 1000 - entry.ts : NaN; + if (!(ageSeconds >= 0 && ageSeconds <= NATIVE_CHECK_CACHE_MAX_AGE_S)) { + nativeCheckCacheExhausted = true; return undefined; } - const ageSeconds = Date.now() / 1000 - entry.ts; - if (ageSeconds < 0 || ageSeconds > 120) { + const configJson = this.getNativeConfigJson(); + if ( + typeof entry.body !== 'string' || + typeof entry.request !== 'string' || + typeof entry.config !== 'string' || + !configJson || + !nativeConfigsEquivalent(entry.config, configJson) || + !this.jsonValuesEqual(JSON.parse(entry.request), requestBody) + ) { return undefined; } const result: unknown = JSON.parse(entry.body); @@ -1098,6 +1128,19 @@ export class Pushy { if (sharedState.marked || __DEV__ || !isFirstTime) { return; } + // Concurrent callers (auto mark + an explicit call) would all pass the + // check above before the first native call settles: share it. + if (!markingPromise) { + const promise = this.performMarkSuccess().finally(() => { + if (markingPromise === promise) { + markingPromise = undefined; + } + }); + markingPromise = promise; + } + return markingPromise; + }; + private performMarkSuccess = async () => { try { await Promise.resolve(PushyModule.markSuccess()); } catch (e) { @@ -1519,6 +1562,11 @@ export class Pushy { hash, }), }; + // Mirror ranking per strategy, kept across retry attempts: the HEAD race + // runs once per strategy per download instead of once per attempt. A + // winner that failed its download is demoted to the back for the next + // attempt (every mirror is tried on every attempt anyway). + const rankedUrls = new Map(); const errorKeys = { diff: 'error_diff_failed', pdiff: 'error_pdiff_failed', @@ -1559,7 +1607,11 @@ export class Pushy { // a transfer can die mid-stream). Every mirror of the same artifact // gets a real download attempt before the strategy is abandoned — // the native cold-start path already behaves this way. - const orderedUrls = await this.rankDownloadUrls(urls); + let orderedUrls = rankedUrls.get(type); + if (!orderedUrls) { + orderedUrls = await this.rankDownloadUrls(urls); + rankedUrls.set(type, orderedUrls); + } for (const url of orderedUrls) { log(`downloading ${type} from ${url}`); try { @@ -1573,6 +1625,9 @@ export class Pushy { // `[CODE] ` message prefix. Either way it survives into the // i18n-wrapped error together with the original as cause. const { code, message } = readErrorCode(e); + if (url === orderedUrls[0] && orderedUrls.length > 1) { + rankedUrls.set(type, [...orderedUrls.slice(1), url]); + } const errorMessage = this.t(errorKeys[type], { message: message || String(e ?? ''), }); diff --git a/src/core.ts b/src/core.ts index 15508260..89ab5ed0 100644 --- a/src/core.ts +++ b/src/core.ts @@ -1,7 +1,7 @@ import { NativeEventEmitter, NativeModules, Platform } from 'react-native'; import { UpdateError } from './error'; import i18n from './i18n'; -import { emptyModule, error, log } from './utils'; +import { createNonSecureId, emptyModule, error, log } from './utils'; // RN's JS global object. Declared locally because RN 0.87's own typings no // longer define `global` (only @types/node did), and this source is @@ -156,7 +156,7 @@ export async function getCurrentVersionInfo(): Promise<{ export const pushyNativeEventEmitter = new NativeEventEmitter(PushyModule); if (!uuid) { - uuid = require('nanoid/non-secure').nanoid(); + uuid = createNonSecureId(); // If persisting fails the uuid drifts on every launch, which skews gray // release bucketing and inflates stats — log it instead of failing silently. // Web has nowhere to persist it (nothing runs there anyway). diff --git a/src/provider.tsx b/src/provider.tsx index 3f531dfb..b8163a79 100644 --- a/src/provider.tsx +++ b/src/provider.tsx @@ -539,6 +539,16 @@ export const UpdateProvider = ({ [client] ); + // parseTestPayload's identity follows checkUpdate (and through it the + // update strategy callbacks); the Linking subscription reads it through a + // ref so it is set up once instead of re-running — and replaying the + // initial URL — on every such change. + const latestParseTestPayload = useRef(parseTestPayload); + useEffect(() => { + latestParseTestPayload.current = parseTestPayload; + }, [parseTestPayload]); + const initialUrlHandled = useRef(false); + useEffect(() => { if (!assertWeb()) { return; @@ -552,13 +562,18 @@ export const UpdateProvider = ({ // malformed deep link must not become an unhandled rejection / a throw // inside the 'url' event handler. const params = parseQueryParams(url); - parseTestPayload({ + latestParseTestPayload.current({ type: params.type ?? null, data: params.data ?? null, }); }; - Linking.getInitialURL().then(parseLinking).catch(noop); + // The launch URL is one event: a remount (StrictMode included) must not + // replay it. + if (!initialUrlHandled.current) { + initialUrlHandled.current = true; + Linking.getInitialURL().then(parseLinking).catch(noop); + } const linkingHandler = ({ url }: { url: string }) => { parseLinking(url); }; @@ -570,7 +585,7 @@ export const UpdateProvider = ({ linkingListener.remove(); } }; - }, [parseTestPayload]); + }, []); // progress lives in its own context (see context.ts), so this value only // changes when the update state itself changes, not on every progress tick. diff --git a/src/updateFlowCore.ts b/src/updateFlowCore.ts index ce39ba4a..05b645e9 100644 --- a/src/updateFlowCore.ts +++ b/src/updateFlowCore.ts @@ -118,6 +118,107 @@ export const dedupeEndpoints = ( return result; }; +// Values the natives assume for keys a persisted native check config omits. +const NATIVE_CONFIG_DEFAULTS: Record = { + disabled: false, + queryUrls: [], + rnu: '', + rn: '', + afterDownload: 'none', +}; + +const jsonDeepEqual = (a: unknown, b: unknown): boolean => { + if (a === b) { + return true; + } + if (Array.isArray(a) || Array.isArray(b)) { + return ( + Array.isArray(a) && + Array.isArray(b) && + a.length === b.length && + a.every((value, index) => jsonDeepEqual(value, b[index])) + ); + } + if (!a || !b || typeof a !== 'object' || typeof b !== 'object') { + return false; + } + const left = a as Record; + const right = b as Record; + const leftKeys = Object.keys(left); + return ( + leftKeys.length === Object.keys(right).length && + leftKeys.every( + (key) => Object.hasOwn(right, key) && jsonDeepEqual(left[key], right[key]) + ) + ); +}; + +// Each string loses every trailing '/', then entries (strings or not) are +// deduplicated by JSON value, keeping the first occurrence. +const canonicalUrlList = (value: unknown): unknown => { + if (!Array.isArray(value)) { + return value; + } + const result: unknown[] = []; + const visited = new Set(); + for (const item of value) { + if (typeof item === 'string') { + const url = item.replace(/\/+$/, ''); + if (!visited.has(url)) { + visited.add(url); + result.push(url); + } + } else if (!result.some((existing) => jsonDeepEqual(existing, item))) { + result.push(item); + } + } + return result; +}; + +/** + * Canonical form of a persisted native check config (shared contract C1 with + * the Android/iOS/Harmony natives): absent keys take their native defaults, + * `endpoints`/`queryUrls` lose trailing '/' and duplicates (first occurrence + * kept). Undefined when the JSON is unparseable or not an object. + */ +export function canonicalizeNativeConfig( + configJson: string +): Record | undefined { + let parsed: unknown; + try { + parsed = JSON.parse(configJson); + } catch { + return undefined; + } + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + return undefined; + } + const canonical: Record = { + ...NATIVE_CONFIG_DEFAULTS, + ...(parsed as Record), + }; + for (const key of ['endpoints', 'queryUrls']) { + if (key in canonical) { + canonical[key] = canonicalUrlList(canonical[key]); + } + } + return canonical; +} + +/** + * Whether two native check config JSON strings describe the same config + * (contract C1): their canonical forms are deep-equal, key order ignored. + * Anything unparseable is never equivalent. + */ +export function nativeConfigsEquivalent( + leftJson: string, + rightJson: string +): boolean { + const left = canonicalizeNativeConfig(leftJson); + const right = canonicalizeNativeConfig(rightJson); + return !!left && !!right && jsonDeepEqual(left, right); +} + /** * The endpoint plan: dedupe, then move the sampled pick to the front (load * spreading), keeping the rest in configured order as the fallback sequence. diff --git a/src/utils.ts b/src/utils.ts index 2dd98319..2a8302b3 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -57,6 +57,23 @@ export function promiseAny(promises: Promise[]) { }); } +// Same alphabet and length as nanoid's default (URL-safe, 21 chars), so ids +// stay format-compatible with the ones nanoid/non-secure used to generate. +const ID_ALPHABET = + 'useandom-26T198340PX75pxJACKVERYMINDBUSHWOLF_GQZbfghjklqvwyzrict'; + +/** + * Non-cryptographic random id (installation uuid). Math.random is enough: it + * only buckets gray releases, it is not a secret. + */ +export const createNonSecureId = (size = 21): string => { + let id = ''; + for (let i = 0; i < size; i++) { + id += ID_ALPHABET[Math.floor(Math.random() * 64)]; + } + return id; +}; + export const noop = () => {}; const emptyModuleTarget: Record = {}; export const emptyModule = new Proxy(emptyModuleTarget, { @@ -65,8 +82,11 @@ export const emptyModule = new Proxy(emptyModuleTarget, { }, }); +// Web never probes (assertWeb gates every flow); the identity keeps the +// signature. Not the bare `Promise.resolve`: called unbound through map() it +// throws. const ping = isWeb - ? Promise.resolve + ? (url: string) => Promise.resolve(url) : async (url: string) => { try { const {